diff --git a/api/README.md b/api/README.md index d58b442..7e2a08a 100644 --- a/api/README.md +++ b/api/README.md @@ -4,13 +4,13 @@ ## 公开范围 -8 组中英文页面覆盖接入认证、Agent Webhook 管理与触发、只读 Agent/Run/回执、HTTP + Socket.IO 对话、模型代理、工作流与 OpenAPI 下载。24 个 HTTP 操作按服务分成三个自包含 OpenAPI 3.1 文件;对话事件和恢复规则维护在指南与可运行 Node.js 示例中。 +10 组中英文页面覆盖接入认证、Agent Webhook 管理与触发、只读 Agent/Run/回执、HTTP + Socket.IO 对话、会话历史与消息、附件与媒体、模型代理、工作流与 OpenAPI 下载。35 个 HTTP 操作按服务分成三个自包含 OpenAPI 3.1 文件;对话事件和恢复规则维护在指南与可运行 Node.js 示例中。 Webhook 所有者管理依赖握手 `capabilities.webhookManagement: true`,由可信入口选择实际工具并生成文件范围快照;旧服务仅支持已有事件。该支持条件没有被改写成未经核对的客户端发行号。现有 Write/Bash 与审批模型保留,不把配置文件宣称为不可修改的安全边界。 -默认 Caddy 未代理 `/health`、Workflow 路径或本机 Compat Proxy。三种服务的地址、认证和权限分别维护。Workflow 只公开管理员预置定义的触发与观察,不伪造直接运行所需 grant 的公开签发流程。 +默认 Caddy 未代理 `/health`、Workflow 路径或本机 Compat Proxy;附件和历史使用 `/api/*`,签名媒体读取使用单独代理的 `/media/*`。三种服务的地址、认证和权限分别维护。Workflow 只公开管理员预置定义的触发与观察,不伪造直接运行所需 grant 的公开签发流程。 -独立示例包在 [examples/agent-client](./examples/agent-client/README.md),静态下载副本为 `static/api-examples/agent-client.mjs`;二者必须逐字一致。依赖和锁文件在示例目录维护,不改变站点依赖。 +独立示例包在 [examples/agent-client](./examples/agent-client/README.md)。`client.mjs`、`history.mjs` 和 `attachments.mjs` 分别对应 `static/api-examples/agent-client.mjs`、`history.mjs` 和 `attachments.mjs`,每一对必须逐字一致。依赖和锁文件在示例目录维护,不改变站点依赖。 ## 修改步骤 @@ -45,7 +45,7 @@ v5 只对 `/more/changelog/v10.0.180` 和 `/en/more/changelog/v10.0.180` 做定 1. 先确认生产页面、canonical、robots、双语 sitemap 与正文正常。API 页应有可直接读取的 `.theme-doc-markdown`、标题及 `docsearch:language`、`docsearch:version`、`docsearch:docusaurus_tag` 元数据。 2. 在现有 crawler 的 URL Tester 中分别检查中英文页面的记录提取,再手动触发该 crawler 抓取。保留已保存配置和现有每日周期,不将编辑器草稿发布或新建密钥作为常规重抓步骤。 3. 等待该次抓取结束,记录时间、成功/忽略/失败 URL 数与索引记录数;检查失败原因,不能只看记录总数增长。先保留路径规则、选择器、调度等变量,只有抓取证据指向具体配置问题时再处理该项。 -4. 按站点实际过滤条件查询:中文 `language:zh-Hans`、英文 `language:en`,并使用 `docusaurus_tag:default OR docs-default-current`。逐项检查 16 个双语 API 页面标题、代表性端点和两种语言的旧页面对照,确认返回目标 URL、`current` 版本和正确 tag;结果排名不要求固定第一。 +4. 按站点实际过滤条件查询:中文 `language:zh-Hans`、英文 `language:en`,并使用 `docusaurus_tag:default OR docs-default-current`。逐项检查 清单内全部双语 API 页面标题、代表性端点和两种语言的旧页面对照,确认返回目标 URL、`current` 版本和正确 tag;结果排名不要求固定第一。 5. 在网站搜索框分别查找“智能体 Webhook”和“Agent Webhook”,打开目标页面,再更新 [每日调度与分块验收记录](./search-daily-verification.json)。公开记录只保留必要的时间、配置、查询与结果证据,不保存密钥、账号身份、请求头或本机绝对路径。 首次收录时的 [v4 搜索验收记录](./search-verification.json) 保留当时的月度调度快照:2026-10-15 手动抓取使索引从 19,324 条增至 21,572 条,22 项查询及双语浏览器检查通过,但两篇 `v10.0.180` 更新日志因记录过大而失败。该历史记录不随 v5 改写;每日调度、定向分块和后续真实抓取结果单独记录,URL Tester 通过不等于整站抓取已全部通过。 @@ -85,3 +85,11 @@ v5 只对 `/more/changelog/v10.0.180` 和 `/en/more/changelog/v10.0.180` 做定 ## 当前验证记录 参见 [核查与验证](./verification.md)、[发布与验收摘要](./publication.json)、[生产逐项报告](./production-verification.json)、[首次搜索验收记录](./search-verification.json)及[每日调度与分块验收记录](./search-daily-verification.json)。后续更改认证、路由、配置入口或结果交付时,只重新核对受影响合同和相关示例,不将本批测试结果传播为其它功能的运行保证。 + +## Run、历史消息与附件媒体扩展 + +本批固定核对源码 `7fe016c76ab98e0643ab4f2710c3733f757ef4fb`、内部文档 `b17e6a7a2d3dc4d969ea422ee37825ae39eb6177`,记录见 [本批验证](./history-attachments-verification.md)。公共规范中 Agent Service 为 27 个操作,Compat Proxy 3 个、Workflow Service 5 个。后两份规范保留原来源基线。 + +RunMeta 接受 `handed_off`,但现行 runtime-state HTTP 入口仍对该状态返回 `404`。共享 Schema 的五值枚举不能直接当作该路由的实际响应合同。历史消息的分页窗口与返回消息条数不同,示例按请求窗口推进;媒体读取使用短期签名,附件下载使用实例身份。 + +`publication.json`、生产和搜索验收 JSON 保留已经发布批次的事实;本批提交 PR 不代表这些新页面已经部署或收录。 diff --git a/api/endpoint-catalog.json b/api/endpoint-catalog.json index a2449db..b7efae7 100644 --- a/api/endpoint-catalog.json +++ b/api/endpoint-catalog.json @@ -3,9 +3,9 @@ "reviewBaseline": { "reviewedAt": "2026-10-15", "clientRepository": "desirecore/desirecore", - "clientRevision": "e45f22902af2dc45b109cfa8f454795538aea2ed", - "internalDocsRevision": "9cc47fc9216db4a3803a982f00649f7af667c933", - "publicDocsBaseRevision": "34fb4a7cbc8bd2f453ee79536981d22c7448373b" + "clientRevision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "internalDocsRevision": "b17e6a7a2d3dc4d969ea422ee37825ae39eb6177", + "publicDocsBaseRevision": "3e0e3de7168e2392802d37d295f73a65af070f5e" }, "pages": [ { @@ -47,6 +47,16 @@ "id": "api/openapi", "file": "docs/07-api/07-openapi.md", "slug": "/api/openapi" + }, + { + "id": "api/session-history-and-messages", + "file": "docs/07-api/08-session-history-and-messages.md", + "slug": "/api/session-history-and-messages" + }, + { + "id": "api/attachments-and-media", + "file": "docs/07-api/09-attachments-and-media.md", + "slug": "/api/attachments-and-media" } ], "endpoints": [ @@ -65,7 +75,7 @@ "resultDelivery": "Synchronous JSON metadata. A 200 response verifies this authenticated request, not every optional capability.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/client-handshake-routes.ts", "packages/agent-service/src/service-metadata.ts", @@ -81,7 +91,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Fixed-source auth and handshake unit tests plus a real loopback HTTPS readiness integration; no public-domain or Caddy-container run." + "scope": "Fixed-source auth and handshake unit tests plus a real loopback HTTPS readiness integration; no public-domain or Caddy-container run.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -103,7 +114,7 @@ "resultDelivery": "200 {accepted:true,agentId,event} acknowledges receipt, with no Run ID or delivery deduplication. Only explicit authorized SendUserMessage output is delivered to the user; final model answers are not automatically returned to the caller or added to chat.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/webhook-routes.ts", "packages/agent-service/src/routes/webhook-state.ts", @@ -121,7 +132,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Real production owner management, atomic AgentFS snapshot writes, local fake-provider runtime and SendUserMessage delivery, rotation, failure and disable/delete integration. No commercial-provider or packaged-client claim." + "scope": "Real production owner management, atomic AgentFS snapshot writes, local fake-provider runtime and SendUserMessage delivery, rotation, failure and disable/delete integration. No commercial-provider or packaged-client claim.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -142,7 +154,7 @@ "resultDelivery": "Synchronous read; live state is observational, not a query completion signal.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/agent-routes.ts", "packages/agent-service/src/agent/list-projection.ts" @@ -155,7 +167,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture." + "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -176,7 +189,7 @@ "resultDelivery": "Synchronous read; live state is observational, not a query completion signal.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/runs-routes.ts", "packages/agent-service/src/session/run-manager.ts" @@ -188,8 +201,9 @@ "effectiveVersion": null, "versionEvidence": null, "verification": { - "record": "api/verification.md", - "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture." + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -210,7 +224,7 @@ "resultDelivery": "Synchronous read; live state is observational, not a query completion signal.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/runs-routes.ts", "packages/agent-service/src/session/run-meta-read.ts" @@ -222,8 +236,9 @@ "effectiveVersion": null, "versionEvidence": null, "verification": { - "record": "api/verification.md", - "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture." + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -244,7 +259,7 @@ "resultDelivery": "Synchronous read; live state is observational, not a query completion signal.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/runs-routes.ts" ] @@ -255,8 +270,9 @@ "effectiveVersion": null, "versionEvidence": null, "verification": { - "record": "api/verification.md", - "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture." + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -277,7 +293,7 @@ "resultDelivery": "Synchronous read; live state is observational, not a query completion signal.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/runs-routes.ts", "packages/agent-service/src/receipts/reader.ts" @@ -290,7 +306,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture." + "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -311,7 +328,7 @@ "resultDelivery": "HTTP admission or lookup JSON; query completion and incremental messages arrive through correlated Socket.IO events.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/query-routes.ts", "packages/agent-service/src/query/client-request-registry.ts" @@ -324,7 +341,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture." + "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -345,7 +363,7 @@ "resultDelivery": "HTTP admission or lookup JSON; query completion and incremental messages arrive through correlated Socket.IO events.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/query-routes.ts", "packages/agent-service/src/query/client-request-registry.ts" @@ -358,7 +376,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture." + "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -379,7 +398,7 @@ "resultDelivery": "HTTP admission or lookup JSON; query completion and incremental messages arrive through correlated Socket.IO events.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/query-routes.ts", "packages/agent-service/src/query/client-request-registry.ts" @@ -392,7 +411,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture." + "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -413,7 +433,7 @@ "resultDelivery": "Synchronous configuration or explicit delivery JSON. First configuration and rotation disclose a secret once; GET does not return it.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/webhook-management-routes.ts", "packages/agent-service/src/auth/governed-endpoint-auth.ts" @@ -426,7 +446,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification." + "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -447,7 +468,7 @@ "resultDelivery": "Synchronous configuration or explicit delivery JSON. First configuration and rotation disclose a secret once; GET does not return it.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/webhook-management-routes.ts", "packages/agent-service/src/auth/governed-endpoint-auth.ts" @@ -460,7 +481,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification." + "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -481,7 +503,7 @@ "resultDelivery": "Synchronous configuration or explicit delivery JSON. First configuration and rotation disclose a secret once; GET does not return it.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/webhook-management-routes.ts", "packages/agent-service/src/auth/governed-endpoint-auth.ts" @@ -494,7 +516,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification." + "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -515,7 +538,7 @@ "resultDelivery": "Synchronous configuration or explicit delivery JSON. First configuration and rotation disclose a secret once; GET does not return it.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/webhook-management-routes.ts", "packages/agent-service/src/auth/governed-endpoint-auth.ts" @@ -528,7 +551,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification." + "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -549,7 +573,7 @@ "resultDelivery": "Synchronous configuration or explicit delivery JSON. First configuration and rotation disclose a secret once; GET does not return it.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/webhook-management-routes.ts", "packages/agent-service/src/auth/governed-endpoint-auth.ts" @@ -562,7 +586,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification." + "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -583,7 +608,7 @@ "resultDelivery": "Synchronous {items,total,limit,offset}; default limit50, maximum200. Items may include other authorized background deliveries.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/schedule-routes.ts", "packages/agent-service/src/auth/governed-endpoint-auth.ts" @@ -594,7 +619,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification." + "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -872,6 +898,414 @@ "securitySchemes": [ "WorkflowToken" ] + }, + { + "operationId": "uploadAttachment", + "service": "agent-service", + "method": "POST", + "path": "/api/attachments", + "doc": "api/attachments-and-media", + "deploymentModes": [ + "standalone-owner", + "standalone-external" + ], + "authentication": "X-Agent-Service-Token supplied by the instance administrator", + "permissions": "One non-empty multipart file, at most 20 MiB. Stores bytes without extraction or execution. Per-user retained-directory limit 256, total retained data limit 512 MiB. Each successful POST creates a new attachment; no automatic retry/idempotency contract. The receipt fileReference is passed unchanged in Query options.fileReferences on this instance and does not grant tool or cross-user permission.", + "resultDelivery": "Complete persisted attachment receipt.", + "source": { + "repository": "desirecore/desirecore", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "files": [ + "packages/agent-service/src/routes/attachment-routes.ts", + "packages/schemas/src/agent-service/attachments.ts", + "packages/agent-service/src/auth/instance-auth.ts", + "packages/agent-service/src/http-server.ts" + ] + }, + "schemaRefs": [ + "packages/schemas/src/agent-service/attachments.ts:attachmentReceiptSchema" + ], + "effectiveVersion": null, + "versionEvidence": null, + "verification": { + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source route/schema/auth review. Standalone uploader: 12 local HTTP contract tests with a fake server, not production storage or model execution. Root records actual fixed-source route validation separately." + }, + "spec": "static/openapi/agent-service.json", + "securitySchemes": [ + "InstanceToken" + ] + }, + { + "operationId": "downloadAttachment", + "service": "agent-service", + "method": "GET", + "path": "/api/attachments/:attachmentId", + "doc": "api/attachments-and-media", + "deploymentModes": [ + "standalone-owner", + "standalone-external" + ], + "authentication": "X-Agent-Service-Token supplied by the instance administrator", + "permissions": "Returns original bytes as an attachment, not JSON. The user is resolved from instance authentication. IDs do not confer cross-user permission. This route has no media byte-Range contract.", + "resultDelivery": "Original file bytes with no-store and nosniff.", + "source": { + "repository": "desirecore/desirecore", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "files": [ + "packages/agent-service/src/routes/attachment-routes.ts", + "packages/schemas/src/agent-service/attachments.ts", + "packages/agent-service/src/auth/instance-auth.ts", + "packages/agent-service/src/http-server.ts" + ] + }, + "schemaRefs": [], + "effectiveVersion": null, + "versionEvidence": null, + "verification": { + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source route/schema/auth review. Standalone uploader: 12 local HTTP contract tests with a fake server, not production storage or model execution. Root records actual fixed-source route validation separately." + }, + "spec": "static/openapi/agent-service.json", + "securitySchemes": [ + "InstanceToken" + ] + }, + { + "operationId": "uploadMedia", + "service": "agent-service", + "method": "POST", + "path": "/api/media/upload", + "doc": "api/attachments-and-media", + "deploymentModes": [ + "standalone-owner", + "standalone-external" + ], + "authentication": "X-Agent-Service-Token supplied by the instance administrator", + "permissions": "Single multipart file with an allowed MIME. Service multipart limit is 2 GiB; deployment/proxy limits may be smaller. Empty files are rejected. New files retain authenticated owner metadata. Metadata may be passed in top-level Query mediaRefs; model support is a separate condition.", + "resultDelivery": "Direct media metadata.", + "source": { + "repository": "desirecore/desirecore", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "files": [ + "packages/agent-service/src/routes/media-routes.ts", + "packages/agent-service/src/media/media-store.ts", + "packages/shared/src/utils/media-upload-policy.ts", + "packages/agent-service/src/auth/instance-auth.ts", + "packages/agent-service/src/http-server.ts" + ] + }, + "schemaRefs": [], + "effectiveVersion": null, + "versionEvidence": null, + "verification": { + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source route/schema/auth review. Standalone uploader: 12 local HTTP contract tests with a fake server, not production storage or model execution. Root records actual fixed-source route validation separately." + }, + "spec": "static/openapi/agent-service.json", + "securitySchemes": [ + "InstanceToken" + ] + }, + { + "operationId": "createMediaAccessUrls", + "service": "agent-service", + "method": "POST", + "path": "/api/media/access-urls", + "doc": "api/attachments-and-media", + "deploymentModes": [ + "standalone-owner", + "standalone-external" + ], + "authentication": "X-Agent-Service-Token supplied by the instance administrator", + "permissions": "Normal instance authentication is required to mint five-minute links. Validates new-media ownership and actual media content. Legacy media without owner metadata retains read compatibility. Existing objects forbidden to the user reject the request. The signing service requires an initialized instance token.", + "resultDelivery": "Links and missing IDs; no universal success/data wrapper.", + "source": { + "repository": "desirecore/desirecore", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "files": [ + "packages/agent-service/src/routes/media-routes.ts", + "packages/agent-service/src/media/media-store.ts", + "packages/shared/src/utils/media-upload-policy.ts", + "packages/agent-service/src/auth/instance-auth.ts", + "packages/agent-service/src/http-server.ts" + ] + }, + "schemaRefs": [ + "packages/agent-service/src/routes/media-routes.ts:mediaAccessUrlsBodySchema" + ], + "effectiveVersion": null, + "versionEvidence": null, + "verification": { + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source route/schema/auth review. Standalone uploader: 12 local HTTP contract tests with a fake server, not production storage or model execution. Root records actual fixed-source route validation separately." + }, + "spec": "static/openapi/agent-service.json", + "securitySchemes": [ + "InstanceToken" + ] + }, + { + "operationId": "getSignedMedia", + "service": "agent-service", + "method": "GET", + "path": "/media/:filename", + "doc": "api/attachments-and-media", + "deploymentModes": [ + "standalone-owner", + "standalone-external" + ], + "authentication": "expires and signature query values from an authenticated media access-urls response; no instance token header for the signed GET", + "permissions": "Only GET/HEAD on the single-segment /media/:filename path bypass normal instance-token authentication, after the application authority fence. The handler requires the signed expires/signature pair; /api/media/*, other methods, and multi-segment paths do not inherit this exemption. Configured Origin policy and service readiness still apply. Anyone holding a valid complete link can read until expiry; owner is checked at issuance, not re-resolved from the unsigned reader. Supports one byte range, open-ended/suffix ranges, and private caching bounded by remaining TTL. The implementation also supplies HEAD with the same validation and no body; HEAD is not selected as another public catalog operation.", + "resultDelivery": "Full media bytes.", + "source": { + "repository": "desirecore/desirecore", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "files": [ + "packages/agent-service/src/routes/media-routes.ts", + "packages/agent-service/src/media/media-store.ts", + "packages/shared/src/utils/media-upload-policy.ts", + "packages/agent-service/src/auth/instance-auth.ts", + "packages/agent-service/src/auth/app-authority-gate.ts", + "packages/agent-service/src/auth/media-access-signing.ts", + "packages/agent-service/src/security/origin-policy.ts", + "packages/agent-service/src/readiness.ts", + "deploy/Caddyfile" + ] + }, + "schemaRefs": [], + "effectiveVersion": null, + "versionEvidence": null, + "verification": { + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source route/schema/auth review. Standalone uploader: 12 local HTTP contract tests with a fake server, not production storage or model execution. Root records actual fixed-source route validation separately." + }, + "spec": "static/openapi/agent-service.json", + "securitySchemes": [ + "SignedMediaSignature" + ] + }, + { + "operationId": "listSessionHistory", + "service": "agent-service", + "method": "GET", + "path": "/api/sessions", + "doc": "api/session-history-and-messages", + "deploymentModes": [ + "standalone-owner" + ], + "authentication": "X-Agent-Service-Token from the deployment administrator", + "permissions": "Valid deployment-owner instance credential; exact history is filtered by the trusted user before preview/body reads. Knowing a Run ID does not grant access.", + "resultDelivery": "Lists ordinary user-visible history by updatedAt descending. Excludes delegated, manual and dedicated schedule/Webhook/Mention timelines. Preserves fork generations; do not concatenate branches as a deduplicated transcript. Pagination is not an immutable snapshot.", + "source": { + "repository": "desirecore/desirecore", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "files": [ + "packages/agent-service/src/routes/history-routes.ts", + "packages/agent-service/src/session/store.ts", + "packages/agent-service/src/session/run-meta-read.ts", + "packages/agent-service/src/auth/instance-auth.ts" + ] + }, + "schemaRefs": [ + "packages/schemas/src/agent-service/session.ts:sessionSummarySchema" + ], + "effectiveVersion": null, + "versionEvidence": null, + "verification": { + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source route/owner/pagination review; real local source history routes and SessionStore fixture verifies authentication, owner isolation, public projection, search validation and empty/404 matter reads. The independent example uses 13 local HTTP contract tests, including paging gaps and truncation; no deployed-instance claim." + }, + "spec": "static/openapi/agent-service.json", + "securitySchemes": [ + "InstanceToken" + ] + }, + { + "operationId": "getSessionSnapshot", + "service": "agent-service", + "method": "GET", + "path": "/api/sessions/:runId", + "doc": "api/session-history-and-messages", + "deploymentModes": [ + "standalone-owner" + ], + "authentication": "X-Agent-Service-Token from the deployment administrator", + "permissions": "Valid deployment-owner instance credential; exact history is filtered by the trusted user before preview/body reads. Knowing a Run ID does not grant access.", + "resultDelivery": "Checks trusted user ownership before body/image reads. Missing, cleared and unowned Runs return 404. Delegated Runs return 403 delegated_run_requires_inspect_view; use the messages inspection view instead. pendingAssistant is temporary and separate from durable messages. Returned configuration is not execution authority.", + "source": { + "repository": "desirecore/desirecore", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "files": [ + "packages/agent-service/src/routes/history-routes.ts", + "packages/agent-service/src/session/store.ts", + "packages/agent-service/src/session/public-session-projection.ts", + "packages/agent-service/src/session/persistent-state/history.ts", + "packages/agent-service/src/auth/instance-auth.ts" + ] + }, + "schemaRefs": [ + "packages/schemas/src/agent-service/session.ts:sessionSnapshotSchema", + "packages/schemas/src/agent-service/session.ts:sessionMessageSchema" + ], + "effectiveVersion": null, + "versionEvidence": null, + "verification": { + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source route/owner/pagination review; real local source history routes and SessionStore fixture verifies authentication, owner isolation, public projection, search validation and empty/404 matter reads. The independent example uses 13 local HTTP contract tests, including paging gaps and truncation; no deployed-instance claim." + }, + "spec": "static/openapi/agent-service.json", + "securitySchemes": [ + "InstanceToken" + ] + }, + { + "operationId": "listSessionMessages", + "service": "agent-service", + "method": "GET", + "path": "/api/sessions/:runId/messages", + "doc": "api/session-history-and-messages", + "deploymentModes": [ + "standalone-owner" + ], + "authentication": "X-Agent-Service-Token from the deployment administrator", + "permissions": "Valid deployment-owner instance credential; exact history is filtered by the trusted user before preview/body reads. Knowing a Run ID does not grant access.", + "resultDelivery": "Checks ownership before content reads. Delegated Runs require view=inspect-run. total counts source rows, not returned messages; advance offset by the requested limit and stop at total, even if a page is empty. Optional live is a temporary delegated display checkpoint, not a durable message or terminal. There is no cross-page consistency revision.", + "source": { + "repository": "desirecore/desirecore", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "files": [ + "packages/agent-service/src/routes/history-routes.ts", + "packages/agent-service/src/session/store.ts", + "packages/agent-service/src/session/message-index.ts", + "packages/agent-service/src/session/public-session-projection.ts", + "packages/agent-service/src/auth/instance-auth.ts" + ] + }, + "schemaRefs": [ + "packages/schemas/src/agent-service/session.ts:sessionMessageSchema", + "packages/schemas/src/agent-service/session.ts:liveRunInspectionSchema" + ], + "effectiveVersion": null, + "versionEvidence": null, + "verification": { + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source route/owner/pagination review; real local source history routes and SessionStore fixture verifies authentication, owner isolation, public projection, search validation and empty/404 matter reads. The independent example uses 13 local HTTP contract tests, including paging gaps and truncation; no deployed-instance claim." + }, + "spec": "static/openapi/agent-service.json", + "securitySchemes": [ + "InstanceToken" + ] + }, + { + "operationId": "searchSessionMessages", + "service": "agent-service", + "method": "GET", + "path": "/api/sessions/messages/search", + "doc": "api/session-history-and-messages", + "deploymentModes": [ + "standalone-owner" + ], + "authentication": "X-Agent-Service-Token from the deployment administrator", + "permissions": "Valid deployment-owner instance credential; exact history is filtered by the trusted user before preview/body reads. Knowing a Run ID does not grant access.", + "resultDelivery": "Matches extracted message text case-insensitively, newest timestamp first. Includes independent manual conversations; excludes archived and delegated Runs. Default-conversation fork families select the visible history window. preferredRunId is neither a credential nor an exact-Run filter. limit/offset and their sum must be safe nonnegative integers. limit=0 returns all remaining hits and hasMore=false. Match original text before cropping previews.", + "source": { + "repository": "desirecore/desirecore", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "files": [ + "packages/agent-service/src/routes/history-routes.ts", + "packages/agent-service/src/session/store.ts", + "packages/agent-service/src/session/lineage.ts", + "packages/agent-service/src/session/history-search-page.ts", + "packages/agent-service/src/auth/instance-auth.ts" + ] + }, + "schemaRefs": [ + "packages/agent-service/src/session/store.ts:FlattenedMessage" + ], + "effectiveVersion": null, + "versionEvidence": null, + "verification": { + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source route/owner/pagination review; real local source history routes and SessionStore fixture verifies authentication, owner isolation, public projection, search validation and empty/404 matter reads. The independent example uses 13 local HTTP contract tests, including paging gaps and truncation; no deployed-instance claim." + }, + "spec": "static/openapi/agent-service.json", + "securitySchemes": [ + "InstanceToken" + ] + }, + { + "operationId": "getConversationOverview", + "service": "agent-service", + "method": "GET", + "path": "/api/conversation/:agentId/overview", + "doc": "api/session-history-and-messages", + "deploymentModes": [ + "standalone-owner" + ], + "authentication": "X-Agent-Service-Token from the deployment administrator", + "permissions": "Deployment owner only: conversation memory is selected by the instance's current local profile. This is not a multi-tenant isolation API. Matter Run sources additionally verify user and Agent ownership.", + "resultDelivery": "Reads the instance's current local profile, not a caller-supplied tenant ID. Intended for the deployment owner. Returns all active matters and at most ten recent entries, not a complete transcript. No pagination. An empty overview does not establish Agent existence or absence of history.", + "source": { + "repository": "desirecore/desirecore", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "files": [ + "packages/agent-service/src/routes/conversation-routes.ts", + "packages/agent-service/src/conversation-memory/overview-projection.ts", + "packages/agent-service/src/conversation-memory/types.ts", + "packages/agent-service/src/auth/instance-auth.ts" + ] + }, + "schemaRefs": [ + "packages/agent-service/src/conversation-memory/types.ts:ConversationOverview" + ], + "effectiveVersion": null, + "versionEvidence": null, + "verification": { + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source route/owner/pagination review; real local source history routes and SessionStore fixture verifies authentication, owner isolation, public projection, search validation and empty/404 matter reads. The independent example uses 13 local HTTP contract tests, including paging gaps and truncation; no deployed-instance claim." + }, + "spec": "static/openapi/agent-service.json", + "securitySchemes": [ + "InstanceToken" + ] + }, + { + "operationId": "getConversationMatterMessages", + "service": "agent-service", + "method": "GET", + "path": "/api/conversation/:agentId/matters/:id/full", + "doc": "api/session-history-and-messages", + "deploymentModes": [ + "standalone-owner" + ], + "authentication": "X-Agent-Service-Token from the deployment administrator", + "permissions": "Deployment owner only: conversation memory is selected by the instance's current local profile. This is not a multi-tenant isolation API. Matter Run sources additionally verify user and Agent ownership.", + "resultDelivery": "Reads the instance owner's local-profile conversation memory. Supports active, recent and archived source data. Underlying Run reads recheck that user and Agent ownership. Reads at most 120 source messages and sets truncated=true if more exist; projection may omit entries. No pagination or limit-override parameter. Entries are not guaranteed to have the per-Run message shape.", + "source": { + "repository": "desirecore/desirecore", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "files": [ + "packages/agent-service/src/routes/conversation-routes.ts", + "packages/agent-service/src/conversation-memory/conversation-store.ts", + "packages/agent-service/src/session/run-meta-read.ts", + "packages/agent-service/src/session/public-session-projection.ts", + "packages/agent-service/src/auth/instance-auth.ts" + ] + }, + "schemaRefs": [ + "packages/agent-service/src/conversation-memory/conversation-store.ts:MatterMessagesResult" + ], + "effectiveVersion": null, + "versionEvidence": null, + "verification": { + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source route/owner/pagination review; real local source history routes and SessionStore fixture verifies authentication, owner isolation, public projection, search validation and empty/404 matter reads. The independent example uses 13 local HTTP contract tests, including paging gaps and truncation; no deployed-instance claim." + }, + "spec": "static/openapi/agent-service.json", + "securitySchemes": [ + "InstanceToken" + ] } ] } diff --git a/api/examples/agent-client/README.md b/api/examples/agent-client/README.md index 7ae33dc..df3c0b7 100644 --- a/api/examples/agent-client/README.md +++ b/api/examples/agent-client/README.md @@ -1,6 +1,6 @@ # DesireCore Node conversation client -This example calls an administrator's existing standalone Agent Service using the same owner credential for HTTP and Socket.IO. Obtain its HTTPS origin, existing instance token, and target Agent ID from the administrator. The Agent needs a working model configuration and permissions appropriate for your prompt. +These examples call an administrator's existing standalone Agent Service using the same owner credential for HTTP and Socket.IO. Obtain its HTTPS origin, existing instance token, and target Agent ID from the administrator. The Agent needs a working model configuration and permissions appropriate for your prompt. Use Node.js 22 or newer. Install dependencies **in this directory**, independently of the documentation website: @@ -21,11 +21,13 @@ The client performs the authenticated read-only handshake, verifies the data-spa Text streams go to stdout. JSON progress records go to stderr. A `202` progress record means HTTP admission; it is not a final answer, durable commitment, or proof of a successful tool action. Only a matching `query:complete` with `terminal.status: "completed"` records completion of the current turn. Run `idle`, message `result`/`completed_idle`, and user message delivery are not treated as completion. An interrupted turn exits with status 1. Completion of a turn does not independently verify that every requested business operation succeeded. +If the source Run is observed as `handed_off`, or its runtime-state precheck is unavailable, the client stops automatic follow-ups and retains the pending text. The current HTTP runtime-state route returns `404` for handed-off Runs, even though Run details can report `handed_off`. A `404` does not establish failure or missing history. The client does not follow `handoffTo` automatically. + Each follow-up gets a new `clientRequestId` and message ID and reuses the current session. The client waits for the preceding turn to complete and checks that its admission belongs to the current server process. If the session is unavailable, it reports the failure; it does not silently create a new Run. Approval and question prompts need the instance's supported user interface; this CLI does not approve operations or answer questions automatically. ## State and recovery -`--state` must name a new file in an existing directory. The client saves request identity before sending, with file mode `0600`. The state contains prompts, queued follow-ups, and request/session/Run IDs, and never stores the access token. Keep it private and leave its frozen request bodies unchanged. Reusing an existing `--state` path is rejected. +`--state` must name a new file in an existing directory. The client saves request identity before sending, with file mode `0600`. The state contains prompts, queued follow-ups, and request/session/Run IDs, and selected file references, and never stores the access token. Keep it private and leave its frozen request bodies unchanged. Reusing an existing `--state` path is rejected. ```bash # Observe the recorded request without resending it or starting a new query. @@ -40,7 +42,7 @@ node client.mjs --cancel ./request.json --timeout-ms 30000 `--resume`, `--recover`, and `--cancel` are separate modes. They use the instance origin and Agent ID in the state file; `DESIRECORE_TOKEN` must still contain a valid token. If `DESIRECORE_URL` is also set, it must match the saved origin. A different handshake `instanceId` prevents submission or recovery on the wrong instance. Only one client process should use a state file at a time: a new connection with the same client identity takes over delivery of future events. -On an unknown admission, the client calls the read-only `POST /api/query/client-requests/lookup`. A `committed` request is not resent; `reserved` is awaited. `absent` may be resent using the exact same IDs, prompt, message, and digest within the replay and tombstone windows reported by the server; only `originSocketId` changes after reconnection. `unknown`, a previous-process commitment, or an expired replay window stops automatic resubmission. The `qrd1:` digest follows the shared UTF-8 FNV-1a contract and covers prompt, user message ID, media IDs, and file references; this text-only example uses empty media/file arrays. It is not a security hash and does not cover all query options. +On an unknown admission, the client calls the read-only `POST /api/query/client-requests/lookup`. A `committed` request is not resent; `reserved` is awaited. `absent` may be resent using the exact same IDs, prompt, message, and digest within the replay and tombstone windows reported by the server; only `originSocketId` changes after reconnection. `unknown`, a previous-process commitment, or an expired replay window stops automatic resubmission. The `qrd1:` digest follows the shared UTF-8 FNV-1a contract and covers prompt, user message ID, media IDs, and file references. This CLI uses an empty media array and the initial turn’s selected attachment file references. It is not a security hash and does not cover all query options. Socket reconnection receives future events; it does not replay missed events. Recovery reads admission state, the known Run's raw runtime state, and a bounded page of durable receipts. If the started event was missed but an exact session ID is known, the client searches up to 100 Agent-filtered Runs for a unique `session_id` match. It never guesses the latest Run. No match leaves the Run identity unknown. @@ -50,13 +52,31 @@ The receipt snapshot may contain other turns in the same Run. An idle/completed Exit codes: **0** = current turn explicitly completed; **1** = rejected, failed, or interrupted; **2** = unknown, read-only recovery, or a client/connection error. Timeouts are local waiting limits and do not automatically cancel server execution. `--http-timeout-ms` and `--poll-ms` control individual HTTP calls and admission polling. +## Upload an attachment and submit it + +`attachments.mjs` needs Node.js 22+ and no dependencies. It uploads one non-empty file up to 20 MiB and verifies the server receipt’s size and SHA-256. It refuses redirects and existing output files and never retries the upload automatically. + +```bash +node attachments.mjs --file ./report.txt --mime text/plain --out ./attachment.json +node client.mjs --prompt 'Summarize the uploaded report.' \ + --attachment-receipt ./attachment.json --state ./request-with-file.json +``` + +`--attachment-receipt` can repeat. The conversation client uses the receipts’ unchanged `fileReference` values in the initial Query’s `options.fileReferences`, deduplicates them in order, and freezes them in the request state and digest. Follow-ups remain text-only. Receipts and request state contain private file references; use them only with the instance that issued them. Upload success does not establish that a model or tool has read the file. Lost upload responses leave an unknown outcome; a new POST creates another attachment. + +For media upload, signed playback URLs, ownership compatibility, and Range behavior, see the bilingual [attachments and media guide](../../../docs/07-api/09-attachments-and-media.md). The upload helper handles general attachments; it does not convert media metadata into a query or issue media URLs. + +## Read historical messages + +`history.mjs` also needs Node.js 22+ and no dependencies. See `node history.mjs --help` and the bilingual [history guide](../../../docs/07-api/08-session-history-and-messages.md) for its commands. The helper reads history; it never resumes execution or infers a missing query terminal from persisted messages. It uses source-row windows for per-Run pagination rather than advancing by the returned message count. + ## Verification and maintenance Run `npm test` for the independent HTTP + Socket.IO contract fixture. It verifies request identity, start/follow-up, events arriving before HTTP, lost acknowledgements, reconnection, conservative recovery, body mismatch, interrupted turns, explicit cancellation, and prevention of false completion. The fixture is a fake instance, with no model credentials or real tool effects; passing it does not establish packaged-client or real-model acceptance. `test/source-route.fixture.ts` can be copied into a fixed-source DesireCore checkout as `packages/agent-service/src/__tests__/api-docs-node-client.integration.test.ts`. Set `DESIRECORE_API_EXAMPLE_PATH` to the absolute path of this directory's `client.mjs`, then run that single file in the repository's integration test project. It verifies this client against the actual query routes, HTTP/Socket authentication, request Schema and digest, durable admission registry and lookup, and renderer delivery room. The query runtime and follow-up execution callback remain fake. Use its isolated temporary data root; do not point it at an existing user profile. -Source baseline: `desirecore/desirecore` commit `d3bac348db5f15ce6a59469ffef8f495c84ced59`. +Current source review: `desirecore/desirecore` commit `7fe016c76ab98e0643ab4f2710c3733f757ef4fb`. Historical validation remains in `api/verification.md`; this extension is recorded in `api/history-attachments-verification.md`. - HTTP submission: `packages/agent-service/src/routes/query-routes.ts`. - Admission lookup: `packages/agent-service/src/routes/client-request-lookup-routes.ts`; shared Schema in `packages/schemas/src/agent-service/client-request-admission.ts`. @@ -65,4 +85,6 @@ Source baseline: `desirecore/desirecore` commit `d3bac348db5f15ce6a59469ffef8f49 - Delivery identity: `packages/agent-service/src/handlers/renderer-delivery.ts` and `auth/delivery-principal.ts`. - Run snapshots/receipts: `packages/agent-service/src/routes/runs-routes.ts`. -After changing `client.mjs`, update `static/api-examples/agent-client.mjs` with identical bytes and revalidate the contract. The example is deliberately a small client, not a complete renderer, SDK, or durable execution orchestrator. +Keep the three published downloads byte-identical to their maintained files: `client.mjs` → `static/api-examples/agent-client.mjs`, `history.mjs` → `static/api-examples/history.mjs`, and `attachments.mjs` → `static/api-examples/attachments.mjs`. Revalidate the affected contracts after changes. The example is deliberately a small client, not a complete renderer, SDK, or durable execution orchestrator. + +`test/source-history.fixture.ts` can be copied to the fixed source tree as `packages/agent-service/src/__tests__/api-docs-history.test.ts` and run in the `unit` project. It uses actual instance authentication, history/topic/Run routes and temporary file storage. The local profile and runtime discovery are stubs. It checks owner filtering, public message projection, search validation, empty topic reads, and the actual `handed_off` detail/`404` runtime-state behavior. It does not run a model or real handoff. Website TypeScript checking excludes these two source fixtures because their imports resolve only inside DesireCore’s source repository. diff --git a/api/examples/agent-client/attachments.mjs b/api/examples/agent-client/attachments.mjs new file mode 100644 index 0000000..989c478 --- /dev/null +++ b/api/examples/agent-client/attachments.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +// Standalone attachment uploader. Requires Node.js 22+, with no npm dependencies. +import { createHash } from 'node:crypto'; +import { open, rm } from 'node:fs/promises'; +import { basename, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024; +const fields = ['attachmentId', 'name', 'mimeType', 'size', 'sha256', 'fileReference']; +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const textWithin = (value, maximum) => typeof value === 'string' + && [...value].length >= 1 && [...value].length <= maximum; + +export function validateAttachmentReceipt(value) { + if (!value || typeof value !== 'object' || Array.isArray(value) + || Object.keys(value).length !== fields.length || fields.some(key => !Object.hasOwn(value, key)) + || typeof value.attachmentId !== 'string' || !uuid.test(value.attachmentId) + || !textWithin(value.name, 240) || !textWithin(value.mimeType, 128) + || !Number.isSafeInteger(value.size) || value.size < 1 || value.size > MAX_ATTACHMENT_BYTES + || typeof value.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(value.sha256) + || !textWithin(value.fileReference, 2048)) { + throw new Error('Invalid attachment receipt from the instance.'); + } + return value; +} + +function instanceOrigin(value) { + const url = new URL(value); + const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname); + if (url.username || url.password || url.pathname !== '/' || url.search || url.hash + || (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback))) { + throw new Error('Use an HTTPS instance origin; HTTP is allowed only on loopback.'); + } + return url.origin; +} + +export class AttachmentUploadError extends Error { + constructor(status, code) { + super(`Attachment upload rejected: HTTP ${status}${code ? ` ${code}` : ''}`); + this.name = 'AttachmentUploadError'; + this.status = status; + this.code = code; + } +} + +export async function uploadAttachment({ baseUrl, token, file, mimeType = 'application/octet-stream', timeoutMs = 60_000 }) { + const origin = instanceOrigin(baseUrl); + if (typeof token !== 'string' || !token.trim()) throw new Error('Set DESIRECORE_TOKEN.'); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('Invalid upload timeout.'); + if (typeof mimeType !== 'string' || mimeType.length > 128 + || !/^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/.test(mimeType)) throw new Error('Invalid MIME type.'); + const path = resolve(file); + const handle = await open(path, 'r'); + let bytes; + try { + const stat = await handle.stat(); + if (!stat.isFile() || stat.size < 1 || stat.size > MAX_ATTACHMENT_BYTES) { + throw new Error('Select one non-empty regular file no larger than 20 MiB.'); + } + // Read at most 20 MiB + 1 from the opened file, even if it grows after stat(). + bytes = Buffer.alloc(Math.min(stat.size + 1, MAX_ATTACHMENT_BYTES + 1)); + let size = 0; + while (size < bytes.length) { + const part = await handle.read(bytes, size, bytes.length - size, null); + if (!part.bytesRead) break; + size += part.bytesRead; + } + if (size !== stat.size) throw new Error('File changed while reading; select it again.'); + bytes = bytes.subarray(0, size); + } finally { await handle.close(); } + const expectedHash = createHash('sha256').update(bytes).digest('hex'); + const form = new FormData(); + form.append('file', new Blob([bytes], { type: mimeType }), basename(path)); + // No automatic retry: each successful POST creates a new stored attachment. + const response = await fetch(`${origin}/api/attachments`, { + method: 'POST', headers: { 'X-Agent-Service-Token': token }, body: form, + redirect: 'error', signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.headers.get('content-type')?.toLowerCase().includes('application/json')) { + throw new Error(`Attachment upload: HTTP ${response.status}; expected JSON from the instance API.`); + } + const value = await response.json(); + if (response.status !== 201) { + throw new AttachmentUploadError(response.status, typeof value?.error === 'string' ? value.error.slice(0, 160) : undefined); + } + const receipt = validateAttachmentReceipt(value); + if (receipt.size !== bytes.length || receipt.sha256 !== expectedHash) { + throw new Error('Attachment receipt does not match the uploaded file size and SHA-256.'); + } + return receipt; +} + +const help = `Usage: node attachments.mjs --file ./report.pdf --out ./attachment.json [--mime application/pdf] +Environment: DESIRECORE_URL, DESIRECORE_TOKEN. Node.js 22+; no npm install required. +The output is the direct attachment receipt, for client.mjs --attachment-receipt. +Keep the receipt private and use it only with the same instance. Uploads are not retried automatically. +`; + +export async function runUploadCli(argv, env = process.env) { + if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(help); return; } + const options = {}; + for (let i = 0; i < argv.length; i++) { + const name = argv[i]; + if (!['--file', '--out', '--mime'].includes(name) || options[name]) throw new Error(help); + const value = argv[++i]; + if (!value || value.startsWith('--')) throw new Error(help); + options[name] = value; + } + if (!options['--file'] || !options['--out']) throw new Error(help); + // Reserve the output first, so an existing receipt cannot be overwritten after an upload. + const outputPath = resolve(options['--out']); + const output = await open(outputPath, 'wx', 0o600); + let saved = false; + try { + const receipt = await uploadAttachment({ baseUrl: env.DESIRECORE_URL, token: env.DESIRECORE_TOKEN, + file: options['--file'], mimeType: options['--mime'] ?? 'application/octet-stream' }); + await output.writeFile(`${JSON.stringify(receipt, null, 2)}\n`); + saved = true; + process.stderr.write(`Saved attachment receipt to ${options['--out']}\n`); + } finally { await output.close(); if (!saved) await rm(outputPath, { force: true }); } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + runUploadCli(process.argv.slice(2)).catch(error => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; }); +} diff --git a/api/examples/agent-client/client.mjs b/api/examples/agent-client/client.mjs index 514f801..872dec2 100644 --- a/api/examples/agent-client/client.mjs +++ b/api/examples/agent-client/client.mjs @@ -7,9 +7,9 @@ import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { setTimeout as delay } from 'node:timers/promises'; -export function queryDigest(prompt, userMessageId) { +export function queryDigest(prompt, userMessageId, fileReferences = []) { // packages/shared/src/query/request-digest.ts: UTF-8 FNV-1a, not a security hash. - const canonical = JSON.stringify(['qrd1', prompt, userMessageId, [], []]); + const canonical = JSON.stringify(['qrd1', prompt, userMessageId, [], [...fileReferences]]); let hash = 0xcbf29ce484222325n; for (const byte of new TextEncoder().encode(canonical)) { hash = ((hash ^ BigInt(byte)) * 0x100000001b3n) & 0xffffffffffffffffn; @@ -18,7 +18,7 @@ export function queryDigest(prompt, userMessageId) { } const help = `Usage: - node client.mjs --prompt "Hello" [--follow-up "And ..."] --state ./request.json + node client.mjs --prompt "Hello" [--attachment-receipt ./receipt.json] [--follow-up "And ..."] --state ./request.json node client.mjs --resume ./request.json node client.mjs --recover ./request.json # read-only; does not resend node client.mjs --cancel ./request.json # explicit cancellation request @@ -27,12 +27,13 @@ Environment: DESIRECORE_URL, DESIRECORE_TOKEN, DESIRECORE_AGENT_ID (new requests Options: --timeout-ms 300000, --http-timeout-ms 10000, --poll-ms 1000. Use HTTPS remotely; HTTP is accepted only for localhost/127.0.0.1/::1. Exit: 0 observed completed turn; 1 rejected/failed/interrupted; 2 unknown or recovery-only. -State files contain prompts and IDs, never the access token. Do not share them. +--attachment-receipt can repeat; it attaches already-uploaded files to the initial turn only. +State files contain prompts, file references and IDs, never the access token. Do not share them. Ctrl+C exits this client without cancelling the server; use --cancel to request cancellation. `; function argumentsFor(argv) { - const options = { followUps: [], timeoutMs: 300_000, httpTimeoutMs: 10_000, pollMs: 1_000 }; + const options = { followUps: [], attachmentReceipts: [], timeoutMs: 300_000, httpTimeoutMs: 10_000, pollMs: 1_000 }; const names = new Map([ ['--prompt', 'prompt'], ['--state', 'state'], ['--resume', 'resume'], ['--recover', 'recover'], ['--cancel', 'cancel'], ['--timeout-ms', 'timeoutMs'], @@ -44,6 +45,7 @@ function argumentsFor(argv) { const value = argv[++i]; if (!value || value.startsWith('--')) throw new Error(`Missing value for ${option}`); if (option === '--follow-up') options.followUps.push(value); + else if (option === '--attachment-receipt') options.attachmentReceipts.push(value); else if (names.has(option)) options[names.get(option)] = value; else throw new Error(`Unknown option ${option}`); } @@ -52,7 +54,7 @@ function argumentsFor(argv) { if (!Number.isSafeInteger(options[key]) || options[key] < 10) throw new Error(`Invalid ${key}`); } const modes = ['resume', 'recover', 'cancel'].filter(key => options[key]); - if (modes.length > 1 || (modes.length && (options.prompt || options.state || options.followUps.length))) { + if (modes.length > 1 || (modes.length && (options.prompt || options.state || options.followUps.length || options.attachmentReceipts.length))) { throw new Error('Use either a new --prompt/--state or one of --resume/--recover/--cancel.'); } if (!modes.length && (!options.prompt?.trim() || !options.state)) throw new Error(help); @@ -71,12 +73,32 @@ function baseUrl(value) { return url.origin; } -function newTurn(prompt, kind, sessionId, agentId, conversationId) { +function attachmentReferences(files) { + const references = []; + const fields = ['attachmentId', 'name', 'mimeType', 'size', 'sha256', 'fileReference']; + const sizedString = (value, maximum) => typeof value === 'string' && [...value].length >= 1 && [...value].length <= maximum; + for (const file of files) { + const receipt = JSON.parse(readFileSync(file, 'utf8')); + if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt) + || Object.keys(receipt).length !== fields.length || fields.some(key => !Object.hasOwn(receipt, key)) + || typeof receipt.attachmentId !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(receipt.attachmentId) + || !sizedString(receipt.name, 240) || !sizedString(receipt.mimeType, 128) + || !Number.isSafeInteger(receipt.size) || receipt.size < 1 || receipt.size > 20 * 1024 * 1024 + || typeof receipt.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(receipt.sha256) + || !sizedString(receipt.fileReference, 2048)) { + throw new Error('Invalid attachment receipt; use the complete JSON returned by the same instance upload.'); + } + references.push(receipt.fileReference); + } + return [...new Set(references)]; // Keep the first occurrence order for the request digest. +} + +function newTurn(prompt, kind, sessionId, agentId, conversationId, fileReferences = []) { const clientRequestId = randomUUID(); const userMessageId = randomUUID(); - const requestDigest = queryDigest(prompt, userMessageId); + const requestDigest = queryDigest(prompt, userMessageId, fileReferences); const body = kind === 'start' - ? { prompt, options: { agentId, conversationId }, clientRequestId, userMessageId, requestDigest } + ? { prompt, options: { agentId, conversationId, ...(fileReferences.length ? { fileReferences: [...fileReferences] } : {}) }, clientRequestId, userMessageId, requestDigest } : { sessionId, message: { id: userMessageId, role: 'user', content: prompt, timestamp: Date.now() }, clientRequestId, requestDigest }; return { kind, body, userMessageId, firstDispatchedAt: null, firstDispatchedServerTime: null, attempts: 0 }; } @@ -89,9 +111,11 @@ function readState(file) { state.baseUrl = baseUrl(state.baseUrl); for (const turn of state.turns) { const prompt = turn.kind === 'start' ? turn.body?.prompt : turn.body?.message?.content; + const fileReferences = (turn.kind === 'start' ? turn.body?.options?.fileReferences : turn.body?.fileReferences) ?? []; if (!['start', 'follow-up'].includes(turn.kind) || typeof prompt !== 'string' || !turn.body?.clientRequestId || !turn.userMessageId - || turn.body.requestDigest !== queryDigest(prompt, turn.userMessageId)) { + || !Array.isArray(fileReferences) || fileReferences.some(value => typeof value !== 'string') + || turn.body.requestDigest !== queryDigest(prompt, turn.userMessageId, fileReferences)) { throw new Error('Invalid or edited request state. Create a new state file for a different request.'); } } @@ -104,6 +128,8 @@ export async function runClient(options, env = process.env, output = {}) { const filename = resolve(options.resume || options.recover || options.cancel || options.state); const existing = Boolean(options.resume || options.recover || options.cancel); if (!existing && existsSync(filename)) throw new Error('State file already exists. Use --resume or a new --state path.'); + if (existing && options.attachmentReceipts?.length) throw new Error('Saved requests retain their original attachment references.'); + const fileReferences = existing ? [] : attachmentReferences(options.attachmentReceipts ?? []); const token = env.DESIRECORE_TOKEN; if (!token) throw new Error('Set DESIRECORE_TOKEN to the existing instance access token.'); const origin = existing ? readState(filename).baseUrl : baseUrl(env.DESIRECORE_URL); @@ -150,7 +176,7 @@ export async function runClient(options, env = process.env, output = {}) { }; if (!state.agentId) throw new Error('Set DESIRECORE_AGENT_ID to the target Agent ID.'); if (state.instanceId !== metadata.instanceId) throw new Error('Instance identity changed; refusing to send or recover requests on another instance.'); - if (!existing) state.turns.push(newTurn(options.prompt, 'start', undefined, state.agentId, state.conversationId)); + if (!existing) state.turns.push(newTurn(options.prompt, 'start', undefined, state.agentId, state.conversationId, fileReferences)); function save() { const temporary = `${filename}.${process.pid}.tmp`; writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 }); @@ -187,6 +213,9 @@ export async function runClient(options, env = process.env, output = {}) { const runtime = await http(`${path}/runtime-state`); const history = await http(`${path}/receipt?offset=0&limit=10`); snapshot.runtime = { httpStatus: runtime.status, ...runtime.data }; + if (runtime.status === 200 && runtime.data.run_id === turn.runId && runtime.data.agent_id === state.agentId) { + turn.runState = { runId: turn.runId, status: runtime.data.status, source: 'runtime-state' }; + } snapshot.receipts = { httpStatus: history.status, ...history.data }; } turn.recovery = snapshot; @@ -251,8 +280,11 @@ export async function runClient(options, env = process.env, output = {}) { save(); log({ event: 'turn_terminal', clientRequestId: data.clientRequestId, ...item.outcome }); }); socket.on('activity:run_status', data => { - if (turn.runId === data.runId) log({ event: 'run_status', runId: data.runId, status: data.status, - note: 'Run state does not identify completion of this turn.' }); + if (turn.runId === data.runId) { + turn.runState = { runId: data.runId, status: data.status, source: 'activity:run_status' }; save(); + log({ event: 'run_status', runId: data.runId, status: data.status, + note: 'Run state does not identify completion of this turn or any handoff target.' }); + } }); async function connected(deadline) { while (!socket.connected) { @@ -343,6 +375,21 @@ export async function runClient(options, env = process.env, output = {}) { || observed.verdict.committedServerInstance !== 'current') { await recovery(turn); log({ event: 'follow_up_unavailable', note: 'A live current-process session was not confirmed.' }); return 2; } + if (turn.runState?.status === 'handed_off') { + log({ event: 'follow_up_unavailable', runId: turn.runId, status: 'handed_off', + note: 'The source Run was handed off. Pending text is retained; no target execution or source replay is inferred.' }); + return 2; + } + if (turn.runId) { + const runtime = await http(`/api/runs/${encodeURIComponent(turn.runId)}/runtime-state`); + if (runtime.status !== 200 || runtime.data.run_id !== turn.runId || runtime.data.agent_id !== state.agentId + || !['running', 'idle', 'completed', 'failed'].includes(runtime.data.status)) { + log({ event: 'follow_up_unavailable', runId: turn.runId, httpStatus: runtime.status, + note: 'Source runtime state is unavailable. Pending text is retained; a 404 can include a handed-off Run.' }); + return 2; + } + turn.runState = { runId: turn.runId, status: runtime.data.status, source: 'runtime-state' }; save(); + } const next = state.pendingFollowUps.shift(); turn = newTurn(next, 'follow-up', turn.sessionId, state.agentId, state.conversationId); state.turns.push(turn); save(); diff --git a/api/examples/agent-client/history.mjs b/api/examples/agent-client/history.mjs new file mode 100644 index 0000000..b1ce2ad --- /dev/null +++ b/api/examples/agent-client/history.mjs @@ -0,0 +1,194 @@ +#!/usr/bin/env node +// Read an administrator-provided DesireCore instance. Node.js 22+, no packages required. +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const HELP = `Usage: + node history.mjs list [--agent-id ID] [--include-archived] [--all-pages] + node history.mjs session RUN_ID + node history.mjs messages RUN_ID [--inspect-run] [--all-pages] + node history.mjs search --agent-id ID [--query TEXT] [--type all|image|file|tool] [--all-pages] + node history.mjs overview --agent-id ID + node history.mjs matter MATTER_ID --agent-id ID + +Environment: DESIRECORE_URL, DESIRECORE_TOKEN, optional DESIRECORE_AGENT_ID. +Pagination: --offset 0; --limit defaults to list=20, messages=100, search=50. +Search alone accepts --limit 0 (all remaining hits), --preferred-run-id ID, +and --preview-chars 1..8000. --all-pages is bounded by --max-pages 20. +HTTP: --timeout-ms 10000. Remote origins must use HTTPS. +Output: one JSON record per page; history may contain private conversation data. +Exit: 0 requested reads succeeded; 1 request/configuration failed; +2 an all-pages read stopped early or a matter response was truncated. +These endpoints do not provide a consistent multi-page history snapshot. +`; + +const PAGED = new Set(['list', 'messages', 'search']); +const NUMERIC = new Set(['limit', 'offset', 'maxPages', 'timeoutMs', 'previewChars']); +const VALUES = new Map([ + ['--agent-id', 'agentId'], ['--limit', 'limit'], ['--offset', 'offset'], + ['--query', 'query'], ['--type', 'type'], ['--preferred-run-id', 'preferredRunId'], + ['--preview-chars', 'previewChars'], ['--max-pages', 'maxPages'], ['--timeout-ms', 'timeoutMs'], +]); +const FLAGS = new Map([ + ['--all-pages', 'allPages'], ['--include-archived', 'includeArchived'], ['--inspect-run', 'inspectRun'], +]); + +export function parseHistoryArgs(argv) { + if (argv.includes('--help') || argv.includes('-h')) return { help: true }; + const [command, ...args] = argv; + const options = { command }; + for (let i = 0; i < args.length; i++) { + const argument = args[i]; + if (FLAGS.has(argument)) options[FLAGS.get(argument)] = true; + else if (VALUES.has(argument)) { + if (args[i + 1] === undefined || args[i + 1].startsWith('--')) throw new Error(`Missing value for ${argument}`); + options[VALUES.get(argument)] = args[++i]; + } else if (!argument.startsWith('-') && !options.id) options.id = argument; + else throw new Error(`Unknown argument: ${argument}`); + } + return options; +} + +function originFor(value) { + let url; + try { url = new URL(value); } catch { throw new Error('Set DESIRECORE_URL to the instance origin.'); } + if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) { + throw new Error('DESIRECORE_URL must be an origin without credentials, a path, query, or fragment.'); + } + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) { + throw new Error('Use HTTPS remotely; HTTP is accepted only on loopback.'); + } + return url.origin; +} + +function identifier(value, label) { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value)) throw new Error(`Use the server's ${label}, containing letters, digits, hyphens or underscores.`); + return value; +} + +function optionsFor(input, env) { + const options = { ...input }; + if (!['list', 'session', 'messages', 'search', 'overview', 'matter'].includes(options.command)) throw new Error(HELP); + options.agentId ??= env.DESIRECORE_AGENT_ID; + if (options.agentId !== undefined) identifier(options.agentId, 'Agent ID'); + if (['search', 'overview', 'matter'].includes(options.command) && !options.agentId) throw new Error('Set DESIRECORE_AGENT_ID or --agent-id.'); + if (['session', 'messages'].includes(options.command)) identifier(options.id, 'Run ID'); + else if (options.command === 'matter') { + if (typeof options.id !== 'string' || !options.id || /[\\/\u0000-\u001f\u007f]/.test(options.id) || options.id.includes('..') || options.id === '.') { + throw new Error('Use a matter ID returned by the conversation overview.'); + } + } else if (options.id !== undefined) throw new Error('This command does not take a positional ID.'); + for (const key of NUMERIC) { + if (options[key] !== undefined) { + if (typeof options[key] === 'string' && !/^(0|[1-9][0-9]*)$/.test(options[key])) throw new Error(`Invalid ${key}: use a decimal integer.`); + options[key] = Number(options[key]); + if (!Number.isSafeInteger(options[key]) || options[key] < 0) throw new Error(`Invalid ${key}.`); + } + } + options.limit ??= options.command === 'list' ? 20 : options.command === 'messages' ? 100 : 50; + options.offset ??= 0; + options.maxPages ??= 20; + options.timeoutMs ??= 10_000; + if ((options.limit === 0 && options.command !== 'search') || !Number.isSafeInteger(options.offset + options.limit)) throw new Error('Use a positive limit and a safe offset; only search permits limit=0.'); + if (options.maxPages < 1 || options.timeoutMs < 1) throw new Error('maxPages and timeoutMs must be positive.'); + if (options.previewChars !== undefined && (options.previewChars < 1 || options.previewChars > 8000)) throw new Error('previewChars must be 1..8000.'); + if (options.type !== undefined && !['all', 'image', 'file', 'tool'].includes(options.type)) throw new Error('type must be all, image, file or tool.'); + if (options.preferredRunId !== undefined) identifier(options.preferredRunId, 'preferred Run ID'); + if (options.includeArchived && options.command !== 'list') throw new Error('--include-archived applies only to list.'); + if (options.inspectRun && options.command !== 'messages') throw new Error('--inspect-run applies only to messages.'); + if (!PAGED.has(options.command) && (options.allPages || input.limit !== undefined || input.offset !== undefined)) throw new Error('This endpoint is not paginated.'); + if (options.command !== 'search' && ['query', 'type', 'preferredRunId', 'previewChars'].some(key => options[key] !== undefined)) throw new Error('Search filters apply only to search.'); + return options; +} + +function endpoint(options, offset) { + const query = new URLSearchParams(); + let path; + if (options.command === 'list') { + path = '/api/sessions'; + if (options.agentId) query.set('agentId', options.agentId); + if (options.includeArchived) query.set('includeArchived', 'true'); + } else if (options.command === 'search') { + path = '/api/sessions/messages/search'; + query.set('agentId', options.agentId); + for (const [key, value] of Object.entries({ q: options.query, type: options.type, preferredRunId: options.preferredRunId, previewChars: options.previewChars })) { + if (value !== undefined) query.set(key, String(value)); + } + } else if (options.command === 'overview') path = `/api/conversation/${encodeURIComponent(options.agentId)}/overview`; + else if (options.command === 'matter') path = `/api/conversation/${encodeURIComponent(options.agentId)}/matters/${encodeURIComponent(options.id)}/full`; + else { + path = `/api/sessions/${encodeURIComponent(options.id)}${options.command === 'messages' ? '/messages' : ''}`; + if (options.inspectRun) query.set('view', 'inspect-run'); + } + if (PAGED.has(options.command)) { query.set('offset', String(offset)); query.set('limit', String(options.limit)); } + return path + (query.size ? `?${query}` : ''); +} + +export async function runHistory(input, env = process.env, output = {}) { + const options = optionsFor(input, env); + const origin = originFor(env.DESIRECORE_URL); + const token = env.DESIRECORE_TOKEN; + if (typeof token !== 'string' || !token || /[\r\n]/.test(token)) throw new Error('Set DESIRECORE_TOKEN to an existing single-line instance access token.'); + const emit = output.emit ?? (record => process.stdout.write(`${JSON.stringify(record)}\n`)); + async function get(path) { + let response; + try { + response = await fetch(origin + path, { + method: 'GET', redirect: 'error', signal: AbortSignal.timeout(options.timeoutMs), + headers: { 'X-Agent-Service-Token': token, Accept: 'application/json' }, + }); + } catch { throw new Error('HTTP request failed or timed out; no automatic retry was made.'); } + if (!response.headers.get('content-type')?.includes('application/json')) throw new Error(`HTTP ${response.status}: expected a JSON API response.`); + let data; + try { data = await response.json(); } catch { throw new Error(`HTTP ${response.status}: invalid JSON response.`); } + if (!response.ok) { + const error = new Error(`HTTP ${response.status}: ${typeof data?.error === 'string' ? data.error : 'request rejected'}`); + error.status = response.status; + error.code = typeof data?.code === 'string' ? data.code : undefined; + throw error; + } + return data; + } + const handshake = await get('/api/client-handshake'); + if (handshake.service !== 'desirecore-agent-service' || handshake.apiProtocolVersion !== '1' || typeof handshake.instanceId !== 'string' || !handshake.instanceId) { + throw new Error('Unexpected service identity or API protocol; history was not requested.'); + } + let offset = options.offset, pages = 0, firstTotal; + while (true) { + const data = await get(endpoint(options, offset)); + if (!PAGED.has(options.command)) { + emit({ kind: options.command, instanceId: handshake.instanceId, data }); + return { exitCode: options.command === 'matter' && data?.truncated === true ? 2 : 0, pages: 1 }; + } + const field = options.command === 'list' ? 'sessions' : options.command === 'search' ? 'items' : 'messages'; + if (!Array.isArray(data?.[field]) || !Number.isSafeInteger(data.total) || data.total < 0 + || (options.command !== 'messages' && typeof data.hasMore !== 'boolean')) throw new Error('Unexpected history page response.'); + if (firstTotal !== undefined && data.total !== firstTotal) { + emit({ kind: 'stopped', reason: 'history-total-changed', offset, nextOffset: offset, previousTotal: firstTotal, observedTotal: data.total }); + return { exitCode: 2, pages, nextOffset: offset }; + } + firstTotal ??= data.total; + pages++; + const hasMore = options.command === 'messages' ? offset + options.limit < data.total : data.hasMore; + const nextOffset = offset + options.limit; + emit({ kind: options.command, instanceId: handshake.instanceId, offset, limit: options.limit, data }); + if (!hasMore || !options.allPages) return { exitCode: 0, pages, ...(hasMore && { nextOffset }) }; + if (pages >= options.maxPages || options.limit === 0 || !Number.isSafeInteger(nextOffset)) { + emit({ kind: 'stopped', reason: pages >= options.maxPages ? 'max-pages' : 'invalid-next-offset', nextOffset }); + return { exitCode: 2, pages, nextOffset }; + } + // Consume the requested source window even if bad rows were omitted from a messages page. + offset = nextOffset; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const options = parseHistoryArgs(process.argv.slice(2)); + if (options.help) process.stdout.write(HELP); + else process.exitCode = (await runHistory(options)).exitCode; + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } +} diff --git a/api/examples/agent-client/test/attachments.test.mjs b/api/examples/agent-client/test/attachments.test.mjs new file mode 100644 index 0000000..cb6fd58 --- /dev/null +++ b/api/examples/agent-client/test/attachments.test.mjs @@ -0,0 +1,128 @@ +// Local HTTP contract fixture; this does not run DesireCore storage or a model. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { createHash } from 'node:crypto'; +import { mkdtemp, writeFile, readFile, rm, open, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { uploadAttachment, validateAttachmentReceipt, AttachmentUploadError, MAX_ATTACHMENT_BYTES } from '../attachments.mjs'; + +const bytes = Buffer.from('Quarterly summary\n'); +const receipt = { attachmentId: '00000000-0000-4000-8000-000000000001', name: 'report.txt', mimeType: 'text/plain', + size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'), fileReference: '/srv/attachments/owner/00000000-0000-4000-8000-000000000001/data' }; + +async function fixture(t, respond = (_request, response) => json(response, 201, receipt)) { + const directory = await mkdtemp(join(tmpdir(), 'desirecore-attachment-example-')); + const file = join(directory, 'report.txt'); + await writeFile(file, bytes); + const requests = []; + const server = createServer(async (request, response) => { + let body = Buffer.alloc(0); + for await (const chunk of request) body = Buffer.concat([body, chunk]); + requests.push({ url: request.url, method: request.method, headers: request.headers, body }); + respond(request, response, body); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(async () => { + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + await rm(directory, { recursive: true, force: true }); + }); + return { directory, file, requests, baseUrl: `http://127.0.0.1:${server.address().port}`, token: 'fixture-only-token', mimeType: 'text/plain' }; +} +function json(response, status, value) { response.writeHead(status, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(value)); } +function cli(args, env) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [fileURLToPath(new URL('../attachments.mjs', import.meta.url)), ...args], { env: { ...process.env, ...env } }); + let stdout = '', stderr = ''; + child.stdout.on('data', value => stdout += value); child.stderr.on('data', value => stderr += value); + child.on('error', reject); child.on('close', code => resolve({ code, stdout, stderr })); + }); +} + +test('uploads a real multipart request and validates the returned bytes/hash receipt', async t => { + const f = await fixture(t); + assert.deepEqual(await uploadAttachment(f), receipt); + assert.equal(f.requests.length, 1); + const request = f.requests[0]; + assert.equal(request.url, '/api/attachments'); assert.equal(request.method, 'POST'); + assert.equal(request.headers['x-agent-service-token'], f.token); + assert.match(request.headers['content-type'], /^multipart\/form-data; boundary=/); + assert.ok(request.body.includes(Buffer.from('name="file"; filename="report.txt"'))); + assert.ok(request.body.includes(Buffer.from('Content-Type: text/plain'))); + assert.ok(request.body.includes(bytes)); +}); + +for (const [status, code] of [[401, 'attachment_identity_required'], [413, 'attachment_too_large'], + [429, 'attachment_storage_limit'], [503, 'attachment_storage_unavailable']]) { + test(`preserves HTTP ${status} and machine error without retrying POST`, async t => { + const f = await fixture(t, (_request, response) => json(response, status, { error: code })); + await assert.rejects(uploadAttachment(f), error => error instanceof AttachmentUploadError && error.status === status && error.code === code); + assert.equal(f.requests.length, 1); + }); +} + +test('rejects a successful-looking receipt for different bytes or an unexpected wrapper', async t => { + const f = await fixture(t, (_request, response) => json(response, 201, { ...receipt, sha256: 'a'.repeat(64) })); + await assert.rejects(uploadAttachment(f), /does not match/); + assert.throws(() => validateAttachmentReceipt({ success: true, data: receipt }), /Invalid attachment receipt/); + assert.throws(() => validateAttachmentReceipt({ ...receipt, extra: true }), /Invalid attachment receipt/); + for (const invalid of [{ size: 0 }, { size: MAX_ATTACHMENT_BYTES + 1 }, { attachmentId: '../escape' }, + { fileReference: '' }, { sha256: 'A'.repeat(64) }]) { + assert.throws(() => validateAttachmentReceipt({ ...receipt, ...invalid }), /Invalid attachment receipt/); + } +}); + +test('does not mistake HTML or HTTP 200 for the upload success contract', async t => { + const f = await fixture(t, (_request, response) => { response.writeHead(200, { 'Content-Type': 'text/html' }); response.end('