远程桌面
-先交还已有控制,再接管应用;其他观看窗口可以保持打开。
+交还控制后自动切回只读观看。再次接管会替换同一密钥的旧控制窗口,切换观看会交还自己的控制。不同使用者仍须先交还;独立观看窗口可保持打开。
@@ -75,5 +95,19 @@项目与审批
工具流与回合事件
尚未收到事件
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 1625b78..468427b 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -26,6 +26,11 @@ jobs: cache: npm - name: Install the locked dependency graph run: npm ci --no-audit --no-fund + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.2 + - name: Prepare Node Agent TokenHub protocol test dependencies + run: npm install --global --prefix "$HOME/.npm-global" --ignore-scripts --no-audit --no-fund @bitkyc08/opencodex@2.78.0 - name: Run source and publication checks # Same gate as `npm run check`, except the suite list. The real-network # web tools tests are excluded here: they turned this required job red diff --git a/README.md b/README.md index b01f4b1..6e24713 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ coordinator、本地执行、协议与渲染层各边界的可读 TypeScript 实 | 能力 | 0.3.0 提供的接口 | | --- | --- | | 对话与任务 | bot 查询、独立 Harness 会话、消息、SSE 事件、回合结果、审批、定向取消 | +| 模型与端点 | 新会话选模型,管理员服务/bot 默认,TokenHub 地址、密钥、模型拉取与实际测试 | | 桌面与单应用 | 整桌面及终端/浏览器窗口的独立观看、接管与交还;手动复制粘贴 | | 项目与恢复 | 文件导入、Git 差异、归档导出、项目与会话备份、网页/CLI 续接同一 thread | | 本机授权 | Bearer 密钥、用户与 bot 权限、短期桌面链接、控制租约、配额、审计、签名 webhook | @@ -55,10 +56,16 @@ npm run node-agent-api -- --backend codex --port 18770 --recover true - [Node Agent API 0.3.0 详细文档](docs/NODE_AGENT_API_0_3.md):前置配置、完整接口索引、请求示例、单应用 VNC、CLI、恢复与排障。 - [安装与运行说明](tools/node-agent-api/README.md):执行包、代理、启动选项及运行边界。 +- [模型选择与默认设置](docs/NODE_AGENT_API_MODELS.md):默认可为空,普通用户只选获准模型。 +- [TokenHub 账户与端点](docs/NODE_AGENT_API_TOKENHUB.md):配置模型 API 地址和密钥,实际测试后授权。 +- [后台运行与排障](docs/NODE_AGENT_API_OPERATIONS.md):macOS 保活、日志及网页/桌面连接问题。 - [OpenAPI 3.0 契约](tools/node-agent-api/openapi.json):机器可读的路径、参数、请求和响应 schema。 - [验证记录](tools/node-agent-api/VERIFICATION.md):离线回归与真实共享盒子验证。 这是 GrokNode 的外部适配 API;版本号为 0.3.0,不表示兼容 OpenAI Agents SDK。运行中的进程按中断处理,恢复保留的文件与会话后,由用户决定重跑任务。 + +2026-10-07 更新包含模型与 TokenHub 扩展、桌面控制和中文标题修复;本分支的实现、测试与文档同步发布。 + ## 仓库里有什么? 检入的版本树包含经过审阅的重建代码、测试、清单、构建脚本,以及用 Git LFS 保存的、 diff --git a/docs/NODE_AGENT_API_0_3.md b/docs/NODE_AGENT_API_0_3.md index b3eeb59..ec92276 100644 --- a/docs/NODE_AGENT_API_0_3.md +++ b/docs/NODE_AGENT_API_0_3.md @@ -1,15 +1,20 @@ # Node Agent API 0.3.0 +更新:2026-10-07。本文与本分支的 0.3.0 实现、回归用例和 OpenAPI 一同发布,涵盖模型选择、TokenHub 端点配置与实际测试、桌面控制与后台运行;OpenAPI 共 64 个方法/路径。 + +专题:[模型选择与空默认](NODE_AGENT_API_MODELS.md) · [TokenHub 地址、密钥与测试](NODE_AGENT_API_TOKENHUB.md) · [后台运行与排障](NODE_AGENT_API_OPERATIONS.md) + [返回仓库首页](../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` 为准。 +本文对应 **0.3.0 共享盒子及扩展实现**。字段类型与机器可读契约见 OpenAPI;后端是否支持某项操作,启动后以 `GET /v1/capabilities` 为准。模型和端点管理要求 capabilities 明确声明支持,旧服务需更新实现才能使用这些功能。 ## 目录 - [概要与架构](#概要与架构) - [快速开始](#快速开始) +- [模型与端点](#模型与端点) - [通用约定](#通用约定) - [完整接口索引](#完整接口索引) - [对话与项目示例](#对话与项目示例) @@ -17,6 +22,7 @@ Node Agent API 是运行在 Mac 上的独立适配服务。网页、CLI 和自 - [CLI 与恢复](#cli-与恢复) - [多用户与 webhook](#多用户与-webhook) - [错误与排障](#错误与排障) +- [后台运行](NODE_AGENT_API_OPERATIONS.md) ## 概要与架构 @@ -81,12 +87,23 @@ npm run node-agent-api -- --backend codex --port 18770 --recover true | `--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 的模型与代理入口 | +| `--model` / `--base-url` | 服务默认模型初始为 null;`--model` 仅初始化尚未保存的默认,已有设置优先。`--base-url` 初始化代理入口 | +| `--codex-bin` | Mac 侧固定版本 Codex 可执行文件的绝对路径,避免全局更新改变此服务版本 | | `--recover true` | 启动前检查并回收已死亡进程留下的服务锁;不抢占正在运行的服务 | | `--allow-writes true` | legacy `grok` 后端的显式写策略;默认关闭,仍须满足后端安全检查 | 服务固定监听 `127.0.0.1`。`codex` 是本文示例使用的后端;legacy 后端不提供独立对话、原生取消、审批、项目和剪贴板能力,调用前应查询 capabilities。停止 API 只关闭它自己的连接与辅助进程,不停止原 GrokNode 或共享容器。 +上述命令在前台运行。日常使用应按[后台运行说明](NODE_AGENT_API_OPERATIONS.md)配置 macOS LaunchAgent;临时终端或工具会话退出后,前台服务可能随之退出。后台服务保留相同 `--state` 与 `--runtime-state`,并独立记录 stdout/stderr。 + +## 模型与端点 + +新会话的模型优先级为显式 `model` → bot 默认 → 服务默认。服务默认初始为空;三级均未提供模型时返回 `400 model_required`,不创建会话。管理员可保存服务/bot 默认和获准清单,普通用户只为自己的新会话选择获准模型。模型、端点版本和推理强度在创建时固定,修改默认不切换已有会话。 + +网页管理员区的 TokenHub 提供模型 API 地址、密钥保存、模型拉取和真实连接测试。所有已拉取模型都可测试;测试通过后由管理员授权,普通用户才能选择。`verification_level` 区分 `unverified`、`connection`、`tool_workflow`:文本连接成功不等于完整工具链验收。API 访问密钥与模型供应商密钥分别用于本机 API 和上游模型服务,不能互换。 + +请求字段、计费归属、密钥存储与端点切换流程见[模型选择](NODE_AGENT_API_MODELS.md)和[TokenHub](NODE_AGENT_API_TOKENHUB.md)。模型请求由 Mac 服务端发出,使用它保存的上游账户额度;调用方的 Node Agent API key 不会自动成为模型账户。 + ## 通用约定 ### 地址与认证 @@ -150,12 +167,26 @@ session/event 的 `created_at`、密钥和 ticket 的 `expires_at` 为 Unix 秒 | POST | `/v1/agents` | 201 | `agents.write`、所有 bot 范围、可写后端;`{name, description?}` | | GET | `/v1/agents/{agent_id}` | 200 | `agents.read`;原 bot 资料 | +### 模型与 TokenHub + +| 方法 | 路径 | 成功状态 | 权限 / 说明 | +| --- | --- | --- | --- | +| GET | `/v1/models` | 200 | `agents.read`;可带 `agent_id`,查询模型目录、获准/验证状态与有效默认 | +| GET | `/v1/settings/models` | 200 | owner;服务默认、bot 覆盖、获准清单、工具链证据 | +| PATCH | `/v1/settings/models` | 200 | owner;保存默认与授权,`default_model: null` 清空服务默认 | +| GET | `/v1/agents/{agent_id}/model` | 200 | owner;该 bot 的默认与有效默认 | +| PATCH | `/v1/agents/{agent_id}/model` | 200 | owner;`default_model: null` 清除 bot 覆盖 | +| GET | `/v1/settings/tokenhub` | 200 | owner;端点版本、协议、`key_saved`、模型与测试记录,不回显密钥 | +| PATCH | `/v1/settings/tokenhub` | 200 | owner;保存 `base_url`、`wire_api` 或只写 `api_key`,建立新端点版本 | +| POST | `/v1/settings/tokenhub/models` | 200 | owner;从当前模型端点拉取目录 | +| POST | `/v1/settings/tokenhub/test` | 200 | owner;`{model, reasoning_effort?}`,发起会消耗上游额度的真实文本测试 | + ### 会话与任务 | 方法 | 路径 | 成功状态 | 权限 / 说明 | | --- | --- | --- | --- | | GET | `/v1/agents/sessions` | 200 | `sessions.read`;可按 `agent_id` 过滤并分页 | -| POST | `/v1/agents/sessions` | 201 / 200 | `sessions.write`;Codex 新建独立 thread;legacy 可复用已有附件 | +| POST | `/v1/agents/sessions` | 201 / 200 | `sessions.write`;`{agent_id, model?, reasoning_effort?, metadata?}`;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`;保全式关闭,活动任务须先结束或取消 | @@ -252,11 +283,13 @@ node_api "$NODE_AGENT_BASE/v1/agents?limit=20" ```sh NODE_AGENT_BOT='BOT_ID_FROM_AGENTS' +node_api "$NODE_AGENT_BASE/v1/models?agent_id=$NODE_AGENT_BOT" +NODE_AGENT_MODEL='APPROVED_MODEL_FROM_MODELS' node_api -X POST "$NODE_AGENT_BASE/v1/agents/sessions" \ - --data "{\"agent_id\":\"$NODE_AGENT_BOT\",\"metadata\":{\"project\":\"demo\"}}" + --data "{\"agent_id\":\"$NODE_AGENT_BOT\",\"model\":\"$NODE_AGENT_MODEL\",\"metadata\":{\"project\":\"demo\"}}" ``` -Codex 后端返回 `201`,包含 `id`、`agent_id`、`context: "codex_harness"`、`thread_id`、`task_status`。同一 bot 可有多个独立 Harness 会话;每次 POST 创建新 thread。legacy 后端可返回 `200`,复用 bot 的现有 transcript 附件。 +从目录中选择 `approved: true` 且 `verified: true` 的实际模型,替换示例变量。已有有效默认时可省略 `model`;没有默认且未显式选择时返回 `400 model_required`。Codex 后端返回 `201`,包含 `id`、`agent_id`、`context: "codex_harness"`、`thread_id`、`task_status`、`model` 和 `model_source`,以及启用 TokenHub 后固定的 `endpoint_revision`、`reasoning_effort`。同一 bot 可有多个独立 Harness 会话;每次 POST 创建新 thread。legacy 后端可返回 `200`,复用 bot 的现有 transcript 附件,不支持此模型选择流程。 ```sh NODE_AGENT_SESSION='SESSION_ID_FROM_RESPONSE' @@ -341,7 +374,7 @@ shasum -a 256 project.tgz ```sh node_api -X POST "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/desktop" \ - --data '{"mode":"view","ttl_seconds":60,"target":{"type":"desktop"}}' + --data '{"mode":"view","replace_own_control":true,"ttl_seconds":60,"target":{"type":"desktop"}}' ``` 响应含 `url`、`display`、`expires_at`、`single_use: true`、`server_enforced: true`、`pauses_agent: false`。在本机浏览器打开 `url` 即兑换授权。`mode` 默认 `view`;接管显式指定 `control`。 @@ -350,6 +383,8 @@ ticket 默认 60 秒、最多 300 秒,且只能兑换一次。兑换后 viewer 每 bot 同时只有一个有效控制租约。申请 ticket 尚未建立租约,必须打开链接兑换后才能启动应用或写剪贴板。接管不会暂停原 bot;用户操作和机器人动作需要自己协调。 +`replace_own_control: true` 允许显式替换同一 API 密钥已经持有的控制 viewer,新 ticket 兑换成功后才移交;`mode: view` 配合此字段会交还自己的控制并转为观看。不同密钥的控制不会被抢占,仍返回 `409 desktop_busy`。网页整桌面观看/接管和独立应用接管采用此选项,独立应用观看保留既有控制。 + ### 终端和浏览器独立窗口 ```sh @@ -364,7 +399,7 @@ node_api -X POST "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/deskto --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`。 +每个单应用 viewer 使用 `/desktop/lane/{viewer_id}/…` 和独立 cookie 路径,因此可以同时打开终端与浏览器的观看窗口。同一密钥切到应用接管可发送 `mode: control, replace_own_control: true`;不替换时须先交还已有控制。窗口 ID 会变化,不能固定旧的 `token=3` 或 `codexvm-app-3-terminal`。应用标题按 X11 属性编码解码,中文正常;旧列表显示乱码时点击“刷新应用”。 没有所需应用时,先兑换该 bot 的控制链接,再明确启动: @@ -386,7 +421,7 @@ node_api -X POST "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/clipbo node_api -X POST "$NODE_AGENT_BASE/v1/agents/sessions/$NODE_AGENT_SESSION/handback" ``` -写剪贴板要求有效控制租约,文本最多 64 KiB。网页只在用户点击按钮时传递文本,默认关闭自动同步。交还返回 `{released: true}` 并断开对应控制 viewer;关闭会话会撤销该 bot 当前的 API 桌面 ticket/viewer,撤销密钥则使相应授权链失效。 +写剪贴板要求有效控制租约,文本最多 64 KiB。网页只在用户点击按钮时传递文本,默认关闭自动同步。交还 API 返回 `{released: true}` 并断开对应控制 viewer;网页随后自动签发并打开 `view` 观看链接,保留画面,不能发送控制输入。观看重连失败显示明确错误且不重新接管。关闭会话会撤销该 bot 当前的 API 桌面 ticket/viewer,撤销密钥则使相应授权链失效。 API 仅保护自身 `/v1` 和 `/desktop` 入口。原 GrokNode 的 `6080/6081` 端口保留原访问策略;访问原始 VNC 链接不会自动受到本 API 的 Bearer 权限约束。 @@ -491,17 +526,21 @@ REST 错误结构为: | 状态 / 常见类型 | 检查方式 | | --- | --- | | 400 `invalid_request` / `invalid_cursor` | 核对字段、标识、分页或 SSE 游标;不要用另一会话的游标 | +| 400 `model_required` | 服务和 bot 默认均为空,为新会话指定获准模型 | | 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 `desktop_busy` / `desktop_changed` | 同一密钥可显式 `replace_own_control: true`;不同密钥先交还已有租约。映射变化须重新签发链接 | | 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 权限与服务诊断;查询状态后再决定恢复或重试 | +| 503 `model_catalog_unavailable` | opencodex 目录不可读,或自定义端点还未拉取模型;已有会话保留原模型 | +| 浏览器连接失败 / 端口无监听 | 按[后台排障](NODE_AGENT_API_OPERATIONS.md#服务打不开)检查 API 进程、LaunchAgent 和日志 | +| 查看器超时 / 页面仍显示旧提示 | Chrome 按 ⌘⇧R 强制刷新,重新登录、选择已有会话,再签发桌面授权;刷新不会删除会话 | `/health` 成功只证明 API 进程在响应,不保证模型、容器或桌面就绪。验证与边界记录见 [VERIFICATION.md](../tools/node-agent-api/VERIFICATION.md);离线 API 回归命令为 `npm run test:node-agent-api`。 diff --git a/docs/NODE_AGENT_API_MODELS.md b/docs/NODE_AGENT_API_MODELS.md new file mode 100644 index 0000000..5b26568 --- /dev/null +++ b/docs/NODE_AGENT_API_MODELS.md @@ -0,0 +1,48 @@ +# Node Agent API 模型选择 + +更新:2026-10-07。本页与本分支的模型选择实现、回归用例和 OpenAPI 同步,完整接口见 [Node Agent API 0.3.0](NODE_AGENT_API_0_3.md)。 + +现在提供[API 地址、密钥、拉取模型和实际测试](NODE_AGENT_API_TOKENHUB.md)。管理员连接测试成功后可授权其他模型;连接测试与工具链验收分开显示。`verified_models` 是工具链证据登记机制,不是唯一授权前置条件。 + +模型调用使用 API 服务所在 Mac 配置的上游账户:默认沿用 opencodex,使用自定义 TokenHub 端点时使用该端点保存的模型 API 密钥。模型下拉框不会把计费切换到调用者账户;Node Agent API 访问密钥只用于本机服务授权。GrokNode 的 bot、盒子、桌面及原配置保持原有管理方式。 + +## 使用 + +连接网页后选择 bot,在“新会话模型”选择获准模型或继承默认,再创建会话。当前会话的模型显示在消息区。owner 可展开“管理员模型设置”,修改服务默认、当前 bot 默认、获准清单和工具链验收记录。普通用户看不到管理区,服务端同样拒绝其管理请求,包括持有 keys.manage 的委派密钥。 + +模型解析优先级:请求 model → API 保存的 bot 默认 → API 服务默认。创建时持久保存 model 与 model_source。之后修改默认和获准列表影响新会话;已有会话不自动换模型。新会话中途换模型需要重新创建会话。 + +服务默认初始为空(API 返回 null)。管理员可选择“未设置默认模型”,或 PATCH {"default_model":null}(也接受空字符串并规范化为 null)。服务和该 bot 默认都为空时,页面要求先选择模型;REST 未指定 model 返回 400 model_required,不创建会话、不回落 gpt-6.1-sol。清空默认不撤销模型授权,也不修改既有会话。 + +未配置自定义端点时目录来源为 Mac 的 ~/.codex/opencodex-catalog.json;自定义 TokenHub 目录从该 API 的 /models 拉取。目录条目、连接测试与工具链验收分开显示。初始只授权已完成 0.3.0 原生工具链验收的 gpt-6.1-sol;其他模型可在 TokenHub 设置里实际测试,成功后授权使用。管理员还可在真实改文件、测试、审批、取消验收后登记 evidence;evidence 是管理员的验收声明,服务不会自动证明某个模型的完整工具能力。 + +## REST + +| 方法与路径 | 权限 | 请求/响应要点 | +| --- | --- | --- | +| GET /v1/models?agent_id=BOT | agents.read 与对应 bot grant | data: id/name/approved/verified/verification_level/verification_evidence/reasoning_efforts;default_model 是该 bot 的有效默认,endpoint_revision 标识当前端点 | +| GET /v1/settings/models | owner | default_model/bot_defaults/allowed_models/verified_models | +| PATCH /v1/settings/models | owner | 可修改 default_model、allowed_models、verified_models;默认可为空,有值时必须获准,授权需存在目录且通过当前端点连接测试或工具链验收 | +| GET /v1/agents/BOT/model | owner | default_model 可为 null,effective_model 为最终默认 | +| PATCH /v1/agents/BOT/model | owner | {"default_model":"gpt-6.1-sol"};null 清除 bot 覆盖 | +| POST /v1/agents/sessions | sessions.write 与对应 bot grant | 可选 model、reasoning_effort;响应固定 model、model_source、endpoint_revision 与 reasoning_effort | + +```json +{"agent_id":"BOT","model":"gpt-6.1-sol","metadata":{"title":"代码审查"}} +``` + +```json +{"allowed_models":["gpt-6.1-sol","VERIFIED_MODEL"],"verified_models":[{"id":"gpt-6.1-sol","evidence":"既有 0.3.0 原生工具验收"},{"id":"VERIFIED_MODEL","evidence":"实际工具链验收报告的编号或路径"}]} +``` + +设置保存于 API state/models.json,原子写入、权限 600;不写 GrokNode 的模型设置。启动参数 --model 仅在首次初始化作为服务默认,不传即为空;已有保存的设置优先,包括已保存的 null。指定非基线模型需先登记并授权,再通过管理接口设置默认。 + +400 表示非法、未获准或不在目录的模型;403 表示非 owner 修改设置;503 model_catalog_unavailable 表示无法读取目录,此时新建模型会话失败且不会启动模型任务。已有会话保留保存的模型。Harness 返回不同模型时 502 model_mismatch,拒绝继续,未设置备用模型。 + +## Harness 与旧会话 + +Mac Harness 的 app-server 按 bot 复用;模型通过 thread/start、thread/resume、turn/start 的原生参数发送,两个 thread 无需争抢 config.toml。模型也记录在回合上。旧会话没有 model 时,以原生续接响应实际返回的模型固定下来;从未产生 rollout 的空会话沿用既有空 thread 重建机制。 + +现有本机 client-cli codex/resume 的 --model 是直接启动本机 Codex 的参数,不受 API key 权限清单管理。API 用户应通过上述 REST 新建会话。该客户端 desktop 创建会话会使用 API 默认;它与模型管理端点共用原有 bot 映射。 + +完整请求与响应 schema 见 [OpenAPI](../tools/node-agent-api/openapi.json)。回归见 tests/node-agent-models.test.mjs,真实验收记录见 [模型验收](node-agent-api-0-3-verify/08-model-selection.md)。 diff --git a/docs/NODE_AGENT_API_OPERATIONS.md b/docs/NODE_AGENT_API_OPERATIONS.md new file mode 100644 index 0000000..8e79c4b --- /dev/null +++ b/docs/NODE_AGENT_API_OPERATIONS.md @@ -0,0 +1,107 @@ +# Node Agent API 后台运行与排障 + +更新:2026-10-07。API 是 Mac 上的独立进程,网页只是客户端。`npm run node-agent-api` 在前台运行;终端关闭或临时工具会话结束后,服务可能退出。本机已改用用户级 LaunchAgent,登录后启动、退出后重启,并保存 stdout/stderr。 + +[返回主手册](NODE_AGENT_API_0_3.md) · [网页工作台](http://127.0.0.1:18770/ui/) · [运行说明](../tools/node-agent-api/README.md) + +后台配置不改变 GrokNode 程序或容器生命周期。GrokNode 盒子不可用时,上游操作仍明确失败;API 保活不会启动、替换或重启它。 + +## 配置之前 + +确认 Node 26.5.x、固定版本 Codex 0.160.0,以及 API 依赖已安装。保留正在使用的 `--state` 和 `--runtime-state` 绝对路径;换成新目录会创建另一份密钥和状态,旧会话不会自动搬过去。 + +先结束或取消 API 正在执行的回合,再优雅停止前台 API。检查 18770 没有旧实例监听,避免两个服务争用端口或状态。`--recover true` 只回收已死亡进程留下的锁;锁属于活进程时拒绝启动,不手动删除锁。 + +## 新建用户后台服务 + +在实际 API 仓库根目录运行以下配置示例。Node 与 Codex 必须解析到支持的固定版本;不在 PATH 时,把对应变量改为绝对路径。已有服务使用不同状态目录时,把两个状态变量改成原目录。 + +```sh +export NODE_AGENT_REPO="$PWD" +export NODE_AGENT_NODE="$(command -v node)" +export NODE_AGENT_CODEX="$(command -v codex)" +export NODE_AGENT_STATE="$NODE_AGENT_REPO/.lab/node-agent-shared" +export NODE_AGENT_RUNTIME_STATE="$NODE_AGENT_REPO/.lab/shared-runtime" +"$NODE_AGENT_NODE" --version +"$NODE_AGENT_CODEX" --version +``` + +以下程序生成 `~/Library/LaunchAgents/com.groknode.node-agent-api.plist`。它拒绝覆盖已存在的配置,密钥不写入 plist。 + +```python +import os +import pathlib +import plistlib + +repo = pathlib.Path(os.environ['NODE_AGENT_REPO']).resolve() +node = str(pathlib.Path(os.environ['NODE_AGENT_NODE']).resolve(strict=True)) +codex = str(pathlib.Path(os.environ['NODE_AGENT_CODEX']).resolve(strict=True)) +state = str(pathlib.Path(os.environ['NODE_AGENT_STATE']).resolve()) +runtime = str(pathlib.Path(os.environ['NODE_AGENT_RUNTIME_STATE']).resolve()) +logs = repo / '.lab' / 'node-agent-service' +logs.mkdir(parents=True, exist_ok=True, mode=0o700) +logs.chmod(0o700) +target = pathlib.Path.home() / 'Library/LaunchAgents/com.groknode.node-agent-api.plist' +target.parent.mkdir(parents=True, exist_ok=True) +config = { + 'Label': 'com.groknode.node-agent-api', + 'ProgramArguments': [node, str(repo / 'tools/node-agent-api/cli.mjs'), + '--recover', 'true', '--port', '18770', + '--state', state, '--runtime-state', runtime, + '--codex-bin', codex], + 'WorkingDirectory': str(repo), + 'EnvironmentVariables': { + 'PATH': ':'.join([str(pathlib.Path(node).parent), '/usr/local/bin', + '/opt/homebrew/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin']) + }, + 'RunAtLoad': True, + 'KeepAlive': True, + 'ThrottleInterval': 10, + 'ExitTimeOut': 30, + 'ProcessType': 'Background', + 'Umask': 0o077, + 'StandardOutPath': str(logs / 'stdout.log'), + 'StandardErrorPath': str(logs / 'stderr.log'), +} +with target.open('xb') as handle: + target.chmod(0o600) + plistlib.dump(config, handle) +print(target) +``` + +将 Python 代码保存为临时配置脚本并用 `python3` 运行,再校验与加载: + +```sh +plutil -lint "$HOME/Library/LaunchAgents/com.groknode.node-agent-api.plist" +launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/com.groknode.node-agent-api.plist" +launchctl print "gui/$(id -u)/com.groknode.node-agent-api" +curl --fail --max-time 5 http://127.0.0.1:18770/health +``` + +后台进程不依赖当前聊天或终端。`KeepAlive` 只保活 API;应用进程内存不恢复,API 会话与文件仍按原保全策略恢复。本机已有部署的路径和日志位置以 `launchctl print` 的 `arguments`、`stdout path`、`stderr path` 为准,不以本文示例的新目录替代。 + +## 停止与重启 + +先结束或取消当前 API 回合。停止本次后台实例: + +```sh +launchctl bootout "gui/$(id -u)/com.groknode.node-agent-api" +``` + +再次运行前面的 `bootstrap` 启动,保持原状态目录即可续接已有会话。plist 仍保留时,下次登录会自动加载;若需禁止下次自动启动,使用 `launchctl disable "gui/$(id -u)/com.groknode.node-agent-api"`,以后可用 `launchctl enable` 恢复。不要用普通 `kill` 作为停止方式,保活配置会重新拉起进程。 + +## 服务打不开 + +```sh +lsof -nP -iTCP:18770 -sTCP:LISTEN +curl --fail --max-time 5 http://127.0.0.1:18770/health +launchctl print "gui/$(id -u)/com.groknode.node-agent-api" +``` + +- **端口没有监听**:检查 LaunchAgent 是否存在、进程是否运行、`last exit code` 和配置中的 stdout/stderr 文件。前台实例退出且没有后台服务时,浏览器无法连接。 +- **反复退出**:从 stderr 检查可执行文件、依赖、目录权限、端口占用和服务锁。确认原锁所属进程已死亡后使用 `--recover true`,不覆盖活实例。 +- **health 为 200,bot/工具失败**:检查原 GrokNode 盒子、gateway、Mac 模型代理和对应授权。health 只证明 API 在响应。 +- **更新后网页提示还是旧版或查看器超时**:Chrome 按 ⌘⇧R,重新输入 API 访问密钥、选择已有会话,再点击观看或接管。桌面 ticket/cookie 会因服务重启失效,需要重新签发;刷新网页不删除持久会话。 +- **应用中文标题乱码**:更新 API 到包含 UTF-8 标题读取修复的实现,点击刷新应用。不要在浏览器猜测转码。 + +2026-10-07 的实际恢复检查确认 `/health`、`/ui/` 与原密钥认证均成功,已有会话仍可读取。没有通过重启 Mac 验证登录自动启动;该行为来自 LaunchAgent 配置,不能当成已经完成的重启验收。进程此前退出的具体触发原因没有退出日志,不把临时会话生命周期猜测成已证实的崩溃原因。 diff --git a/docs/NODE_AGENT_API_TOKENHUB.md b/docs/NODE_AGENT_API_TOKENHUB.md new file mode 100644 index 0000000..7688f11 --- /dev/null +++ b/docs/NODE_AGENT_API_TOKENHUB.md @@ -0,0 +1,46 @@ +# TokenHub 账户、端点与模型 + +更新:2026-10-07。本页与本分支的 TokenHub 实现和 OpenAPI 同步,完整接口见 [主手册](NODE_AGENT_API_0_3.md)。 + +Node Agent API 参考 GrokNode 本地实现的 fetchEndpointModels、probeEffortSupport 与 openrouter-proxy 的推理强度处理,提供独立的账户和 API 端点设置。没有修改原 GrokNode 程序、登录、凭据、bot 管理或盒子。 + +## 网页操作 + +1. 用 Node Agent API 访问密钥连接工作台,以 owner 身份展开“管理员模型设置”。 +2. 点击“TokenHub 账户与端点”。API 地址填模型 API 根地址,例如 `https://api.z.ai/api/coding/paas/v4`,协议选 Chat Completions;opencodex 通常选 Responses。 +3. 保存地址后输入该模型服务的 API 密钥,点击保存。换到不同地址会清除当前密钥引用,避免误发送旧服务的凭据。 +4. 点击“重新拉取”:服务端在 Mac 请求 `{API地址}/models`,网页只收到模型与能力列表。 +5. 从模型下拉选择任何已拉取模型。推理强度显示目录已知或实测通过的档位;未知档位可以在测试输入框单独验证。空档位表示端点默认,不发送推理字段。 +6. 点击“测试连接”:向 `/chat/completions` 或 `/responses` 发起一个最多 64 输出 token 的真实文本请求。会消耗上游额度;失败、空文本、非 2xx 不会登记成功。 +7. 成功后显示“连接已验证”,点击“授权当前模型”。新会话可选该模型;服务默认仍保持原设置,包括 null。 + +“灰色”现在表示当前端点尚未完成连接测试或工具链验收,不是该模型不可用。所有已拉取模型都能在 TokenHub 测试下拉中选择。连接成功只证明 API 和所选参数能返回文本,不冒充改文件、测试、审批、取消全链路验收;管理员可另登记实际工具链证据。 + +## 密钥与会话边界 + +模型密钥通过 AES-256-GCM 加密存于 Mac 的 API state/tokenhub.json;加密密钥在同目录 tokenhub.master.key,目录 700、文件 600。网页只能看到 key_saved,不会回显;密钥不会进入 Linux 执行进程。该加密保护文件静态内容,不是防御同一 Mac 用户同时读取两个文件的安全边界。原 ChatGPT/opencodex/GrokNode 密钥不自动复制到 TokenHub。 + +改变 API 地址、协议或密钥建立一个新的端点版本,连接测试记录重新开始。旧版本保留,已有会话继续使用创建时的端点版本、模型和推理强度,直到管理员另行处置这些历史凭据;不静默把旧会话转到另一个模型服务。 + +管理员设置服务和 bot 默认;普通用户只在获准范围内创建会话,不可管理端点和密钥。无需把 gpt-6.1-sol 设为默认。直接创建新会话时可带 `reasoning_effort`,只接受目录广告或当前端点实测通过的值。 + +## 协议适配与依赖 + +Codex 0.160 使用 Responses。Mac 本地 TokenHub relay 复用已安装 `@bitkyc08/opencodex` 的 Chat adapter、Responses parser 与 SSE bridge,转发文本、推理、函数调用与工具结果;Codex Harness 仍管理模型回合、工具循环、审批与取消。连接 TokenHub 不替换 GrokNode 的模型循环。 + +TokenHub relay 需要 Bun 与 opencodex 安装,当前验收为 Bun 1.4.2、opencodex 2.78.0。默认读取 `~/.bun/bin/bun` 和 `~/.npm-global/lib/node_modules/@bitkyc08/opencodex`;未安装时返回明确的适配器不可用错误。CI 在隔离 runner 安装这两个固定版本,真实流式函数调用/工具结果回归不跳过。该依赖只用于 API 协议适配,不改变原 GrokNode 程序或当前 Mac 的全局版本。 + +relay 只监听 127.0.0.1,具有随机 capability token,真实模型密钥通过私有 stdin 输入。版本变动后应重跑 tokenhub 回归。原 Codex 0.160.0 严格检查保留;API 启动支持 `--codex-bin` 指定固定版本,本机全局更新不自动改变项目支持范围。 + +无思考按 GrokNode 已有契约处理:Flash 模型发送 low;MiniMax M3 发送 thinking.type=disabled;其他未知 Chat 模型拒绝猜测关闭思考的参数,使用端点默认或单独支持的档位。HTTPS 与本机 HTTP 根地址可保存,禁止 URL 内的凭据、query、fragment;不跟随 HTTP 重定向。图片、音频、托管搜索及完整第三方协议兼容性没有通过本轮验收,不声称全部支持。 + +## API + +| 方法与路径 | owner 操作 | +| --- | --- | +| GET /v1/settings/tokenhub | 地址、协议、key_saved、模型与测试记录;无密钥 | +| PATCH /v1/settings/tokenhub | base_url / wire_api(chat 或 responses) / api_key(write-only);建立新版本 | +| POST /v1/settings/tokenhub/models | 从当前端点实际拉取模型 | +| POST /v1/settings/tokenhub/test | {model,reasoning_effort:null或字符串};只有成功文本响应才通过 | + +设置与测试参数的完整 schema 见 [OpenAPI](../tools/node-agent-api/openapi.json)。[验收记录](node-agent-api-0-3-verify/10-tokenhub.md)区分本地模拟上游、真实 Harness 和尚未提供凭据的第三方服务。 diff --git a/docs/node-agent-api-0-3-verify/00-README.md b/docs/node-agent-api-0-3-verify/00-README.md new file mode 100644 index 0000000..a85053b --- /dev/null +++ b/docs/node-agent-api-0-3-verify/00-README.md @@ -0,0 +1,28 @@ +# Node Agent API 0.3.0 逐接口验证记录 + +验证时间:2026-10-06 晚间(Asia/Shanghai)。分支 codex/node-agent-api-0-3,本分支代码服务于 http://127.0.0.1:18771,backend codex,writes enabled。测试 bot A 与 GrokNode 共享盒子 grok-node-local-vm:同容器同数据,桌面 display 6。 + +## 覆盖 + +- 回归测试 8 文件:87 通过、0 失败(M1-M3 修复后重跑仍全绿)。 +- 真实服务实测:81 次全端点扫描 + 对话 / import / 票据 / 恢复取消 / M1-M3 复验专项,约 110 次请求。 +- CLI 7 命令:status、ensure、desktop、backup 实测成功;resume 缺参、未知命令、未知 bot 的错误路径实测;recreate 只到代码级(见 06);codex/resume 交互只验参数校验。 +- OpenAPI 55 个操作全部覆盖,其中 createAgent 正向、launchApplication 正向因会产生真实副作用而有意跳过(见 07 清单)。 + +## 原始证据 + +实测原始 JSON 在 /private/tmp/naapi-probe.json、/private/tmp/naapi-probe2.json(本机临时文件,不入库,含一次性票据与会话标识)。本文只记录状态码、形状与结论,不含密钥。 + +## 分篇 + +- 01 agents 与会话 +- 02 消息与取消 +- 03 项目与环境 +- 04 桌面与剪贴板 +- 05 治理:密钥用户 webhook +- 06 CLI +- 07 需求偏差清单 + +## 总 verdict + +需求 9 条目标行为全部成立;M1-M3 三处状态码偏差已修复并逐条 live 复验(07 已关闭);5 项有意未覆盖均有理由与替代证据。详见 07。 diff --git a/docs/node-agent-api-0-3-verify/01-agents-sessions.md b/docs/node-agent-api-0-3-verify/01-agents-sessions.md new file mode 100644 index 0000000..287898a --- /dev/null +++ b/docs/node-agent-api-0-3-verify/01-agents-sessions.md @@ -0,0 +1,23 @@ +# 01 agents 与会话 + +需求对应:条目 1(bot 身份与桌面由 GrokNode 管理)、条目 5(各自会话身份、thread 不混)。 + +## 实测 + +- GET /health:200,service/version/backend/compatible_with_openai_sdk=false。 +- GET /v1/capabilities:200,writes_enabled true,会话上下文 codex_harness,取消能力随 adapter.cancel 如实广告。 +- GET /v1/usage:200,requests/streams/storageBytes/limits 四组配额。 +- GET /v1/agents:200,16 个 bot,含验证 A/B;bot 条目带 backend codex_harness。 +- 无凭据与假凭据:401;未知路径:404;limit=abc:400。 +- GET /v1/agents/{id}:验证 A 200;全零 UUID:404。 +- POST /v1/agents 空名/缺名/超长名:均为 400。正向创建有意跳过(会在 GrokNode 建真 bot),替代证据:测试 bot A/B 由原接口创建记录 + 400 校验路径。 +- POST /v1/agents/sessions(验证 A):201,status idle,含独立 thread_id;两次创建耗时 33s / 12s(harness attach 成本,见 07 M5)。缺 agent_id:400;未知 bot:404。 +- GET /v1/agents/sessions + agent_id 过滤:200,数量与 bot 归属一致。 +- GET / PATCH session:200;PATCH 空体:400;metadata 写入后读回一致。 +- items/turns/traces/actions(新会话):200 空列表;真实回合后 items 出现 userMessage/agentMessage,traces 带 native token 用量(见 02)。 +- events GET:200 列表;after 取到最后 id:200 空页;after 胡诌:400 invalid_cursor。 +- POST /close:200 closed;关闭后 GET 确认;关闭后 submit:409 session_closed(形状正确时;错误形状先报 400,符合先校验后状态的顺序)。 + +## verdict + +一致。会话相互独立,thread_id 随会话建,不存在跨 bot 混 thread。 diff --git a/docs/node-agent-api-0-3-verify/02-messaging-cancel.md b/docs/node-agent-api-0-3-verify/02-messaging-cancel.md new file mode 100644 index 0000000..710b19a --- /dev/null +++ b/docs/node-agent-api-0-3-verify/02-messaging-cancel.md @@ -0,0 +1,15 @@ +# 02 消息与取消 + +需求对应:条目 4(模型调用在 Mac,执行端不可用则失败不回落)、条目 5(真实项目对话)、取消走 Harness 机制。 + +## 实测 + +- submit 形状:{type:message,text} 与 agent.session.input.message + input_text 均为合法(OpenAPI 与 input.mjs 一致)。探针误用的 agent.session.input.text 得 400,属探针错误,接口行为正确。 +- submit 需顶层 events 数组(单条)+ 可选 idempotency_key;header 与 body key 不一致:400。 +- 真实回合(3 字符 trivial 回复):202 accepted 带 turn_id;约 6 秒后 completed;agent 原样回复 ok1;traces 记录 native 用量(input 9282 / output 6,cost 恒 null,与文档“未知成本为 null”一致)。 +- 取消路径:提交 sleep 45 长任务,running 确认后 POST cancel 事件:202 accepted;8 秒后 turns 显示 cancelled;盒内复查无 sleep 45 残留(仅 GrokNode 自有 ticker)。actions 为空(该回合无审批门)。 +- capabilities.events.cancellation 为 true,与 adapter.cancel 存在一致;取消只停当回合、不停共享盒子(进程检查佐证)。 + +## verdict + +一致。对话→回合→用量→取消全链路真实走通;fail-closed 与中断语义符合需求 4、8。单次 submit accept 耗时 10~37s(harness 来回成本),见 07 M5。 diff --git a/docs/node-agent-api-0-3-verify/03-project-environment.md b/docs/node-agent-api-0-3-verify/03-project-environment.md new file mode 100644 index 0000000..d3d63a1 --- /dev/null +++ b/docs/node-agent-api-0-3-verify/03-project-environment.md @@ -0,0 +1,18 @@ +# 03 项目与环境 + +需求对应:条目 3(数据按原始语义共享,不建私有卷)、条目 5(改代码/测试/审查/导出)、条目 8(备份恢复;进程按中断处理)。 + +## 实测 + +- GET project/diff:200,15KB,真实现场 diff。 +- POST project/export:200 artifact 描述;GET artifacts/{id}/content:200 application/gzip 14KB。未知 artifact:404。 +- POST project/import 数组形状:200 {imported:N}(单文件与多文件含子目录均验);随后 diff 即见新文件;探针文件已清走并复查。 +- import 缺 files({}):400 files array is required(M1 已修,复验通过)。 +- import 同路径复写:409 import_conflict(M2 已修,复验通过;拒绝覆盖防丢数据)。 +- POST environment/backup:201,backup_id,preserved 含 source/uncommitted_changes/conversation/logs,processes_restored false。 +- POST environment/restore(刚取的 backup_id):200 restored true,processes_restored false,与需求 8 一致。未知 backup_id:404 backup_not_found(M3 已修,复验通过)。 +- POST environment/recreate:409 managed_by_grok_node,盒子生命周期归 GrokNode(需求原程序不变)。 + +## verdict + +一致。M1-M3 三处状态码已按 07 修完并逐条复验;失败语义均为 fail-closed(拒绝、不丢数据、不回落 Mac)。 diff --git a/docs/node-agent-api-0-3-verify/04-desktop.md b/docs/node-agent-api-0-3-verify/04-desktop.md new file mode 100644 index 0000000..208ce9f --- /dev/null +++ b/docs/node-agent-api-0-3-verify/04-desktop.md @@ -0,0 +1,17 @@ +# 04 桌面、应用与剪贴板 + +需求对应:条目 2(同一 bot 同一桌面)、条目 6(整桌面/单应用观看与操作,短期授权、明确接管交还)、条目 7(剪贴板只显式传递)。 + +## 实测 + +- POST desktop {mode:view}:201,display 6(与 GrokNode 分配一致),single_use true,server_enforced true,pauses_agent false。{} 空体默认 view(OpenAPI 标 default:view,文档行为一致)。 +- 票据链路:GET /desktop/open?ticket=(无须 Authorization 头)→ 303 + HttpOnly SameSite=Strict nodeviewer cookie → GET /desktop/vnc.html 200(18KB noVNC 页)→ mandatory.json 200 view_only:true,直连本服务 socket 路径。 +- 票据复用:401 ticket_invalid;过期票据(默认 60s):401;无 cookie 直访 vnc:401。 +- 未持控制租约时 POST clipboard 与 POST applications:403(先查租约后校验体,属显式接管门禁,符合条目 6/7)。 +- GET clipboard:200;GET applications:200,3 个真实窗口。launch 正向有意跳过(会在用户盒子开 GUI),替代证据见 applications 回归测试。 +- POST handback:200 released。POST handoff / resume:200(resume 35s,harness 重连成本)。 +- 代码级:同 bot 单控制租约(desktop_busy 409)、桌面重分配 desktop_changed 409、viewer 15 分钟过期,均与文档描述一致。 + +## verdict + +一致。桌面区分是交互归属(cookie/租约隔离),不是 Linux 安全隔离——与需求 6 的表述完全吻合。 diff --git a/docs/node-agent-api-0-3-verify/05-governance.md b/docs/node-agent-api-0-3-verify/05-governance.md new file mode 100644 index 0000000..6012fb9 --- /dev/null +++ b/docs/node-agent-api-0-3-verify/05-governance.md @@ -0,0 +1,15 @@ +# 05 治理:密钥、用户、配额、webhook + +需求对应:条目 9(多用户、权限、轮换、配额、审计、签名 webhook、trace/usage)。 + +## 实测 + +- POST /v1/keys(sessions.read + 单 bot + 600s):201;rotate:201 新密钥;delete:200;非法 scope / 空体:400。 +- POST /v1/users:201;GET /v1/users:200 列表;disable:200;空名:400。 +- GET /v1/usage:200 四组配额数字随调用推进(回归测试另有并发/zero-quota 覆盖)。 +- POST /v1/webhooks(127.0.0.1:9/hook):201 含 signingKey;内网元数据地址:400(SSRF 拦截);dispatch 无到期:200 空数组;未知 delivery:404;rotate:200。 +- 审计与持久化由 governance 回归测试覆盖(并发准入、SIGKILL 恢复、损坏 fail-closed)。 + +## verdict + +一致。bot 范围限制只控接口访问,不声称文件系统隔离——与需求 9 的边界表述吻合。 diff --git a/docs/node-agent-api-0-3-verify/06-cli.md b/docs/node-agent-api-0-3-verify/06-cli.md new file mode 100644 index 0000000..0be4a5d --- /dev/null +++ b/docs/node-agent-api-0-3-verify/06-cli.md @@ -0,0 +1,17 @@ +# 06 CLI(client-cli.mjs 7 命令) + +需求对应:条目 5(网页与 CLI 同一 bot 同一桌面同一 thread)。 + +## 实测(bot A,--api-origin 指向 18771) + +- status:0,running true,容器 grok-node-local-vm,display 6,workspaceRoot /workspace/codex-projects/node-agent-api,transport stdio。 +- ensure:0,同上(幂等就绪)。 +- desktop --mode view:0,签发 session + ticket + desktopUrl,可在浏览器打开(票据链路见 04)。 +- backup:0,落盘 .lab/shared-runtime/.../backups/,consistency 注明不暂停 GrokNode 进程。 +- resume 缺 --thread:报错退出;未知命令:打印 Usage;status 未知 bot:Bot not found。 +- recreate:未执行。自动审批以“共享盒子重建影响全部 bot”为由拒绝了即使是验证拒绝行为的试运行;替代证据:shared-runtime.mjs:132 直接抛 409 managed_by_grok_node(不碰盒子)+ API recreate 实测 409 两次。 +- codex / resume 交互:未进真机(需人工终端 + 模型成本);参数校验路径已覆盖。 + +## verdict + +一致。CLI 与 API 共用同一映射(bot A → display 6 → 同一共享项目),未另建编号规则。 diff --git a/docs/node-agent-api-0-3-verify/07-mismatch-list.md b/docs/node-agent-api-0-3-verify/07-mismatch-list.md new file mode 100644 index 0000000..4e75888 --- /dev/null +++ b/docs/node-agent-api-0-3-verify/07-mismatch-list.md @@ -0,0 +1,40 @@ +# 07 需求偏差清单(list) + +判定口径:行为与 requirements.md / OpenAPI / 文档描述逐条比对。M1-M3 已修复并复验,保留记录备查。 + +## M1 import 缺 files 报 502,应 400 —— 已修 + +- 曾复现:POST project/import {} → 502 execution_failed。 +- 根因:server.mjs 的 object() 只排斥未知键、不校验必填,files=undefined 直落 Linux 程序抛错,被包成 502。 +- 修复:server.mjs 补 Array.isArray(files) 校验,缺失即 400 files array is required。复验:{} → 400(13ms,未进盒子)。 + +## M2 import 复写同路径报 502,建议 409 —— 已修 + +- 曾复现:同 path 第二次 import → 502。Linux 程序拒绝覆盖是对的,重试语义不对。 +- 修复:runtime/project.mjs 改为收集 conflicts 并返回 {imported:0,conflicts};codex.mjs 映射为 409 import_conflict。复验:复写 → 409;多文件正向 → 200 {imported:2}(含子目录)。 + +## M3 restore 未知 backup_id 报 500,建议 404 —— 已修 + +- 曾复现:POST restore {backup_id:nope} → 500 internal_error(realpath ENOENT 未捕获)。 +- 修复:shared-runtime.mjs restore 对 realpath 与 manifest/archive 缺失均报 404 backup_not_found。复验:nope → 404;正常往返仍 200。 + +## M4 desktop 空体默认 view——已核为一致,不收录 + +- OpenAPI AuthorizeDesktop.mode 有 default:view,{} → view 票据符合契约。ttl 默认 60s 也解释了 CLI 旧票据 401。 + +## M5 会话创建与 submit accept 延迟 11~37s(性能备注,非偏差) + +- createSession 12~33s,submit accept 10~37s,resume 35s,handoff 19s:均为 harness 接线成本,成功语义不受影响。网页 loading 需按此设超时。 + +## 有意未覆盖(理由 + 替代证据) + +1. createAgent 正向:会在 GrokNode 建真 bot;400 校验已验,A/B 真 bot 创建记录在案。 +2. launchApplication 正向:会在用户盒子弹 GUI;403 未持租约路径已验,窗口枚举与租约逻辑由 applications 回归测试覆盖。 +3. SSE 长连接保活:列表/游标/心跳逻辑由回归测试覆盖;实测只到 GET 列表。 +4. CLI recreate 试运行:自动审批拒绝(共享盒子风险);代码级拒绝 + API 409 两次已验。 +5. CLI codex/resume 交互:需人工终端;参数校验已验。 + +## 回归基线 + +- 8 文件 87/87 通过(修复后重跑);service-load 41 项 fixture 在 VERIFICATION.md 有载。 +- M1-M3 修复后已逐条 live 复验(见 03),本清单关闭。 diff --git a/docs/node-agent-api-0-3-verify/08-model-selection.md b/docs/node-agent-api-0-3-verify/08-model-selection.md new file mode 100644 index 0000000..e0b1de0 --- /dev/null +++ b/docs/node-agent-api-0-3-verify/08-model-selection.md @@ -0,0 +1,24 @@ +# 模型选择验收(2026-10-06) + +本轮增加服务/bot/会话三级模型选择。以当前 worktree 与本机 Codex 0.160 导出的 app-server JSON schema 为依据,原生 thread/start、thread/resume、turn/start 均支持 model。 + +| 项目 | 结果与证据 | +| --- | --- | +| 新接口与全部 API 回归 | 91 项通过,0 失败;新 tests/node-agent-models.test.mjs 覆盖 owner 边界、优先级、重启恢复、目录丢失及两个线程模型不串扰 | +| GET /v1/models | 真实 HTTP 200;基线获准,其他目录模型未获准 | +| 服务/bot 默认管理 | 真实浏览器 owner 保存 bot 默认成功;普通委派密钥修改两种设置均 403 | +| POST session 指定模型 | 真实 Mac Harness 返回指定 model 和独立 thread;非法/待验证模型 400,修改已有 session model 400 | +| 原生双模型 thread | 同一 bot 分别 thread/start gpt-6.1-sol 与 gpt-6-luna,返回模型与 thread ID 分别匹配;该检查仅证明参数接线,gpt-6-luna 仍未完成工具链验收、不予授权 | +| 实际推理与续接 | 基线模型简短回复回合 completed;handoff 后通过 API resume 返回原 model | +| 网页 | owner 管理区、获准模型下拉、默认保存、新建指定模型、会话显示均实际操作通过 | +| 普通用户网页 | 短期委派密钥登录后管理区隐藏,下拉仅获准模型;REST 独立验证非 owner 修改设置 403 | + +浏览器截图 API 多次超时,未产生可用截图;实际 DOM 状态、点击后结果与 REST/Harness 证据已记录。LSP 未安装且诊断不可用;使用 Node 语法检查、OpenAPI schema 与回归测试验证,未声称 LSP 通过。 + +## 空默认模型修订 + +根据用户要求,服务默认改为 null,取消 prepareCodex 内的固定模型回退;gpt-6.1-sol 仍可显式选择。新增回归覆盖空默认的持久化、缺少模型时 400 model_required、显式模型创建成功、bot 覆盖与已有会话保持原模型。网页服务默认包含“未设置默认模型”,新会话未选模型且无可继承默认时禁止创建。 + +空默认修订回归:92 项通过,0 失败。原 18770 的管理员 PATCH default_model:null 返回 200;GET /v1/models 返回 null;未指定 model 的新建请求返回 400 model_required。浏览器确认服务默认空选项、未选择时禁止新建、显式选模型后可创建、切回空选项再禁用。该次验收时默认为 null,既有会话保留;之后管理员可自行保存默认。 + +模型真实请求日志与原始 JSON 存在机器本地 .lab/model-live.log、.lab/model-live-results.json,包含临时验收会话,未纳入提交。未声称目录中所有模型已验证,也未承诺 CLI 直接启动绕过 API 后仍受 API 清单约束。 diff --git a/docs/node-agent-api-0-3-verify/09-connection-feedback.md b/docs/node-agent-api-0-3-verify/09-connection-feedback.md new file mode 100644 index 0000000..9fe40bf --- /dev/null +++ b/docs/node-agent-api-0-3-verify/09-connection-feedback.md @@ -0,0 +1,9 @@ +# 网页连接反馈修复(2026-10-07) + +用户报告 18770 点击“连接”没有反应。当前运行服务的 /health、带实际 owner 密钥的 /v1/capabilities、/v1/agents、/v1/models 均为 200;实际浏览器使用裸密钥成功加载 16 个 bot。因此未能证明用户当时具体的密钥或页面加载状态有误,也没有更换密钥。 + +已复现的反馈缺陷:无效测试密钥返回 401,错误只显示在中间栏,密钥框旁没有状态;连接期间也缺少就地进度提示。另有独立回归证明粘贴 Bearer 前缀会生成重复前缀,客户端的只读请求没有有界等待。 + +修复后在密钥框下显示连接进度、成功与中文失败提示,连接按钮显示“连接中…”;允许裸密钥和 Bearer 前缀输入,只发送单个前缀;只读 JSON 请求 30 秒超时且不重试。POST 等写操作和 SSE 不应用该超时,以保持接受状态与任务完成状态的原有区别。不会展示或持久化密钥。 + +真实浏览器验证:无效密钥显示“访问密钥无效或已过期”,带 Bearer 前缀的真实密钥成功,机器人列表 16 条,输入框清空,默认模型仍为空。新增回归覆盖前缀、超时、网络故障和取消。不宣称捕获了用户原页面的控制台错误。 diff --git a/docs/node-agent-api-0-3-verify/10-tokenhub.md b/docs/node-agent-api-0-3-verify/10-tokenhub.md new file mode 100644 index 0000000..c4f83d4 --- /dev/null +++ b/docs/node-agent-api-0-3-verify/10-tokenhub.md @@ -0,0 +1,19 @@ +# TokenHub 外部适配验收(2026-10-07) + +依据:用户要求复制附件 GrokNode TokenHub 账户、API 地址、模型刷新、推理强度能力,并通过 API 地址实际测试;原 GrokNode 不改,默认模型为空。 + +| 验收 | 证据 | +| --- | --- | +| 原实现参考 | source/electron-preload/preload.ts 的 fetchEndpointModels/probeEffortSupport;source/shared/node/openrouter-proxy.ts 的推理档位和 none 策略,仅只读参考 | +| API 实际请求 | node-agent-tokenhub.test.mjs 使用本地真实 HTTP 测试服务,验证带密钥 /models、实际文本测试、连接成功后授权;错误密钥、拒绝参数、空响应均未登记成功 | +| 凭据 | API 不回显;磁盘密文不含 fixture secret;600 文件;不同 URL 不沿用旧密钥;错误消息脱敏 | +| 原生协议 | 真实安装的 opencodex Chat adapter → Responses SSE;函数调用与工具结果往返、随机 relay 鉴权 401 | +| 实际 Mac Harness | 私有固定版本 Codex 0.160.0 + 原 GrokNode 共享 Linux 执行端 + 本地模拟 Chat 上游;指定模型、密钥、low 档位确实到所选端点,turn completed | +| 网页 | 真实浏览器保存端点状态读取、模型拉取、支持档位、测试连接、授权当前模型均实际操作;连接与工具链状态分别标记 | +| 全部回归 | 99 项通过,0 失败,涵盖原模型、桌面、用户、配额、webhook 与新 TokenHub 测试 | +| 原服务更新 | 18770 当前地址为原 opencodex 127.0.0.1:10100/v1、协议 Responses、未保存第三方密钥;原 8 会话保留,服务默认仍 null | +| 原网页实际拉取 | 原 18770 从 opencodex 取得 54 个模型;测试下拉中禁用模型数为 0,gpt-6-luna 可选进行测试、未通过测试前不可授权;没有发送真实第三方推理 | + +当前全局 Codex 在验收时已升为 0.160.1,固定版本检查阻止了初次启动;在项目 .lab 内单独安装 0.160.0 后验收通过,不改全局二进制。私有测试凭据与模拟端点不部署进原 18770 服务。 + +本轮没有用户提供的第三方模型 API 密钥,因此没有声称 Z.ai 或截图中其他远端模型服务已实测通过。测试脚本与原始请求观察保存于 .lab/tokenhub;返回图文、多媒体、平台托管工具与所有第三方模型能力仍须分别验证。 diff --git a/docs/node-agent-api-0-3-verify/11-desktop-control.md b/docs/node-agent-api-0-3-verify/11-desktop-control.md new file mode 100644 index 0000000..5d031b5 --- /dev/null +++ b/docs/node-agent-api-0-3-verify/11-desktop-control.md @@ -0,0 +1,11 @@ +# 桌面重复接管修复(2026-10-07) + +用户截图显示接管按钮返回 HTTP 409 desktop_busy。旧 API 对任何现有控制租约都拒绝新的接管票据,包括使用同一密钥的用户自己重复接管;切换观看时也未明确移交该密钥的控制。因此用户可能看着观看窗口但仍存在同密钥控制租约。 + +新增显式参数 replace_own_control(默认 false)。只有同一 credential 的活动租约可替换;签发票据时绑定旧租约 ID,新查看器真正打开时才撤销旧授权。不同密钥保持 409,即使调用者是 owner 也不自动抢占另一密钥。并发旧票据不能替换新建立的控制租约。 + +网页“接管”明确发送 replace_own_control:true,可重新建立自己的控制窗口;整桌面“观看”同样明确交还自己的控制。独立应用接管替换同密钥旧控制,但独立观看不撤销已有控制。发生其他使用者冲突时在桌面区显示中文提示,不再只在聊天头部放英文错误。 + +复现回归先红:同 credential 替换请求被原请求字段校验拒绝;修复后通过。覆盖旧 cookie 失效、view_only、其他密钥不可抢占与并发票据竞态。默认独占租约行为测试保持不变。全部 API 回归 101 通过,0 失败。 + +真实 Linux 桌面验收还发现 UI 竞态:重复接管未完成票据兑换就切换观看,旧控制 ID 的保护导致 iframe 收到 409。修复在查看器加载期间串行限制整桌面切换,并读取授权错误做中文提示。真实浏览器在原 GrokNode 测试 bot A 的 display 6 上连续“接管 → 再接管 → 观看 → 再接管”,每次查看器均为 noVNC_connected;最后手动交还,画面容器清空。没有执行桌面输入或模型任务。截图存在本机 .lab/desktop-replace.png。 diff --git a/docs/node-agent-api-0-3-verify/12-handback-view.md b/docs/node-agent-api-0-3-verify/12-handback-view.md new file mode 100644 index 0000000..ef2bb92 --- /dev/null +++ b/docs/node-agent-api-0-3-verify/12-handback-view.md @@ -0,0 +1,13 @@ +# 交还控制后继续观看(2026-10-07) + +用户要求点击“交还控制”后进入观看状态,而不是空黑区域。旧网页交还成功后调用 desktop.replaceChildren() 删除 iframe,真实浏览器复现 framePresent:false 与“可重新点击观看”。 + +修复:复用串行桌面切换函数,先调用原 POST handback 释放控制,再打开新 view 授权和查看器。交还中禁用桌面切换按钮,结束后显示“控制已交还,当前为只读观看”;不删除并留下空容器。若观看重连失败,明确说明控制已经交还并允许手动观看重试,不恢复控制权限。会话切换/取消保留原 AbortError 行为。 + +真实浏览器在测试 bot A 的 display 6 上完成接管→交还,iframe 仍存在,标题“机器人桌面 · 观看”,noVNC_connected,canvas 1280×800。实际 GET /desktop/mandatory.json 返回 view_only:true。服务器的只读键盘、指针、剪贴板过滤既有回归保持绿色。没有输入桌面应用或执行模型任务。 + +全部 API 回归 101 通过,0 失败。更新真实 Chromium 契约场景,要求交还后 iframe 为观看且发出 handback 和 view 请求;该场景默认是 opt-in,不能把默认回归数量当成浏览器自动化执行的证据,本轮浏览器证据来自真实手动自动化操作。 + +原 18770 验收发现额外的查看器加载超时:Codex 适配器为每个 noVNC JS 模块重复 runtime.status,涉及 bot/桌面查询,资源单项约 2.8 秒。共享运行时改为按容器 ID 缓存静态资源与并发请求,容器 ID 改变失效;每次仍检查容器可用,viewer cookie、归属和控制租约检查保持原逻辑。错误读取会移除缓存以便下次重试,路径越界仍 404。 + +新增 node-agent-viewer-assets.test.mjs 覆盖缓存并发合并、容器换代、失败重试与安全路径;全部 API 回归现为 102 通过。原 18770 从接管到交还实测:首次接管约 15.2 秒完成连接,交还后 iframe 标题为“机器人桌面 · 观看”,noVNC_connected,mandatory.view_only:true,画面继续显示。主服务保留原状态;未重启共享盒子。 diff --git a/docs/node-agent-api-0-3-verify/13-application-title-utf8.md b/docs/node-agent-api-0-3-verify/13-application-title-utf8.md new file mode 100644 index 0000000..e347bb0 --- /dev/null +++ b/docs/node-agent-api-0-3-verify/13-application-title-utf8.md @@ -0,0 +1,11 @@ +# 应用窗口中文标题(2026-10-07) + +截图中的 Chrome 页面中文正常,但应用列表显示乱码。真实 GET applications 也返回相同乱码,问题发生在 API 读取 X11 窗口标题时。 + +低权限命令使用 env -i 清理环境,未传递 UTF-8 locale。C locale 下,xprop 把 UTF8_STRING 输出为八进制字节转义;旧解析器把每个字节独立转成 Unicode 字符,生成 mojibake。对同一窗口 0x200004 的只读探测证实:未设置 locale 时输出字节转义;LC_ALL=C.UTF-8 时直接输出“百度一下,你就知道 - Google Chrome”。 + +修复在应用枚举命令中指定 LC_ALL=C.UTF-8,并在共享运行时的 Linux 命令中显式传递这个 locale。没有把 Mac 的整个环境传入 Linux。解析器按属性类型处理字节转义:UTF8_STRING 连续字节按 UTF-8 解码,旧式 STRING 保留 Latin-1 语义;原有 Unicode、引号和字面反斜杠保持正确。 + +新增三项回归覆盖真实中文转义、Emoji、字面转义、旧式 STRING 和应用枚举。修复前中文用例失败,修复后通过。全部 Node Agent API 回归 105 通过,0 失败;共享运行时和原 18770 真实网页均读取到正确标题。网页验证使用已有“全栈工程师”会话,点击刷新应用并选择 Chrome 窗口,没有发送模型任务或桌面输入。 + +原 API 状态已备份,确认没有运行中的回合后优雅重启 API,保留原端口、密钥、运行时目录和会话。没有修改或重启 GrokNode 程序及共享容器。用户在现有页面点击“刷新应用”即可重新获取标题;刷新整个页面后需重新输入访问密钥。 diff --git a/tests/node-agent-api-web-ui-codex-contract.test.mjs b/tests/node-agent-api-web-ui-codex-contract.test.mjs index d790520..a760fd5 100644 --- a/tests/node-agent-api-web-ui-codex-contract.test.mjs +++ b/tests/node-agent-api-web-ui-codex-contract.test.mjs @@ -13,6 +13,33 @@ 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('connection accepts pasted Bearer prefix and never sends the prefix twice', async () => { + let authorization; + const client = createClient(' Bearer contract-test-key\n', 'http://localhost', async (url, options) => { + authorization = options.headers.Authorization; + return Response.json({ writes_enabled: false }); + }); + await client.json('/v1/capabilities'); + assert.equal(authorization, 'Bearer contract-test-key'); +}); + +test('connection read request times out visibly rather than waiting forever', async () => { + const client = createClient('test-key', 'http://localhost', async (url, options) => new Promise((resolve, reject) => { + options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true }); + })); + await assert.rejects(client.json('/v1/capabilities', { timeoutMs: 20 }), /超时/); +}); + +test('connection failures and cancellation remain explicit without automatic retries', async () => { + let calls = 0; + const offline = createClient('test-key', 'http://localhost', async () => { calls++; throw new TypeError('Failed to fetch'); }); + await assert.rejects(offline.json('/v1/capabilities'), /无法连接本机 API/); + assert.equal(calls, 1); + const controller = new AbortController(); controller.abort(); + const cancelled = createClient('test-key', 'http://localhost', async (url, options) => { throw options.signal.reason; }); + await assert.rejects(cancelled.json('/v1/capabilities', { signal: controller.signal }), { name: 'AbortError' }); +}); + 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) => { @@ -245,8 +272,10 @@ if (process.env.NODE_AGENT_UI_BROWSER === '1') test('real Chromium UI contract: 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'); + assert.equal(requests.filter(row => row.path.endsWith('/desktop')).at(-1).body.replace_own_control, true); 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(requests.filter(row => row.path.endsWith('/desktop')).at(-1).body.replace_own_control, true); 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 === '测试剪贴板内容'"); @@ -254,7 +283,11 @@ if (process.env.NODE_AGENT_UI_BROWSER === '1') test('real Chromium UI contract: 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"); + await click('#handback'); + await waitFor("document.querySelector('#desktop iframe')?.title === '机器人桌面 · 观看' && !document.getElementById('handback').disabled"); + assert.equal(requests.filter(row => row.path.endsWith('/desktop')).at(-1).body.mode, 'view'); + assert.equal(requests.some(row => row.path.endsWith('/handback') && row.method === 'POST'), true); + assert.match(await evaluate("document.getElementById('desktop-status').textContent"), /控制已交还/); 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('已断开')"); diff --git a/tests/node-agent-api.test.mjs b/tests/node-agent-api.test.mjs index 2bfb59c..91ef97f 100644 --- a/tests/node-agent-api.test.mjs +++ b/tests/node-agent-api.test.mjs @@ -276,6 +276,43 @@ test('control viewers can deliver input and hold a single exclusive lease', asyn assert.equal(released.data.released, true); }); +test('explicit own-control replacement recovers a viewer without stealing another credential lease', async () => { + const route = '/v1/agents/sessions/' + sessions.a.id + '/desktop'; + async function open(input, auth = authorization) { + const result = await call('POST', route, { headers: { authorization: auth }, body: input }); + assert.equal(result.status, 201); + const opened = await call('GET', new URL(result.data.url).pathname + new URL(result.data.url).search, { raw: true }); + assert.equal(opened.status, 303); + return /nodeviewer=([A-Za-z0-9_-]+)/.exec(opened.headers.get('set-cookie'))[1]; + } + try { + const old = await open({ mode: 'control' }); + const peer = await call('POST', '/v1/keys', { headers: { authorization }, body: { scopes: ['sessions.read', 'desktop.view', 'desktop.control'], bot_ids: ['bot-a'], ttl_seconds: 300 } }); + const refused = await call('POST', route, { headers: { authorization: 'Bearer ' + peer.data.key }, body: { mode: 'control', replace_own_control: true } }); + assert.equal(refused.status, 409); + const current = await open({ mode: 'control', replace_own_control: true }); + assert.equal((await call('GET', '/desktop/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + old } })).status, 401); + const settings = await (await call('GET', '/desktop/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + current } })).json(); + assert.equal(settings.view_only, false); + const view = await open({ mode: 'view', replace_own_control: true }); + assert.equal((await call('GET', '/desktop/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + current } })).status, 401); + assert.equal((await (await call('GET', '/desktop/mandatory.json', { raw: true, headers: { cookie: 'nodeviewer=' + view } })).json()).view_only, true); + await open({ mode: 'control' }); + } finally { await call('POST', '/v1/agents/sessions/' + sessions.a.id + '/handback', { headers: { authorization }, body: {} }); } +}); + +test('pending replacement tickets cannot evict a newer controller after a race', async () => { + const route = '/v1/agents/sessions/' + sessions.a.id + '/desktop'; + async function issue() { const r = await call('POST', route, { headers: { authorization }, body: { mode: 'control', replace_own_control: true } }); assert.equal(r.status, 201); return new URL(r.data.url); } + async function open(url) { return call('GET', url.pathname + url.search, { raw: true }); } + try { + const initial = await issue(); assert.equal((await open(initial)).status, 303); + const a = await issue(), b = await issue(); + assert.equal((await open(a)).status, 303); + assert.equal((await open(b)).status, 409); + } finally { await call('POST', '/v1/agents/sessions/' + sessions.a.id + '/handback', { headers: { authorization }, body: {} }); } +}); + 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]; diff --git a/tests/node-agent-applications.test.mjs b/tests/node-agent-applications.test.mjs index 0da2ecb..0453a4c 100644 --- a/tests/node-agent-applications.test.mjs +++ b/tests/node-agent-applications.test.mjs @@ -3,7 +3,31 @@ 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'; +import { createApplicationPool, listApplications, parseWindowName, parseWindowTree } from '../tools/node-agent-api/runtime/applications.mjs'; + +test('UTF8_STRING window titles decode octal bytes as UTF-8', () => { + const output = String.raw`_NET_WM_NAME(UTF8_STRING) = "\347\231\276\345\272\246\344\270\200\344\270\213\357\274\214\344\275\240\345\260\261\347\237\245\351\201\223 - Google Chrome"`; + assert.equal(parseWindowName(output), '百度一下,你就知道 - Google Chrome'); + assert.equal(parseWindowName(String.raw`WM_NAME(UTF8_STRING) = "Test \360\237\232\200"`), 'Test 🚀'); +}); + +test('window title decoding preserves Unicode, escaped literals and legacy STRING bytes', () => { + assert.equal(parseWindowName(String.raw`_NET_WM_NAME(UTF8_STRING) = "中文 \"title\" \\347"`), '中文 "title" \\347'); + assert.equal(parseWindowName(String.raw`WM_NAME(STRING) = "Caf\351"`), 'Café'); +}); + +test('application discovery uses a UTF-8 locale and returns decoded Chinese titles', async () => { + const applications = await listApplications({ display: ':5', sanitize: true, execFileImpl: (file, args, options, callback) => { + assert.equal(options.env.DISPLAY, ':5'); + assert.equal(options.env.LC_ALL, 'C.UTF-8'); + assert.equal(file, 'xprop'); + const stdout = args[0] === '-root' + ? '_NET_CLIENT_LIST(WINDOW): window id # 0x200004' + : String.raw`_NET_WM_NAME(UTF8_STRING) = "\344\270\255\346\226\207 - Google Chrome"`; + queueMicrotask(() => callback(null, stdout, '')); + } }); + assert.deepEqual(applications, [{ object: 'desktop.application', application_id: '0x200004', name: '中文 - Google Chrome' }]); +}); 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'; diff --git a/tests/node-agent-models.test.mjs b/tests/node-agent-models.test.mjs new file mode 100644 index 0000000..e3044ab --- /dev/null +++ b/tests/node-agent-models.test.mjs @@ -0,0 +1,132 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, realpath, writeFile, readFile, unlink } 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'; + +async function fixture({ returnedModel, defaultModel = 'gpt-6.1-sol' } = {}) { + const dir = await realpath(await mkdtemp(join(tmpdir(), 'node-models-'))), catalogFile = join(dir, 'catalog.json'); + await writeFile(catalogFile, JSON.stringify({ models: [{ slug: 'gpt-6.1-sol' }, { slug: 'model-b' }, { slug: 'unverified' }] })); + const calls = [], events = new EventEmitter(), threads = new Map(); let seq = 0; + const harness = { + redact: value => value, subscribe(fn) { events.on('event', fn); return () => events.off('event', fn); }, + async request(bot, method, params) { + const safe = rpcParams(method, params); calls.push({ bot, method, params: safe }); + if (method === 'thread/start') { const id = 'thread_' + ++seq; threads.set(id, safe.model); return { thread: { id }, model: returnedModel ?? safe.model }; } + if (method === 'thread/resume') return { model: threads.get(safe.threadId) }; + if (method === 'thread/read') return { thread: { turns: [] } }; + if (method === 'turn/start') return { turn: { id: 'turn_' + ++seq, status: 'completed' } }; + return {}; + }, + async disconnect() {}, async close() {}, + }; + const roster = { async requireAgent(id) { if (!['bot-a', 'bot-b'].includes(id)) throw Object.assign(new Error('missing'), { status: 404 }); return { id }; }, async agents() { return [{ id: 'bot-a' }, { id: 'bot-b' }]; } }; + const runtime = { shared: true, async ensure() {}, async status() { return { containerId: 'shared' }; }, async close() {} }; + const createAdapter = () => createCodexAdapter({ runtime, harness, roster, namespace: 'grok-node-lab-test', stateRoot: dir }); + const adapter = createAdapter(); + const stateDirectory = join(dir, 'api'); + let service = await startNodeAgentApi({ adapter, stateDirectory, modelOptions: { catalogFile, defaultModel } }); + const owner = (await readFile(service.keyFile, 'utf8')).trim(); + async function call(method, route, data, key = owner) { + const r = await fetch(service.origin + route, { method, headers: { authorization: 'Bearer ' + key, 'content-type': 'application/json' }, body: data === undefined ? undefined : JSON.stringify(data) }); + return { status: r.status, body: await r.json() }; + } + return { dir, calls, catalogFile, call, async close() { await service.close(); }, async restart() { await service.close(); service = await startNodeAgentApi({ adapter: createAdapter(), stateDirectory, modelOptions: { catalogFile, defaultModel } }); } }; +} +const approval = { allowed_models: ['gpt-6.1-sol', 'model-b'], verified_models: [{ id: 'gpt-6.1-sol', evidence: 'fixture baseline' }, { id: 'model-b', evidence: 'fixture native tools and cancellation verified' }] }; + +test('owner alone manages defaults and approval requires tool-workflow evidence', async () => { + const f = await fixture(); + try { + const issued = await f.call('POST', '/v1/keys', { scopes: ['agents.read', 'agents.write', 'sessions.read', 'sessions.write', 'keys.manage'], bot_ids: ['bot-a'], ttl_seconds: 600 }); + const key = issued.body.key; + assert.equal((await f.call('PATCH', '/v1/settings/models', { default_model: 'gpt-6.1-sol' }, key)).status, 403); + assert.equal((await f.call('PATCH', '/v1/agents/bot-a/model', { default_model: 'gpt-6.1-sol' }, key)).status, 403); + assert.equal((await f.call('GET', '/v1/models?agent_id=bot-b', undefined, key)).status, 403); + assert.equal((await f.call('PATCH', '/v1/settings/models', { allowed_models: ['unverified'] })).status, 400); + assert.equal((await f.call('PATCH', '/v1/settings/models', { default_model: 123 })).status, 400); + assert.equal((await f.call('PATCH', '/v1/settings/models', { bot_defaults: {} })).status, 400); + assert.equal((await f.call('PATCH', '/v1/settings/models', { verified_models: [{ id: 'model-b', evidence: '' }] })).status, 400); + assert.equal((await f.call('POST', '/v1/agents/sessions', { agent_id: 'bot-a', model: 'unverified' }, key)).status, 400); + assert.equal((await f.call('POST', '/v1/agents/sessions', { agent_id: 'bot-a', model: 'absent' }, key)).status, 400); + assert.equal((await f.call('POST', '/v1/agents/sessions', { agent_id: 'bot-a', model: null }, key)).status, 400); + assert.equal(f.calls.length, 0); + assert.equal((await f.call('PATCH', '/v1/settings/models', approval)).status, 200); + const selected = await f.call('POST', '/v1/agents/sessions', { agent_id: 'bot-a', model: 'model-b' }, key); + assert.equal(selected.status, 201); assert.equal(selected.body.model, 'model-b'); + } finally { await f.close(); } +}); + +test('session override wins over bot then service default and two threads keep their models after defaults change and restart', async () => { + const f = await fixture(); + try { + await f.call('PATCH', '/v1/settings/models', approval); + const service = await f.call('POST', '/v1/agents/sessions', { agent_id: 'bot-a' }); + assert.equal(service.body.model_source, 'service_default'); + await f.call('PATCH', '/v1/agents/bot-a/model', { default_model: 'model-b' }); + const bot = await f.call('POST', '/v1/agents/sessions', { agent_id: 'bot-a' }); + const override = await f.call('POST', '/v1/agents/sessions', { agent_id: 'bot-a', model: 'gpt-6.1-sol' }); + assert.equal(bot.body.model_source, 'bot_default'); assert.equal(bot.body.model, 'model-b'); + assert.equal(override.body.model_source, 'session'); assert.equal(override.body.model, 'gpt-6.1-sol'); + assert.notEqual(bot.body.thread_id, override.body.thread_id); + await f.call('PATCH', '/v1/settings/models', { default_model: 'model-b' }); + assert.equal((await f.call('PATCH', '/v1/agents/sessions/' + bot.body.id, { model: 'gpt-6.1-sol' })).status, 400); + await f.restart(); + assert.equal((await f.call('GET', '/v1/agents/sessions/' + service.body.id)).body.model, 'gpt-6.1-sol'); + assert.equal((await f.call('GET', '/v1/agents/bot-a/model')).body.default_model, 'model-b'); + for (const s of [bot.body, override.body]) { + assert.equal((await f.call('POST', '/v1/agents/sessions/' + s.id + '/events', { events: [{ type: 'message', text: 'probe' }] })).status, 202); + assert.equal(f.calls.findLast(c => c.method === 'thread/resume').params.model, s.model); + assert.equal(f.calls.findLast(c => c.method === 'turn/start').params.model, s.model); + } + } finally { await f.close(); } +}); + +test('missing catalog rejects new sessions before side effects and existing pinned threads continue', async () => { + const f = await fixture(); + try { + const session = (await f.call('POST', '/v1/agents/sessions', { agent_id: 'bot-a' })).body; + await unlink(f.catalogFile); const count = f.calls.length; + const result = await f.call('POST', '/v1/agents/sessions', { agent_id: 'bot-a' }); + assert.equal(result.status, 503); assert.equal(result.body.error.type, 'model_catalog_unavailable'); assert.equal(f.calls.length, count); + assert.equal((await f.call('GET', '/v1/models')).status, 503); + assert.equal((await f.call('GET', '/v1/agents/sessions/' + session.id)).body.model, 'gpt-6.1-sol'); + assert.equal((await f.call('POST', '/v1/agents/sessions/' + session.id + '/events', { events: [{ type: 'message', text: 'continue' }] })).status, 202); + assert.equal(f.calls.at(-1).params.model, 'gpt-6.1-sol'); + } finally { await f.close(); } +}); + +test('native Harness model mismatch is refused and no inference turn is submitted', async () => { + const f = await fixture({ returnedModel: 'unexpected-model' }); + try { + const result = await f.call('POST', '/v1/agents/sessions', { agent_id: 'bot-a', model: 'gpt-6.1-sol' }); + assert.equal(result.status, 502); assert.equal(result.body.error.type, 'model_mismatch'); + assert.equal(f.calls.some(c => c.method === 'turn/start'), false); + } finally { await f.close(); } +}); + +test('empty service default persists and requires explicit model while existing sessions and bot overrides keep working', async () => { + const f = await fixture({ defaultModel: null }); + try { + assert.equal((await f.call('GET', '/v1/settings/models')).body.default_model, null); + assert.equal((await f.call('GET', '/v1/models?agent_id=bot-a')).body.default_model, null); + const rejected = await f.call('POST', '/v1/agents/sessions', { agent_id: 'bot-a' }); + assert.equal(rejected.status, 400); assert.equal(rejected.body.error.type, 'model_required'); assert.equal(f.calls.length, 0); + const session = await f.call('POST', '/v1/agents/sessions', { agent_id: 'bot-a', model: 'gpt-6.1-sol' }); + assert.equal(session.status, 201); + await f.call('PATCH', '/v1/settings/models', { default_model: 'gpt-6.1-sol' }); + assert.equal((await f.call('PATCH', '/v1/settings/models', { default_model: '' })).body.default_model, null); + await f.restart(); + assert.equal((await f.call('GET', '/v1/settings/models')).body.default_model, null); + assert.equal((await f.call('GET', '/v1/agents/sessions/' + session.body.id)).body.model, 'gpt-6.1-sol'); + await f.call('PATCH', '/v1/agents/bot-a/model', { default_model: 'gpt-6.1-sol' }); + assert.equal((await f.call('POST', '/v1/agents/sessions', { agent_id: 'bot-a' })).body.model_source, 'bot_default'); + await f.call('PATCH', '/v1/agents/bot-a/model', { default_model: null }); + assert.equal((await f.call('GET', '/v1/agents/bot-a/model')).body.effective_model, null); + assert.equal((await f.call('POST', '/v1/agents/sessions', { agent_id: 'bot-a' })).body.error.type, 'model_required'); + } finally { await f.close(); } +}); diff --git a/tests/node-agent-tokenhub.test.mjs b/tests/node-agent-tokenhub.test.mjs new file mode 100644 index 0000000..a52995f --- /dev/null +++ b/tests/node-agent-tokenhub.test.mjs @@ -0,0 +1,120 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { mkdtemp, realpath, readFile, writeFile, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createTokenHub, endpointUrl } from '../tools/node-agent-api/tokenhub.mjs'; +import { createModelPolicy } from '../tools/node-agent-api/models.mjs'; +import { startNodeAgentApi } from '../tools/node-agent-api/server.mjs'; + +async function fixture() { + const dir = await realpath(await mkdtemp(join(tmpdir(), 'tokenhub-'))), requests = []; + const server = createServer(async (req, res) => { + let raw = ''; for await (const data of req) raw += data; + const body = raw ? JSON.parse(raw) : null; + requests.push({ path: req.url, auth: req.headers.authorization, body }); + const json = (status, value) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(value)); }; + if (req.headers.authorization !== 'Bearer fixture-secret') return json(401, { error: { message: 'fixture-secret must never be echoed' } }); + if (req.url === '/v1/models') return json(200, { data: [{ id: 'glm-test', supported_reasoning_levels: [{ effort: 'low' }, { effort: 'high' }] }] }); + if (body.reasoning_effort === 'ultra') return json(400, { error: { message: 'unsupported' } }); + if (body.stream) { + res.writeHead(200, { 'content-type': 'text/event-stream' }); + const calls = body.tools?.length && !body.messages.some(m => m.role === 'tool'); + const name = body.tools?.[0]?.function?.name; + const delta = calls ? { tool_calls: [{ index: 0, id: 'call_fixture', type: 'function', function: { name, arguments: '{"command":"echo hi"}' } }] } : { content: 'OK fixture reply' }; + res.write('data: ' + JSON.stringify({ choices: [{ index: 0, delta, finish_reason: null }] }) + '\n\n'); + res.write('data: ' + JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: calls ? 'tool_calls' : 'stop' }], usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 } }) + '\n\n'); res.end('data: [DONE]\n\n'); return; + } + return json(200, { id: 'chat_fixture', choices: [{ index: 0, message: { role: 'assistant', content: 'OK' }, finish_reason: 'stop' }], usage: { prompt_tokens: 2, completion_tokens: 1, total_tokens: 3 } }); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const base_url = `http://127.0.0.1:${server.address().port}/v1`; + const hub = await createTokenHub(dir); + return { dir, requests, base_url, hub, async close() { await hub.close(); server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); } }; +} + +test('TokenHub encrypts secrets, discovers remote models, tests actual requests and invalidates evidence on URL/key revisions', async () => { + const f = await fixture(); + try { + const changed = await f.hub.update({ base_url: f.base_url, api_key: 'fixture-secret', wire_api: 'chat' }); + assert.equal(changed.key_saved, true); assert.doesNotMatch(JSON.stringify(changed), /fixture-secret/); + assert.doesNotMatch(await readFile(join(f.dir, 'tokenhub.json'), 'utf8'), /fixture-secret/); + assert.equal((await stat(join(f.dir, 'tokenhub.json'))).mode & 0o777, 0o600); + const rows = await f.hub.refresh(); assert.equal(rows.data[0].id, 'glm-test'); + const policy = await createModelPolicy(f.dir, { tokenhub: f.hub }); + await assert.rejects(policy.update({ allowed_models: ['glm-test'] }), /Test this model/); + const result = await f.hub.test({ model: 'glm-test', reasoning_effort: 'low' }); + assert.equal(result.verification_level, 'connection'); + await policy.update({ allowed_models: ['glm-test'] }); + const chosen = await policy.resolve('bot-a', 'glm-test', 'low'); assert.equal(chosen.endpoint_revision, changed.revision); + assert.equal((await policy.list('bot-a')).data[0].verification_level, 'connection'); + await assert.rejects(f.hub.test({ model: 'glm-test', reasoning_effort: 'ultra' }), error => error.code === 'model_endpoint_rejected'); + await f.hub.update({ api_key: 'new-secret' }); + assert.equal((await f.hub.catalog()).tests.length, 0); + assert.equal(f.requests[1].body.reasoning_effort, 'low'); + assert.equal(f.hub.redact(new Error('new-secret fixture-secret')), '[redacted] [redacted]'); + await f.hub.update({ base_url: 'https://example.com/v1' }); assert.equal((await f.hub.settings()).key_saved, false); + for (const bad of ['http://remote.example/v1', 'https://user:password@example.com', 'https://example.com?api_key=secret', 'http://169.254.169.254/']) assert.throws(() => endpointUrl(bad)); + } finally { await f.close(); } +}); + +test('installed opencodex relay carries actual streaming function calls and tool results without exposing upstream key to client', async () => { + const f = await fixture(); + try { + const configured = await f.hub.update({ base_url: f.base_url, api_key: 'fixture-secret', wire_api: 'chat' }); + const relay = await f.hub.connection(configured.revision, 'low'); + assert.ok(relay.baseUrl.startsWith('http://127.0.0.1:')); assert.notEqual(relay.token, 'fixture-secret'); + const tools = [{ type: 'function', name: 'probe_shell', parameters: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] } }]; + async function send(input) { + const r = await fetch(relay.baseUrl + '/responses', { method: 'POST', headers: { authorization: 'Bearer ' + relay.token, 'content-type': 'application/json' }, body: JSON.stringify({ model: 'glm-test', stream: true, input, tools }) }); + assert.equal(r.status, 200); return r.text(); + } + const first = await send([{ role: 'user', content: 'Run a tool' }]); + assert.match(first, /response.completed/); assert.match(first, /function_call/); assert.match(first, /probe_shell/); + assert.doesNotMatch(first, /fixture-secret/); + const second = await send([{ role: 'user', content: 'Run a tool' }, { type: 'function_call', call_id: 'call_fixture', name: 'probe_shell', arguments: '{"command":"echo hi"}' }, { type: 'function_call_output', call_id: 'call_fixture', output: 'hi' }]); + assert.match(second, /OK fixture reply/); assert.match(second, /response.completed/); + assert.equal(f.requests.at(-1).body.messages.at(-1).role, 'tool'); + assert.equal(f.requests.at(-1).body.reasoning_effort, 'low'); + const unauthorized = await fetch(relay.baseUrl + '/responses', { method: 'POST', body: '{}' }); assert.equal(unauthorized.status, 401); + } finally { await f.close(); } +}); + +test('failed or empty model tests never grant verification and disable-thinking only uses known model contracts', async () => { + const f = await fixture(); + try { + await f.hub.update({ base_url: f.base_url, api_key: 'wrong-key', wire_api: 'chat' }); + await assert.rejects(f.hub.refresh(), error => error.code === 'model_endpoint_rejected'); + assert.equal((await f.hub.catalog()).models.length, 0); + await f.hub.update({ api_key: 'fixture-secret' }); await f.hub.refresh(); + await assert.rejects(f.hub.test({ model: 'glm-test', reasoning_effort: 'ultra' }), error => error.code === 'model_endpoint_rejected'); + await assert.rejects(f.hub.test({ model: 'glm-test', reasoning_effort: 'none' }), /known disable-thinking/); + assert.equal((await f.hub.catalog()).tests.length, 0); + const empty = await createTokenHub(join(f.dir, 'empty'), { fetcher: async (url) => Response.json(url.endsWith('/models') ? { data: [{ id: 'empty-model' }] } : { choices: [{ message: { content: '' } }] }) }); + try { + await empty.update({ base_url: f.base_url, api_key: 'fixture-secret', wire_api: 'chat' }); await empty.refresh(); + await assert.rejects(empty.test({ model: 'empty-model' }), error => error.code === 'model_test_incomplete'); + assert.equal((await empty.catalog()).tests.length, 0); + } finally { await empty.close(); } + } finally { await f.close(); } +}); + +test('TokenHub REST owner-only configuration never returns API key and performs real list/test before approval', async () => { + const f = await fixture(); let api; + try { + const catalog = join(f.dir, 'catalog.json'); await writeFile(catalog, JSON.stringify({ models: [{ slug: 'gpt-6.1-sol' }] })); + api = await startNodeAgentApi({ stateDirectory: join(f.dir, 'api'), adapter: { independentSessions: true, writesEnabled: true, async close() {} }, modelOptions: { catalogFile: catalog } }); + const owner = (await readFile(api.keyFile, 'utf8')).trim(); + async function call(method, path, body, token = owner) { const r = await fetch(api.origin + path, { method, headers: { authorization: 'Bearer ' + token, 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) }); return { status: r.status, data: await r.json() }; } + const key = await call('POST', '/v1/keys', { scopes: ['agents.read','sessions.read'], bot_ids: ['*'], ttl_seconds: 300 }); + assert.equal((await call('PATCH', '/v1/settings/tokenhub', { base_url: f.base_url }, key.data.key)).status, 403); + const saved = await call('PATCH', '/v1/settings/tokenhub', { base_url: f.base_url, api_key: 'fixture-secret', wire_api: 'chat' }); + assert.equal(saved.status, 200); assert.doesNotMatch(JSON.stringify(saved.data), /fixture-secret/); + assert.equal((await call('POST', '/v1/settings/tokenhub/models', {})).status, 200); + assert.equal((await call('POST', '/v1/settings/tokenhub/test', { model: 'glm-test' })).status, 200); + assert.equal((await call('PATCH', '/v1/settings/models', { allowed_models: ['glm-test'] })).status, 200); + assert.equal((await call('GET', '/v1/models')).data.data[0].approved, true); + assert.equal((await call('GET', '/v1/settings/models')).data.default_model, null); + } finally { await api?.close(); await f.close(); } +}); diff --git a/tests/node-agent-viewer-assets.test.mjs b/tests/node-agent-viewer-assets.test.mjs new file mode 100644 index 0000000..681d236 --- /dev/null +++ b/tests/node-agent-viewer-assets.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, realpath } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createSharedRuntime } from '../tools/node-agent-api/shared-runtime.mjs'; + +test('viewer assets reuse bytes within the container generation without querying bot desktop per module', async () => { + const stateRoot = await realpath(await mkdtemp(join(tmpdir(), 'viewer-assets-'))); + let generation = 'container-a', reads = 0, failed = true; + const runtime = await createSharedRuntime({ stateRoot, execute: async (file, args) => { + if (args[0] === 'inspect') return { stdout: JSON.stringify([{ Id: generation, Name: '/grok-node-local-vm', State: { Running: true } }]) }; + assert.deepEqual(args.slice(0, 3), ['exec', generation, 'cat']); + reads++; + if (args[3].endsWith('missing.js') && failed) { failed = false; throw new Error('fixture missing'); } + return { stdout: Buffer.from(generation + ':asset') }; + } }); + try { + const bytes = await Promise.all([runtime.viewerAsset('app/ui.js'), runtime.viewerAsset('app/ui.js')]); + assert.equal(reads, 1); assert.equal(bytes[0].toString(), 'container-a:asset'); + await runtime.viewerAsset('app/ui.js'); assert.equal(reads, 1); + generation = 'container-b'; + assert.equal((await runtime.viewerAsset('app/ui.js')).toString(), 'container-b:asset'); assert.equal(reads, 2); + await assert.rejects(runtime.viewerAsset('core/missing.js')); + await runtime.viewerAsset('core/missing.js'); assert.equal(reads, 4); + await assert.rejects(runtime.viewerAsset('core/../../secret.js'), error => error.status === 404); + assert.equal(reads, 4); + } finally { await runtime.close(); } +}); diff --git a/tools/node-agent-api/README.md b/tools/node-agent-api/README.md index 6a9bc6a..96c4d42 100644 --- a/tools/node-agent-api/README.md +++ b/tools/node-agent-api/README.md @@ -1,5 +1,11 @@ # Node Agent API 0.3.0 +更新:2026-10-07。本分支同步发布 0.3.0 的模型/TokenHub、桌面修复、回归用例、文档与 OpenAPI 契约,运行能力以服务的 `/v1/capabilities` 为准。 + +新增[模型选择与管理员设置](../../docs/NODE_AGENT_API_MODELS.md):新会话指定模型优先于 bot 默认与服务默认;owner 管理获准清单,普通用户只能选择已验证且获准的模型。 + +[TokenHub 账户与端点](../../docs/NODE_AGENT_API_TOKENHUB.md)提供 URL、模型 API 密钥加密保存、拉取模型、推理强度和实际连接测试。连接测试通过后可授权,不再只能手工登记 gpt-6.1-sol。默认模型仍可为空。 + 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)。 @@ -33,12 +39,14 @@ codex 是共享盒子后端。grok 是显式 legacy gateway 后端,默认只 Ctrl+C 只关闭 API 与它自己的连接、执行端和 VNC 辅助进程,不关闭 GrokNode 或共享容器。Linux 中用户启动的应用按正常应用管理。 +上述命令在前台运行。日常使用的 macOS 后台保活、日志、停止/重启和“服务打不开”检查见[后台运行与排障](../../docs/NODE_AGENT_API_OPERATIONS.md)。保留原 `--state` 与 `--runtime-state` 才能沿用密钥和会话。 + ## 日常流程 1. 选择原 GrokNode 中的 bot,打开或新建 Mac Codex 会话。 2. 导入项目或使用配置的共享项目;发送任务,观察原生工具记录和回合结果。 3. 查看差异、审批待处理动作、取消当前回合、导出项目。 -4. 点击观看或接管。刷新应用列表可选择实际窗口,分别打开终端、浏览器的观看或接管窗口。打开应用须先持有该 bot 的控制租约。 +4. 点击观看或接管。交还控制后自动切回只读观看;同密钥可显式替换自己的旧控制窗口,不同密钥不能抢占。刷新应用列表可选择实际窗口,中文标题按 UTF-8 正常显示;终端、浏览器可分别观看或接管。打开应用须先持有该 bot 的控制租约。 5. 剪贴板仅通过文本框和按钮手动传递。没有自动同步 Mac 系统剪贴板。 6. 用备份按钮保存项目与会话。恢复前会自动备份当前项目;进程内存不恢复。 @@ -84,6 +92,9 @@ API 的授权保护 API 入口。原 GrokNode 的 6080/6081 VNC 端口保持原 | GET /v1/capabilities、/health | 当前后端能力与版本 | | GET/POST /v1/agents | 原 bot 查询、原接口创建 | | GET/POST /v1/agents/sessions | 查询、新建独立 Codex thread | +| GET /v1/models | 获准模型、验证等级、推理强度和有效默认 | +| GET/PATCH /v1/settings/models、/v1/agents/{agent_id}/model | owner 管理服务/bot 默认与获准清单;默认可为空 | +| GET/PATCH /v1/settings/tokenhub、POST models/test | owner 管理模型端点、密钥、目录与真实测试 | | GET/PATCH session、POST close | 会话资料、metadata、保全式关闭 | | GET items、turns、traces | 原生记录、权威回合状态、工具及 token 用量;未知成本为 null | | GET/POST events | 持久游标 SSE、消息和取消 | diff --git a/tools/node-agent-api/VERIFICATION.md b/tools/node-agent-api/VERIFICATION.md index 4e5a5c7..6c9d5bf 100644 --- a/tools/node-agent-api/VERIFICATION.md +++ b/tools/node-agent-api/VERIFICATION.md @@ -22,3 +22,14 @@ - 详细子页面与 OpenAPI 的 55 个方法/路径逐项对应,无漏项或重复。 - 首页、运行说明与详细子页面的相对链接和目录锚点通过;API 文档的 26 个 shell 示例完成语法检查,4 个 JSON 示例完成解析检查。 - 修正 OpenAPI 的旧实验环境说明、请求关联头名称和单应用 viewer 路径;原 GrokNode 和 API 执行代码没有修改。 + +## 本机 0.3.0 扩展验证(2026-10-07) + +以下记录对应本分支的扩展实现及本机部署,不改写上面的历史基线结果。 + +- 最近完整 Node Agent API 回归:105 通过,0 失败。模型选择、TokenHub、控制租约替换、交还后观看、资源缓存与中文标题有直接回归或真实浏览器证据。 +- [模型选择与空默认](../../docs/node-agent-api-0-3-verify/08-model-selection.md)、[连接反馈](../../docs/node-agent-api-0-3-verify/09-connection-feedback.md)、[TokenHub](../../docs/node-agent-api-0-3-verify/10-tokenhub.md)、[桌面控制](../../docs/node-agent-api-0-3-verify/11-desktop-control.md)、[交还后观看](../../docs/node-agent-api-0-3-verify/12-handback-view.md)、[中文标题](../../docs/node-agent-api-0-3-verify/13-application-title-utf8.md)分别说明验收范围,不能用连接测试代替完整工具验收。 +- macOS 用户 LaunchAgent 已实际启动;网页、健康接口、原密钥及已有会话检查成功。登录自动启动来自配置,尚未通过重启 Mac 验收,详见[后台运行](../../docs/NODE_AGENT_API_OPERATIONS.md)。 +- 机器私有 plist、密钥、模型账户、原始会话、日志和截图不随文档发布。示例路径与参数需要按实际部署替换。 +- 发布前重跑完整 API 回归,105 通过、0 失败;64 个 OpenAPI 方法/路径与详细手册逐项一致。54 个相对链接/锚点、31 个 shell 示例、7 个 JSON 示例和 1 个 Python 配置示例通过检查;OpenAPI 本地引用及 CI YAML 可解析。JS 使用 Node 语法检查,未声称缺失的 YAML LSP 诊断通过。 +- CI 增加 Bun 1.4.2 和 opencodex 2.78.0 的 runner 安装步骤,为协议回归提供明确依赖,不跳过实际流式工具测试。本机已有安装版本没有改变。 diff --git a/tools/node-agent-api/auth.mjs b/tools/node-agent-api/auth.mjs index c806819..c8d449c 100644 --- a/tools/node-agent-api/auth.mjs +++ b/tools/node-agent-api/auth.mjs @@ -92,7 +92,7 @@ export async function createAuth(directory, clock = Date.now) { 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, + keyFile, usersFile, authenticate, requireScope, requireOwner, async createUser(principal, input) { input = structuredClone(object(input, ['name'])); return mutate('users', next => { diff --git a/tools/node-agent-api/cli.mjs b/tools/node-agent-api/cli.mjs index 38ada8b..a5cef52 100644 --- a/tools/node-agent-api/cli.mjs +++ b/tools/node-agent-api/cli.mjs @@ -10,7 +10,7 @@ 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]'); + if (!['--port','--state','--container','--allow-writes','--backend','--runtime-state','--namespace','--model','--base-url','--recover','--workspace','--codex-bin'].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] [--codex-bin FILE]'); options[name] = flags[i + 1]; } const port = Number(options['--port'] ?? 18770); @@ -28,10 +28,10 @@ else if (backend === 'codex') { 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'] }); + const harness = createHarness({ runtime, namespace, stateRoot, model: options['--model'], baseUrl: options['--base-url'], binary: options['--codex-bin'] }); 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 }); +const service = await startNodeAgentApi({ adapter, stateDirectory, port, ...(backend === 'codex' ? { modelOptions: { defaultModel: options['--model'] }, tokenhubOptions: { baseUrl: options['--base-url'] } } : {}) }); 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/codex.mjs b/tools/node-agent-api/codex.mjs index fcfafa0..84b5837 100644 --- a/tools/node-agent-api/codex.mjs +++ b/tools/node-agent-api/codex.mjs @@ -14,6 +14,16 @@ export function createCodexAdapter({ runtime, harness, roster, namespace, stateR let store, unsubscribe, tail = Promise.resolve(); const liveThreads = new Set(); const busyActions = new Set(); + let tokenhub; + async function threadModel(session) { + const selection = session.model ? { model: session.model } : {}; + if (session.reasoning_effort) selection.reasoningEffort = session.reasoning_effort; + if (session.endpoint_revision && session.endpoint_revision !== 'opencodex') { + const connection = await tokenhub.connection(session.endpoint_revision, session.reasoning_effort); + selection.providerConnection = connection; + } + return selection; + } function requireIdleBot(session) { if (store.list().some(row => (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() { @@ -24,8 +34,9 @@ export function createCodexAdapter({ runtime, harness, roster, namespace, stateR } const adapter = { backend: 'codex_harness', writesEnabled: true, independentSessions: true, + setTokenHub(value) { tokenhub = value; }, 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'), + diagnostic: error => harness.redact(tokenhub?.redact(error) ?? error?.message ?? 'Unknown Harness failure'), agents: () => roster.agents(), requireAgent: id => roster.requireAgent(id), createAgent: input => roster.createAgent(input), attachStore(value) { store = value; @@ -76,8 +87,10 @@ export function createCodexAdapter({ runtime, harness, roster, namespace, stateR 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', {}); + const result = await harness.request(session.agent_id, 'thread/start', await threadModel(session)); if (!result.thread?.id) throw new ApiError(502, 'harness_schema', 'Harness did not return a thread'); + if (session.model && result.model !== session.model) throw new ApiError(502, 'model_mismatch', 'Harness did not use the selected model'); + if (!session.model && result.model) await store.update(session.id, { model: result.model, model_source: 'harness' }); 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); }, @@ -91,11 +104,17 @@ export function createCodexAdapter({ runtime, harness, roster, namespace, stateR } 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 }); } + try { + const resumed = await harness.request(session.agent_id, 'thread/resume', { threadId: session.thread_id, ...await threadModel(session) }); + if (session.model && resumed.model !== session.model) throw new ApiError(502, 'model_mismatch', 'Harness did not resume the selected model'); + if (!session.model && resumed.model) await store.update(session.id, { model: resumed.model, model_source: 'harness' }); + } catch (error) { if (session.turns?.length || !error.message.includes('no rollout found')) throw error; - const empty = await harness.request(session.agent_id, 'thread/start', {}); + const empty = await harness.request(session.agent_id, 'thread/start', await threadModel(session)); if (!empty.thread?.id) throw new ApiError(502, 'harness_schema', 'Harness did not return an empty thread'); + if (session.model && empty.model !== session.model) throw new ApiError(502, 'model_mismatch', 'Harness did not use the selected model'); + if (!session.model && empty.model) await store.update(session.id, { model: empty.model, model_source: 'harness' }); 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; @@ -103,7 +122,7 @@ export function createCodexAdapter({ runtime, harness, roster, namespace, stateR 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 })); + const turns = restored.thread.turns.map(turn => ({ object: 'node.turn', id: turn.id, ...(session.model ? { model: session.model } : {}), 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' }); } }, @@ -111,13 +130,14 @@ export function createCodexAdapter({ runtime, harness, roster, namespace, stateR 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 }] }); + const result = await harness.request(session.agent_id, 'turn/start', { threadId: session.thread_id, input: [{ type: 'text', text }], ...(session.model ? { model: session.model } : {}), ...(session.reasoning_effort ? { reasoningEffort: session.reasoning_effort } : {}) }); 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; + if (session.model) turn.model = session.model; 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 }; @@ -183,12 +203,13 @@ export function createCodexAdapter({ runtime, harness, roster, namespace, stateR }, 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'); + if (runtime.shared) return runtime.viewerAsset(asset); 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 project(session, action, input) { if (action === 'import') requireIdleBot(session); const result = await runtime.project(session.agent_id, action, input); if (action === 'import' && Array.isArray(result?.conflicts) && result.conflicts.length) throw new ApiError(409, 'import_conflict', 'Import refuses to overwrite: ' + result.conflicts.slice(0, 8).join(', ')); return result; }, async exportProject(session, reserve = async () => {}) { requireIdleBot(session); await mkdir(artifactRoot, { recursive: true, mode: 0o700 }); diff --git a/tools/node-agent-api/desktop.mjs b/tools/node-agent-api/desktop.mjs index 3cd11ff..2248e77 100644 --- a/tools/node-agent-api/desktop.mjs +++ b/tools/node-agent-api/desktop.mjs @@ -98,11 +98,13 @@ export function createDesktop({ adapter, auth, store, governance, stateDirectory 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'); + if (input.replace_own_control !== undefined && typeof input.replace_own_control !== 'boolean') throw badRequest('replace_own_control must be boolean'); auth.requireScope(principal, mode === 'control' ? 'desktop.control' : 'desktop.view', session.agent_id); const target = normalizeTarget(input.target); + const held = leases.get(session.agent_id); + const replaces = input.replace_own_control === true && held && controls(held) && held.principal.id === principal.id ? held.id : null; 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'); + if (held && controls(held) && !replaces) throw new ApiError(409, 'desktop_busy', 'A controller already holds this desktop'); } const environment = await adapter.desktop(session.agent_id, { type: target.type, @@ -112,7 +114,7 @@ export function createDesktop({ adapter, auth, store, governance, stateDirectory 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 }); + tickets.set(ticket, { principal, botId: session.agent_id, sessionId: session.id, target, environment, mode, expiresAt, replaces }); 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 }; }, @@ -128,7 +130,9 @@ export function createDesktop({ adapter, auth, store, governance, stateDirectory }, 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 replacing = lease && controls(lease) && ticket.replaces === lease.id && ticket.principal.id === lease.principal.id; + if (lease && controls(lease) && ((ticket.mode === 'control' && !replacing) || (ticket.replaces && !replacing))) throw new ApiError(409, 'desktop_busy', 'Desktop control changed; request a new authorization'); + if (replacing) { disconnect(lease); viewers.delete(lease.id); } 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); diff --git a/tools/node-agent-api/models.mjs b/tools/node-agent-api/models.mjs new file mode 100644 index 0000000..ca3019e --- /dev/null +++ b/tools/node-agent-api/models.mjs @@ -0,0 +1,119 @@ +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { readFile } from 'node:fs/promises'; +import { ApiError, badRequest, object } from './errors.mjs'; +import { atomicJson, readPrivateJson } from './persistence.mjs'; + +export const DEFAULT_MODEL = null; +const VERIFIED_BASELINE_MODEL = 'gpt-6.1-sol'; +export const modelId = value => { + if (typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(value)) throw badRequest('Invalid model identifier'); + return value; +}; + +export async function createModelPolicy(directory, { catalogFile = join(homedir(), '.codex/opencodex-catalog.json'), defaultModel = DEFAULT_MODEL, tokenhub } = {}) { + const file = join(directory, 'models.json'); + const saved = await readPrivateJson(file, null); + let state = saved ?? { + default_model: defaultModel, bot_defaults: {}, allowed_models: [VERIFIED_BASELINE_MODEL], + verified_models: [{ id: VERIFIED_BASELINE_MODEL, evidence: 'Node Agent API 0.3.0 shared project, native tools, approval and cancellation acceptance, 2026-10-06' }], + }; + function validate(value) { + object(value, ['default_model', 'bot_defaults', 'allowed_models', 'verified_models']); + if (value.default_model !== null) modelId(value.default_model); + object(value.bot_defaults, Object.keys(value.bot_defaults ?? {})); + Object.values(value.bot_defaults).forEach(modelId); + if (!Array.isArray(value.verified_models) || !Array.isArray(value.allowed_models) || !value.allowed_models.length) throw badRequest('Allowed and verified models are required'); + for (const row of value.verified_models) { + object(row, ['id', 'evidence', 'endpoint_revision']); modelId(row.id); + if (typeof row.evidence !== 'string' || !row.evidence.trim() || row.evidence.length > 1000) throw badRequest('Tool workflow verification evidence is required'); + } + const verified = new Set(value.verified_models.map(row => row.id)); + if (verified.size !== value.verified_models.length || new Set(value.allowed_models).size !== value.allowed_models.length) throw badRequest('Duplicate model identifiers'); + for (const id of value.allowed_models) modelId(id); + if (![value.default_model, ...Object.values(value.bot_defaults)].filter(id => id !== null).every(id => value.allowed_models.includes(id))) throw badRequest('Defaults must select approved verified models'); + } + validate(state); + if (saved === null) await atomicJson(file, state); + let saving = Promise.resolve(), fault; + async function catalog() { + const endpoint = await tokenhub?.catalog(); + if (endpoint?.external) { + if (!endpoint.models.length) throw new ApiError(503, 'model_catalog_unavailable', 'Refresh models from the configured TokenHub API first'); + return endpoint.models; + } + try { + const value = JSON.parse(await readFile(catalogFile, 'utf8')); + if (!Array.isArray(value.models) || !value.models.length) throw new Error('Invalid catalog'); + return value.models.map(row => ({ id: modelId(row.slug), name: typeof row.display_name === 'string' ? row.display_name : row.slug, reasoning_efforts: (row.supported_reasoning_levels ?? []).map(x => x.effort).filter(x => ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'].includes(x)) })); + } catch { throw new ApiError(503, 'model_catalog_unavailable', 'Model catalog is unavailable; existing session models are preserved'); } + } + function current() { if (fault) throw fault; return state; } + function effectiveDefault(botId) { + const value = current(); + return Object.hasOwn(value.bot_defaults, botId) ? value.bot_defaults[botId] : value.default_model; + } + function selection(botId, requested) { + const value = current(); + const model = requested === undefined ? effectiveDefault(botId) : modelId(requested); + if (model === null) throw new ApiError(400, 'model_required', 'No default model is set; select a model for the new session'); + if (!value.allowed_models.includes(model)) throw badRequest('Model is not approved for new sessions'); + return { model, model_source: requested !== undefined ? 'session' : Object.hasOwn(value.bot_defaults, botId) ? 'bot_default' : 'service_default' }; + } + async function mutate(operation) { + const task = saving.then(async () => { + const next = structuredClone(current()); operation(next); validate(next); + const rows = await catalog(), ids = new Set(rows.map(row => row.id)); + const endpoint = await tokenhub?.catalog(); + for (const id of next.allowed_models) { + if (!ids.has(id)) throw badRequest('Approved model is absent from the current catalog'); + const workflow = next.verified_models.some(v => v.id === id && (v.endpoint_revision ?? 'opencodex') === (endpoint?.revision ?? 'opencodex')); + if (!workflow && !endpoint?.tests.some(t => t.model === id && t.status === 'passed')) throw badRequest('Test this model API connection before approval'); + } + try { await atomicJson(file, next); } catch { fault = new ApiError(503, 'model_settings_persistence_failed', 'Model settings could not be saved'); throw fault; } + state = next; return structuredClone(state); + }); + saving = task.catch(() => {}); return task; + } + return { + async resolve(botId, requested, reasoningEffort) { + await saving; + const selected = selection(botId, requested); + const entry = (await catalog()).find(row => row.id === selected.model); + if (!entry) throw badRequest('Model is absent from the current catalog'); + const endpoint = await tokenhub?.catalog(), revision = endpoint?.revision ?? 'opencodex'; + const workflow = state.verified_models.some(v => v.id === selected.model && (v.endpoint_revision ?? 'opencodex') === revision); + const tests = endpoint?.tests.filter(t => t.model === selected.model && t.status === 'passed') ?? []; + if (!workflow && !tests.length) throw badRequest('Model connection has not been tested for this endpoint'); + const levels = [...new Set([...(entry.reasoning_efforts ?? []), ...tests.map(t => t.reasoning_effort).filter(Boolean)])]; + if (reasoningEffort != null && !levels.includes(reasoningEffort)) throw badRequest('Reasoning effort is not supported or tested for this model'); + return { ...selected, ...(tokenhub ? { endpoint_revision: revision, reasoning_effort: reasoningEffort ?? null } : {}) }; + }, + async list(botId) { + await saving; const value = current(); + const endpoint = await tokenhub?.catalog(), revision = endpoint?.revision ?? 'opencodex'; + return { object: 'list', data: (await catalog()).map(row => { + const workflow = value.verified_models.find(v => v.id === row.id && (v.endpoint_revision ?? 'opencodex') === revision); + const tests = endpoint?.tests.filter(t => t.model === row.id && t.status === 'passed') ?? [], verified = !!workflow || tests.length > 0; + return { ...row, reasoning_efforts: [...new Set([...(row.reasoning_efforts ?? []), ...tests.map(t => t.reasoning_effort).filter(Boolean)])], approved: verified && value.allowed_models.includes(row.id), verified, verification_level: workflow ? 'tool_workflow' : tests.length ? 'connection' : 'unverified', verification_evidence: workflow?.evidence ?? (tests.length ? 'Actual model API test passed' : null) }; + }), has_more: false, default_model: botId ? effectiveDefault(botId) : value.default_model, endpoint_revision: revision }; + }, + async settings() { await saving; return { object: 'node.model_settings', ...structuredClone(current()) }; }, + async botSettings(botId) { await saving; const value = current(); return { object: 'node.bot_model_settings', agent_id: botId, default_model: Object.hasOwn(value.bot_defaults, botId) ? value.bot_defaults[botId] : null, effective_model: effectiveDefault(botId) }; }, + async update(input) { + object(input, ['default_model', 'allowed_models', 'verified_models']); + if (!Object.keys(input).length) throw badRequest('Model settings patch is empty'); + const copy = structuredClone(input); + if (copy.verified_models && tokenhub) { const revision = await tokenhub.activeRevision(); copy.verified_models = copy.verified_models.map(v => ({ ...v, endpoint_revision: v.endpoint_revision ?? revision })); } + if (copy.default_model === '') copy.default_model = null; + return mutate(next => Object.assign(next, copy)); + }, + async updateBot(botId, input) { + object(input, ['default_model']); + if (!Object.hasOwn(input, 'default_model')) throw badRequest('default_model is required'); + const selected = input.default_model === null ? null : modelId(input.default_model); + await mutate(next => { if (selected === null) delete next.bot_defaults[botId]; else Object.defineProperty(next.bot_defaults, botId, { value: selected, enumerable: true, configurable: true, writable: true }); }); + return this.botSettings(botId); + }, + }; +} diff --git a/tools/node-agent-api/openapi.json b/tools/node-agent-api/openapi.json index 0f90843..ac6eca2 100644 --- a/tools/node-agent-api/openapi.json +++ b/tools/node-agent-api/openapi.json @@ -536,7 +536,7 @@ "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.", + "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. replace_own_control=true transfers only this credential control; no owner privilege to silently seize another credential lease. Transfer happens on ticket redemption, not issuance.", "requestBody": { "required": true, "content": { @@ -955,73 +955,160 @@ } }, "/desktop/lane/{viewer_id}/mandatory.json": { - "parameters": [{ "$ref": "#/components/parameters/ViewerId" }], + "parameters": [ + { + "$ref": "#/components/parameters/ViewerId" + } + ], "get": { "operationId": "getApplicationViewerSettings", "summary": "Server-selected settings for one application viewer", - "security": [{ "viewerCookie": [] }], + "security": [ + { + "viewerCookie": [] + } + ], "responses": { "200": { "description": "Application viewer settings with its own socket path", - "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ViewerSettings" } } } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewerSettings" + } + } + } }, - "default": { "$ref": "#/components/responses/Error" } + "default": { + "$ref": "#/components/responses/Error" + } } } }, "/desktop/lane/{viewer_id}/defaults.json": { - "parameters": [{ "$ref": "#/components/parameters/ViewerId" }], + "parameters": [ + { + "$ref": "#/components/parameters/ViewerId" + } + ], "get": { "operationId": "getApplicationViewerDefaults", "summary": "Empty defaults for one application viewer", - "security": [{ "viewerCookie": [] }], + "security": [ + { + "viewerCookie": [] + } + ], "responses": { "200": { "description": "Empty settings", - "content": { "application/json": { "schema": { "type": "object", "maxProperties": 0 } } } + "content": { + "application/json": { + "schema": { + "type": "object", + "maxProperties": 0 + } + } + } }, - "default": { "$ref": "#/components/responses/Error" } + "default": { + "$ref": "#/components/responses/Error" + } } } }, "/desktop/lane/{viewer_id}/vnc.html": { - "parameters": [{ "$ref": "#/components/parameters/ViewerId" }], + "parameters": [ + { + "$ref": "#/components/parameters/ViewerId" + } + ], "get": { "operationId": "getApplicationViewerPage", "summary": "noVNC page for one application, with the control bar hidden", - "security": [{ "viewerCookie": [] }], + "security": [ + { + "viewerCookie": [] + } + ], "responses": { - "200": { "description": "Authorized application viewer HTML" }, - "default": { "$ref": "#/components/responses/Error" } + "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" } } + { + "$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": [] }], + "security": [ + { + "viewerCookie": [] + } + ], "responses": { - "200": { "description": "Resource with its native content type" }, - "default": { "$ref": "#/components/responses/Error" } + "200": { + "description": "Resource with its native content type" + }, + "default": { + "$ref": "#/components/responses/Error" + } } } }, "/desktop/lane/{viewer_id}/socket": { - "parameters": [{ "$ref": "#/components/parameters/ViewerId" }], + "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" } }], + "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" } + "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" + } } } }, @@ -2229,124 +2316,460 @@ } } } - } - }, - "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" + "/v1/models": { + "get": { + "operationId": "listModels", + "summary": "Discover catalog models with verification and approval status", + "x-required-scopes": [ + "agents.read" + ], + "responses": { + "200": { + "description": "Discover catalog models with verification and approval status", + "headers": { + "X-Request-ID": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelList" + } + } } + }, + "default": { + "$ref": "#/components/responses/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": { + "parameters": [ + { + "in": "query", + "name": "agent_id", "schema": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/Identifier" } } - }, - "headers": { - "X-Request-ID": { - "description": "Service-generated request correlation ID", - "schema": { - "type": "string" + ], + "description": "Requires the bot grant when agent_id is supplied. Only approved AND verified models may be selected. Catalog failure returns 503 model_catalog_unavailable; no fallback or task replay. Existing pinned sessions continue." + } + }, + "/v1/settings/models": { + "get": { + "operationId": "getModelSettings", + "summary": "Read service model settings (owner only)", + "x-required-scopes": [ + "owner" + ], + "responses": { + "200": { + "description": "Read service model settings (owner only)", + "headers": { + "X-Request-ID": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelSettings" + } + } } + }, + "default": { + "$ref": "#/components/responses/Error" } } - } - }, - "schemas": { - "Identifier": { - "type": "string", - "pattern": "^[\\p{L}\\p{N}][\\p{L}\\p{N}_-]{0,127}$" }, - "Scope": { - "type": "string", - "enum": [ - "agents.read", + "patch": { + "operationId": "updateModelSettings", + "summary": "Update service default, approved list or verification records (owner only)", + "x-required-scopes": [ + "owner" + ], + "responses": { + "200": { + "description": "Update service default, approved list or verification records (owner only)", + "headers": { + "X-Request-ID": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelSettings" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelSettingsPatch" + } + } + } + }, + "description": "Settings persist in API state only. Approvals require a catalog entry and recorded tool-workflow acceptance. Defaults must remain approved. Existing sessions are unchanged. Delegated keys, including keys.manage, cannot manage models." + } + }, + "/v1/agents/{agent_id}/model": { + "parameters": [ + { + "$ref": "#/components/parameters/AgentId" + } + ], + "get": { + "operationId": "getBotModelDefault", + "summary": "Read API bot default (owner only)", + "x-required-scopes": [ + "owner" + ], + "responses": { + "200": { + "description": "Read API bot default (owner only)", + "headers": { + "X-Request-ID": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BotModelSettings" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "patch": { + "operationId": "updateBotModelDefault", + "summary": "Set API bot default; null inherits service default (owner only)", + "x-required-scopes": [ + "owner" + ], + "responses": { + "200": { + "description": "Set API bot default; null inherits service default (owner only)", + "headers": { + "X-Request-ID": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BotModelSettings" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BotModelPatch" + } + } + } + } + } + }, + "/v1/settings/tokenhub": { + "get": { + "operationId": "getTokenHub", + "summary": "Read TokenHub address, protocol and secret status", + "x-required-scopes": [ + "owner" + ], + "description": "Independent Node Agent API only; original GrokNode settings are not modified. Actual model test consumes a small number of upstream tokens. Tests attest API connection, not full tool workflow. Endpoint/key revisions invalidate connection tests for new sessions.", + "responses": { + "200": { + "description": "Read TokenHub address, protocol and secret status", + "headers": { + "X-Request-ID": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenHubSettings" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "patch": { + "operationId": "updateTokenHub", + "summary": "Save model API endpoint and encrypted key", + "x-required-scopes": [ + "owner" + ], + "description": "Independent Node Agent API only; original GrokNode settings are not modified. Actual model test consumes a small number of upstream tokens. Tests attest API connection, not full tool workflow. Endpoint/key revisions invalidate connection tests for new sessions.", + "responses": { + "200": { + "description": "Save model API endpoint and encrypted key", + "headers": { + "X-Request-ID": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenHubSettings" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenHubPatch" + } + } + } + } + } + }, + "/v1/settings/tokenhub/models": { + "post": { + "operationId": "refreshTokenHubModels", + "summary": "Fetch available models from configured API", + "x-required-scopes": [ + "owner" + ], + "description": "Independent Node Agent API only; original GrokNode settings are not modified. Actual model test consumes a small number of upstream tokens. Tests attest API connection, not full tool workflow. Endpoint/key revisions invalidate connection tests for new sessions.", + "responses": { + "200": { + "description": "Fetch available models from configured API", + "headers": { + "X-Request-ID": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenHubModelList" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/settings/tokenhub/test": { + "post": { + "operationId": "testTokenHubModel", + "summary": "Perform a real model connection and reasoning-effort test", + "x-required-scopes": [ + "owner" + ], + "description": "Independent Node Agent API only; original GrokNode settings are not modified. Actual model test consumes a small number of upstream tokens. Tests attest API connection, not full tool workflow. Endpoint/key revisions invalidate connection tests for new sessions.", + "responses": { + "200": { + "description": "Perform a real model connection and reasoning-effort test", + "headers": { + "X-Request-ID": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenHubTestResponse" + } + } + } + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenHubTestInput" + } + } + } + } + } + } + }, + "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", @@ -2355,35 +2778,651 @@ "keys.manage" ] }, - "Health": { + "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" + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$", + "description": "Effective immutable model; present on model-enabled sessions. Older sessions are pinned from a native Harness response upon resume." + }, + "model_source": { + "type": "string", + "enum": [ + "session", + "bot_default", + "service_default", + "harness" + ] + }, + "endpoint_revision": { + "type": "string", + "description": "Immutable endpoint and key revision pinned at creation." + }, + "reasoning_effort": { + "type": "string", + "nullable": true + } + } + }, + "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 + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$", + "description": "Codex backend only: approved verified model. Omission resolves bot default then service default. Frozen for this session and sent to native thread/start, thread/resume and turn/start. Changing defaults affects new sessions only. When both defaults are unset, omission returns 400 model_required before creating a session or invoking Harness." + }, + "reasoning_effort": { + "type": "string", + "nullable": true, + "description": "Only catalog-advertised or successfully tested efforts; null omits reasoning on the endpoint wire." + } + } + }, + "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": [ - "service", - "version", - "backend", - "compatible_with_openai_sdk" + "events" ], "properties": { - "service": { + "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": [ - "node-agent-api" + "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." + }, + "replace_own_control": { + "type": "boolean", + "default": false, + "description": "Explicitly replace the active control lease held by this exact API credential. Other credentials retain 409 desktop_busy. In view mode release own lease when new viewer opens. Tickets bind the observed lease id; cannot replace a newer lease after a race. Default false preserves exclusive-lease behavior." + } + } + }, + "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" ] }, - "version": { - "type": "string", + "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": [ - "0.3.0" + true ] }, - "backend": { - "type": "string", + "server_enforced": { + "type": "boolean", "enum": [ - "grok_node", - "codex_harness" + true ] }, - "compatible_with_openai_sdk": { + "pauses_agent": { "type": "boolean", "enum": [ false @@ -2391,712 +3430,671 @@ } } }, - "Agent": { + "ViewerSettings": { "type": "object", "required": [ - "object", - "id", - "name", - "description", - "backend" + "autoconnect", + "resize", + "reconnect", + "view_only", + "path" ], "properties": { - "object": { - "type": "string", + "autoconnect": { + "type": "boolean", "enum": [ - "node.agent" + true ] }, - "id": { - "$ref": "#/components/schemas/Identifier" + "resize": { + "type": "string", + "enum": [ + "scale" + ] }, - "name": { - "type": "string" + "reconnect": { + "type": "boolean", + "enum": [ + false + ] }, - "description": { - "type": "string" + "view_only": { + "type": "boolean" }, - "backend": { + "path": { "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." + "pattern": "^desktop/(lane/[A-Za-z0-9_-]+/)?socket$", + "description": "Server-selected whole-desktop or application viewer WebSocket path. Clients must use the returned setting." } } }, - "CreateAgent": { + "IssueKey": { "type": "object", "additionalProperties": false, "required": [ - "name" + "scopes", + "bot_ids" ], "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 120, - "pattern": "\\S" + "user_id": { + "$ref": "#/components/schemas/Identifier", + "description": "Defaults to the caller's API user. Only the owner can issue keys for another active user." }, - "description": { - "type": "string", - "maxLength": 4000 + "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 } } }, - "Session": { + "ApiKey": { "type": "object", "required": [ "object", "id", - "agent_id", - "status", - "created_at", - "context", - "metadata" + "key", + "scopes", + "bot_ids", + "expires_at" ], "properties": { "object": { "type": "string", "enum": [ - "node.agent.session" + "node.api_key" ] }, "id": { "$ref": "#/components/schemas/Identifier" }, - "agent_id": { - "$ref": "#/components/schemas/Identifier" - }, - "status": { + "key": { "type": "string", - "enum": [ - "idle", - "pending", - "accepted", - "rejected", - "unknown", - "closed" - ], - "description": "Input submission state, not task completion" + "pattern": "^[A-Za-z0-9_-]{43}$" }, - "created_at": { - "type": "integer" + "scopes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Scope" + } }, - "context": { + "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": [ - "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" + "list" + ] }, - "metadata": { - "$ref": "#/components/schemas/Metadata" + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Agent" + } }, - "thread_id": { - "type": "string", - "description": "Harness thread id; present on codex-backend sessions after bindSession" + "has_more": { + "type": "boolean" }, - "task_status": { + "first_id": { "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" + "nullable": true }, - "active_turn_id": { + "last_id": { "type": "string", - "nullable": true, - "description": "Running turn id, or null when idle" + "nullable": true } } }, - "CreateSession": { + "SessionList": { "type": "object", - "additionalProperties": false, - "anyOf": [ - { - "required": [ - "agent_id" - ] - }, - { - "required": [ - "agent" - ] - } + "required": [ + "object", + "data", + "has_more", + "first_id", + "last_id" ], "properties": { - "agent_id": { - "$ref": "#/components/schemas/Identifier" + "object": { + "type": "string", + "enum": [ + "list" + ] }, - "agent": { - "type": "object", - "required": [ - "id" - ], - "properties": { - "id": { - "$ref": "#/components/schemas/Identifier" - } - }, - "additionalProperties": false + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Session" + } }, - "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.", + "has_more": { + "type": "boolean" + }, + "first_id": { + "type": "string", + "nullable": true + }, + "last_id": { + "type": "string", "nullable": true } } }, - "Environment": { + "EventList": { "type": "object", "required": [ "object", - "id", - "agent_id", - "type", - "isolation", - "container", - "display" + "data", + "has_more", + "first_id", + "last_id" ], "properties": { "object": { "type": "string", "enum": [ - "node.environment" + "list" ] }, - "id": { - "type": "string" + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Event" + } }, - "agent_id": { - "$ref": "#/components/schemas/Identifier" + "has_more": { + "type": "boolean" }, - "type": { + "first_id": { "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." + "nullable": true }, - "isolation": { + "last_id": { "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 + "nullable": true } } }, - "Event": { + "ItemList": { "type": "object", "required": [ - "id", - "type", - "session_id", - "created_at", - "data" + "object", + "data", + "has_more", + "context", + "first_id", + "last_id" ], "properties": { - "id": { - "type": "string" - }, - "type": { + "object": { "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" + "list" ] }, - "session_id": { - "$ref": "#/components/schemas/Identifier" + "data": { + "type": "array", + "items": {} }, - "created_at": { - "type": "integer" + "has_more": { + "type": "boolean" }, - "data": { - "type": "object", - "additionalProperties": true + "context": { + "type": "string", + "enum": [ + "grok_node_bot_transcript" + ] + }, + "first_id": { + "type": "string", + "nullable": true + }, + "last_id": { + "type": "string", + "nullable": true } } }, - "SubmitMessage": { + "Error": { "type": "object", - "additionalProperties": false, "required": [ - "events" + "error" ], "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" } + "error": { + "type": "object", + "required": [ + "type", + "message" + ], + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" } } } } }, - "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] } - } + "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." }, - "InputResult": { + "Capabilities": { "type": "object", "required": [ - "request_id", - "status", - "completed" + "object", + "version", + "backend", + "writes_enabled", + "sessions", + "events", + "environment", + "security", + "desktop", + "project", + "clipboard", + "service" ], "properties": { - "request_id": { - "$ref": "#/components/schemas/Identifier" - }, - "status": { + "object": { "type": "string", "enum": [ - "pending", - "accepted", - "rejected", - "unknown" + "node.api.capabilities" ] }, - "completed": { - "type": "boolean", + "version": { + "type": "string", "enum": [ - false - ] + "0.3.0" + ], + "description": "API implementation version, consistent with health and the CLI banner." }, - "turn_id": { + "backend": { "type": "string", - "description": "Harness turn id when the backend starts one synchronously (codex backend only)" + "enum": [ + "grok_node", + "codex_harness" + ] }, - "replayed": { - "type": "boolean", - "description": "Saved response returned; backend input was not reexecuted" + "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" + } + } } } }, - "AuthorizeDesktop": { + "Usage": { "type": "object", - "additionalProperties": false, "properties": { - "mode": { - "type": "string", - "enum": [ - "view", - "control" - ], - "default": "view" + "requests": { + "type": "integer", + "description": "Requests counted in the current rate window" }, - "ttl_seconds": { + "streams": { "type": "integer", - "minimum": 1, - "maximum": 300, - "default": 60 + "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" }, - "target": { - "$ref": "#/components/schemas/DesktopTarget", - "description": "Defaults to the whole desktop. Applications must belong to the current bot desktop." + "signingKey": { + "$ref": "#/components/schemas/WebhookKey" } - } + }, + "required": [ + "destination", + "signingKey" + ] }, - "DesktopAuthorization": { + "WebhookDelivery": { "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" + "id": { + "type": "string" }, - "mode": { + "status": { "type": "string", "enum": [ - "view", - "control" + "pending", + "delivering", + "retry", + "delivered", + "failed" ] }, - "display": { + "attempts": { "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 - ] + "nextAttemptAt": { + "type": "integer" }, - "pauses_agent": { - "type": "boolean", - "enum": [ - false - ] - } - } - }, - "ViewerSettings": { - "type": "object", - "required": [ - "autoconnect", - "resize", - "reconnect", - "view_only", - "path" - ], - "properties": { - "autoconnect": { - "type": "boolean", - "enum": [ - true - ] + "createdAt": { + "type": "integer" }, - "resize": { + "lastError": { "type": "string", "enum": [ - "scale" + "none", + "interrupted", + "timeout", + "transport", + "http_status" ] }, - "reconnect": { - "type": "boolean", - "enum": [ - false + "lastStatus": { + "type": [ + "integer", + "null" ] - }, - "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 - } - } + "id", + "status", + "attempts", + "nextAttemptAt", + "createdAt", + "lastError", + "lastStatus" + ] }, - "ApiKey": { + "Turn": { "type": "object", - "required": [ - "object", - "id", - "key", - "scopes", - "bot_ids", - "expires_at" - ], "properties": { "object": { "type": "string", "enum": [ - "node.api_key" + "node.turn" ] }, "id": { - "$ref": "#/components/schemas/Identifier" + "type": "string" }, - "key": { + "status": { "type": "string", - "pattern": "^[A-Za-z0-9_-]{43}$" + "enum": [ + "running", + "completed", + "failed", + "interrupted", + "cancelled", + "unknown" + ], + "description": "unknown means the outcome was never observed; never read it as success" }, - "scopes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Scope" - } + "started_at": { + "type": "integer" }, - "bot_ids": { - "type": "array", - "items": { - "type": "string" - } + "ended_at": { + "type": [ + "integer", + "null" + ] }, - "expires_at": { - "type": "integer" - } - } - }, - "AgentList": { - "type": "object", - "required": [ - "object", - "data", - "has_more", - "first_id", - "last_id" - ], - "properties": { - "object": { - "type": "string", - "enum": [ - "list" + "error": { + "type": [ + "string", + "null" ] }, - "data": { + "items": { "type": "array", - "items": { - "$ref": "#/components/schemas/Agent" - } - }, - "has_more": { - "type": "boolean" + "items": {} }, - "first_id": { - "type": "string", - "nullable": true + "usage": { + "type": [ + "object", + "null" + ], + "additionalProperties": true }, - "last_id": { - "type": "string", - "nullable": true + "request_id": { + "$ref": "#/components/schemas/Identifier" } - } - }, - "SessionList": { - "type": "object", + }, "required": [ "object", - "data", - "has_more", - "first_id", - "last_id" - ], + "id", + "status", + "started_at" + ] + }, + "TurnList": { + "type": "object", "properties": { "object": { "type": "string", @@ -3107,67 +4105,78 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/Turn" } }, "has_more": { "type": "boolean" }, "first_id": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] }, "last_id": { - "type": "string", - "nullable": true + "type": [ + "string", + "null" + ] } - } - }, - "EventList": { - "type": "object", + }, "required": [ "object", "data", "has_more", "first_id", "last_id" - ], + ] + }, + "TurnTrace": { + "type": "object", "properties": { - "object": { - "type": "string", - "enum": [ - "list" - ] + "turn_id": { + "type": "string" }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Event" - } + "started_at": { + "type": "integer" }, - "has_more": { - "type": "boolean" + "ended_at": { + "type": [ + "integer", + "null" + ] }, - "first_id": { + "status": { "type": "string", - "nullable": true + "enum": [ + "running", + "completed", + "failed", + "interrupted", + "cancelled", + "unknown" + ] }, - "last_id": { - "type": "string", - "nullable": true + "usage": { + "type": [ + "object", + "null" + ] + }, + "spans": { + "type": "array", + "items": {} } - } + }, + "required": [ + "turn_id", + "started_at", + "status" + ] }, - "ItemList": { + "TurnTraceList": { "type": "object", - "required": [ - "object", - "data", - "has_more", - "context", - "first_id", - "last_id" - ], "properties": { "object": { "type": "string", @@ -3177,423 +4186,336 @@ }, "data": { "type": "array", - "items": {} + "items": { + "$ref": "#/components/schemas/TurnTrace" + } }, "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" - ], + "data", + "has_more" + ] + }, + "RequiredAction": { + "type": "object", "properties": { "object": { "type": "string", "enum": [ - "node.api.capabilities" + "node.required_action" ] }, - "version": { - "type": "string", - "enum": [ - "0.3.0" - ], - "description": "API implementation version, consistent with health and the CLI banner." + "id": { + "type": "string" }, - "backend": { - "type": "string", - "enum": [ - "grok_node", - "codex_harness" - ] + "request_id": { + "type": [ + "string", + "null" + ], + "description": "JSON-RPC request id used to answer this approval" }, - "writes_enabled": { - "type": "boolean" + "thread_id": { + "type": "string" }, - "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" - } - } + "turn_id": { + "type": "string" }, - "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 - ] - } - } + "item_id": { + "type": "string" }, - "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" - } - } + "type": { + "type": "string", + "enum": [ + "item/commandExecution/requestApproval", + "item/fileChange/requestApproval", + "item/tool/call" + ] }, - "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." - } - } + "status": { + "type": "string", + "enum": [ + "pending" + ] }, - "desktop": { + "details": { "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" - } - } + "additionalProperties": true }, - "project": { - "type": "object", - "description": "Codex-backend project operations; absent/false on the legacy backend", - "properties": { - "diff": { - "type": "boolean" - }, - "import": { - "type": "boolean" - }, - "export": { - "type": "boolean" - } - } + "created_at": { + "type": "integer" + } + }, + "required": [ + "object", + "id", + "type", + "status" + ] + }, + "RequiredActionList": { + "type": "object", + "properties": { + "object": { + "type": "string", + "enum": [ + "list" + ] }, - "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" - } + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RequiredAction" } }, - "service": { - "type": "object", - "description": "Governance quotas, audit journal and webhook outbox are always on", - "properties": { - "quotas": { - "type": "boolean" - }, - "audit": { - "type": "boolean" - }, - "webhooks": { - "type": "boolean" - } - } + "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" + ] } } }, - "Usage": { + "SessionHandoff": { "type": "object", "properties": { - "requests": { - "type": "integer", - "description": "Requests counted in the current rate window" + "thread_id": { + "type": "string" }, - "streams": { - "type": "integer", - "description": "Live SSE and desktop streams held by this principal (limit 4)" + "bot_id": { + "type": "string" }, - "storageBytes": { - "type": "integer", - "description": "Logical storage reservation bytes, including session state and export artifacts" + "target": { + "type": "string", + "enum": [ + "codex_cli" + ] }, - "limits": { + "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": { - "requests": { - "type": "integer" + "id": { + "type": "string" }, - "windowMs": { - "type": "integer" + "name": { + "type": "string", + "enum": [ + "project.tgz" + ] }, - "streams": { + "size": { "type": "integer" }, - "storageBytes": { - "type": "integer" + "sha256": { + "type": "string" + }, + "download_url": { + "type": "string" } } } }, "required": [ - "requests", - "streams", - "storageBytes", - "limits" + "artifact" ] }, - "WebhookKey": { + "EnvironmentBackup": { "type": "object", "properties": { - "id": { + "backup_id": { "type": "string" }, - "secret": { - "type": "string", - "description": "Base64url signing secret; provision out of band" + "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": [ - "id", - "secret" + "backup_id", + "preserved", + "processes_restored" ] }, - "WebhookConfiguration": { + "EnvironmentRecreate": { "type": "object", "properties": { - "destination": { - "type": "string" + "running": { + "type": "boolean" }, - "signingKey": { - "$ref": "#/components/schemas/WebhookKey" + "preserved_files": { + "type": "boolean", + "enum": [ + true + ] + }, + "processes_restored": { + "type": "boolean", + "enum": [ + false + ] } }, "required": [ - "destination", - "signingKey" + "running", + "preserved_files", + "processes_restored" ] }, - "WebhookDelivery": { + "EnvironmentRestore": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "status": { - "type": "string", + "restored": { + "type": "boolean", "enum": [ - "pending", - "delivering", - "retry", - "delivered", - "failed" + true ] }, - "attempts": { - "type": "integer" - }, - "nextAttemptAt": { - "type": "integer" - }, - "createdAt": { - "type": "integer" - }, - "lastError": { - "type": "string", + "processes_restored": { + "type": "boolean", "enum": [ - "none", - "interrupted", - "timeout", - "transport", - "http_status" - ] - }, - "lastStatus": { - "type": [ - "integer", - "null" + false ] } }, "required": [ - "id", - "status", - "attempts", - "nextAttemptAt", - "createdAt", - "lastError", - "lastStatus" + "restored", + "processes_restored" ] }, - "Turn": { + "User": { "type": "object", "properties": { "object": { "type": "string", "enum": [ - "node.turn" + "node.user" ] }, "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" - ] + "description": "owner or nuser_<24 hex>" }, - "error": { - "type": [ - "string", - "null" - ] + "name": { + "type": "string" }, - "items": { - "type": "array", - "items": {} + "disabled": { + "type": "boolean" }, - "usage": { - "type": [ - "object", - "null" - ], - "additionalProperties": true + "created_at": { + "type": "integer" }, - "request_id": { - "$ref": "#/components/schemas/Identifier" + "disabled_at": { + "type": "integer" } }, "required": [ "object", "id", - "status", - "started_at" + "name", + "disabled" ] }, - "TurnList": { + "UserList": { "type": "object", "properties": { "object": { @@ -3605,7 +4527,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Turn" + "$ref": "#/components/schemas/User" } }, "has_more": { @@ -3632,133 +4554,82 @@ "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" - ] + "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" + ] + } + } }, - "spans": { - "type": "array", - "items": {} + { + "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}$" + } + } } - }, - "required": [ - "turn_id", - "started_at", - "status" ] }, - "TurnTraceList": { + "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", - "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", + "id", + "name", + "running" + ], "properties": { "object": { "type": "string", "enum": [ - "node.required_action" + "node.application" ] }, "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" - ] + "pattern": "^0x[0-9a-f]{1,16}$", + "description": "Current X11 window id; not an authorization token." }, - "details": { - "type": "object", - "additionalProperties": true + "name": { + "type": "string" }, - "created_at": { - "type": "integer" + "running": { + "type": "boolean" } - }, - "required": [ - "object", - "id", - "type", - "status" - ] + } }, - "RequiredActionList": { + "ApplicationList": { "type": "object", + "required": [ + "object", + "data", + "has_more" + ], "properties": { "object": { "type": "string", @@ -3769,7 +4640,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/RequiredAction" + "$ref": "#/components/schemas/Application" } }, "has_more": { @@ -3787,343 +4658,405 @@ "null" ] } - }, + } + }, + "ModelId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "ModelVerification": { + "type": "object", + "additionalProperties": false, "required": [ - "object", - "data", - "has_more", - "first_id", - "last_id" - ] + "id", + "evidence" + ], + "properties": { + "id": { + "$ref": "#/components/schemas/ModelId" + }, + "evidence": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Administrator attestation of real native tool workflow acceptance; model catalog membership alone is not verification." + }, + "endpoint_revision": { + "type": "string", + "description": "Tool-workflow attestation applies only to this endpoint revision." + } + } }, - "AnswerAction": { + "ModelSettingsPatch": { "type": "object", "additionalProperties": false, - "required": ["decision"], + "minProperties": 1, "properties": { - "decision": { + "default_model": { "type": "string", - "enum": ["accept", "decline"] + "nullable": true, + "maxLength": 128, + "pattern": "^$|^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$", + "description": "null or empty string clears the default; responses normalize to null." + }, + "allowed_models": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/components/schemas/ModelId" + } + }, + "verified_models": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModelVerification" + } } } }, - "SessionHandoff": { + "ModelSettings": { "type": "object", + "required": [ + "object", + "default_model", + "bot_defaults", + "allowed_models", + "verified_models" + ], "properties": { - "thread_id": { - "type": "string" + "object": { + "type": "string", + "enum": [ + "node.model_settings" + ] }, - "bot_id": { - "type": "string" + "default_model": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$", + "nullable": true, + "description": "null means no default model. New sessions must select model explicitly unless this bot has an approved default." }, - "target": { + "allowed_models": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/components/schemas/ModelId" + } + }, + "verified_models": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModelVerification" + } + }, + "bot_defaults": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ModelId" + } + } + } + }, + "ModelList": { + "type": "object", + "required": [ + "object", + "data", + "has_more", + "default_model" + ], + "properties": { + "object": { "type": "string", "enum": [ - "codex_cli" + "list" ] }, - "replayed": { + "has_more": { "type": "boolean", "enum": [ false ] + }, + "default_model": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$", + "nullable": true, + "description": "null means no default model. New sessions must select model explicitly unless this bot has an approved default." + }, + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "name", + "approved", + "verified", + "verification_evidence" + ], + "properties": { + "id": { + "$ref": "#/components/schemas/ModelId" + }, + "name": { + "type": "string" + }, + "approved": { + "type": "boolean" + }, + "verified": { + "type": "boolean" + }, + "verification_evidence": { + "type": "string", + "nullable": true + }, + "verification_level": { + "type": "string", + "enum": [ + "unverified", + "connection", + "tool_workflow" + ] + }, + "reasoning_efforts": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "endpoint_revision": { + "type": "string" } - }, - "required": [ - "thread_id", - "bot_id", - "target", - "replayed" - ] + } }, - "ClipboardRead": { + "BotModelPatch": { "type": "object", + "additionalProperties": false, + "required": [ + "default_model" + ], "properties": { - "text": { - "type": "string" + "default_model": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$", + "nullable": true, + "description": "null clears this override and inherits the service default." } - }, - "required": [ - "text" - ] + } }, - "ClipboardWrite": { + "BotModelSettings": { "type": "object", + "required": [ + "object", + "agent_id", + "default_model", + "effective_model" + ], "properties": { - "written": { - "type": "boolean", + "object": { + "type": "string", "enum": [ - true + "node.bot_model_settings" ] + }, + "agent_id": { + "$ref": "#/components/schemas/Identifier" + }, + "default_model": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$", + "nullable": true + }, + "effective_model": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$", + "nullable": true, + "description": "null means no default model. New sessions must select model explicitly unless this bot has an approved default." } - }, - "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": { + "TokenHubModel": { "type": "object", + "required": [ + "id", + "name", + "reasoning_efforts" + ], "properties": { - "backup_id": { + "id": { + "$ref": "#/components/schemas/ModelId" + }, + "name": { "type": "string" }, - "preserved": { + "reasoning_efforts": { "type": "array", "items": { - "type": "string", - "enum": [ - "source", - "uncommitted_changes", - "conversation", - "logs" - ] + "type": "string" } - }, - "processes_restored": { - "type": "boolean", - "enum": [ - false - ], - "description": "Running processes are never restored" } - }, - "required": [ - "backup_id", - "preserved", - "processes_restored" - ] + } }, - "EnvironmentRecreate": { + "TokenHubTest": { "type": "object", + "required": [ + "model", + "reasoning_effort", + "status", + "verified_at", + "verification_level" + ], "properties": { - "running": { - "type": "boolean" + "model": { + "$ref": "#/components/schemas/ModelId" }, - "preserved_files": { - "type": "boolean", - "enum": [ - true - ] + "reasoning_effort": { + "type": "string", + "nullable": true }, - "processes_restored": { - "type": "boolean", - "enum": [ - false - ] - } - }, - "required": [ - "running", - "preserved_files", - "processes_restored" - ] - }, - "EnvironmentRestore": { - "type": "object", - "properties": { - "restored": { - "type": "boolean", + "status": { + "type": "string", "enum": [ - true + "passed" ] }, - "processes_restored": { - "type": "boolean", + "verified_at": { + "type": "string" + }, + "verification_level": { + "type": "string", "enum": [ - false + "connection" ] } - }, - "required": [ - "restored", - "processes_restored" - ] + } }, - "User": { + "TokenHubSettings": { "type": "object", + "required": [ + "object", + "revision", + "base_url", + "wire_api", + "key_saved", + "models", + "tests" + ], "properties": { "object": { "type": "string", "enum": [ - "node.user" + "node.tokenhub" ] }, - "id": { - "type": "string", - "description": "owner or nuser_<24 hex>" - }, - "name": { + "revision": { "type": "string" }, - "disabled": { - "type": "boolean" - }, - "created_at": { - "type": "integer" + "base_url": { + "type": "string" }, - "disabled_at": { - "type": "integer" - } - }, - "required": [ - "object", - "id", - "name", - "disabled" - ] - }, - "UserList": { - "type": "object", - "properties": { - "object": { + "wire_api": { "type": "string", "enum": [ - "list" + "chat", + "responses" ] }, - "data": { + "key_saved": { + "type": "boolean" + }, + "models": { "type": "array", "items": { - "$ref": "#/components/schemas/User" + "$ref": "#/components/schemas/TokenHubModel" } }, - "has_more": { - "type": "boolean" - }, - "first_id": { - "type": [ - "string", - "null" - ] - }, - "last_id": { - "type": [ - "string", - "null" - ] + "tests": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TokenHubTest" + } } - }, - "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" - ] - } - } + "TokenHubPatch": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "base_url": { + "type": "string", + "description": "HTTPS API base or literal loopback HTTP base. No credentials, query or fragment. Saving changes creates immutable endpoint revision; changing URL without api_key clears prior secret." }, - { - "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}$" - } - } + "wire_api": { + "type": "string", + "enum": [ + "chat", + "responses" + ] + }, + "api_key": { + "type": "string", + "minLength": 1, + "maxLength": 8192, + "writeOnly": true, + "description": "Encrypted on Mac; never returned, exported to Linux or shown in logs." } - ] + } }, - "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.", + "TokenHubTestInput": { "type": "object", + "additionalProperties": false, "required": [ - "object", - "id", - "name", - "running" + "model" ], "properties": { - "object": { + "model": { + "$ref": "#/components/schemas/ModelId" + }, + "reasoning_effort": { "type": "string", + "nullable": true, "enum": [ - "node.application" + null, + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra" ] - }, - "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": { + "TokenHubModelList": { "type": "object", "required": [ "object", "data", - "has_more" + "has_more", + "revision" ], "properties": { "object": { @@ -4135,23 +5068,62 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Application" + "$ref": "#/components/schemas/TokenHubModel" } }, "has_more": { - "type": "boolean" + "type": "boolean", + "enum": [ + false + ] }, - "first_id": { - "type": [ - "string", - "null" + "revision": { + "type": "string" + } + } + }, + "TokenHubTestResponse": { + "type": "object", + "required": [ + "model", + "reasoning_effort", + "status", + "verified_at", + "verification_level", + "object", + "revision" + ], + "properties": { + "model": { + "$ref": "#/components/schemas/ModelId" + }, + "reasoning_effort": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "passed" ] }, - "last_id": { - "type": [ - "string", - "null" + "verified_at": { + "type": "string" + }, + "verification_level": { + "type": "string", + "enum": [ + "connection" + ] + }, + "object": { + "type": "string", + "enum": [ + "node.model_test" ] + }, + "revision": { + "type": "string" } } } diff --git a/tools/node-agent-api/runtime/applications.mjs b/tools/node-agent-api/runtime/applications.mjs index e395396..70274dd 100644 --- a/tools/node-agent-api/runtime/applications.mjs +++ b/tools/node-agent-api/runtime/applications.mjs @@ -15,10 +15,10 @@ export function parseClientList(output) { return [...source.matchAll(/0x[0-9a-f]+/gi)].map(match => match[0].toLowerCase()); } -function unquoteXprop(value) { +function unquoteXprop(value, encoding) { 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 value.slice(1, -1).replace(/((?:\\[0-7]{3})+)|\\([^0-7])/g, (_, octal, escape) => { + if (octal) return Buffer.from([...octal.matchAll(/\\([0-7]{3})/g)].map(match => Number.parseInt(match[1], 8))).toString(encoding); return { n: '\n', r: '\r', t: '\t', '\\': '\\', '"': '"', "'": "'", a: '\x07', b: '\b', s: ' ' }[escape] ?? ''; }); } @@ -27,10 +27,11 @@ 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; + const property = /^(?:_NET_WM_NAME|WM_NAME)\(([^)]*)\)\s*=/.exec(line); + if (!property) 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 unquoteXprop(value, property[1] === 'UTF8_STRING' ? 'utf8' : 'latin1'); } return ''; } @@ -73,7 +74,7 @@ export async function listApplications({ execFileImpl = defaultExecFile, sanitize = false, } = {}) { - const env = { ...process.env, DISPLAY: display }; + const env = { ...process.env, DISPLAY: display, LC_ALL: 'C.UTF-8' }; let ids = []; try { const root = await exec(execFileImpl, 'xprop', ['-root', '_NET_CLIENT_LIST'], { env }); diff --git a/tools/node-agent-api/runtime/codex.mjs b/tools/node-agent-api/runtime/codex.mjs index 495286f..66c140c 100644 --- a/tools/node-agent-api/runtime/codex.mjs +++ b/tools/node-agent-api/runtime/codex.mjs @@ -23,7 +23,7 @@ export async function prepareCodex({ namespace, botId, stateRoot, runtime, codex } 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"', + ...(model ? [`model = ${JSON.stringify(model)}`] : []), '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', diff --git a/tools/node-agent-api/runtime/harness.mjs b/tools/node-agent-api/runtime/harness.mjs index 2214e8b..7b267af 100644 --- a/tools/node-agent-api/runtime/harness.mjs +++ b/tools/node-agent-api/runtime/harness.mjs @@ -2,6 +2,7 @@ import { spawn } from 'node:child_process'; import { isAbsolute } from 'node:path'; import { EventEmitter } from 'node:events'; import { prepareCodex } from './codex.mjs'; +import { modelId } from '../models.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'] }]; @@ -13,21 +14,36 @@ export function validateBotId(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'], + 'environment/info': [], 'thread/start': ['model', 'providerConnection', 'reasoningEffort'], 'thread/list': ['cursor'], + 'thread/read': ['threadId'], 'thread/resume': ['threadId', 'model', 'providerConnection', 'reasoningEffort'], 'thread/unsubscribe': ['threadId'], 'thread/backgroundTerminals/terminate': ['threadId', 'processId'], - 'turn/start': ['threadId', 'input'], 'turn/interrupt': ['threadId', 'turnId'], + 'turn/start': ['threadId', 'input', 'model', 'reasoningEffort'], '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 (params.model !== undefined) modelId(params.model); + let safe = { ...params }; + if (params.reasoningEffort !== undefined) { + if (!['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'].includes(params.reasoningEffort)) throw new Error('Invalid reasoning effort'); + delete safe.reasoningEffort; + if (method === 'turn/start') safe.effort = params.reasoningEffort; + else safe.config = { model_reasoning_effort: params.reasoningEffort }; + } + if (params.providerConnection !== undefined) { + const connection = params.providerConnection, url = new URL(connection.baseUrl); + if (Object.keys(connection).some(k => !['baseUrl', 'token'].includes(k)) || url.hostname !== '127.0.0.1' || url.protocol !== 'http:' || !/^\/th_[a-f0-9]{24}\/(default|none|minimal|low|medium|high|xhigh|max|ultra)\/v1$/.test(url.pathname) || !/^[A-Za-z0-9_-]{43}$/.test(connection.token)) throw new Error('Managed Mac TokenHub relay required'); + delete safe.providerConnection; + safe.modelProvider = 'node_tokenhub'; + safe.config = { ...safe.config, 'model_providers.node_tokenhub': { name: 'Node Agent TokenHub', base_url: connection.baseUrl, wire_api: 'responses', requires_openai_auth: false, supports_websockets: false, experimental_bearer_token: connection.token } }; + } 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/start' || method === 'turn/start') return { ...safe, 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; + return safe; } export function createHarness({ runtime, namespace, stateRoot, model, baseUrl, binary = 'codex', spawnProcess = spawn, timeoutMs = 60000 }) { @@ -112,7 +128,7 @@ export function createHarness({ runtime, namespace, stateRoot, model, baseUrl, b 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 request(botId, method, params) { if (params?.providerConnection?.token) secrets.add(params.providerConnection.token); 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'); diff --git a/tools/node-agent-api/runtime/project.mjs b/tools/node-agent-api/runtime/project.mjs index 072b7c8..7cf0900 100644 --- a/tools/node-agent-api/runtime/project.mjs +++ b/tools/node-agent-api/runtime/project.mjs @@ -5,7 +5,7 @@ const action=process.argv[1],input=JSON.parse(process.argv[2]||'{}'),root=proces 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}));} +if(action==='import'){if(!Array.isArray(input.files)||input.files.length>200)throw Error('Invalid file set');let total=0;const files=[],conflicts=[];for(const file of input.files){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)){conflicts.push(file.path);continue}files.push({target,content:file.content})}if(conflicts.length){console.log(JSON.stringify({imported:0,conflicts}))}else{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 index 5390e63..bcc5a4d 100644 --- a/tools/node-agent-api/server.mjs +++ b/tools/node-agent-api/server.mjs @@ -12,8 +12,10 @@ import { createGovernance } from './governance.mjs'; import { createWebhookOutbox } from './webhooks.mjs'; import { readFile, appendFile } from 'node:fs/promises'; import path from 'node:path'; +import { createModelPolicy } from './models.mjs'; +import { createTokenHub } from './tokenhub.mjs'; -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 } : {}) }); +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.model ? { model: value.model, model_source: value.model_source } : {}), ...(value.endpoint_revision ? { endpoint_revision: value.endpoint_revision, reasoning_effort: value.reasoning_effort ?? null } : {}), ...(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); @@ -24,8 +26,11 @@ export async function startNodeAgentApi(options) { } catch (error) { await release(); throw error; } } -async function startUnlocked({ adapter, stateDirectory, port = 0, pollIntervalMs = 2000, governanceOptions = {} }) { +async function startUnlocked({ adapter, stateDirectory, port = 0, pollIntervalMs = 2000, governanceOptions = {}, modelOptions, tokenhubOptions = {} }) { const auth = await createAuth(stateDirectory), store = await createStore(stateDirectory); + const tokenhub = modelOptions && adapter.independentSessions ? await createTokenHub(stateDirectory, tokenhubOptions) : null; + adapter.setTokenHub?.(tokenhub); + const models = modelOptions && adapter.independentSessions ? await createModelPolicy(stateDirectory, { ...modelOptions, tokenhub }) : null; 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 }); @@ -83,8 +88,36 @@ async function startUnlocked({ adapter, stateDirectory, port = 0, pollIntervalMs 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 }, + models: { selection: !!models, management: !!models && principal.id === 'owner', immutable_session: !!models }, + tokenhub: { configuration: !!tokenhub && principal.id === 'owner', actual_tests: !!tokenhub }, }); if (url.pathname === '/v1/usage' && req.method === 'GET') return respond(res, 200, await governance.usage(quotaPrincipal)); + if (url.pathname === '/v1/settings/tokenhub' || url.pathname.startsWith('/v1/settings/tokenhub/')) { + auth.requireOwner(principal); + if (!tokenhub) throw new ApiError(409, 'unsupported', 'TokenHub is unavailable on this backend'); + if (url.pathname === '/v1/settings/tokenhub' && req.method === 'GET') return respond(res, 200, await tokenhub.settings()); + if (url.pathname === '/v1/settings/tokenhub' && req.method === 'PATCH') return respond(res, 200, await tokenhub.update(await body(req))); + if (url.pathname === '/v1/settings/tokenhub/models' && req.method === 'POST') return respond(res, 200, await tokenhub.refresh()); + if (url.pathname === '/v1/settings/tokenhub/test' && req.method === 'POST') return respond(res, 200, await tokenhub.test(await body(req))); + } + if (url.pathname === '/v1/models' && req.method === 'GET') { + if (!models) throw new ApiError(409, 'unsupported', 'Model selection is unavailable on this backend'); + const bot = url.searchParams.get('agent_id'); auth.requireScope(principal, 'agents.read', bot ? identifier(bot) : undefined); + if (bot) await adapter.requireAgent(bot); + return respond(res, 200, await models.list(bot)); + } + if (url.pathname === '/v1/settings/models') { + auth.requireOwner(principal); + if (!models) throw new ApiError(409, 'unsupported', 'Model selection is unavailable on this backend'); + if (req.method === 'GET') return respond(res, 200, await models.settings()); + if (req.method === 'PATCH') return respond(res, 200, { object: 'node.model_settings', ...await models.update(await body(req)) }); + } + if (parts[1] === 'agents' && parts.length === 4 && parts[3] === 'model') { + auth.requireOwner(principal); identifier(parts[2]); await adapter.requireAgent(parts[2]); + if (!models) throw new ApiError(409, 'unsupported', 'Model selection is unavailable on this backend'); + if (req.method === 'GET') return respond(res, 200, await models.botSettings(parts[2])); + if (req.method === 'PATCH') return respond(res, 200, await models.updateBot(parts[2], await body(req))); + } 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); } @@ -113,12 +146,14 @@ async function startUnlocked({ adapter, stateDirectory, port = 0, pollIntervalMs 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 input = object(await body(req), ['agent_id', 'agent', 'metadata', 'model', 'reasoning_effort']); 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 (input.model !== undefined && !models) throw new ApiError(409, 'unsupported', 'Model selection is unavailable on this backend'); + const selected = models ? await models.resolve(agentId, input.model, input.reasoning_effort) : {}; + const result = await store.create(agentId, input.metadata === undefined ? {} : metadata(input.metadata), { reuse: !adapter.independentSessions, quota_user_id: quotaPrincipal.id, ...selected }); if (result.created) await adapter.bindSession?.(result.session); return respond(res, result.created ? 201 : 200, publicSession(result.session)); } @@ -131,7 +166,7 @@ async function startUnlocked({ adapter, stateDirectory, port = 0, pollIntervalMs 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] === 'import') { const importInput = object(await body(req, 16 * 1024 * 1024), ['files']); if (!Array.isArray(importInput.files)) throw badRequest('files array is required'); return respond(res, 200, await adapter.project(session, 'import', importInput)); } 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') { @@ -181,13 +216,13 @@ async function startUnlocked({ adapter, stateDirectory, port = 0, pollIntervalMs 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] === 'traces' && req.method === 'GET') { await adapter.flush?.(); return respond(res, 200, { object: 'list', data: (session.turns ?? []).map(turn => ({ turn_id: turn.id, ...(turn.model ? { model: turn.model } : {}), 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] === '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','replace_own_control']), 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')) { @@ -227,5 +262,5 @@ async function startUnlocked({ adapter, stateDirectory, port = 0, pollIntervalMs 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; } }; + 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?.(), () => tokenhub?.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 index 7363257..f8ad43c 100644 --- a/tools/node-agent-api/shared-runtime.mjs +++ b/tools/node-agent-api/shared-runtime.mjs @@ -23,6 +23,8 @@ export async function createSharedRuntime({ container = 'grok-node-local-vm', st 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; + let assetGeneration; + const viewerAssets = new Map(); 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'); } @@ -40,7 +42,7 @@ export async function createSharedRuntime({ container = 'grok-node-local-vm', st 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); } }); + return listApplications({ display: ':' + environment.display, sanitize: true, execFileImpl: (file, args, options, callback) => { execFile(docker, lowPrivilege(row.Id, ['env', 'DISPLAY=:' + environment.display, 'LC_ALL=' + options.env.LC_ALL, 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(); }); @@ -67,6 +69,17 @@ export async function createSharedRuntime({ container = 'grok-node-local-vm', st const origin = 'ws://127.0.0.1:' + server.address().port; const runtime = { shared: true, workspaceRoot, roster: bridge, applications, + async viewerAsset(asset) { + 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 row = await info(); + if (assetGeneration !== row.Id) { viewerAssets.clear(); assetGeneration = row.Id; } + if (!viewerAssets.has(asset)) { + const pending = execute(docker, ['exec', row.Id, 'cat', '/usr/share/novnc/' + asset], { encoding: null, timeout: 10000, maxBuffer: 4 * 1024 * 1024 }).then(result => result.stdout); + viewerAssets.set(asset, pending); + pending.catch(() => { if (viewerAssets.get(asset) === pending) viewerAssets.delete(asset); }); + } + return viewerAssets.get(asset); + }, async launchApplication(botId, kind) { if (!['terminal', 'browser'].includes(kind)) throw new ApiError(400, 'application_kind', 'Choose terminal or browser'); const environment = await seat(botId); @@ -118,9 +131,9 @@ export async function createSharedRuntime({ container = 'grok-node-local-vm', st 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; + const n = resourceNames(namespace, botId); let directory; try { directory = await realpath(backupPath); } catch { throw new ApiError(404, 'backup_not_found', 'Backup not found'); } const 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'); + let manifest, archive; try { manifest = JSON.parse(await readFile(path.join(directory, 'manifest.json'), 'utf8')); archive = path.join(directory, 'workspace.tgz'); await lstat(archive); } catch (error) { if (error instanceof ApiError) throw error; throw new ApiError(404, 'backup_not_found', 'Backup not found'); } 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]); diff --git a/tools/node-agent-api/store.mjs b/tools/node-agent-api/store.mjs index 7552d98..ccf78c2 100644 --- a/tools/node-agent-api/store.mjs +++ b/tools/node-agent-api/store.mjs @@ -54,7 +54,9 @@ export async function createStore(directory) { 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 } }; + if (options.model) Object.assign(session, { model: options.model, model_source: options.model_source }); + if (options.endpoint_revision) Object.assign(session, { endpoint_revision: options.endpoint_revision, reasoning_effort: options.reasoning_effort ?? null }); + const event = { id: 'nevt_' + randomUUID(), type: 'node.session.created', session_id: session.id, created_at: session.created_at, data: { agent_id: agentId, ...(session.model ? { model: session.model, model_source: session.model_source } : {}) } }; session.events.push(event); await persist([...sessions.values(), session]); sessions.set(session.id, session); emitter.emit('*', event); return { session, created: true }; }); }, diff --git a/tools/node-agent-api/tokenhub-worker.mjs b/tools/node-agent-api/tokenhub-worker.mjs new file mode 100644 index 0000000..cfd2914 --- /dev/null +++ b/tools/node-agent-api/tokenhub-worker.mjs @@ -0,0 +1,72 @@ +// Mac-only transport adapter. Native Codex continues to own the model/tool loop. +import { createServer } from 'node:http'; +import { createInterface } from 'node:readline'; +import { timingSafeEqual } from 'node:crypto'; + +const lines = createInterface({ input: process.stdin }); +let initialize; +const initial = new Promise(resolve => { initialize = resolve; }); +let config; +lines.on('line', line => { try { const value = JSON.parse(line); if (!config) { config = value; initialize(); } else config.revisions = value.revisions; } catch { process.exit(1); } }); +lines.on('close', () => process.exit(0)); +await initial; +const root = config.root + '/src'; +const [{ parseRequest }, { createOpenAIChatAdapter }, { bridgeToResponsesSSE }, { buildResponseJSON }, { createTranslatorBudget }] = await Promise.all([ + import(root + '/responses/parser.ts'), import(root + '/adapters/openai-chat.ts'), import(root + '/bridge/sse.ts'), import(root + '/bridge/response-json.ts'), import(root + '/lib/translator-budget.ts'), +]); +const server = createServer(async (req, res) => { + let budget; + const abort = new AbortController(); + res.on('close', () => abort.abort()); + try { + const supplied = Buffer.from(req.headers.authorization ?? ''), expected = Buffer.from('Bearer ' + config.token); + if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) { res.writeHead(401); res.end(); return; } + const match = /^\/(th_[a-f0-9]{24})\/(default|none|minimal|low|medium|high|xhigh|max|ultra)\/v1\/responses$/.exec(req.url ?? ''), row = config.revisions.find(r => r.id === match?.[1]); + if (req.method !== 'POST' || !row) { res.writeHead(404); res.end(); return; } + let bytes = 0; const chunks = []; + for await (const data of req) { bytes += data.length; if (bytes > 16 * 1024 * 1024) throw new Error('Request too large'); chunks.push(data); } + const body = JSON.parse(Buffer.concat(chunks)); + if (match[2] === 'default') delete body.reasoning; + else body.reasoning = { effort: match[2] }; + const timer = setTimeout(() => abort.abort(), 10 * 60 * 1000); + let upstream; + try { + if (row.wire_api === 'responses') { + upstream = await fetch(row.base_url + '/responses', { method: 'POST', headers: { 'content-type': 'application/json', ...(row.api_key ? { authorization: 'Bearer ' + row.api_key } : {}) }, body: JSON.stringify(body), redirect: 'error', signal: abort.signal }); + if (!upstream.ok) { res.writeHead(upstream.status, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'TokenHub HTTP ' + upstream.status, type: 'upstream_rejected' } })); return; } + res.writeHead(upstream.status, { 'content-type': upstream.headers.get('content-type') ?? 'application/json' }); + for await (const chunk of upstream.body) { if (!res.write(chunk)) await new Promise(resolve => res.once('drain', resolve)); } + } else { + budget = createTranslatorBudget(); + const parsed = parseRequest(body), adapter = createOpenAIChatAdapter({ adapter: 'openai-chat', baseUrl: row.base_url, apiKey: row.api_key, authMode: row.api_key ? 'key' : 'none', keyOptional: !row.api_key, codexToolMode: 'shell' }); + const request = await adapter.buildRequest(parsed, { headers: new Headers(), translatorBudget: budget, abortSignal: abort.signal }); + if (match[2] === 'none') { + const chatBody = JSON.parse(request.body); + if (/flash/i.test(body.model)) chatBody.reasoning_effort = 'low'; + else if (/^minimax[-_]?m3/i.test(body.model)) { delete chatBody.reasoning_effort; chatBody.thinking = { type: 'disabled' }; } + else throw new Error('Unsupported disable-thinking contract'); + request.body = JSON.stringify(chatBody); + } + upstream = await fetch(request.url, { method: request.method, headers: request.headers, body: request.body, redirect: 'error', signal: abort.signal }); + if (!upstream.ok) { res.writeHead(upstream.status, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'TokenHub HTTP ' + upstream.status, type: 'upstream_rejected' } })); return; } + const tools = new Map(parsed.context.tools.filter(t => t.namespace || t.freeform).map(t => [t.name, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) }])); + const freeform = new Set(parsed.context.tools.filter(t => t.freeform).map(t => t.name)); + if (parsed.stream) { + const response = bridgeToResponsesSSE(adapter.parseStream(upstream, budget, request.tierLog), parsed.modelId, tools, freeform, undefined, () => abort.abort(), 2000, { translatorBudget: budget, declaredToolNames: new Set(parsed.context.tools.map(t => t.name)) }); + res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store' }); + for await (const chunk of response) { if (!res.write(chunk)) await new Promise(resolve => res.once('drain', resolve)); } + } else { + const events = await adapter.parseResponse(upstream, budget, request.tierLog); + res.writeHead(200, { 'content-type': 'application/json' }); res.write(JSON.stringify(buildResponseJSON(events, parsed.modelId, { translatorBudget: budget, toolNsMap: tools }))); + } + } + res.end(); + } finally { clearTimeout(timer); } + } catch { + if (!res.headersSent) { res.writeHead(502, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { type: 'tokenhub_transport_error', message: 'TokenHub transport or protocol failed; no fallback' } })); } + else res.destroy(); + } finally { budget?.dispose(); } +}); +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +console.log(JSON.stringify({ port: server.address().port })); +process.once('SIGTERM', () => { server.close(); process.exit(0); }); diff --git a/tools/node-agent-api/tokenhub.mjs b/tools/node-agent-api/tokenhub.mjs new file mode 100644 index 0000000..b3581a5 --- /dev/null +++ b/tools/node-agent-api/tokenhub.mjs @@ -0,0 +1,130 @@ +import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto'; +import { readFile, writeFile, open, chmod } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { spawn } from 'node:child_process'; +import { ApiError, badRequest, object } from './errors.mjs'; +import { atomicJson, readPrivateJson } from './persistence.mjs'; +import { modelId } from './models.mjs'; +import { privateDirectory } from './runtime/common.mjs'; + +export const EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']; +export function endpointUrl(value) { + let url; + try { url = new URL(value); } catch { throw badRequest('Invalid model API URL'); } + if (url.username || url.password || url.search || url.hash || (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname)))) throw badRequest('Use HTTPS or a local HTTP model API, without credentials or query parameters'); + if (['169.254.169.254', 'metadata.google.internal'].includes(url.hostname)) throw badRequest('Metadata endpoints are not model APIs'); + return url.href.replace(/\/+$/, ''); +} + +export async function createTokenHub(directory, { baseUrl = 'http://127.0.0.1:10100/v1', fetcher = fetch, bun = join(homedir(), '.bun/bin/bun'), opencodexRoot = join(homedir(), '.npm-global/lib/node_modules/@bitkyc08/opencodex') } = {}) { + await privateDirectory(directory); + const file = join(directory, 'tokenhub.json'), keyFile = join(directory, 'tokenhub.master.key'); + let master; + try { const handle = await open(keyFile, constants.O_RDONLY | constants.O_NOFOLLOW); try { if (!(await handle.stat()).isFile()) throw new Error('Unsafe TokenHub key file'); master = await handle.readFile(); } finally { await handle.close(); } await chmod(keyFile, 0o600); } + catch (error) { if (error.code !== 'ENOENT') throw error; master = randomBytes(32); await writeFile(keyFile, master, { flag: 'wx', mode: 0o600 }); } + if (master.length !== 32) throw new Error('Invalid TokenHub storage key'); + function encrypt(value) { const iv = randomBytes(12), cipher = createCipheriv('aes-256-gcm', master, iv), data = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]); return { iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), data: data.toString('base64') }; } + function decrypt(value) { if (!value) return ''; const decipher = createDecipheriv('aes-256-gcm', master, Buffer.from(value.iv, 'base64')); decipher.setAuthTag(Buffer.from(value.tag, 'base64')); return Buffer.concat([decipher.update(Buffer.from(value.data, 'base64')), decipher.final()]).toString('utf8'); } + let state = await readPrivateJson(file, { active: 'opencodex', revisions: [{ id: 'opencodex', base_url: endpointUrl(baseUrl), wire_api: 'responses', key: null, models: [], tests: [] }] }); + if (!Array.isArray(state.revisions) || !state.revisions.some(r => r.id === state.active)) throw new Error('Invalid TokenHub state'); + for (const r of state.revisions) { endpointUrl(r.base_url); if (!['chat', 'responses'].includes(r.wire_api)) throw new Error('Invalid TokenHub protocol'); decrypt(r.key); } + let saving = Promise.resolve(), fault, worker; + const bridgeToken = randomBytes(32).toString('base64url'); + const current = () => { if (fault) throw fault; return state.revisions.find(r => r.id === state.active); }; + const publicSettings = row => ({ object: 'node.tokenhub', revision: row.id, base_url: row.base_url, wire_api: row.wire_api, key_saved: !!row.key, models: row.models, tests: row.tests }); + function redact(error) { let message = String(error?.message ?? error); for (const row of state.revisions) { const key = decrypt(row.key); if (key) message = message.split(key).join('[redacted]'); } return message.replace(/Bearer\s+\S+/gi, 'Bearer [redacted]').slice(0, 600); } + function mutate(operation) { + const task = saving.then(async () => { + if (fault) throw fault; + const next = structuredClone(state), result = await operation(next); + try { await atomicJson(file, next); } catch { fault = new ApiError(503, 'tokenhub_persistence_failed', 'TokenHub settings could not be saved'); throw fault; } + state = next; if (worker) worker.child.stdin.write(JSON.stringify({ revisions: state.revisions.map(r => ({ ...r, api_key: decrypt(r.key), key: undefined })) }) + '\n'); + return result; + }); saving = task.catch(() => {}); return task; + } + async function request(row, suffix, body, signal) { + const key = decrypt(row.key), headers = { 'content-type': 'application/json', ...(key ? { authorization: 'Bearer ' + key } : {}) }; + let res; + try { res = await fetcher(row.base_url + suffix, { method: body ? 'POST' : 'GET', headers, body: body ? JSON.stringify(body) : undefined, redirect: 'error', signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(45000)]) : AbortSignal.timeout(45000) }); } + catch (error) { throw new ApiError(502, 'model_endpoint_unavailable', 'Model API request failed: ' + redact(error)); } + if (!res.ok) throw new ApiError(502, 'model_endpoint_rejected', 'Model API returned HTTP ' + res.status); + const chunks = []; let bytes = 0; + for await (const chunk of res.body) { bytes += chunk.byteLength; if (bytes > 2 * 1024 * 1024) throw new ApiError(502, 'model_endpoint_schema', 'Model API response exceeds limit'); chunks.push(Buffer.from(chunk)); } + const text = Buffer.concat(chunks).toString('utf8'); + try { return JSON.parse(text); } catch { throw new ApiError(502, 'model_endpoint_schema', 'Model API returned invalid JSON'); } + } + async function bridge() { + if (worker) return worker.ready; + const child = spawn(bun, [join(import.meta.dirname, 'tokenhub-worker.mjs')], { stdio: ['pipe', 'pipe', 'pipe'], env: { PATH: process.env.PATH, HOME: directory } }); + let buffer = '', settled = false; + const ready = new Promise((resolve, reject) => { + const timer = setTimeout(() => { child.kill(); reject(new ApiError(503, 'tokenhub_bridge_unavailable', 'TokenHub protocol adapter did not start')); }, 10000); + child.once('error', () => { clearTimeout(timer); worker = undefined; reject(new ApiError(503, 'tokenhub_bridge_unavailable', 'TokenHub chat adapter requires installed Bun and opencodex')); }); + child.stdout.on('data', data => { buffer += data; const end = buffer.indexOf('\n'); if (end < 0 || settled) return; settled = true; clearTimeout(timer); try { const value = JSON.parse(buffer.slice(0, end)); if (!value.port) throw new Error(); resolve(value.port); } catch { reject(new ApiError(503, 'tokenhub_bridge_unavailable', 'TokenHub protocol adapter failed to start')); } }); + child.once('exit', () => { clearTimeout(timer); worker = undefined; if (!settled) reject(new ApiError(503, 'tokenhub_bridge_unavailable', 'TokenHub protocol adapter stopped')); }); + }); + worker = { child, ready }; child.stderr.on('data', () => {}); + child.stdin.write(JSON.stringify({ root: opencodexRoot, token: bridgeToken, revisions: state.revisions.map(r => ({ ...r, api_key: decrypt(r.key), key: undefined })) }) + '\n'); + return ready; + } + return { + redact, + async settings() { await saving; return publicSettings(current()); }, + update(input) { + object(input, ['base_url', 'api_key', 'wire_api']); + if (!Object.keys(input).length) throw badRequest('TokenHub patch is empty'); + const copy = structuredClone(input); + if (copy.api_key !== undefined && (typeof copy.api_key !== 'string' || !copy.api_key.trim() || copy.api_key.length > 8192 || /[\r\n]/.test(copy.api_key))) throw badRequest('Invalid API key'); + if (copy.wire_api !== undefined && !['chat', 'responses'].includes(copy.wire_api)) throw badRequest('Invalid model API protocol'); + if (copy.base_url !== undefined) copy.base_url = endpointUrl(copy.base_url); + return mutate(next => { + const prior = next.revisions.find(r => r.id === next.active); + const row = { id: 'th_' + randomBytes(12).toString('hex'), base_url: copy.base_url ?? prior.base_url, wire_api: copy.wire_api ?? (copy.base_url ? 'chat' : prior.wire_api), key: copy.api_key === undefined ? prior.key : encrypt(copy.api_key.trim()), models: [], tests: [] }; + if (row.base_url !== prior.base_url && copy.api_key === undefined) row.key = null; + next.revisions.push(row); next.active = row.id; return publicSettings(row); + }); + }, + async refresh() { + await saving; const row = structuredClone(current()), data = await request(row, '/models'); + if (!Array.isArray(data.data) || !data.data.length) throw new ApiError(502, 'model_endpoint_schema', 'Model API returned no model list'); + const models = [...new Map(data.data.map(item => { + const id = modelId(item.id), advertised = item.supported_reasoning_levels ?? item.reasoning_efforts ?? []; + if (!Array.isArray(advertised)) throw new ApiError(502, 'model_endpoint_schema', 'Invalid model reasoning capabilities'); + const efforts = advertised.map(x => typeof x === 'string' ? x : x?.effort).filter(x => EFFORTS.includes(x)); + return [id, { id, name: item.name ?? item.display_name ?? id, reasoning_efforts: [...new Set(efforts)] }]; + })).values()]; + return mutate(next => { if (next.active !== row.id) throw new ApiError(409, 'endpoint_changed', 'TokenHub endpoint changed; refresh again'); next.revisions.find(r => r.id === row.id).models = models; return { object: 'list', data: models, has_more: false, revision: row.id }; }); + }, + async test(input) { + object(input, ['model', 'reasoning_effort']); const id = modelId(input.model), effort = input.reasoning_effort ?? null; + if (effort !== null && !EFFORTS.includes(effort)) throw badRequest('Unsupported reasoning effort'); + await saving; const row = structuredClone(current()); + if (!row.models.some(m => m.id === id)) throw badRequest('Refresh models and select a model from this endpoint first'); + const body = row.wire_api === 'chat' + ? { model: id, messages: [{ role: 'user', content: 'Reply OK.' }], max_tokens: 64, ...(effort === null ? {} : { reasoning_effort: effort }) } + : { model: id, input: 'Reply OK.', max_output_tokens: 64, ...(effort === null ? {} : { reasoning: { effort } }) }; + if (effort === 'none' && row.wire_api === 'chat') { + if (/flash/i.test(id)) body.reasoning_effort = 'low'; + else if (/^minimax[-_]?m3/i.test(id)) { delete body.reasoning_effort; body.thinking = { type: 'disabled' }; } + else throw badRequest('This chat model has no known disable-thinking contract; use the endpoint default'); + } + const data = await request(row, row.wire_api === 'chat' ? '/chat/completions' : '/responses', body); + const text = row.wire_api === 'chat' ? data.choices?.[0]?.message?.content : (data.output_text ?? data.output?.flatMap(x => x.content ?? []).map(x => x.text ?? '').join('')); + if (typeof text !== 'string' || !text.trim()) throw new ApiError(502, 'model_test_incomplete', 'Model API returned no text; connection is not verified'); + const result = { model: id, reasoning_effort: effort, status: 'passed', verified_at: new Date().toISOString(), verification_level: 'connection' }; + return mutate(next => { if (next.active !== row.id) throw new ApiError(409, 'endpoint_changed', 'TokenHub endpoint changed during test'); const target = next.revisions.find(r => r.id === row.id); target.tests = [...target.tests.filter(t => !(t.model === id && t.reasoning_effort === effort)), result]; return { object: 'node.model_test', ...result, revision: row.id }; }); + }, + async catalog() { await saving; const row = current(); return { revision: row.id, external: row.id !== 'opencodex', models: structuredClone(row.models), tests: structuredClone(row.tests) }; }, + async connection(revision, reasoningEffort = null) { + await saving; const row = state.revisions.find(r => r.id === revision); + if (!row) throw new ApiError(409, 'endpoint_missing', 'Saved session endpoint is unavailable'); + if (revision === 'opencodex') return null; + const port = await bridge(); + return { baseUrl: `http://127.0.0.1:${port}/${row.id}/${reasoningEffort ?? 'default'}/v1`, token: bridgeToken }; + }, + async activeRevision() { await saving; return current().id; }, + async close() { await saving; if (worker) { worker.child.kill(); worker = undefined; } }, + }; +} diff --git a/tools/node-agent-api/web/README.md b/tools/node-agent-api/web/README.md index aa58bd5..96266a5 100644 --- a/tools/node-agent-api/web/README.md +++ b/tools/node-agent-api/web/README.md @@ -1,9 +1,15 @@ # 中文薄客户端接入契约 +桌面重复接管:整桌面观看/接管与独立应用接管发送 replace_own_control:true,显式替换同密钥旧控制租约;独立应用观看不替换。控制权只在新票据成功兑换后移交,另一个 credential 保持 409。旧票据遇到更新的控制权不得覆盖;无静默抢占或自动回落。 + +模型选择见[接口说明](../../../docs/NODE_AGENT_API_MODELS.md)。页面通过 capabilities.models.selection 查询是否支持,获准且通过当前端点连接测试或工具链验收的模型才可选。owner 通过 capabilities.models.management 展开默认值和清单管理;普通用户隐藏管理区。默认为空时需显式选择模型。模型目录失败时禁用新建模型会话,已有会话可读、可继续。TokenHub 的地址、密钥、模型刷新和测试见[专题](../../../docs/NODE_AGENT_API_TOKENHUB.md)。 + 入口为 `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 归属和租约检查。 +连接按钮下方显示验证、加载、成功和错误提示;连接期间按钮显示“连接中…”并禁用。密钥可以是文件原内容,也兼容粘贴 Bearer 前缀(客户端只发送一个前缀);认证失败会明确提示粘贴文件内容而非路径或命令。只读 JSON 请求在 30 秒后明确超时,取消保持 AbortError;写操作和 SSE 不应用此超时策略,所有请求均不自动重试。 + ## 能力响应 `GET /v1/capabilities` 的以下字段必须显式为 `true` 才启用;缺失、false、未知值均禁用。能力不是授权,HTTP 错误原样展示。 @@ -40,7 +46,7 @@ | 导入 | `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,成功后移除查看器 | +| 交还 | `POST …/handback {}`,成功后 `POST …/desktop {mode:"view",replace_own_control:true}` | 自动切回只读观看,保留桌面画面;观看重连失败明确提示,不能恢复控制 | | 手动剪贴板 | `GET …/clipboard` / `POST …/clipboard {text}` | 读取 `{text:string}`;写入 JSON 或 204。仅按钮触发,无系统剪贴板 API | 列表消费 `limit=100` 和 `after=last_id` 直到 `has_more=false`,不会静默截断。JSON 错误格式沿用 `{error:{type,message}}`。 diff --git a/tools/node-agent-api/web/app.js b/tools/node-agent-api/web/app.js index bb63dc6..fe37f8a 100644 --- a/tools/node-agent-api/web/app.js +++ b/tools/node-agent-api/web/app.js @@ -6,6 +6,12 @@ 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; +let modelSelection = false, modelManager = false, modelsReady = false; +let modelSettings; +let defaultModel = null; +let authPending = false; +let desktopChanging = false; +let modelRows = [], tokenhubSettings; const cursors = new Map(), liveItems = new Map(), busy = new Map(); const status = value => text('status', value); @@ -13,21 +19,28 @@ 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), + history: Boolean(client && botId), attach: Boolean(client && botId && (!modelSelection || (modelsReady && (defaultModel || $('model').value)))), + model: Boolean(client && botId && modelsReady), 'service-model': modelManager && modelsReady, 'bot-model': modelManager && Boolean(botId) && modelsReady, + 'reasoning-effort': Boolean(client && botId && modelsReady && $('model').value), 'tokenhub-open': Boolean(client && modelManager), + 'save-service-model': modelManager && modelsReady, 'save-bot-model': modelManager && Boolean(botId) && modelsReady, + 'save-allowed-models': modelManager && modelsReady, 'verify-model': modelManager && modelsReady, 'model-evidence': modelManager && modelsReady, 'save-model-evidence': modelManager && modelsReady, + 'tokenhub-key-save': modelManager, 'tokenhub-url-save': modelManager, 'tokenhub-protocol-save': modelManager, 'tokenhub-model-refresh': modelManager, + 'tokenhub-test': modelManager && Boolean($('tokenhub-model').value), 'tokenhub-authorize': modelManager && modelRows.some(row => row.id === $('tokenhub-model').value && row.verified), 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, + view: writable && flags.view && !desktopChanging, control: writable && flags.control && !desktopChanging, handback: attached && flags.control && !desktopChanging, 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), + 'application-view': writable && flags.applications && Boolean($('applications').value) && !desktopChanging, 'application-control': writable && flags.applications && flags.control && Boolean($('applications').value) && !desktopChanging, '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'); + $('connect').disabled = authPending; + $('connect').textContent = authPending ? '连接中…' : '连接'; } async function run(id, action) { @@ -43,6 +56,7 @@ function resetSession() { ++epoch; controller.abort(); controller = new AbortController(); streamController?.abort(); busy.clear(); session = undefined; turnId = ''; sending = false; liveItems.clear(); + desktopChanging = false; $('transcript').replaceChildren(); $('approvals').replaceChildren(); $('desktop').replaceChildren(); $('clipboard').value = ''; $('prompt').value = ''; $('import-file').value = ''; $('applications').replaceChildren(new Option('点击刷新应用', '')); $('backup-id').value = ''; @@ -52,11 +66,16 @@ function resetSession() { controls(); } function disconnect() { + authPending = false; resetSession(); client = undefined; flags = capabilities(); botId = ''; cursors.clear(); $('key').value = ''; $('bots').replaceChildren(new Option('请先连接', '')); $('sessions').replaceChildren(); text('capabilities', '尚未查询'); text('context', '会话语义尚未查询。'); + modelSelection = false; modelManager = false; modelsReady = false; modelSettings = undefined; defaultModel = null; $('model-admin').hidden = true; + $('model').replaceChildren(new Option('使用默认模型', '')); text('model-status', '连接后查询获准模型;已有会话保留原模型。'); text('title', '选择机器人与会话'); text('desktop-status', '未连接'); text('workspace-status', '能力未声明的操作保持禁用。'); status('已断开,访问密钥已从本页清除。'); controls(); + text('auth-status', '已断开。请重新输入访问密钥。'); + $('tokenhub-key').value = ''; $('tokenhub-dialog').close(); tokenhubSettings = undefined; modelRows = []; } const options = extra => ({ signal: controller.signal, ...extra }); async function scopedJSON(path, extra) { @@ -67,6 +86,76 @@ async function scopedJSON(path, extra) { } const post = (path, body) => scopedJSON(path, { method: 'POST', body }); +async function loadModels() { + modelsReady = false; controls(); + if (!modelSelection) { text('model-status', '服务未声明模型选择能力。'); return; } + const selectedBot = botId, generation = epoch; + try { + const value = await scopedJSON('/v1/models' + (selectedBot ? '?agent_id=' + encodeURIComponent(selectedBot) : '')); + if (generation !== epoch || selectedBot !== botId) return; + const approved = value.data.filter(row => row.approved === true && row.verified === true); + modelRows = value.data; + defaultModel = value.default_model; + $('model').replaceChildren(new Option(defaultModel ? '使用默认:' + defaultModel : '请选择模型(默认未设置)', '')); + for (const row of approved) $('model').add(new Option(row.name || row.id, row.id)); + text('model-status', defaultModel ? `默认:${defaultModel}。已有会话保留原模型。` : '未设置默认模型,请为新会话选择模型。'); + if (modelManager) { + const settings = await scopedJSON('/v1/settings/models'); + modelSettings = settings; + const bot = selectedBot ? await scopedJSON(`/v1/agents/${encodeURIComponent(selectedBot)}/model`) : null; + if (generation !== epoch || selectedBot !== botId) return; + $('service-model').replaceChildren(new Option('未设置默认模型', '')); $('bot-model').replaceChildren(new Option('继承服务默认', '')); + for (const row of approved) { $('service-model').add(new Option(row.name || row.id, row.id)); $('bot-model').add(new Option(row.name || row.id, row.id)); } + $('service-model').value = settings.default_model ?? ''; $('bot-model').value = bot?.default_model ?? ''; + $('allowed-models').replaceChildren(); $('verify-model').replaceChildren(); + for (const row of value.data) { + const label = document.createElement('label'), check = document.createElement('input'); + check.type = 'checkbox'; check.value = row.id; check.checked = row.approved; check.disabled = !row.verified; + label.append(check, document.createTextNode(' ' + row.id + (row.verification_level === 'connection' ? ' · 连接已验证' : row.verified ? ' · 工具链已验收' : ' · 待测试'))); $('allowed-models').append(label); + $('verify-model').add(new Option(row.name || row.id, row.id)); + } + } + modelsReady = true; + updateEfforts('model', 'reasoning-effort'); + } catch (error) { if (generation === epoch && selectedBot === botId && error.name !== 'AbortError') text('model-status', '无法读取模型目录:' + error.message); } + controls(); +} +bind('save-service-model', async () => { await scopedJSON('/v1/settings/models', { method: 'PATCH', body: { default_model: $('service-model').value || null } }); await loadModels(); status('服务默认模型已保存;已有会话保持原模型。'); }); +$('model').addEventListener('change', () => { updateEfforts('model', 'reasoning-effort'); controls(); }); +bind('save-bot-model', async () => { await scopedJSON(`/v1/agents/${encodeURIComponent(botId)}/model`, { method: 'PATCH', body: { default_model: $('bot-model').value || null } }); await loadModels(); status('bot 默认模型已保存;已有会话保持原模型。'); }); +bind('save-allowed-models', async () => { const allowed_models = [...$('allowed-models').querySelectorAll('input:checked')].map(input => input.value); await scopedJSON('/v1/settings/models', { method: 'PATCH', body: { allowed_models } }); await loadModels(); status('获准清单已保存。'); }); +bind('save-model-evidence', async () => { const id = $('verify-model').value, evidence = $('model-evidence').value.trim(); if (!evidence) throw new Error('请填写实际工具链验收记录。'); const verified_models = [...modelSettings.verified_models.filter(row => row.id !== id), { id, evidence }]; await scopedJSON('/v1/settings/models', { method: 'PATCH', body: { verified_models } }); $('model-evidence').value = ''; await loadModels(); status('验证记录已登记;可在清单中授权该模型。'); }); + +const effortLabels = { none: '无(不思考)', minimal: '极低', low: '低', medium: '中', high: '高', xhigh: '很高', max: '最高', ultra: 'Ultra' }; +function updateEfforts(modelControl, effortControl) { + const row = modelRows.find(row => row.id === $(modelControl).value); + $(effortControl).replaceChildren(new Option('端点默认', '')); + for (const effort of row?.reasoning_efforts ?? []) $(effortControl).add(new Option(effortLabels[effort] ?? effort, effort)); +} +async function loadTokenHub() { + tokenhubSettings = await scopedJSON('/v1/settings/tokenhub'); + $('tokenhub-url').value = tokenhubSettings.base_url; $('tokenhub-protocol').value = tokenhubSettings.wire_api; + text('tokenhub-key-status', tokenhubSettings.key_saved ? '已加密保存在 Mac;不会回显。' : '未保存密钥。本机 opencodex 可按原配置使用。'); + $('tokenhub-model').replaceChildren(new Option('先重新拉取模型', '')); + for (const row of tokenhubSettings.models) $('tokenhub-model').add(new Option(row.name || row.id, row.id)); + updateEfforts('tokenhub-model', 'tokenhub-effort'); controls(); +} +async function tokenhubAction(message, action) { + text('tokenhub-status', message); + try { await action(); } + catch (error) { text('tokenhub-status', '操作失败:' + error.message); throw error; } +} +bind('tokenhub-open', async () => { $('tokenhub-dialog').showModal(); await tokenhubAction('正在读取端点…', async () => { await loadTokenHub(); text('tokenhub-status', '保存 API 地址与密钥,拉取模型后测试连接。'); }); }); +bind('tokenhub-close', () => { $('tokenhub-key').value = ''; $('tokenhub-dialog').close(); }); +$('tokenhub-dialog').addEventListener('close', () => { $('tokenhub-key').value = ''; }); +$('tokenhub-model').addEventListener('change', () => { updateEfforts('tokenhub-model', 'tokenhub-effort'); $('tokenhub-effort-probe').value = ''; controls(); }); +bind('tokenhub-key-save', () => tokenhubAction('正在保存密钥…', async () => { const api_key = $('tokenhub-key').value.trim(); if (!api_key) throw new Error('请输入模型 API 密钥。'); await scopedJSON('/v1/settings/tokenhub', { method: 'PATCH', body: { api_key } }); $('tokenhub-key').value = ''; await loadTokenHub(); await loadModels(); text('tokenhub-status', '密钥已保存,旧测试失效;请重新拉取模型并测试。'); })); +bind('tokenhub-url-save', () => tokenhubAction('正在保存端点…', async () => { await scopedJSON('/v1/settings/tokenhub', { method: 'PATCH', body: { base_url: $('tokenhub-url').value.trim(), wire_api: $('tokenhub-protocol').value } }); await loadTokenHub(); await loadModels(); text('tokenhub-status', '端点已保存。若地址变更,请重新保存密钥、拉取模型。'); })); +bind('tokenhub-protocol-save', () => tokenhubAction('正在保存协议…', async () => { await scopedJSON('/v1/settings/tokenhub', { method: 'PATCH', body: { wire_api: $('tokenhub-protocol').value } }); await loadTokenHub(); await loadModels(); text('tokenhub-status', '协议已保存,请重新拉取模型。'); })); +bind('tokenhub-model-refresh', () => tokenhubAction('正在从 API 拉取模型…', async () => { await post('/v1/settings/tokenhub/models', {}); await loadModels(); await loadTokenHub(); text('tokenhub-status', '模型已拉取;选择任意模型进行连接测试。'); })); +bind('tokenhub-test', () => tokenhubAction('正在请求模型(少量 token)…', async () => { const reasoning_effort = $('tokenhub-effort-probe').value.trim() || $('tokenhub-effort').value || null; const result = await post('/v1/settings/tokenhub/test', { model: $('tokenhub-model').value, reasoning_effort }); await loadModels(); controls(); text('tokenhub-status', `${result.model} · 连接已验证。可点击授权;尚不代表改代码、测试、审批、取消全链路验收。`); })); +bind('tokenhub-authorize', () => tokenhubAction('正在授权模型…', async () => { const selected = $('tokenhub-model').value; if (!modelRows.some(row => row.id === selected && row.verified)) throw new Error('先测试模型连接。'); const allowed_models = [...new Set([...modelRows.filter(row => row.approved).map(row => row.id), selected])]; await scopedJSON('/v1/settings/models', { method: 'PATCH', body: { allowed_models } }); await loadModels(); text('tokenhub-status', '模型已授权,新会话可以选择。服务默认仍按原设置。'); })); + async function loadBots() { const rows = await client.list('/v1/agents', controller.signal); resetSession(); botId = ''; @@ -147,7 +236,7 @@ async function selectSession(id) { if (value.agent_id !== botId) throw new Error('会话所属机器人不匹配'); session = value; turnId = value.active_turn_id ?? ''; - text('identity', flags.shared ? '机器人共用 Linux 项目与数据,桌面按机器人区分' : '当前机器人使用 Linux 项目环境'); text('thread', '当前会话'); + text('identity', flags.shared ? '机器人共用 Linux 项目与数据,桌面按机器人区分' : '当前机器人使用 Linux 项目环境'); text('thread', '当前会话' + (value.model ? ' · 模型:' + value.model : '')); status(`会话已恢复 · ${stateName(value.task_status ?? value.status)}`); text('turn-status', turnId ? `活动回合:${turnId} · 结果未知` : '尚未观察到权威回合结果'); controls(); @@ -213,16 +302,26 @@ $('auth').addEventListener('submit', event => { event.preventDefault(); const key = $('key').value.trim(); if (!key) return; + if (authPending) return; disconnect(); + authPending = true; + text('auth-status', '正在验证访问密钥…'); status('正在连接本机 API…'); void run('auth', async () => { client = createClient(key, location.origin); try { const value = await client.json('/v1/capabilities', options()); + text('auth-status', '密钥验证通过,正在加载机器人…'); flags = capabilities(value); text('capabilities', pretty(value)); + modelSelection = value.models?.selection === true; modelManager = value.models?.management === true; $('model-admin').hidden = !modelManager; 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); } } + await loadBots(); await loadModels(); + text('auth-status', '已连接。请选择机器人。'); + } catch (error) { if (error.name !== 'AbortError') { + disconnect(); + const message = error.message.includes('HTTP 401') ? '连接失败:访问密钥无效或已过期。请粘贴 owner.key 的文件内容,不是文件路径或复制命令。' : '连接失败:' + error.message; + status(message); text('auth-status', message); + } } finally { authPending = false; controls(); } }); }); bind('disconnect', disconnect); @@ -231,11 +330,13 @@ $('bots').addEventListener('change', () => { resetSession(); botId = $('bots').value; $('sessions').replaceChildren(); text('title', botId ? $('bots').selectedOptions[0].textContent : '选择机器人与会话'); status(botId ? '请选择已有会话,或关联当前机器人。' : '请选择机器人。'); controls(); + modelsReady = false; controls(); + void run('models', loadModels); if (botId) void run('history', loadSessions); }); bind('history', loadSessions); bind('attach', async () => { - const value = await post('/v1/agents/sessions', { agent_id: botId }); + const value = await post('/v1/agents/sessions', { agent_id: botId, ...(modelSelection && $('model').value ? { model: $('model').value, reasoning_effort: $('reasoning-effort').value || null } : {}) }); await selectSession(value.id); }); bind('resume', async () => { await Promise.all([loadItems(), loadActions()]); liveItems.clear(); startStream(); }); @@ -264,13 +365,51 @@ 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 }); +async function desktop(mode, handback = false) { + if (desktopChanging) return; + desktopChanging = true; controls(); + let released = false; + const generation = epoch, selectedSession = session.id; + text('desktop-status', handback ? '正在交还控制并切回观看…' : mode === 'control' ? '正在打开接管窗口…' : '正在切换到观看…'); + try { + if (handback) { + await post(`${sessionPath(selectedSession)}/handback`, {}); + released = true; + } + let value; + try { value = await post(`${sessionPath(selectedSession)}/desktop`, { mode, replace_own_control: true }); } + catch (error) { + if (error.code === 'desktop_busy') { const message = '桌面已有控制窗口,或控制权刚刚发生变化。同一密钥可重新点击接管;其他使用者需要先交还控制。'; text('desktop-status', message); throw new Error(message); } + throw error; + } 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 ? '接管不会暂停机器人。' : '机器人暂停状态未确认。'}`); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('桌面查看器加载超时,请重新点击观看或接管。')), 30000); + iframe.addEventListener('load', () => { + clearTimeout(timer); + try { + const body = iframe.contentDocument?.body?.textContent ?? ''; + if (body.trim().startsWith('{')) { + const response = JSON.parse(body); + if (response.error) { reject(new Error('桌面授权已变化,请重新点击接管;其他使用者持有控制时需要先交还。')); return; } + } + resolve(); + } catch (error) { reject(error); } + }, { once: true }); + $('desktop').replaceChildren(iframe); + }); + if (generation === epoch) { + text('desktop-status', handback ? '控制已交还,当前为只读观看;画面连接由查看器显示。' : `${mode === 'control' ? '已签发接管授权' : '已签发观看授权'};画面连接由查看器显示。${value.pauses_agent === false ? '接管不会暂停机器人。' : '机器人暂停状态未确认。'}`); + if (handback) status('控制已交还,已切回观看。'); + } + } catch (error) { + if (generation !== epoch || error.name === 'AbortError') throw error; + const message = released ? '控制已交还,但观看重连失败。请点击“观看”重试。' + error.message : error.message; + text('desktop-status', message); throw new Error(message); + } + finally { if (generation === epoch) { desktopChanging = false; controls(); } } } bind('view', () => desktop('view')); bind('control', () => desktop('control')); bind('applications-refresh', async () => { @@ -285,20 +424,19 @@ for (const [id, kind] of [['launch-terminal', 'terminal'], ['launch-browser', 'b }); for (const [id, mode] of [['application-view', 'view'], ['application-control', 'control']]) { $(id).addEventListener('click', () => { - if (!session || !$('applications').value) return; + if (!session || !$('applications').value || desktopChanging) return; const selected = $('applications').value, popup = window.open('', '_blank'); if (!popup) { status('浏览器阻止了新窗口,请允许此页面打开窗口后再点击。'); return; } popup.opener = null; + desktopChanging = true; controls(); 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' ? '应用接管窗口已打开。' : '应用观看窗口已打开。'); } + try { const value = await post(`${sessionPath(session.id)}/desktop`, { mode, ...(mode === 'control' ? { replace_own_control: true } : {}), target: { type: 'application', application_id: selected } }); popup.location.href = desktopURL(value.url, location.origin); if (mode === 'control') $('desktop').replaceChildren(); status(mode === 'control' ? '应用接管窗口已打开,已替换当前密钥的旧控制窗口。' : '应用观看窗口已打开。'); } catch (error) { popup.close(); throw error; } + finally { desktopChanging = false; controls(); } }); }); } -bind('handback', async () => { - const value = await post(`${sessionPath(session.id)}/handback`, {}); - $('desktop').replaceChildren(); text('desktop-status', `交还响应:${pretty(value)}。可重新点击观看。`); -}); +bind('handback', () => desktop('view', true)); bind('clipboard-read', async () => { const value = await scopedJSON(`${sessionPath(session.id)}/clipboard`); if (typeof value.text !== 'string') throw new Error('剪贴板响应缺少 text'); diff --git a/tools/node-agent-api/web/client.js b/tools/node-agent-api/web/client.js index 639245e..f85e375 100644 --- a/tools/node-agent-api/web/client.js +++ b/tools/node-agent-api/web/client.js @@ -19,6 +19,7 @@ export function capabilities(value = {}) { } export function createClient(key, origin, fetcher = fetch) { + key = key.trim().replace(/^Bearer\s+/i, '').trim(); 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('拒绝向其他来源发送密钥'); @@ -29,14 +30,25 @@ export function createClient(key, origin, fetcher = fetch) { }); if (!response.ok) { const value = await response.json().catch(() => ({})); - throw new Error(`HTTP ${response.status} · ${value.error?.type ?? 'request_failed'} · ${value.error?.message ?? '请求失败;操作结果未知'}`); + const error = new Error(`HTTP ${response.status} · ${value.error?.type ?? 'request_failed'} · ${value.error?.message ?? '请求失败;操作结果未知'}`); + error.code = value.error?.type; error.status = response.status; + throw error; } return response; } async function json(path, options) { - const response = await request(path, options); - if (response.status === 204) return {}; - return response.json(); + const { timeoutMs = options?.method && options.method !== 'GET' ? 0 : 30000, ...extra } = options ?? {}; + const timeout = new AbortController(); + const timer = timeoutMs ? setTimeout(() => timeout.abort(new Error('连接请求超时,请检查本机 API 与 GrokNode 状态后重试。')), timeoutMs) : undefined; + try { + const response = await request(path, timeoutMs ? { ...extra, signal: extra.signal ? AbortSignal.any([extra.signal, timeout.signal]) : timeout.signal } : extra); + if (response.status === 204) return {}; + return await response.json(); + } catch (error) { + if (timeout.signal.aborted && !extra.signal?.aborted) throw timeout.signal.reason; + if (error instanceof TypeError) throw new Error('无法连接本机 API;请检查服务是否运行,或刷新页面后重试。'); + throw error; + } finally { clearTimeout(timer); } } async function list(path, signal) { const rows = [], seen = new Set(); diff --git a/tools/node-agent-api/web/index.html b/tools/node-agent-api/web/index.html index 9ee26c6..5547b4b 100644 --- a/tools/node-agent-api/web/index.html +++ b/tools/node-agent-api/web/index.html @@ -14,18 +14,38 @@
会话语义尚未查询。
+ + + +连接后查询获准模型;已有会话保留原模型。
更改只影响以后创建的会话。
+ +灰色表示尚未测试。进入 TokenHub 设置选择模型并测试连接,通过后可授权;连接测试不等于工具链验收。
+ + + + +尚未查询
先交还已有控制,再接管应用;其他观看窗口可以保持打开。
+交还控制后自动切回只读观看。再次接管会替换同一密钥的旧控制窗口,切换观看会交还自己的控制。不同使用者仍须先交还;独立观看窗口可保持打开。
@@ -75,5 +95,19 @@尚未收到事件