diff --git a/.env.example b/.env.example index 6c6924f..265b41d 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,9 @@ FIRE_SAFETY_SUPERAGENT_PROBE_TIMEOUT=10m # User-facing chat API (disabled by default; requires SuperAgent above) FIRE_SAFETY_CHAT_ENABLED=false +# Optional public test chat page at /chat. Enabling it also requires Chat, +# FIRE_SAFETY_CHAT_COMPAT_APP_ID, and at least one exact allowed browser origin. +FIRE_SAFETY_CHAT_PAGE_ENABLED=false # Test-stage static Bearer only. By default, generate a distinct high-entropy # value of at least 32 printable ASCII characters. # Never reuse FIRE_SAFETY_SUPERAGENT_OPEN_API_KEY or FIRE_SAFETY_MCP_AUTH_TOKEN. diff --git a/CONTEXT.md b/CONTEXT.md index 077a3d8..3b9354e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -13,9 +13,9 @@ | 路径或系统 | 当前职责 | 当前状态 | | --- | --- | --- | | `cmd/server` | Go 服务进程入口 | 已建立 | -| `internal/app` | 应用装配、readiness 和 HTTP 生命周期 | 已建立;按开关装配原生/兼容 Chat、SuperAgent 和 MCP/PostGIS | -| `internal/config` | 环境配置入口 | 已包含 HTTP、SuperAgent、Chat 兼容 App ID/legacy 短凭证开关、MCP 与 PostGIS 配置校验和凭证分离门禁 | -| `internal/handler` | HTTP/MCP 入站协议层 | `GET /health` 已启用;默认关闭的原生 `/api/chat`、可选 DashScope 风格 `completion` 和 `/mcp` 已实现 | +| `internal/app` | 应用装配、readiness 和 HTTP 生命周期 | 已建立;按开关装配原生/兼容 Chat、可选 `/chat` 测试页面、SuperAgent 和 MCP/PostGIS | +| `internal/config` | 环境配置入口 | 已包含 HTTP、SuperAgent、Chat 兼容 App ID/页面开关/legacy 短凭证开关、MCP 与 PostGIS 配置校验和凭证分离门禁 | +| `internal/handler` | HTTP/MCP 入站协议层 | `GET /health` 已启用;默认关闭的原生 `/api/chat`、可选 DashScope 风格 `completion`、可选 `/chat` 页面/资源和 `/mcp` 已实现 | | `internal/service` | 业务用例编排 | 已实现单进程聊天会话/并发 Run 控制,以及地名候选、有界空间查询、可信数据库全范围/镇街白名单和结果语义 | | `internal/domain` | 森林防火领域模型与规则 | 已包含点位、水源、候选设施、通道、队伍和风险区模型 | | `internal/repository` | PostgreSQL/PostGIS 持久化适配 | 已实现 pgxpool、只读固定 SQL 与 schema/SRID readiness;实库 SRID 元数据、严格 readiness 和 7 个工具真实查询已验证 | @@ -23,7 +23,7 @@ | `internal/integration/superagent` | SuperAgent Open API 出站适配 | 已实现并通过模拟 Provider 测试,默认关闭 | | `cmd/superagent-probe` | 无业务数据的显式连通性探针 | 已实现;需要项目专属测试配置 | | `cmd/postgis-probe` | 不读取业务行的 PostGIS readiness 探针 | 已实现;需要只读数据库配置 | -| `Dockerfile` / `compose.yaml` | 测试环境容器构建与单实例进程托管 | 已建立;容器内监听 8080,只发布宿主机回环端口 16587;目标机镜像已构建,容器启动和 health 待重建后验证 | +| `Dockerfile` / `compose.yaml` | 测试环境容器构建与单实例进程托管 | 已建立;容器内监听 8080,只发布宿主机回环端口 16587;页面开关默认关闭;目标机镜像已构建,容器启动和 health 待重建后验证 | | `pkg` | 可被外部 module 复用的稳定 Go API | 当前为空 | | `docs/import` | 字段/表映射、通用模板和本地数据库样例 | 样例 SQL 含受限数据并被 Git 忽略,不会执行 | | SuperAgent | 对话理解、工具选择和答案组织 | 对话客户端按仓库内 2026-07-12 协议基线实现;同一 SuperAgent 中 `th-hotel-simple-superagent` 已稳定启用/调用,证明其兼容档案不依赖版本配置;公网消防 `/mcp` 已收到一次请求并到达 Go,但旧版本门禁返回错误,消防兼容档案待部署验证,公网消防链路尚未调用数据库工具 | @@ -38,21 +38,21 @@ - HTTP:Go 标准库 `net/http`。 - 测试:Go 标准库 `testing`、`httptest`。 - 配置:环境变量;支持 HTTP、SuperAgent、Chat、MCP 与 PostGIS 配置,并默认关闭 Chat 及两个外部方向。 -- Chat API:标准库 HTTP/SSE;原生 `/api/chat` 使用静态联调 Bearer,可选 `completion` 兼容入口使用同一信任方向的 `xtoken`;默认要求 Chat 凭证至少 32 个可打印 ASCII 字符,受控迁移时可通过默认关闭的 `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN` 兼容已交付短凭证;两者共享精确 Origin、严格 JSON、总超时、有界单进程会话和同会话并发冲突。兼容入口只在严格成功后发送正文。 +- Chat API:标准库 HTTP/SSE;原生 `/api/chat` 使用静态联调 Bearer,可选 `completion` 兼容入口使用同一信任方向的 `xtoken`;默认要求 Chat 凭证至少 32 个可打印 ASCII 字符,受控迁移时可通过默认关闭的 `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN` 兼容已交付短凭证;两者共享精确 Origin、严格 JSON、总超时、有界单进程会话和同会话并发冲突。兼容入口只在严格成功后发送正文。可选测试页面由 `FIRE_SAFETY_CHAT_PAGE_ENABLED` 控制,提供 `/chat`(开启时 308 到 `/chat/`)、`/chat/`、`/chat/app.css` 和 `/chat/app.js`;页面只在内存中使用用户手动输入的 `xtoken`,复用兼容 SSE 的 `session_id`,不嵌入或持久化 Token。 - SuperAgent:标准库 HTTP/SSE 客户端,分离 Session 创建和消息发送,支持严格完成判定与既有 Run 断流恢复。 - MCP:标准库 HTTP/JSON-RPC,服务端固定返回版本标识 `2025-06-18`,同步 JSON 响应,独立 Bearer 和 7 个只读工具;按已稳定接通的 `th-hotel-simple-superagent` 兼容档案处理,initialize 中的 `protocolVersion` 和后续 `MCP-Protocol-Version` Header 都不作为版本拒绝门禁,SuperAgent 无需也不能配置版本。固定返回该版本不表示支持任意其他版本,也不是追求最新协议。地名工具只搜索现有业务记录并要求用户确认候选。 - PostgreSQL:`github.com/jackc/pgx/v5 v5.10.0` 原生连接池;连接默认只读并设置 statement timeout。 - PostGIS:`ST_Covers`、`ST_DWithin`、`ST_Distance` 和 `ST_ClosestPoint`;只在实库确认 EPSG:4326 后启用。 -- 部署:多阶段 Docker 镜像与单实例 Compose;Secret 通过未提交的 `.env` 在运行时注入,容器内 8080 只发布到宿主机 `127.0.0.1:16587`,由宿主机 Nginx 终止 TLS。Docker build 的 Go module proxy 可按环境覆盖,但默认使用官方代理、保留 checksum 校验,且所选构建代理在运行容器内强制清空。 +- 部署:多阶段 Docker 镜像与单实例 Compose;Secret 通过未提交的 `.env` 在运行时注入,容器内 8080 只发布到宿主机 `127.0.0.1:16587`,由宿主机 Nginx 终止 TLS。Docker build 的 Go module proxy 可按环境覆盖,但默认使用官方代理、保留 checksum 校验,且所选构建代理在运行容器内强制清空。Nginx 示例精确反代 `/chat`、`/chat/`、页面 CSS/JavaScript、兼容 completion 和 `/mcp`;页面仍由 Go 开关控制。 ### 计划但尚未接入或确认 - PostgreSQL/PostGIS 的适用索引和生产查询计划验证。 - SuperAgent 到 `/mcp` 的 proven-profile 兼容实现部署后重测,以及 TLS、网络白名单、Token 和完整工具调用链联调;版本 Header 不需要配置,也不是验收门禁。 - 任意地址/山名的外部地理编码、别名词典和大数据量地名索引。 -- 用户聊天的真实身份认证、动态授权、共享/持久会话、主动取消和限流策略;首版默认关闭的静态 Bearer + 内存会话 API 已实现。legacy 短凭证仅限受控测试/迁移窗口,轮换后须关闭兼容开关。 +- 用户聊天的真实身份认证、动态授权、共享/持久会话、主动取消和限流策略;首版默认关闭的静态 Bearer + 内存会话 API 以及默认关闭的静态测试页面已实现。legacy 短凭证仅限受控测试/迁移窗口,轮换后须关闭兼容开关。 - 最终用户鉴权、动态角色/区域或租户隔离、持久审计与完整可观测性方案。 -- 正式前端身份接入;现有客户端仅通过受限 DashScope 风格协议适配。 +- 正式前端身份接入;当前 `/chat` 页面仅用于受控测试,现有第三方客户端仅通过受限 DashScope 风格协议适配。 ## 4. 已知业务数据范围 @@ -73,19 +73,19 @@ ## 5. 目标职责边界 -- 用户侧应用:采集用户输入并展示结果;具体形态待确认。 +- 用户侧应用:采集用户输入并展示结果;仓库提供默认关闭的 `/chat` 受控测试页面,正式用户端形态仍待确认。 - 本 Go 服务:鉴权上下文、会话转发、MCP 工具、数据查询、权限、安全和审计。 -- SuperAgent:理解自然语言、决定是否调用工具、组织自然语言结果。 +- SuperAgent:理解自然语言、决定是否调用工具、组织自然语言结果;页面和第三方客户端都通过同一兼容 completion SSE 入口发起对话。 - MCP 工具:提供固定的森林防火领域查询,不开放任意 SQL 或跨权限访问。 - PostgreSQL/PostGIS:保存和计算可信空间事实;资源是否可用仍取决于明确状态和数据时效。 ## 6. 当前开发方向与非目标 -当前阶段已有可运行、可测试、文档自解释的 Go 基线、SuperAgent Open API Adapter、默认关闭的原生用户对话 API、可选 DashScope 风格兼容入口,以及空间只读 MCP/PostGIS 实现。对话入口使用独立静态联调凭证和单进程内存会话;仓库已有多阶段 Docker/Compose 基线以及精确路径、无 Secret 的 Nginx HTTPS 反向代理示例。目标机镜像构建已成功,但容器启动和公网 HTTPS 仍待重建后验证;现场截图已证明公网 `/mcp` 请求到达 Go,Bearer、Content-Type 和 JSON-RPC 前置检查通过,随后旧版本门禁返回错误,尚未发生公网 SuperAgent 数据库工具调用。同一 SuperAgent 中 `th-hotel-simple-superagent` 已稳定启用/调用,为本项目提供了 proven-profile 兼容参照,但不能替代消防 endpoint 的验收。部署后通过 `direct_success` 或 `compatibility_success` 日志分类确认实际请求,不记录客户端原始版本值。MCP 默认关闭,实库严格 readiness 和全部 7 个工具的本地真实查询已通过。下一阶段先部署 proven-profile 兼容修复,再在测试服务器使用真实消防 Profile 完成 `initialize` → `notifications/initialized` → `tools/list` → 至少一个 `tools/call` 的公网联调。 +当前阶段已有可运行、可测试、文档自解释的 Go 基线、SuperAgent Open API Adapter、默认关闭的原生用户对话 API、可选 DashScope 风格兼容入口、默认关闭的 `/chat` 测试页面,以及空间只读 MCP/PostGIS 实现。对话入口使用独立静态联调凭证和单进程内存会话;页面不嵌入或持久化 Token,使用者手动输入 `xtoken` 并复用同页面内存中的 `session_id`。仓库已有多阶段 Docker/Compose 基线以及精确路径、无 Secret 的 Nginx HTTPS 反向代理示例,模板公开反代 `/chat`、`/chat/`、`/chat/app.css`、`/chat/app.js`、兼容 completion 和 `/mcp`,页面是否可用由 Go 开关控制。目标机镜像构建和 Nginx 语法检查已由现场截图证明通过,容器稳定运行、页面公网响应和 TLS 实际状态仍待验证。2026-09-05 22:35 的现场日志已证明公网 MCP 完成 `fire_safety_search_place_candidates` 一次成功调用(此前的 initialize/notifications/tools/list 也有日志),但其余消防工具和完整多工具链仍待验收。同一 SuperAgent 中 `th-hotel-simple-superagent` 已稳定启用/调用,为本项目提供了 proven-profile 兼容参照,但不能替代消防 endpoint 的完整验收。部署后通过 `direct_success` 或 `compatibility_success` 日志分类确认实际请求,不记录客户端原始版本值。MCP 默认关闭,实库严格 readiness 和全部 7 个工具的本地真实查询已通过。下一阶段先在测试服务器验证页面开关、静态资源、兼容 Chat 首轮/多轮和公网 TLS,再完成真实消防 Profile 的完整 MCP 工具链联调。 本阶段不实现: -- 面向真实用户的认证、动态权限、持久会话和已验证生产能力;当前对话入口只用于受控联调。 +- 面向真实用户的认证、动态权限、持久会话和已验证生产能力;当前对话 API 与 `/chat` 页面只用于受控联调。 - PostgreSQL/PostGIS 数据导入或索引 DDL;SRID 元数据迁移是已经显式执行的一次受控运维操作,不是应用运行时行为。 - 用户登录、权限模型、审计存储和生产部署。 - 面向真实火情的自动决策或路径规划。 diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index e9cf810..e22e39d 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -2,16 +2,16 @@ | 项 | 内容 | | --- | --- | -| 最近更新 | 2026-09-05 | +| 最近更新 | 2026-09-06 | | 当前分支 | `main` | -| 当前阶段 | 对话、SuperAgent、空间 MCP 与测试环境容器部署基线已完成;公网 `/mcp` 已到达 Go,已按稳定接通的 th-hotel SuperAgent compatibility profile 完成版本兼容调整,待部署验证 | -| 当前重点 | 审查并部署 proven-profile 兼容实现,重建目标机容器后验证 health 和完整公网 MCP 工具链路 | +| 当前阶段 | 对话、SuperAgent、空间 MCP、默认关闭的测试页面与测试环境容器部署基线已完成;公网地点候选 MCP 已现场成功一次,页面公网验收和完整 MCP 多工具链仍待验证 | +| 当前重点 | 完成 `/chat` 测试页面的配置开关、静态资源与公网验收,同时重建目标机并验证完整公网 MCP 工具链路 | ## 1. 当前 Checkpoint -- 名称:`fire-safety-ymd-superagent-mcp-proven-profile-compatibility` -- 状态:In Progress -- 目标:让无法配置协议版本的 SuperAgent 按已稳定接通的 `th-hotel-simple-superagent` compatibility profile 调用 `/mcp`:版本字段和 `MCP-Protocol-Version` Header 不作为拒绝门禁,`initialize` 固定返回 `2025-06-18`,同时保持鉴权、数据范围和只读工具契约不变,并在 `/home/firee-safety-ymd` 重建后完成完整工具链路验收。 +- 名称:`fire-safety-ymd-public-chat-page-v1` +- 状态:Ready for Review(本地实现与验证完成;测试服务器部署待用户执行) +- 目标:提供默认关闭的 `/chat` 测试页面和 `/chat/` 规范化入口,按第三方兼容 `completion` SSE 请求,支持用户手动输入测试 `xtoken` 和同页面内存 `session_id` 复用;通过 Nginx 公网反代页面及其 CSS/JavaScript 资源,并保持 Token、真实用户认证和生产授权边界不变。 - 非目标:替用户提交或推送 Git、直接修改远程服务器、创建数据库容器、迁移生产数据、签发证书、改变 DNS/安全组、实现真实用户认证、动态授权、会话持久化或生产审计。 当前进展: @@ -20,8 +20,8 @@ - Docker build 支持通过 `FIRE_SAFETY_BUILD_GOPROXY` 选择目标机可达的可信 Go module proxy;默认仍为官方代理并保留 checksum database,`git` 只存在于 builder,构建代理配置在运行容器中强制清空。 - `compose.yaml` 只运行一个 API 实例,从未提交的 `.env` 注入配置,强制清空一次性迁移 DSN,把容器内 8080 发布到宿主机 `127.0.0.1:16587`,并设置健康检查、只读文件系统、权限收紧和日志轮转。 - 现有 PostgreSQL/PostGIS 不进入 Compose;同宿主机数据库需要使用容器可达的宿主机地址,且仍需受 `listen_addresses`、`pg_hba.conf` 和防火墙约束。 -- Nginx 示例增加 HTTP 到 HTTPS 跳转和 HTTP 429 JSON 限流响应,仍不比较、保存或注入 Chat、MCP、SuperAgent 或数据库 Secret。 -- 运维手册记录 Git 前置条件、服务器目录、Secret 权限、Compose/Nginx 启停、Chat/MCP 冒烟、SuperAgent 回调、更新和回滚。 +- Nginx 示例增加 HTTP 到 HTTPS 跳转和 HTTP 429 JSON 限流响应,精确反代 `/chat`、`/chat/`、`/chat/app.css`、`/chat/app.js`、兼容 completion 和 `/mcp`,仍不比较、保存或注入 Chat、MCP、SuperAgent 或数据库 Secret。 +- 运维手册记录 Git 前置条件、服务器目录、Secret 权限、Compose/Nginx 启停、页面开关、Chat/MCP 冒烟、SuperAgent 回调、更新和回滚。 - SuperAgent 无法在其配置中指定 MCP 协议版本;同一 SuperAgent 中 `th-hotel-simple-superagent` 已稳定启用/调用,现场实现不读取 `initialize.params.protocolVersion`,不读取或校验 `MCP-Protocol-Version` Header,并固定返回 `2025-06-18`。消防 MCP 按该 proven profile 兼容:版本字段和 Header 不作为拒绝门禁,响应固定为 `2025-06-18`。这不表示支持任意其他版本,也不是追求最新协议;日志只使用 `direct_success`/`compatibility_success`,不记录原始版本值。 已实现验收项: @@ -30,18 +30,19 @@ - Compose 配置不包含明文 Secret,且没有数据库容器、数据卷或迁移命令;现有业务数据库不会被部署动作重建。 - Nginx 上游固定为宿主机回环地址,兼容 Chat SSE 禁用缓冲和自动重试,未列出路径固定 404。 - 目标机执行步骤包含不渲染 `.env` 内容的 Compose 检查、`nginx -t` 前置门禁和可恢复的配置替换。 -- 目标服务器已开始部署:Nginx 配置语法检查和 Docker 镜像构建已通过;曾因 Chat 短凭证门禁发生重启,随后最新公网 `/mcp` 请求已到达 Go,说明服务已至少恢复到可处理请求的状态,但独立 `16587/health` 通过证据尚未提供。 -- SuperAgent 现场截图证明公网 `/mcp` 请求已经到达 Go 服务,Bearer、`Content-Type` 和 JSON-RPC 前置校验均已通过;随后旧版本门禁返回错误。截图没有捕获客户端版本字段是否存在、类型和值,公网 SuperAgent 尚未调用数据库工具。 +- 目标服务器已开始部署:Nginx 配置语法检查和 Docker 镜像构建已通过;曾因 Chat 短凭证门禁发生重启,随后公网 MCP 请求已到达 Go,页面、容器稳定运行和独立 `16587/health` 通过证据仍待提供。 +- SuperAgent 现场日志已证明公网 MCP 完成 `initialize`、`notifications/initialized`、`tools/list`,并于 2026-09-05 22:35 成功调用 `fire_safety_search_place_candidates`;这只证明地点候选工具成功,其他消防工具和完整多工具链仍待验收。 ## 2. 当前优先级 1. 由用户审查本 checkpoint 变更后提交并推送 `origin/main`;服务器只部署明确提交的 revision。 2. 在 `/home/firee-safety-ymd` 重建 Compose,确认宿主机 16587 只绑定回环地址、容器内 8080 健康可达且数据库连接正常。 -3. 使用 SuperAgent 无版本配置的实际握手重测:通过 `direct_success` 或 `compatibility_success` 日志确认请求已按 proven profile 处理,并验证 `initialize` → `notifications/initialized` → `tools/list` → 至少一个 `tools/call`;版本字段/Header 不需要配置,也不作为拒绝门禁。 -4. reload 已通过语法检查的 Nginx 配置,只公开 HTTPS 兼容路径、`/mcp` 和可选 `/health`。 -5. 使用项目专属测试 Key 和已发布消防 Profile 做兼容 `completion` 首轮/多轮真实冒烟,核对最终回答、用量和会话复用。 -6. 为所有查询表补齐适用 GiST 索引并验证查询计划;当前小数据可做联调,但生产前必须完成索引与并发验证。 -7. 设计真实用户身份、动态角色/区域授权、共享会话、限流、Secret 轮换、指标和持久审计。 +3. 页面测试保持 `FIRE_SAFETY_CHAT_PAGE_ENABLED=false` 默认边界;需要开启时配置 `https://agent.nianxx.com` 精确 Origin,重建容器并验证 `/chat` 308、`/chat/` 及 CSS/JavaScript 资源 200,以及关闭后的直接 404。 +4. reload 已通过语法检查的 Nginx 配置,只公开 HTTPS 页面/资源、兼容路径、`/mcp` 和可选 `/health`。 +5. 使用项目专属测试 Key 和已发布消防 Profile 做兼容 `completion` 首轮/多轮真实冒烟,核对最终回答、用量和会话复用;页面必须证明使用同一请求契约。 +6. 使用 SuperAgent 无版本配置的实际握手重测:通过 `direct_success` 或 `compatibility_success` 日志确认请求已按 proven profile 处理,并验证 `initialize` → `notifications/initialized` → `tools/list` → 7 个工具的 `tools/call`;当前公网地点搜索的一次成功不替代完整链路,版本字段/Header 不需要配置,也不作为拒绝门禁。 +7. 为所有查询表补齐适用 GiST 索引并验证查询计划;当前小数据可做联调,但生产前必须完成索引与并发验证。 +8. 设计真实用户身份、动态角色/区域授权、共享会话、限流、Secret 轮换、指标和持久审计。 ## 3. 已确认事实 @@ -57,11 +58,12 @@ - Go module 当前使用临时名称 `fire-safety-ymd`,本地工具链为 Go `1.26.6`。 - 直接第三方依赖为 `github.com/jackc/pgx/v5 v5.10.0`;HTTP、JSON-RPC/MCP 和测试仍使用 Go 标准库。 - 服务默认监听 `:8080`;`GET /health` 仍只是 liveness,不访问外部依赖。 -- 用户对话 API、SuperAgent Open API Adapter 和 MCP endpoint 都默认关闭;Chat Bearer、Open API Key 和 MCP Token 属于三个独立信任方向并禁止复用。 +- 用户对话 API、SuperAgent Open API Adapter、默认关闭的 `/chat` 测试页面和 MCP endpoint 都默认关闭;Chat Bearer、页面手动输入的 xtoken、Open API Key 和 MCP Token 属于三个独立信任方向并禁止复用。 - `/api/chat` 已实现单进程内存会话映射、同会话并发 Run 冲突和严格 SSE 最终回答;模拟 Provider 端到端测试通过,真实 SuperAgent 尚未通过该入口联调。 - 可选兼容入口已实现截图所示路径、`xtoken`、`input.prompt/session_id` 和 `event: result` 外形;正文仍只在严格成功的 `stop` 事件中出现,不是 DashScope 全量 API。 +- 可选 `/chat` 测试页面已纳入 Go 路由边界:页面开关默认关闭,关闭时 `/chat`、`/chat/`、`/chat/app.css`、`/chat/app.js` 均直接 404;开启时 `/chat` 返回 308 到 `/chat/`,`/chat/` 与 `/chat/app.css`、`/chat/app.js` 提供同源页面资源。页面不嵌入或持久化 Token,用户手动输入 xtoken,页面仅在内存中复用 `session_id`。 - `Dockerfile`、`.dockerignore` 和 `compose.yaml` 已建立测试部署基线;容器单实例运行,容器内 8080 仅发布到宿主机 `127.0.0.1:16587`,一次性迁移 DSN 在服务容器中强制为空。 -- `deploy/nginx/fire-safety-ymd.conf.example` 已将公网调用指向宿主机 `127.0.0.1:16587`,再由 Docker 映射到容器 8080;配置不保存或注入任何 Provider/Chat/MCP Secret。目标机仅 `nginx -t` 语法检查已通过,reload 和 HTTPS 实际响应尚未验证。 +- `deploy/nginx/fire-safety-ymd.conf.example` 已将公网页面/资源、兼容对话和 MCP 调用指向宿主机 `127.0.0.1:16587`,再由 Docker 映射到容器 8080;配置不保存或注入任何 Provider/Chat/MCP Secret。目标机仅 `nginx -t` 语法检查已通过,reload、页面资源和 HTTPS 实际响应尚未验证。 - 用户确认真实数据包含大量镇街,环境变量不适合枚举全量值;MCP 现支持显式数据库全范围 `all` 和默认镇街白名单 `town_allowlist` 两种服务端范围。 - 用户选择先实现简单地名能力、后续再优化;当前只查询既有森林防火记录,不调用外部地图服务,也不把候选代表点自动认定为演练点。 - 本地真实 MCP 冒烟已完成:7 个工具均成功访问实库,响应和错误边界符合契约;该结果不等于公网、SuperAgent 或生产并发已验证。 @@ -74,11 +76,11 @@ - 35 条无效面几何和 7 条空几何会被查询排除;尤其 4 条无效防火网格可能造成所属网格和责任中队结果缺口,27 条无效墓地面可能造成风险区域漏项。 - 除防火网格外 7 张表缺少 GiST 几何索引;当前 geography 距离表达式的生产索引方案需根据实库查询计划确认。 - 水源/设施 `syzt`、水源 `hc_datetime` 等字段的枚举、单位、时区和更新责任人尚未确认。 -- SuperAgent MCP 的公网 URL 已有一次请求到达 Go 的现场证据,Bearer、`Content-Type`、JSON-RPC 已通过;旧版本门禁返回错误,但现场证据未捕获客户端版本字段是否存在、类型和值。同一 SuperAgent 的 th-hotel 服务已稳定调用,为 proven profile 参照;消防服务兼容实现、TLS、网络白名单、Token 轮换和公网真实工具调用仍待联调。是否发送协议 Header 不构成兼容阻塞。 -- `/api/chat` 静态 Bearer 和兼容路径 `xtoken` 只适用于受控联调,浏览器用户可以看到它;真实用户身份、动态授权、生产速率限制和滥用防护尚未实现。Chat 短凭证仅可通过默认关闭的显式 legacy 开关在受控测试/迁移窗口使用,MCP Token 仍要求至少 32 个可打印 ASCII 字符,三种凭证必须不同。 +- SuperAgent MCP 的公网 URL 已有现场成功证据:Bearer、`Content-Type`、JSON-RPC 通过,随后记录了 `initialize`、`notifications/initialized`、`tools/list`,并于 2026-09-05 22:35 成功调用 `fire_safety_search_place_candidates`。同一 SuperAgent 的 th-hotel 服务已稳定调用,为 proven profile 参照;消防服务其余 6 个工具、完整多工具链、TLS、网络白名单和 Token 轮换仍待联调。是否发送协议 Header 不构成兼容阻塞。 +- `/api/chat` 静态 Bearer、兼容路径 `xtoken` 和 `/chat` 测试页面只适用于受控联调,浏览器用户可以看到手动输入的 Token;页面默认关闭,开启时要求 `FIRE_SAFETY_CHAT_ALLOWED_ORIGINS` 包含精确 `https://agent.nianxx.com`。真实用户身份、动态授权、生产速率限制和滥用防护尚未实现。Chat 短凭证仅可通过默认关闭的显式 legacy 开关在受控测试/迁移窗口使用,MCP Token 仍要求至少 32 个可打印 ASCII 字符,三种凭证必须不同。 - 目标公网机器的 Docker/Compose 和 Nginx 版本、配置 include 层级、证书、DNS、安全组及 PostgreSQL 网络拓扑尚未完整验证;用户已开始远程部署,仓库资产与服务器现场配置仍需完成一致性核验。 - 目标服务器此前连续两次访问 `proxy.golang.org:443` 均在约 91 秒后超时,随后已通过可达的构建路径完成 Docker 镜像构建;该事实不代表所有外部 HTTPS 都可达。 -- 目标机曾因已交付的 Chat 短凭证未通过默认配置门禁而反复重启;`801c0af` 已包含显式兼容方案。最新公网 `/mcp` 请求已到达 Go 并通过前置校验,说明该启动阻塞已不再是当前首要问题;但独立 health、公网 Chat、MCP 完整握手和工具调用仍未形成通过证据。 +- 目标机曾因已交付的 Chat 短凭证未通过默认配置门禁而反复重启;`801c0af` 已包含显式兼容方案。最新公网 `/mcp` 请求已完成地点候选工具调用,说明该启动阻塞已不再是当前首要问题;但独立 health、页面公网响应、兼容 Chat 多轮、MCP 其余工具和完整链路仍未形成通过证据。 - Chat 会话只在单个 Go 进程内存中保存;重启或多实例切换会丢失上下文,且当前没有历史查询、持久审计或主动取消 Provider Run。 - 当前 `all`/`town_allowlist` 都是服务账号静态范围,不是最终用户级授权;`all` 会授权当前数据库中 MCP 固定查询表内所有镇街和镇街字段为空的记录,身份提供方、角色、租户和精确位置权限尚未确定。 - 地名搜索是无索引的有界包含匹配;真实数据量下的耗时、重名率和名称字段质量尚未验证,生产优化可能需要标准地名表、别名词典或 `pg_trgm` 索引。 @@ -97,32 +99,38 @@ - 用户提交并推送本 checkpoint 后,目标机能在 `/home/firee-safety-ymd` clone 或 `git pull --ff-only` 到明确 revision。 - `docker compose build --pull`、`up -d` 和容器健康检查通过,宿主机 16587 只绑定 `127.0.0.1`,运行容器中没有迁移凭证。 - 在目标机替换安全 App ID、执行 `nginx -t` 后 reload,并验证 TLS、HTTP 到 HTTPS 跳转、429 和未列出路径 404。 +- 验证页面开关关闭时 `/chat`、`/chat/`、`/chat/app.css` 和 `/chat/app.js` 均直接返回 404;开启并 recreate 后 `/chat` 返回 308、`/chat/` 返回 200,两个资源也返回 200;页面 Origin 白名单包含 `https://agent.nianxx.com`。 - 使用无敏感信息的问题验证兼容首轮 `null -> stop`、后续 `session_id` 复用、错误 xtoken、断流和超时。 +- 使用浏览器测试页面手动输入静态测试 xtoken,确认页面不写入 Token、同源发送兼容 completion SSE,并在第二轮复用内存 `session_id`;页面不作为生产认证。 - 配置 SuperAgent 对公网 `/mcp` 的独立 Bearer,验证真实工具调用、TLS 和 warning 保留。 - 不在输出、命令历史、Nginx、镜像层或 Git 中记录任何 Secret。 ## 6. 验证记录 - `gofmt -w ./cmd ./internal`:通过。 -- `GOCACHE=/private/tmp/fire-safety-go-cache go test -count=1 ./...`:通过;新增覆盖兼容 App ID、`xtoken`、CORS、严格请求子集、`null -> stop` SSE、会话复用映射、用量/模型名、错误脱敏和路由/应用装配;原有 Chat、SuperAgent、MCP/PostGIS 覆盖继续通过。 -- `GOCACHE=/private/tmp/fire-safety-go-cache go vet ./...`:通过。 -- `GOCACHE=/private/tmp/fire-safety-go-cache go test -race -count=1 ./...`:通过。 +- `GOCACHE=/private/tmp/fire-safety-ymd-go-cache go test -count=1 ./...`:通过;新增覆盖页面开关依赖、开启/关闭路由、HTML/静态资源、安全响应头和应用装配;原有 Chat、SuperAgent、MCP/PostGIS 覆盖继续通过。 +- `GOCACHE=/private/tmp/fire-safety-ymd-go-cache go vet ./...`:通过。 +- `GOCACHE=/private/tmp/fire-safety-ymd-go-cache go test -race -count=1 ./...`:通过。 +- `node --check internal/handler/chatpage/app.js`:通过。 +- 本地真实浏览器:首轮兼容 SSE、第二轮 `session_id` 复用、错误凭证、Nginx 风格 429、未完成 SSE 断流后清会话、恶意 HTML 纯文本显示均通过;375px 视口无横向溢出,4 个按钮高度均为 44px,控制台无错误。 +- `ruby -e 'require "yaml"; YAML.load_file("compose.yaml")'`:通过基础 YAML 解析;开发机没有 Docker CLI,未执行 `docker compose config --quiet`。 - Chat legacy 短凭证回归:默认仍拒绝少于 32 个字符的 Chat Token;仅在 `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN=true` 时接受非空、最多 4096 字节且仅含 ASCII `0x21-0x7e` 的短值;原生与兼容入口均通过,MCP 至少 32 字符和三凭证分离门禁保持不变。 - 模拟 SuperAgent Chat 端到端:原生与兼容入口均通过;应用创建 Provider Session、发送消息、解析严格完成事件,原生返回 `conversation/message/done`,兼容入口返回 `result` 且最终 `finish_reason=stop`。 - Nginx:配置已完成静态检查且未包含真实 Secret;开发机未安装 Nginx。据用户截图,目标机 `nginx -t` 已通过,reload 和 HTTPS 实际响应尚未验证。 +- 测试页面:已补齐默认关闭开关、`/chat` 规范化入口、`/chat/` HTML、`/chat/app.css`、`/chat/app.js`、同源兼容 SSE、手动 xtoken/内存 session_id 以及开启/关闭/回滚文档;Go 自动化测试、JavaScript 语法检查和本地真实浏览器首轮/续轮、错误凭证、429、断流、纯文本注入与 375px 移动端验收通过,公网静态资源和真实 SuperAgent 多轮仍待部署验收。 - Docker/Compose:部署文件已通过 YAML/静态安全断言;开发机未安装 Docker。目标机 Docker 镜像构建已成功,曾因 Chat 短凭证默认门禁反复重启;最新公网 `/mcp` 请求证明服务随后已恢复处理请求,但 `docker compose config --quiet`、独立 health 和完整公网链路仍待现场确认。 - 构建代理回归检查:修复前静态反馈命令返回 `RED: Docker build has no configurable GOPROXY`;修复后确认 Compose build arg、Dockerfile `GOPROXY` 和运行容器清空边界,返回 GREEN。 - `CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -trimpath ./cmd/server`:通过,生成 Linux 静态服务二进制;不替代目标机真实 Docker build。 - 目标机首次 Docker 构建:失败;`go mod download` 获取 `github.com/jackc/pgpassfile@v1.0.0` 时连接 `proxy.golang.org:443` 超时,镜像/容器未生成,随后 `curl http://127.0.0.1:16587/health` 得到 connection refused,符合前置构建失败。 - 目标机后续 Docker 构建:成功;但 `docker compose logs --tail=100 api` 报 `FIRE_SAFETY_CHAT_AUTH_TOKEN must contain at least 32 printable ASCII characters`,容器因配置校验失败重启,故 `curl http://127.0.0.1:16587/health` 仍未形成通过证据。 - 目标机 Nginx:用户截图显示 `nginx -t` 配置语法检查成功;是否已 reload 以及 HTTPS 实际响应仍待确认。 -- SuperAgent MCP 公网现场:用户截图中的服务端错误证明请求已到达 Go 服务,Bearer、`Content-Type`、JSON-RPC 校验通过,随后旧版本门禁返回错误;截图未捕获客户端版本字段是否存在、类型和值。数据库 readiness 虽已在本地通过,但公网 SuperAgent 尚未调用任何数据库工具。 +- SuperAgent MCP 公网现场:用户提供的 2026-09-05 22:35 日志证明请求已到达 Go 服务,Bearer、`Content-Type` 和 JSON-RPC 校验通过,完成 initialize/notifications/tools-list 后,`fire_safety_search_place_candidates` 已返回 success。该证据只覆盖地点候选工具;数据库 readiness 虽已在本地通过,但其余 6 个工具和完整公网多工具链仍待验收。 - MCP proven-profile 回归:旧实现对 SuperAgent 请求返回截图中的 `MCP_PROTOCOL_VERSION_UNSUPPORTED`(RED);兼容实现对版本字段缺失、空值、非字符串、直接匹配或其他值均固定返回 `2025-06-18`,并覆盖带/不带/不同值 Header;同一 Handler 内的 `initialize` → `notifications/initialized` → `tools/list` → `tools/call` 回归通过,日志仅输出 `direct_success` 或 `compatibility_success`(GREEN)。公网完整链路仍待部署验证。 - SRID 迁移预检:通过;使用临时 `admin` 连接确认 4,048 条候选、8 表 UPDATE 权限和 7 表 ALTER 权限,未输出 DSN 或业务记录。 - SRID 数据迁移:通过;事务更新 4,048 条非空几何,7 张二维表改为 `geometry(Geometry,4326)`,防火通道保持裸 `geometry`,迁移前后几何载荷指纹与维度一致。 - 真实 PostGIS 严格 audit:通过;运行时只读账号报告 8 表 SRID 均为 4326、类型与范围门禁通过。预期保留 7 条空几何、35 条无效面几何和 7 张缺 GiST 索引表 warning。 - 本地真实 MCP 冒烟:通过;health、鉴权、Origin、方法限制、协议初始化、7 工具发现、7 tools/call、无结果、非法参数、响应一致性、字段脱敏、warning 和结构化日志均符合契约。第二轮实库查询约 0.35 至 0.99 秒。 - 数据导入:用户报告 8 份 SQL 已导入,防火通道在使用裸 `geometry` 保留混合二维/Z 后重导成功;这是现场反馈,不替代项目只读 probe 的最终验证。 -- 真实 SuperAgent 对话与 MCP 联调:对话 API 仅完成模拟 Provider 端到端测试,真实消防 Profile 尚未测试;MCP readiness 和本地真实工具冒烟已通过。公网 `/mcp` 已收到 SuperAgent initialize,但旧版本门禁返回错误,尚未完成 proven-profile 兼容部署,公网数据库工具尚未调用,不能宣称公网工具联调或部署完成。th-hotel 成功只证明同一 SuperAgent 的兼容档案可行,不替代消防 endpoint 的完整链路验收。 +- 真实 SuperAgent 对话与 MCP 联调:对话 API 仅完成模拟 Provider 端到端测试,真实消防 Profile 尚未完成 Chat 页面/多轮验收;MCP readiness 和本地真实工具冒烟已通过。公网 `/mcp` 已完成一次 `fire_safety_search_place_candidates`,但其余 6 个工具和完整多工具链尚待验证,不能宣称公网工具联调或部署完成。th-hotel 成功只证明同一 SuperAgent 的兼容档案可行,不替代消防 endpoint 的完整链路验收。 - 样例 SQL:未执行;含受限数据的 `*.sql` 已被 Git 忽略。 - Git:`801c0af`(Chat 门禁兼容修复)已由用户提交并推送到 `origin/main`;本次 MCP 协商变更尚未提交,未执行自动 commit/push。 diff --git a/compose.yaml b/compose.yaml index d9491c5..b92cd63 100644 --- a/compose.yaml +++ b/compose.yaml @@ -16,6 +16,9 @@ services: # The host binding below keeps this port private; the process must bind # all container interfaces for Docker's loopback publish to work. FIRE_SAFETY_HTTP_ADDR: ":8080" + # Explicitly preserve the default-off public test page boundary. The + # value may be enabled through the deployment's uncommitted .env file. + FIRE_SAFETY_CHAT_PAGE_ENABLED: "${FIRE_SAFETY_CHAT_PAGE_ENABLED:-false}" # This setting is consumed only while resolving the build arg above. # Do not retain the selected proxy URL in the runtime environment. FIRE_SAFETY_BUILD_GOPROXY: "" diff --git a/deploy/nginx/fire-safety-ymd.conf.example b/deploy/nginx/fire-safety-ymd.conf.example index bdf53f4..88c2cbe 100644 --- a/deploy/nginx/fire-safety-ymd.conf.example +++ b/deploy/nginx/fire-safety-ymd.conf.example @@ -3,6 +3,9 @@ # This file is intended to be included from nginx's http context (normally # /etc/nginx/conf.d/*.conf). Replace the safe app-id in the exact chat # location with the value configured in FIRE_SAFETY_CHAT_COMPAT_APP_ID. +# /chat and /chat/ are the optional public test page. They are always +# proxied here, while Go's FIRE_SAFETY_CHAT_PAGE_ENABLED switch decides +# whether they serve HTML or return 404. # Never put FIRE_SAFETY_CHAT_AUTH_TOKEN, FIRE_SAFETY_MCP_AUTH_TOKEN, a # SuperAgent key, or a database credential in this file. @@ -34,6 +37,108 @@ server { ssl_protocols TLSv1.2 TLSv1.3; limit_req_status 429; + # Optional same-origin public test page. The Go handler serves the page + # only when FIRE_SAFETY_CHAT_PAGE_ENABLED=true; otherwise these exact + # routes return 404. The page never receives a token from Nginx. + location = /chat { + limit_req zone=fire_safety_chat burst=20 nodelay; + client_max_body_size 16k; + + proxy_pass http://fire_safety_ymd_backend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Server $host; + proxy_set_header X-Request-ID $request_id; + proxy_buffering on; + proxy_cache off; + proxy_connect_timeout 5s; + proxy_read_timeout 30s; + proxy_send_timeout 30s; + proxy_next_upstream off; + proxy_intercept_errors off; + } + + location = /chat/ { + limit_req zone=fire_safety_chat burst=20 nodelay; + client_max_body_size 16k; + + proxy_pass http://fire_safety_ymd_backend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Server $host; + proxy_set_header X-Request-ID $request_id; + proxy_buffering on; + proxy_cache off; + proxy_connect_timeout 5s; + proxy_read_timeout 30s; + proxy_send_timeout 30s; + proxy_next_upstream off; + proxy_intercept_errors off; + } + + # The page keeps CSS and JavaScript as same-origin static resources. Keep + # these locations exact and proxy them to Go so the Go page switch applies + # to the complete page surface (HTML, CSS and JavaScript). + location = /chat/app.css { + limit_req zone=fire_safety_chat burst=20 nodelay; + client_max_body_size 16k; + + proxy_pass http://fire_safety_ymd_backend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Server $host; + proxy_set_header X-Request-ID $request_id; + proxy_buffering on; + proxy_cache off; + proxy_connect_timeout 5s; + proxy_read_timeout 30s; + proxy_send_timeout 30s; + proxy_next_upstream off; + proxy_intercept_errors off; + } + + location = /chat/app.js { + limit_req zone=fire_safety_chat burst=20 nodelay; + client_max_body_size 16k; + + proxy_pass http://fire_safety_ymd_backend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Server $host; + proxy_set_header X-Request-ID $request_id; + proxy_buffering on; + proxy_cache off; + proxy_connect_timeout 5s; + proxy_read_timeout 30s; + proxy_send_timeout 30s; + proxy_next_upstream off; + proxy_intercept_errors off; + } + # DashScope-compatible user chat. Keep this exact location restricted to # the one configured app ID; do not replace it with a catch-all regex. # The incoming xtoken is forwarded unchanged and validated by Go. diff --git a/docs/architecture/public-chat-entry-v1.md b/docs/architecture/public-chat-entry-v1.md index 3f4d028..e993c3c 100644 --- a/docs/architecture/public-chat-entry-v1.md +++ b/docs/architecture/public-chat-entry-v1.md @@ -4,8 +4,10 @@ ```mermaid flowchart LR + B[受控测试浏览器] -->|GET /chat 或 /chat/| N[Nginx] C[既有用户客户端] -->|HTTPS + xtoken\nDashScope 风格 JSON/SSE| N[Nginx] N -->|HTTP 127.0.0.1:16587宿主机映射到容器 :8080| D[兼容 Chat Handler] + N -->|HTTP 127.0.0.1:16587| P0[可选 Chat Page Handler] D -->|ChatRequest / ChatTurn| S[共享 Chat Service] S -->|Provider-neutral port| A[SuperAgent Adapter] A -->|Open API Key + HTTPS/SSE| SA[SuperAgent] @@ -21,9 +23,17 @@ Nginx 不再直接调用 DashScope。它只负责 TLS、精确公开路径、基 | --- | --- | --- | --- | --- | | `/api/chat` | 本项目原生客户端 | `Authorization: Bearer` | `message`、`conversation_id` | `conversation`、`progress`、`message`、`done` | | `/api/v1/apps/{app_id}/completion` | 既有 DashScope 风格客户端 | `xtoken` | `input.prompt`、`input.session_id`、受限 `parameters` | `result`,最终 `finish_reason=stop` | +| `/chat`、`/chat/` | 受控测试浏览器页面 | 页面本身不鉴权;页面发出的兼容请求使用用户输入的 `xtoken` | `/chat` 返回 308 到 `/chat/`;`/chat/` 返回 HTML | 页面消费同一 `result` SSE | +| `/chat/app.css`、`/chat/app.js` | 页面同源静态资源 | 页面资源不鉴权 | CSS/JavaScript | 页面加载资源 | 两个 Handler 都调用同一个 `ChatService.Prepare` 和 `ChatTurn.Stream`。兼容层只负责协议转换,不复制会话或 SuperAgent 业务逻辑。 +`/chat` 和 `/chat/` 是同一个最小测试页面入口,其中 `/chat` 规范化重定向到 `/chat/`,不是第三种对话协议。页面调用的仍是当前配置的 +`/api/v1/apps/{FIRE_SAFETY_CHAT_COMPAT_APP_ID}/completion`,请求体、`xtoken`、SSE +`event: result`、`finish_reason` 和 `session_id` 语义与既有第三方客户端完全相同。页面只把 +`session_id` 保存在当前页面的 JavaScript 内存中,用于同一页面的后续轮次;刷新页面或关闭页面 +后不会恢复会话。 + ## 3. 标识映射 ```text @@ -41,13 +51,27 @@ Nginx 不再直接调用 DashScope。它只负责 TLS、精确公开路径、基 这样保留了当前应急辅助场景的失败语义:断流或 Provider 协议不完整时,客户端不会把半截模型文本误当成已完成方案。代价是首版没有逐字动画;如后续必须实时输出,需要独立安全决策、取消/失败 UX 和新 Spec。 +测试页面使用浏览器 `fetch` 发起 POST,以便同时设置 `xtoken` 并读取 SSE;它不会把 Token +写进 HTML、Cookie、localStorage、sessionStorage、URL 或服务端配置。用户每次打开页面都要手动 +输入测试 `xtoken`,页面只在本次页面生命周期内使用该值。页面可展示用户输入的问题和最终回答, +但不承担身份认证、权限判断、历史持久化或审计职责。 + ## 5. 网络与伸缩边界 - Docker 部署中 Go 在容器内监听 `:8080`,宿主机只发布 `127.0.0.1:16587` 给 Nginx;若直接运行二进制,则监听 `127.0.0.1:16587`。公网只开放 Nginx 443,PostgreSQL 5432 不对公网开放。 -- Nginx 对兼容路径关闭缓冲、缓存、gzip 和重试,超时必须覆盖 Go Chat Run Timeout。 +- Nginx 对兼容路径关闭缓冲、缓存、gzip 和重试,超时必须覆盖 Go Chat Run Timeout;页面 HTML、 + CSS 和 JavaScript 使用精确 location 反代,不能因只代理 `/chat/` 而让资源路径落入默认 404。 - `/mcp` 使用独立 Bearer;Chat `xtoken`、MCP Bearer 与 SuperAgent Open API Key 三者不得复用。 - 当前会话在单进程内存中。多实例部署必须先实现共享会话映射或粘性路由;否则后续轮次可能落到另一实例并返回 404。 - 浏览器可读取静态 `xtoken`,所以该入口仍只适用于受控联调;生产最终用户入口需要真实身份认证和动态授权。 +- 页面开关 `FIRE_SAFETY_CHAT_PAGE_ENABLED` 默认关闭。Nginx 可固定反代 `/chat`、`/chat/`、 + `/chat/app.css` 和 `/chat/app.js`,但 Go 仅在开关开启且 Chat 兼容入口配置完整时注册路由; + 关闭时四个路径均直接返回 404;开启时 `/chat` 才规范化为 308,跟随后 `/chat/` 和资源 + 路径返回页面内容。 +- 页面与 API 使用同一公网 Origin 时,服务端 `FIRE_SAFETY_CHAT_ALLOWED_ORIGINS` 必须包含精确值 + `https://agent.nianxx.com`。不使用通配符;Nginx 不注入或保存 `xtoken`。 +- 页面是静态测试工具,不是生产用户认证。任何能访问页面的人都可以看到输入框,凭证仍由用户 + 手动提供;页面开启前必须确认测试 Token 的范围和轮换计划。 ## 6. 相关文档 @@ -55,3 +79,4 @@ Nginx 不再直接调用 DashScope。它只负责 TLS、精确公开路径、基 - [`chat-api-v1.md`](chat-api-v1.md) - [`../workflows/user-chat.md`](../workflows/user-chat.md) - [`../project/operations/nginx-public-entry.md`](../project/operations/nginx-public-entry.md) +- [`../specs/fire-safety-ymd-chat-page-v1.md`](../specs/fire-safety-ymd-chat-page-v1.md) diff --git a/docs/project/README.md b/docs/project/README.md index 91d55dd..1ff3ab9 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -16,10 +16,11 @@ | [`integrations/superagent-openapi.md`](integrations/superagent-openapi.md) | SuperAgent Open API、Session、SSE 恢复与探针说明 | 中 | | [`integrations/superagent-mcp-spatial.md`](integrations/superagent-mcp-spatial.md) | SuperAgent 空间只读 MCP、配置、工具和联调门禁 | 高 | | [`operations/postgis-srid-4326.md`](operations/postgis-srid-4326.md) | 已确认 WGS84 数据的 SRID 元数据迁移、验证与权限边界 | 高 | -| [`operations/nginx-public-entry.md`](operations/nginx-public-entry.md) | `agent.nianxx.com` 到 Go 对话/MCP 的 HTTPS 反向代理示例 | 高 | +| [`operations/nginx-public-entry.md`](operations/nginx-public-entry.md) | `agent.nianxx.com` 到 Go 页面、对话/MCP 的 HTTPS 反向代理示例 | 高 | | [`operations/docker-test-deployment.md`](operations/docker-test-deployment.md) | `/home/firee-safety-ymd` 测试服务器的 Docker Compose、Nginx、验证与回滚手册 | 高 | | [`../architecture/chat-api-v1.md`](../architecture/chat-api-v1.md) | 用户对话入口、内存 Session 映射、SSE 和三凭证边界 | 高 | | [`../architecture/public-chat-entry-v1.md`](../architecture/public-chat-entry-v1.md) | DashScope 风格兼容入口、Nginx、会话映射与严格结果边界 | 高 | +| [`../specs/fire-safety-ymd-chat-page-v1.md`](../specs/fire-safety-ymd-chat-page-v1.md) | 默认关闭的 `/chat` 测试页面、同源兼容 SSE、Token 与会话边界 | 高 | | [`../architecture/spatial-mcp-v1.md`](../architecture/spatial-mcp-v1.md) | MCP、Service、Repository 与 8 张 PostGIS 表的架构 | 中 | | [`../workflows/user-chat.md`](../workflows/user-chat.md) | 首轮/多轮对话、失败处理和 curl 联调流程 | 高 | | [`../workflows/exercise-plan-evidence.md`](../workflows/exercise-plan-evidence.md) | 从地名候选或演练点坐标查询方案证据的流程与能力边界 | 中 | @@ -45,6 +46,6 @@ - `docs/architecture/`:已有用户对话、DashScope 风格公网兼容入口和空间 MCP v1;后续保存系统、数据、模块和部署架构。 - `docs/workflows/`:已有用户对话和演练方案证据查询流程;后续保存异常补偿流程。 - `docs/adr/`:不可逆或跨模块的重要决策。 -- `docs/specs/`:可验收的功能规格;已有原生/兼容用户对话 API、SuperAgent Open API 和空间只读 MCP Spec。 +- `docs/specs/`:可验收的功能规格;已有原生/兼容用户对话 API、受控测试页面、SuperAgent Open API 和空间只读 MCP Spec。 新增文档必须能回答“未来的新 Agent 为什么需要阅读它”,并从本索引或上级文档建立入口。 diff --git a/docs/project/operations/docker-test-deployment.md b/docs/project/operations/docker-test-deployment.md index 00ecc8b..3188675 100644 --- a/docs/project/operations/docker-test-deployment.md +++ b/docs/project/operations/docker-test-deployment.md @@ -6,7 +6,7 @@ | 运行方式 | 宿主机 Nginx 终止 HTTPS,Docker Compose 运行单个 Go 服务 | | 应用监听边界 | 容器内监听 8080,只发布到宿主机 127.0.0.1:16587 | | 数据库 | 使用现有 PostgreSQL/PostGIS;本手册不创建数据库容器、不导入 SQL | -| 状态 | 测试环境部署基线;目标机镜像构建和 Nginx 语法检查已由现场截图证明通过,但容器稳定运行、公网 TLS 和 SuperAgent MCP 握手/工具回调仍待重测 | +| 状态 | 测试环境部署基线;目标机镜像构建和 Nginx 语法检查已由现场截图证明通过,但容器稳定运行、页面资源、公网 TLS 和 SuperAgent MCP 完整工具链仍待重测 | 本手册不包含真实 Token、API Key、数据库密码或旧 Nginx 配置内容。命令中的尖括号是服务器上需要替换的占位符;不要把 Secret 写进命令行、Nginx 文件、镜像构建参数或 Git。 @@ -117,8 +117,10 @@ FIRE_SAFETY_CHAT_AUTH_TOKEN=<独立的高熵xtoken> FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN=false FIRE_SAFETY_CHAT_SUBJECT_ID=fire-safety-ymd-chat-test-subject FIRE_SAFETY_CHAT_COMPAT_APP_ID=<与Nginx路径完全一致的公开app-id> -# 仅浏览器实际 Origin;CLI/服务端调用可以留空 -FIRE_SAFETY_CHAT_ALLOWED_ORIGINS=https:// +# 可选的受控测试页面默认关闭;开启时必须同时启用 Chat/兼容入口 +FIRE_SAFETY_CHAT_PAGE_ENABLED=false +# 页面从 agent.nianxx.com 同源加载时必须包含该精确 Origin;可按需追加其他测试 Origin +FIRE_SAFETY_CHAT_ALLOWED_ORIGINS=https://agent.nianxx.com FIRE_SAFETY_MCP_ENABLED=true FIRE_SAFETY_MCP_AUTH_TOKEN=<独立的高熵MCP-bearer> @@ -136,6 +138,11 @@ FIRE_SAFETY_POSTGIS_EXPECTED_SRID=4326 - FIRE_SAFETY_CHAT_AUTH_TOKEN 只用于兼容对话入口的 xtoken;FIRE_SAFETY_MCP_AUTH_TOKEN 只用于 SuperAgent 调用 /mcp;FIRE_SAFETY_SUPERAGENT_OPEN_API_KEY 只用于 Go 服务访问 SuperAgent。三者必须不同。 - Chat Token 默认至少 32 个可打印 ASCII 字符。如果已交付的旧客户端凭证较短且无法立即更换,只能临时设置 `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN=true`;此时仍要求 Token 非空、不超过 4096 字节,并且每个字符都在 ASCII `0x21-0x7e` 范围内(无空格、控制字符或 Unicode)。该开关只影响 Chat 原生 Bearer 和兼容 `xtoken`,不放宽 MCP Token 至少 32 个可打印 ASCII 字符的要求。新环境不得开启,凭证轮换完成后必须恢复 `false`;开关启用时启动日志会记录不含 Secret 的安全 warning。 - FIRE_SAFETY_CHAT_COMPAT_APP_ID 是公开路径标识,不是 Secret;它必须与 Nginx 的精确 location = /api/v1/apps//completion 完全一致。 +- FIRE_SAFETY_CHAT_PAGE_ENABLED 默认必须为 false。开启后 Go 提供 `/chat`、`/chat/` 以及同源的 + `/chat/app.css`、`/chat/app.js`;`/chat` 会 308 到 `/chat/`,页面和资源的最终可用性仍由 Go + 开关决定。页面只用于受控测试,不嵌入或持久化 Token。 +- 页面开启时 FIRE_SAFETY_CHAT_ALLOWED_ORIGINS 必须包含精确的 `https://agent.nianxx.com`,并且 + Chat 兼容 App ID、Chat Token 和 SuperAgent 配置已经可用;该静态页面不是最终用户认证。 - 使用 FIRE_SAFETY_MCP_SCOPE_MODE=all 时,FIRE_SAFETY_MCP_ALLOWED_TOWNS 必须保持为空。all 是服务账号级的固定查询表范围,不是最终用户级授权。 - FIRE_SAFETY_POSTGIS_MIGRATION_DSN 不应配置给运行服务;迁移凭证只供一次性迁移命令使用,完成后应移除。 - FIRE_SAFETY_HTTP_ADDR 在容器内应为 :8080,安全边界由 Compose 的 127.0.0.1:16587:8080 和宿主机 Nginx 提供;不要在 Compose 场景设为容器内的 127.0.0.1:8080。 @@ -295,7 +302,86 @@ curl -N --fail \ unset CHAT_XTOKEN ~~~ -## 8. SuperAgent MCP 回调配置与冒烟 +## 8. 公网 Chat 测试页面 + +测试页面由 Go 可选提供,不需要单独的前端容器或 Nginx 静态目录。页面及两个同源资源的 +Nginx 精确路径已经包含在仓库模板中:`/chat`、`/chat/`、`/chat/app.css` 和 `/chat/app.js`。 +Nginx 始终可以反代这些路径,但是否返回内容由 Go 的 +`FIRE_SAFETY_CHAT_PAGE_ENABLED` 控制。 + +### 开启页面 + +在服务器受保护的 `.env` 中确认或修改以下非 Secret 配置: + +~~~text +FIRE_SAFETY_CHAT_ENABLED=true +FIRE_SAFETY_CHAT_COMPAT_APP_ID=<与Nginx completion路径一致的公开app-id> +FIRE_SAFETY_CHAT_PAGE_ENABLED=true +FIRE_SAFETY_CHAT_ALLOWED_ORIGINS=https://agent.nianxx.com +~~~ + +页面开启要求 Chat、兼容 App ID、Chat 测试凭证和 SuperAgent 配置已经有效; +`https://agent.nianxx.com` 必须是精确 Origin,不能使用 `*`。页面是受控测试工具,不是最终 +用户认证,用户要在页面中手动输入 xtoken,不能把 Token 写入页面、Nginx 或镜像。 + +修改 `.env` 后必须重新创建容器,单独 restart 不会重新读取环境变量: + +~~~bash +cd /home/firee-safety-ymd +docker compose config --quiet +docker compose up -d --force-recreate --no-build +docker compose ps +curl --fail http://127.0.0.1:16587/health +~~~ + +如果 Nginx 模板刚刚更新,再执行 `sudo nginx -t`,确认通过后再 reload;只改 Go 开关时不需要 +改 Nginx 文件。 + +### 页面验收 + +先不带 Token 检查页面和资源路由: + +~~~bash +curl -i https://agent.nianxx.com/chat +curl -i https://agent.nianxx.com/chat/ +curl -i https://agent.nianxx.com/chat/app.css +curl -i https://agent.nianxx.com/chat/app.js +~~~ + +开启时预期 `/chat` 返回 HTTP 308 并指向 `/chat/`,`/chat/` 返回 HTTP 200 HTML,CSS 和 +JavaScript 返回 HTTP 200。需要自动跟随入口重定向时使用: + +~~~bash +curl -iL https://agent.nianxx.com/chat +~~~ + +关闭时,Go 不注册页面 Handler,`/chat`、`/chat/`、`/chat/app.css` 和 `/chat/app.js` 均 +直接返回 HTTP 404;这与 Nginx 是否保留四个精确 location 无关。 + +浏览器打开 `https://agent.nianxx.com/chat/`,手动输入受控测试 xtoken(页面不会预填或保存), +发送不含敏感信息的问题。Network 面板应看到同源 POST 到 +`/api/v1/apps//completion`,请求体为第三方兼容格式,Header 含 `xtoken`, +响应为 `event: result` SSE。只有 `output.finish_reason=stop` 才是成功;第二轮请求应把第一轮 +成功返回的 `output.session_id` 放入 `input.session_id`。刷新或关闭页面后,Token 和 session_id +均应清除,不得自动恢复。 + +### 关闭与回滚 + +紧急关闭页面时,将 `.env` 的 `FIRE_SAFETY_CHAT_PAGE_ENABLED` 改回 `false`,执行: + +~~~bash +cd /home/firee-safety-ymd +docker compose up -d --force-recreate --no-build +curl -i https://agent.nianxx.com/chat +curl -i https://agent.nianxx.com/chat/ +~~~ + +确认两个路径直接 404(无 308),并按需检查 CSS/JavaScript 资源也为 404。该操作不会自动 +关闭兼容 completion;如需一并关闭 Chat,再按 Chat 总开关执行配置回滚。若页面版本或 Nginx +版本导致故障,回到维护者指定的已验证 Git revision 或 root-only Nginx 备份,先 `nginx -t` +再 reload;不要恢复旧的全路径 DashScope 代理、关闭 Go 鉴权或把 Secret 写入 Nginx。 + +## 9. SuperAgent MCP 回调配置与冒烟 在 SuperAgent 的工具/MCP 配置中新增远程 MCP 服务: @@ -309,7 +395,7 @@ Authorization: Bearer 服务端兼容规则如下:对可解析的 JSON-RPC `initialize`,`params.protocolVersion` 的缺失、空值、非字符串或其他值都不作为版本拒绝条件,响应固定返回 `result.protocolVersion: "2025-06-18"`;`MCP-Protocol-Version` Header 的缺失或值也不作为版本拒绝条件。固定返回该版本不表示服务实现或声明支持任意客户端版本,`2025-03-26`、`2025-11-25` 等值也不改变服务端实现目标。兼容仅针对版本元数据,Bearer、`Content-Type`、非空 `Origin`、JSON-RPC 结构、只读工具、服务端数据范围和敏感字段边界仍严格执行。 -现场截图只证明公网 `/mcp` 已到达 Go 服务:Bearer、`Content-Type` 和 JSON-RPC 前置检查已通过,随后旧版本门禁返回错误。该历史截图没有捕获客户端版本字段是否存在、类型和值;同一 SuperAgent 中 `th-hotel-simple-superagent` 已稳定调用是兼容档案参照,但不是消防 MCP 成功证据。部署后通过 `direct_success`(客户端值为 `2025-06-18`)或 `compatibility_success`(版本字段缺失或其他值被兼容处理)日志分类确认,并完成完整调用链;服务端不得记录原始版本值。公网 SuperAgent 尚未调用消防数据库工具,因此不能把该截图当成公网部署或业务联调完成。 +现场日志已证明公网 `/mcp` 完成 initialize、notifications/initialized、tools/list,并成功调用一次 `fire_safety_search_place_candidates`;该证据只覆盖地点搜索,不能替代其余消防工具和完整多工具链验收。同一 SuperAgent 中 `th-hotel-simple-superagent` 已稳定调用是兼容档案参照。部署后继续通过 `direct_success`(客户端值为 `2025-06-18`)或 `compatibility_success`(版本字段缺失或其他值被兼容处理)日志分类确认,服务端不得记录原始版本值。 如果修改了 MCP Handler 或握手协商逻辑,目标机必须拉取包含该修改的明确 revision 后重建镜像;仅 `docker compose restart` 不会更新镜像中的代码: @@ -368,7 +454,7 @@ curl --fail \ unset MCP_BEARER ~~~ -## 9. 更新、重启与回滚 +## 10. 更新、重启与回滚 ### 发布新版本 @@ -429,15 +515,17 @@ cd /home/firee-safety-ymd docker compose down ~~~ -## 10. 完成判定与当前限制 +## 11. 完成判定与当前限制 本手册完成不代表公网部署已完成。现场交付至少应记录: - 目标机 Compose 配置校验、build、up、健康检查和无 Secret 的有限日志结果。 - 目标机 nginx -t 和 reload 成功;443 证书、DNS、安全组及旧 location / 已确认不再生效。 +- 页面开关关闭时 `/chat`、`/chat/`、`/chat/app.css`、`/chat/app.js` 直接返回 404;开启时 `/chat` 返回 308、`/chat/` 返回 200 HTML,两个资源返回 200;`FIRE_SAFETY_CHAT_ALLOWED_ORIGINS` 包含 `https://agent.nianxx.com`。 - Chat 首轮/多轮 SSE、错误凭证、未知 app/path 的实际 HTTPS 响应。 +- 页面浏览器验收证明用户手动输入 xtoken、Token 不被页面持久化、兼容 completion SSE 及同页面 `session_id` 复用;这只是静态测试页面,不是生产认证。 - 若为已交付旧客户端临时开启 Chat legacy 短凭证兼容,应记录受控迁移窗口,确认启动 warning 不含 Secret,并在凭证轮换后恢复 `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN=false`。 -- SuperAgent -> `/mcp` 的独立 Bearer、TLS/网络白名单、无版本配置 initialize 兼容响应、`notifications/initialized`、`tools/list` 和至少一个受控只读 `tools/call`。版本字段/Header 不作为配置或拒绝门禁;公网请求到达但没有完成这条调用链,不算完成。 +- SuperAgent -> `/mcp` 的独立 Bearer、TLS/网络白名单、无版本配置 initialize 兼容响应、`notifications/initialized`、`tools/list` 和全部 7 个受控只读 `tools/call`。现场已有地点搜索一次成功证据,但其余工具未验收;版本字段/Header 不作为配置或拒绝门禁;公网请求到达但没有完成这条调用链,不算完成。 - 数据库未公开 5432,运行账号保持只读,PostGIS readiness warning 和 35 条无效面几何缺口已记录。 若目标服务器无法使用 host.docker.internal、Compose 不支持 --quiet、Nginx include 目录不同、证书路径不同或 SuperAgent 对 MCP 的认证格式不同,应先记录实际环境并调整部署 Spec;不要通过放开端口、写入 Token、关闭 readiness 或恢复全路径反代规避问题。 diff --git a/docs/project/operations/nginx-public-entry.md b/docs/project/operations/nginx-public-entry.md index 8c51768..eecf9dc 100644 --- a/docs/project/operations/nginx-public-entry.md +++ b/docs/project/operations/nginx-public-entry.md @@ -2,7 +2,7 @@ | 项 | 内容 | | --- | --- | -| 目标 | 让 `agent.nianxx.com` 通过 HTTPS 访问本项目的兼容对话接口和 MCP | +| 目标 | 让 `agent.nianxx.com` 通过 HTTPS 访问本项目的可选测试页面、兼容对话接口和 MCP | | 状态 | 示例配置;公网机器、证书、网络白名单和真实鉴权仍待联调 | | 上游 | 仅反代本机 `127.0.0.1:16587`;容器内部仍监听 8080 | @@ -27,6 +27,8 @@ Nginx 不再调用 DashScope,也不保存或注入 SuperAgent Open API Key、C | 路径 | 用途 | 鉴权与限制 | | --- | --- | --- | | `/api/v1/apps//completion` | 截图所示的 DashScope-compatible 用户对话 SSE | Go 校验 `xtoken`;请求体 128 KiB;示例限流 5 req/s、burst 20 | +| `/chat`、`/chat/` | 可选的受控测试对话页面 | `/chat` 由 Go 规范化重定向到 `/chat/`(308);页面 GET 不要求 Token;Go 由 `FIRE_SAFETY_CHAT_PAGE_ENABLED` 决定返回 HTML 或 404;示例限流 5 req/s、burst 20 | +| `/chat/app.css`、`/chat/app.js` | 测试页面静态资源 | 页面开关关闭时由 Go 返回 404;开启时返回对应资源;示例限流 5 req/s、burst 20 | | `/mcp` | SuperAgent 调用本项目的 MCP | Go 校验独立 `Authorization: Bearer`;请求体 256 KiB;示例限流 20 req/s、burst 40 | | `/health` | 可选进程存活检查 | 不访问数据库;如不希望公开可删除该 location | | 其他路径 | 不对外提供 | Nginx 固定返回 404 | @@ -39,6 +41,20 @@ Nginx 不再调用 DashScope,也不保存或注入 SuperAgent Open API Key、C 4. 直接在宿主机运行 Go 时,应选择未占用的回环端口并同步修改 Nginx upstream;使用本仓库 Compose 时,容器内监听 `:8080`,端口映射保持为 `127.0.0.1:16587:8080`。不要把 16587、容器 8080 或 PostgreSQL 5432 暴露到公网。 5. 按环境配置 DNS、云防火墙/安全组和 SuperAgent 对 `/mcp` 的来源 IP/TLS 要求;这些部署事实尚未由本项目验证。 +示例中的 `location = /chat`、`location = /chat/`、`location = /chat/app.css` 和 +`location = /chat/app.js` 是页面及其资源的精确入口,均反代到同一个 `127.0.0.1:16587` +上游。Nginx 不判断页面开关,也不提供备用 HTML、CSS 或 JavaScript: + +- `FIRE_SAFETY_CHAT_PAGE_ENABLED=true` 且依赖完整时,Go 返回页面 HTML; +- 页面请求 `/chat` 时,Go 返回 308 并规范化到 `/chat/`;随后 `/chat/` 返回页面 HTML; +- 页面请求 CSS/JavaScript 资源时,Go 返回资源内容; +- 开关为 `false` 或页面依赖不满足时,Go 对页面和资源路径返回 404; +- 不要把这些 location 改成旧配置的 `location /`,也不要在 Nginx 中注入 xtoken。 + +页面默认关闭。测试开启时,Go 进程配置的 `FIRE_SAFETY_CHAT_ALLOWED_ORIGINS` 必须包含精确 +Origin `https://agent.nianxx.com`;该值是浏览器从页面同源发出 completion 请求时的来源。不要 +用 `*`,也不要把 Token 写入 Nginx。 + `limit_req_zone` 必须位于 Nginx `http` context,不能放进 `server` 或 `location`。示例文件假定它被 `conf.d/*.conf` 从 `http {}` 中 include;如果部署系统不是这样 include,应把两条 `limit_req_zone` 指令单独移到 `http {}`,并保留 `server`/`upstream` 在合法上下文。 ## 3. Go 服务配置 @@ -60,6 +76,10 @@ FIRE_SAFETY_CHAT_AUTH_TOKEN= # when an already-issued legacy Chat credential is shorter than 32 characters. FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN=false FIRE_SAFETY_CHAT_COMPAT_APP_ID= +# Optional public test page; keep false unless this test surface is needed. +FIRE_SAFETY_CHAT_PAGE_ENABLED=false +# If the page is enabled, this exact same-origin value is required. +FIRE_SAFETY_CHAT_ALLOWED_ORIGINS=https://agent.nianxx.com FIRE_SAFETY_MCP_ENABLED=true FIRE_SAFETY_MCP_AUTH_TOKEN= @@ -74,6 +94,12 @@ FIRE_SAFETY_POSTGIS_DSN= - `FIRE_SAFETY_MCP_AUTH_TOKEN` 只用于 SuperAgent -> Go `/mcp`,不应复用 Chat Token。 - Chat、SuperAgent、MCP 和 PostGIS 的完整配置校验以 `.env.example` 和对应项目文档为准。 - 浏览器会看到 `xtoken`;它不能代表最终用户身份、角色、租户或数据授权。公网真实用户入口仍需身份提供方、动态授权、限流、Secret 轮换和持久审计。 +- `FIRE_SAFETY_CHAT_PAGE_ENABLED` 默认必须为 `false`。开启页面只适用于受控测试;页面 GET 本身 + 不需要 Token,用户在页面输入的 `xtoken` 只在当前页面内存中使用,不写入 HTML、Cookie、URL、 + localStorage 或 sessionStorage。 +- 页面与兼容接口同源时,`FIRE_SAFETY_CHAT_ALLOWED_ORIGINS` 必须包含精确的 + `https://agent.nianxx.com`。这不是把页面变成生产认证;Token 仍为静态测试凭证,必须由用户 + 手动输入并按测试范围管理。 - Nginx 不读取、比较或存储 Chat Token,也不需要为 legacy 开关增加配置;请求 Header 原样转发给 Go 校验。开关启用时 Go 仅记录不含 Secret 的安全 warning,便于迁移完成后清理。 测试服务器使用 Docker Compose 的完整目录、启动、更新和回滚步骤见 [`docker-test-deployment.md`](docker-test-deployment.md)。 @@ -135,6 +161,47 @@ curl -N --fail \ 如果目标客户端不接受 `text/event-stream` 或只需要一次性 JSON,应先以接口 Spec 为准;不要让 Nginx 擅自将 SSE 缓冲成普通 JSON。 +### 受控测试页面 + +页面由 Go 在开关开启时提供,Nginx 只做四个精确路径的反代(页面、规范化入口和两个资源): + +```bash +curl -i https://agent.nianxx.com/chat +curl -i https://agent.nianxx.com/chat/ +``` + +预期行为: + +- `FIRE_SAFETY_CHAT_PAGE_ENABLED=false`:`/chat` 和 `/chat/` 均直接返回 HTTP 404; +- `FIRE_SAFETY_CHAT_PAGE_ENABLED=true` 且 Chat 兼容配置完整:`/chat` 返回 HTTP 308, + `/chat/` 返回 HTTP 200,`Content-Type` 为 `text/html`; +- 开启时页面加载的 `/chat/app.css` 和 `/chat/app.js` 也应返回 HTTP 200;关闭时均为 404。 + +需要跟随 `/chat` 的规范化重定向时使用: + +```bash +curl -iL https://agent.nianxx.com/chat +``` + +浏览器打开 `https://agent.nianxx.com/chat/` 后,手动输入受控测试 `xtoken` 和不含敏感信息的 +问题。页面使用 `fetch` 向同源的 +`/api/v1/apps//completion` 发起 POST,Header 为 `xtoken`、 +`Content-Type: application/json`、`Accept: text/event-stream`,请求体仍是: + +```json +{"input":{"prompt":"观水镇附近有哪些地点候选?"},"parameters":{}} +``` + +页面只在内存中保存最终成功的 `output.session_id`,下一轮把它放入 `input.session_id`;刷新或 +关闭页面后不恢复。只有 `finish_reason=stop` 才显示为成功,初始 `finish_reason=null`、HTTP +错误、`event: error` 或断流不能当作完整答案。页面不会把 xtoken 写入 HTML、浏览器存储、Cookie、 +URL、Nginx 或日志。 + +浏览器 Network 面板应能确认:请求为同源 POST、Origin 为 `https://agent.nianxx.com`、使用正确 +的公开 App ID、没有跨域失败,并且第二次请求包含上一轮返回的 `session_id`。如果出现 403,优先 +检查 `FIRE_SAFETY_CHAT_ALLOWED_ORIGINS` 是否包含精确 Origin;如果出现 404,检查页面开关和 +容器是否已 recreate;如果出现 401,重新输入正确的测试 xtoken。 + ### MCP 探活/初始化 MCP 请求需要独立 Token,具体 JSON-RPC body 以 MCP Spec 为准: @@ -166,13 +233,19 @@ curl --fail \ 完成 `nginx -t` 和 reload 后,应至少验证: 1. `/health` 返回 200(如果保留可选 location)。 -2. 兼容对话首轮在正确 `xtoken` 下返回 SSE,末尾出现 `finish_reason: stop`;错误 Token 返回 401,且 Go 不会创建 SuperAgent Run。 -3. 兼容对话多轮使用返回的本地会话 ID;服务重启或会话失效后按契约返回会话不存在,不把 ID 当成 Provider Session。 -4. `/mcp` 使用独立 Bearer,错误或缺失 Bearer 返回 401;Nginx 没有注入任何 MCP Token。 -5. 未列出的路径(例如 `/api/chat`、`/api/v1/apps/other/completion` 和 `/anything`)返回 404。 +2. 页面开关关闭时 `/chat` 和 `/chat/` 均直接返回 404;开启且依赖完整时 `/chat` 返回 308、`/chat/` 返回 200 HTML,且两个资源路径返回 200。 +3. 兼容对话首轮在正确 `xtoken` 下返回 SSE,末尾出现 `finish_reason: stop`;错误 Token 返回 401,且 Go 不会创建 SuperAgent Run。 +4. 兼容对话多轮使用返回的本地会话 ID;服务重启或会话失效后按契约返回会话不存在,不把 ID 当成 Provider Session。 +5. `/mcp` 使用独立 Bearer,错误或缺失 Bearer 返回 401;Nginx 没有注入任何 MCP Token。 +6. 未列出的路径(例如 `/api/chat`、`/api/v1/apps/other/completion` 和 `/anything`)返回 404。 如果 reload 后发现兼容路由、证书或 SSE 行为异常,先恢复上一个已验证的 Nginx 配置并保留 `nginx -t` 输出;不要通过放开 `location /`、关闭 Go 鉴权或把 Secret 写入 Nginx 来排障。 +如只需紧急关闭测试页面,不必改动 Nginx:将 Go 环境中的 +`FIRE_SAFETY_CHAT_PAGE_ENABLED` 改为 `false`,重新创建容器后确认 `/chat`、`/chat/` 和资源 +路径均直接返回 404。这样不会自动关闭兼容 completion;如果也要关闭对话 API,再按 Chat 配置和对应回滚流程 +处理。页面开关和 Nginx 路由均应保留清晰的变更记录。 + ## 7. 未确认事项 - 目标公网机器的 Nginx 版本、include 层级、TLS 终止位置和证书续期方式;示例同时监听 80 做 HTTPS 跳转,安全组需按实际策略决定是否允许 80。 diff --git a/docs/specs/fire-safety-ymd-chat-page-v1.md b/docs/specs/fire-safety-ymd-chat-page-v1.md new file mode 100644 index 0000000..5a35e7a --- /dev/null +++ b/docs/specs/fire-safety-ymd-chat-page-v1.md @@ -0,0 +1,216 @@ +# fire-safety-ymd 测试对话页面 v1 Spec + +| 项 | 内容 | +| --- | --- | +| 状态 | Implemented locally;测试服务器公网部署待验收 | +| 日期 | 2026-09-06 | +| 负责人 | fire-safety-ymd 后端 | +| 关联接口 | `POST /api/v1/apps/{app_id}/completion` | +| 关联配置 | `FIRE_SAFETY_CHAT_PAGE_ENABLED` | + +## 1. 背景 + +项目已有 DashScope 风格的兼容对话接口。SuperAgent、数据库和公网 MCP 联调时,使用者需要 +一个无需另建前端工程的最小浏览器页面,用来输入问题、观察 SSE 结果并验证同一会话的后续轮次。 +这个页面只服务于测试和受控联调,不改变既有第三方请求契约,也不提供真实用户登录或授权。 + +## 2. 目标 + +- 由 Go 服务可选地提供 `GET /chat` 和 `GET /chat/` 两个页面入口,并提供同源的 CSS/JavaScript 资源。 +- `FIRE_SAFETY_CHAT_PAGE_ENABLED` 默认值为 `false`;关闭时两个入口都返回 HTTP 404。 +- 开启时页面使用与第三方完全相同的兼容对话请求: + `POST /api/v1/apps/{FIRE_SAFETY_CHAT_COMPAT_APP_ID}/completion`。 +- 页面让用户手动输入 `xtoken`,在当前页面内发起 SSE 请求,不把凭证写入页面源码、Cookie、 + URL、localStorage、sessionStorage 或服务端配置。 +- 页面保存上一轮成功响应的 `output.session_id` 仅在 JavaScript 内存中,并将它用于同一页面的 + 后续轮次。 +- 页面能区分 SSE 的中间会话事件、最终 `finish_reason=stop`、上游错误和 HTTP 错误。 +- Nginx 公网入口固定反代 `/chat`、`/chat/`、`/chat/app.css`、`/chat/app.js` 和兼容 `completion` 路径;是否实际提供页面由 Go + 配置开关决定。 + +## 3. 非目标 + +- 不实现注册、登录、JWT、SSO、角色、租户或最终用户级动态授权。 +- 不把静态测试 `xtoken` 写入 HTML、JavaScript 常量、Nginx、镜像或 Git。 +- 不持久化 Token、问题、答案、聊天历史或会话映射;刷新页面后不自动恢复会话。 +- 不新增一套页面专用的聊天协议、Provider Session 或数据库访问接口。 +- 不实现逐字流式动画、文件上传、图像、地图选点、后台任务或多实例共享会话。 +- 不把页面公开等同于公网生产能力;页面和当前 Chat 兼容 API 都只适用于受控联调。 + +## 4. 路由与开关 + +| 路由 | 开关关闭 | 开关开启 | 说明 | +| --- | --- | --- | --- | +| `GET /chat` | 404 | 308 到 `/chat/` | 页面规范化入口 | +| `GET /chat/` | 404 | 200 `text/html` | 页面入口 | +| `GET /chat/app.css` | 404 | 200 `text/css` | 页面样式资源 | +| `GET /chat/app.js` | 404 | 200 `text/javascript` | 页面脚本资源 | +| `POST /api/v1/apps/{app_id}/completion` | 按 Chat/Compat 配置 | 按 Chat/Compat 配置 | 页面和第三方共用 | + +页面开关为 Go 进程启动时读取的配置。修改 `.env` 后必须重新创建 Compose 容器;仅执行 +`docker compose restart` 不会把新的环境值加载进已有容器。Nginx 可以始终保留四个页面/资源的 +精确反代 location,但 Go 关闭页面时必须返回 404。 + +当页面开启时,以下依赖也必须满足: + +- `FIRE_SAFETY_CHAT_ENABLED=true`; +- `FIRE_SAFETY_CHAT_COMPAT_APP_ID` 非空且与 Nginx 的精确 completion location 一致; +- `FIRE_SAFETY_CHAT_AUTH_TOKEN` 已配置为 Chat 方向的测试凭证; +- `FIRE_SAFETY_CHAT_ALLOWED_ORIGINS` 包含精确值 `https://agent.nianxx.com`; +- SuperAgent 的服务端配置和 MCP 联调配置按对应 Spec 已完成。 + +页面请求不向 Go 发送身份、角色、区域、租户、Provider Session 或 metadata。页面中出现的 +App ID 是公开路由标识,不是 Secret;Chat Token、MCP Bearer 和 SuperAgent Open API Key +必须分别使用不同值。 + +## 5. 页面行为 + +页面至少包含:Token 输入框、问题输入框、发送按钮、清空当前会话按钮、运行状态和结果区域。 +Token 输入框默认为空,推荐使用密码输入类型;页面不得从 URL、Cookie 或浏览器存储预填。 + +用户发送问题时,页面使用浏览器 `fetch`(不能使用无法设置自定义 Header 的 `EventSource`)向 +同源兼容路径发送: + +```http +POST /api/v1/apps//completion +Origin: https://agent.nianxx.com +xtoken: <用户本次手动输入的测试 Token> +Content-Type: application/json +Accept: text/event-stream +``` + +首轮请求体: + +```json +{ + "input": { "prompt": "观水镇附近有哪些地点候选?" }, + "parameters": {} +} +``` + +后续请求体把同一页面内最近一次成功返回的本地会话 ID 放入 `input.session_id`: + +```json +{ + "input": { + "prompt": "选择第 2 个,再查询附近水源。", + "session_id": "conv_" + }, + "parameters": {} +} +``` + +页面必须逐个读取 `event: result` 的 SSE 数据: + +1. 首个结果一般只包含 `output.session_id` 和 `finish_reason="null"`;页面保存会话 ID,但不把 + 该事件显示为最终答案。 +2. 只有收到同一会话的 `finish_reason="stop"` 且带 `output.text` 时,页面才显示为成功。 +3. `event: error`、非 2xx HTTP 响应、解析失败或没有收到 `stop` 时,页面显示失败,不把部分 + 文本当成可信答案,也不继续复用一个结果未知的会话。 +4. 用户点击清空、刷新页面、关闭页面或新开页面时,页面内存中的 Token 和 `session_id` 都丢弃。 + +页面不应把工具名、工具参数、Provider Session、内部 Trace 或数据库敏感字段直接作为调试 +信息展示。最终回答仍是 SuperAgent 辅助内容,不能替代报警、人员撤离、现场核验或现场指挥。 + +## 6. Origin、Nginx 与安全边界 + +- 页面与 API 均从 `https://agent.nianxx.com` 提供,因此浏览器的 `Origin` 必须命中 Go 的精确 + `FIRE_SAFETY_CHAT_ALLOWED_ORIGINS` 项;不得用 `*` 代替。 +- Nginx 只负责 TLS、精确路径、限流、SSE 传输和反代,不比较、注入、记录或保存 `xtoken`。 +- `/chat` 和 `/chat/` 页面 GET 本身不依赖 Token;真正的兼容 completion 请求仍由 Go 校验 + `xtoken`。页面公开可访问不代表 API 无鉴权。 +- 页面不会把 Token 放入 Referer、查询参数、片段、日志或错误消息。输入框可被浏览器用户读取, + 所以 Token 只能是受控测试凭证,不能作为最终用户身份。 +- 页面表单显式使用 POST,CSP 使用 `form-action 'none'`;JavaScript 不可用时页面显示警告, + 不允许浏览器把 Token 或问题退化提交到 URL。 +- 当前页面和兼容 API 的会话是单进程内存映射;Docker 重建、进程重启或多实例切换会使旧 + `session_id` 失效。 +- 不通过页面对外暴露 `/api/chat`、数据库、迁移命令或其他 Go 路由;Nginx 未列出的路径仍返回 404。 + +## 7. 验收标准 + +### 配置与路由 + +- Given 未设置 `FIRE_SAFETY_CHAT_PAGE_ENABLED`,When 请求 `/chat`,Then 返回 404;When 请求 + `/chat/` 或两个资源路径,Then 均返回 404,且不创建 SuperAgent Run。 +- Given `FIRE_SAFETY_CHAT_PAGE_ENABLED=true` 且依赖配置完整,When 请求 `/chat`,Then 返回 308 + 到 `/chat/`;When 请求 `/chat/`、`/chat/app.css` 和 `/chat/app.js`,Then 分别返回 200 HTML、 + CSS 和 JavaScript。 +- Given 页面开启但 `FIRE_SAFETY_CHAT_ALLOWED_ORIGINS` 不含 `https://agent.nianxx.com`,Then + 配置校验失败或浏览器 completion 请求被精确 Origin 拒绝;不得放行通配符。 +- Given 页面关闭,When Nginx 仍保留 `/chat`、`/chat/` location,Then Go 返回 404,Nginx 不应 + 把它们改成 DashScope 或其他 upstream。 + +### 浏览器与兼容接口 + +- Given 页面打开且用户手动输入合法测试 Token,When 发送首轮问题,Then 请求体和 Header 与 + 第三方 completion 请求相同,并收到 `event: result`。 +- Given 首轮最终事件为 `finish_reason="stop"`,When 用户发送第二个问题,Then 请求使用同一页面 + 内存中的 `input.session_id`,服务端复用会话。 +- Given 用户刷新页面,When 再次发送问题,Then 页面不提交旧的 `session_id` 或 Token。 +- Given 错误或空 Token,When 发送问题,Then 服务返回 401;页面不展示或记录 Token。 +- Given SSE 只有初始 `null` 事件、发生断流或最终不是 `stop`,Then 页面不显示为成功。 + +### 公网部署 + +- Given 服务器 Compose 已重建且 Nginx 已通过 `nginx -t` 并 reload,When `curl -i` 请求公网页面, + Then 开关关闭时四个页面路径均返回 404;开启时 `/chat` 返回 308,`/chat/` 返回 200 `text/html`, + 两个资源路径返回 200。 +- Given 页面开启,When 浏览器从 `https://agent.nianxx.com/chat/` 发起 completion,Then 服务端 + 日志可按现有脱敏规则确认 Chat 请求,且不含 Token、问题、答案或会话内部 ID。 +- Given 已知公网 MCP 地点搜索曾记录 + `operation=fire_safety_search_place_candidates result=success`,Then 该证据只证明地点搜索 + 已到达并成功;`tools/list` 之外的完整多工具链仍需单独验收。 + +## 8. 运行与回滚 + +页面开关变更只修改服务器未提交的 `.env`,不修改 Nginx 中的 Token: + +```text +# 默认关闭 +FIRE_SAFETY_CHAT_PAGE_ENABLED=false + +# 测试开启时 +FIRE_SAFETY_CHAT_PAGE_ENABLED=true +FIRE_SAFETY_CHAT_ALLOWED_ORIGINS=https://agent.nianxx.com +``` + +重新加载运行环境: + +```bash +cd /home/firee-safety-ymd +docker compose config --quiet +docker compose up -d --force-recreate --no-build +docker compose ps +curl --fail http://127.0.0.1:16587/health +``` + +页面验收: + +```bash +curl -i https://agent.nianxx.com/chat/ +curl -i https://agent.nianxx.com/chat +curl -i https://agent.nianxx.com/chat/app.css +curl -i https://agent.nianxx.com/chat/app.js +``` + +关闭页面时预期 `/chat`、`/chat/` 以及两个资源均直接为 HTTP 404;开启页面时 +预期 `/chat` 为 HTTP 308、跟随后 `/chat/` 为 HTTP 200 `text/html`,两个资源也为 HTTP 200。 +使用 `curl -iL https://agent.nianxx.com/chat` 可直接跟随规范化重定向。浏览器 +打开 `https://agent.nianxx.com/chat/`,手动输入受控测试 Token,发送不含敏感信息的问题, +再发送第二轮问题确认会话复用。 + +回滚优先使用配置回滚:把 `FIRE_SAFETY_CHAT_PAGE_ENABLED` 改回 `false`,执行 +`docker compose up -d --force-recreate --no-build`,确认 `/chat`、`/chat/` 和两个资源路径都直接 +返回 404,之后按需继续保留 +兼容 completion 或将其一并关闭。若是版本回滚,切回维护者指定的已验证 revision 后重建;若是 +Nginx 配置回滚,恢复 root-only 备份,先执行 `nginx -t` 再 reload。不得通过恢复全路径 `/`、 +关闭 Go 鉴权或把 Token 写进 Nginx 解决问题。 + +## 9. 相关文档 + +- [`../architecture/public-chat-entry-v1.md`](../architecture/public-chat-entry-v1.md) +- [`../workflows/user-chat.md`](../workflows/user-chat.md) +- [`../project/operations/docker-test-deployment.md`](../project/operations/docker-test-deployment.md) +- [`../project/operations/nginx-public-entry.md`](../project/operations/nginx-public-entry.md) +- [`fire-safety-ymd-dashscope-compatible-chat-v1.md`](fire-safety-ymd-dashscope-compatible-chat-v1.md) diff --git a/docs/workflows/user-chat.md b/docs/workflows/user-chat.md index eb64ee5..4b70b60 100644 --- a/docs/workflows/user-chat.md +++ b/docs/workflows/user-chat.md @@ -11,13 +11,44 @@ 客户端只有在收到 `done` 后才把本轮标记为成功,并保存 `conversation_id`。 -## 2. 后续对话 +## 2. 受控测试页面 + +当 `FIRE_SAFETY_CHAT_PAGE_ENABLED=true` 时,Go 服务提供 `GET /chat` 和 `GET /chat/`。页面只 +用于测试,不是新的对话协议或生产用户入口;关闭开关时两个路径都返回 404。Nginx 可以保留这 +两个精确反代路径,最终是否可用由 Go 开关决定。 + +页面与既有第三方客户端使用完全相同的兼容接口: + +```text +页面 GET /chat/ + -> 同源 POST /api/v1/apps//completion + -> xtoken + input.prompt + parameters + -> event: result SSE +``` + +页面打开后由用户手动输入受控测试 `xtoken`。页面不在 HTML、JavaScript 常量、Cookie、URL、 +localStorage 或 sessionStorage 中嵌入/保存 Token,也不把 Token 发送到除同源 completion 之外 +的地址;页面只在当前 JavaScript 内存中使用它。首轮返回的 `output.session_id` 也只保存在页面 +内存中,并在同一页面的后续请求中作为 `input.session_id` 发送。刷新、关闭或新开页面都会丢弃 +Token 和 Session。 + +浏览器页面的 Origin 是 `https://agent.nianxx.com`,所以服务器配置 +`FIRE_SAFETY_CHAT_ALLOWED_ORIGINS` 必须包含该精确值,不得用 `*`。页面 GET 本身不要求 Token, +但 completion 仍由 Go 校验 `xtoken`;静态测试 Token 不是最终用户认证。 + +页面验收时打开 `https://agent.nianxx.com/chat/`,输入测试 Token 和不含敏感信息的问题。浏览器 +Network 应显示同源 POST 到精确 app ID 的 completion 路径,Request Headers 含 `xtoken`,请求 +体为兼容 JSON,响应为 `event: result` SSE。只有 `output.finish_reason=stop` 才算本轮成功;再 +发送第二个问题时,应在第二次请求中看到上一轮返回的 `session_id` 被放入 `input.session_id`。不要把初始 `finish_reason=null` 或 +断流内容当作最终答案。 + +## 3. 后续对话 后续请求同时发送 `message` 和前一轮保存的 `conversation_id`。成功流中的 `conversation` 事件返回 `reused=true`,说明复用了原 SuperAgent Session 上下文。 同一对话在收到 `done` 或终止 `error` 前不得再次提交。若服务端返回 `CHAT_CONVERSATION_BUSY`,客户端应保留当前流并稍后重试,不能自动改用同一个问题创建多轮并发 Run。 -## 3. 失败处理 +## 4. 失败处理 - HTTP JSON 错误:SSE 尚未开始;按 HTTP 状态和 `error.code` 处理。 - SSE `error`:流已经开始,但本轮没有可信最终回答;不得把此前进度当作答案。 @@ -25,7 +56,7 @@ - 上游超时、协议错误、Run 失败或客户端中途断开:当前实现会使会话映射失效,以免继续复用可能仍有活动 Run 的 Provider Session。 - 网络断开且没有看到 `done`:结果未知;首版不自动重放原消息,避免重复 Run。 -## 4. curl 联调 +## 5. curl 联调 先把 `.env` 显式加载到当前 shell,再启动服务;Go 程序不会自动读取 `.env`: @@ -60,7 +91,7 @@ curl -N \ 浏览器前端还必须把其精确 Origin 加入 `FIRE_SAFETY_CHAT_ALLOWED_ORIGINS`,例如 `http://localhost:5173`。静态 Chat Bearer 会被浏览器用户看到,因此只适用于受控联调,不能直接作为公网最终用户鉴权。 -## 5. 既有 DashScope 风格客户端 +## 6. 既有 DashScope 风格客户端 设置 `FIRE_SAFETY_CHAT_COMPAT_APP_ID` 后,同一个 Chat Service 还会注册: @@ -80,4 +111,4 @@ curl -N \ 兼容流先返回 `finish_reason: "null"` 和本地 `session_id`,严格成功后返回 `finish_reason: "stop"` 与最终 `text`。后续轮次把该 `session_id` 放入 `input.session_id`。客户端不得使用 URL 中的 App ID、静态 token 或 session ID推断身份与权限,也不得把未出现 `stop` 的断流结果当作成功答案。 -公网 Nginx 示例和完整 curl 见 [`../project/operations/nginx-public-entry.md`](../project/operations/nginx-public-entry.md)。兼容字段的唯一项目契约见 [`../specs/fire-safety-ymd-dashscope-compatible-chat-v1.md`](../specs/fire-safety-ymd-dashscope-compatible-chat-v1.md)。 +公网 Nginx 示例和完整 curl 见 [`../project/operations/nginx-public-entry.md`](../project/operations/nginx-public-entry.md)。兼容字段的唯一项目契约见 [`../specs/fire-safety-ymd-dashscope-compatible-chat-v1.md`](../specs/fire-safety-ymd-dashscope-compatible-chat-v1.md)。页面开关、Docker 重建、浏览器验收和回滚见 [`../specs/fire-safety-ymd-chat-page-v1.md`](../specs/fire-safety-ymd-chat-page-v1.md)。 diff --git a/internal/app/app.go b/internal/app/app.go index c3ec66c..f2cdc6e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -37,6 +37,18 @@ func New(ctx context.Context, cfg config.Config) (*Application, error) { routerOptions := handler.RouterOptions{} closeDependencies := func() {} + if cfg.Chat.PageEnabled { + if !cfg.Chat.Enabled { + return nil, fmt.Errorf("initialize chat page: chat must be enabled") + } + if cfg.Chat.CompatAppID == "" { + return nil, fmt.Errorf("initialize chat page: compatibility app ID is required") + } + if len(cfg.Chat.AllowedOrigins) == 0 { + return nil, fmt.Errorf("initialize chat page: at least one allowed origin is required") + } + } + if cfg.Chat.Enabled { if !cfg.SuperAgent.Enabled { return nil, fmt.Errorf("initialize chat: SuperAgent must be enabled") @@ -96,6 +108,15 @@ func New(ctx context.Context, cfg config.Config) (*Application, error) { } routerOptions.DashScopeChat = dashScopeChatHandler } + if cfg.Chat.PageEnabled { + chatPageHandler, err := handler.NewChatPageHandler(handler.ChatPageOptions{ + AppID: cfg.Chat.CompatAppID, + }) + if err != nil { + return nil, fmt.Errorf("initialize chat page handler: %w", err) + } + routerOptions.ChatPage = chatPageHandler + } } if cfg.MCP.Enabled { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index d8b5b17..8578f92 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -39,11 +39,48 @@ func TestNewKeepsExternalIntegrationsDisabledByDefault(t *testing.T) { if chatResponse.Code != http.StatusNotFound { t.Fatalf("chat status = %d, want %d while disabled", chatResponse.Code, http.StatusNotFound) } + for _, path := range []string{"/chat/", "/chat/app.js", "/chat/app.css"} { + chatPageRequest := httptest.NewRequest(http.MethodGet, path, nil) + chatPageResponse := httptest.NewRecorder() + application.server.Handler.ServeHTTP(chatPageResponse, chatPageRequest) + if chatPageResponse.Code != http.StatusNotFound { + t.Fatalf("%s status = %d, want %d while disabled", path, chatPageResponse.Code, http.StatusNotFound) + } + } if application.server.ReadHeaderTimeout <= 0 || application.server.ReadTimeout <= 0 || application.server.WriteTimeout <= 0 || application.server.IdleTimeout <= 0 { t.Fatalf("HTTP timeouts are incomplete: %#v", application.server) } } +func TestNewRejectsIncompleteChatPageConfiguration(t *testing.T) { + tests := []struct { + name string + chat config.ChatConfig + }{ + { + name: "chat disabled", + chat: config.ChatConfig{PageEnabled: true, CompatAppID: "fire-safety-app", AllowedOrigins: []string{"https://fire.example.test"}}, + }, + { + name: "missing app ID", + chat: config.ChatConfig{Enabled: true, PageEnabled: true, AllowedOrigins: []string{"https://fire.example.test"}}, + }, + { + name: "missing allowed origin", + chat: config.ChatConfig{Enabled: true, PageEnabled: true, CompatAppID: "fire-safety-app"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := New(context.Background(), config.Config{HTTPAddress: ":0", Chat: tt.chat}) + if err == nil || !strings.Contains(err.Error(), "initialize chat page") { + t.Fatalf("New() error = %v, want chat page configuration error", err) + } + }) + } +} + func TestNewWiresChatAPIToSuperAgent(t *testing.T) { var applicationLogs bytes.Buffer originalLogOutput := log.Writer() @@ -85,10 +122,12 @@ func TestNewWiresChatAPIToSuperAgent(t *testing.T) { }, Chat: config.ChatConfig{ Enabled: true, + PageEnabled: true, AuthToken: "delivered-key", AllowLegacyShortToken: true, SubjectID: "app-chat-test-subject", CompatAppID: "fire-safety-app", + AllowedOrigins: []string{"https://fire.example.test"}, MaxBodyBytes: 4096, RunTimeout: time.Minute, SessionTTL: time.Minute, @@ -121,6 +160,12 @@ func TestNewWiresChatAPIToSuperAgent(t *testing.T) { !strings.Contains(compatResponse.Body.String(), `"finish_reason":"stop"`) || !strings.Contains(compatResponse.Body.String(), "测试回答") { t.Fatalf("compat chat status=%d body=%s", compatResponse.Code, compatResponse.Body.String()) } + pageRequest := httptest.NewRequest(http.MethodGet, "/chat/", nil) + pageResponse := httptest.NewRecorder() + application.server.Handler.ServeHTTP(pageResponse, pageRequest) + if pageResponse.Code != http.StatusOK { + t.Fatalf("chat page status=%d body=%s", pageResponse.Code, pageResponse.Body.String()) + } if !strings.Contains(applicationLogs.String(), "legacy short chat auth token compatibility is enabled") { t.Fatalf("application logs did not contain legacy compatibility warning: %s", applicationLogs.String()) } diff --git a/internal/config/config.go b/internal/config/config.go index 0082d4f..44449c8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,6 +26,7 @@ const ( SuperAgentProbeTimeoutEnv = "FIRE_SAFETY_SUPERAGENT_PROBE_TIMEOUT" ChatEnabledEnv = "FIRE_SAFETY_CHAT_ENABLED" + ChatPageEnabledEnv = "FIRE_SAFETY_CHAT_PAGE_ENABLED" ChatAuthTokenEnv = "FIRE_SAFETY_CHAT_AUTH_TOKEN" ChatAllowLegacyShortTokenEnv = "FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN" ChatSubjectIDEnv = "FIRE_SAFETY_CHAT_SUBJECT_ID" @@ -119,6 +120,7 @@ type SuperAgentConfig struct { // final end-user authentication. type ChatConfig struct { Enabled bool + PageEnabled bool AuthToken string AllowLegacyShortToken bool SubjectID string @@ -231,6 +233,10 @@ func loadChatConfig() (ChatConfig, error) { if err != nil { return ChatConfig{}, err } + pageEnabled, err := parseBool(ChatPageEnabledEnv, false) + if err != nil { + return ChatConfig{}, err + } allowLegacyShortToken, err := parseBool(ChatAllowLegacyShortTokenEnv, false) if err != nil { return ChatConfig{}, err @@ -258,6 +264,7 @@ func loadChatConfig() (ChatConfig, error) { cfg := ChatConfig{ Enabled: enabled, + PageEnabled: pageEnabled, AuthToken: os.Getenv(ChatAuthTokenEnv), AllowLegacyShortToken: allowLegacyShortToken, SubjectID: valueOrDefault(ChatSubjectIDEnv, defaultChatSubjectID), @@ -492,6 +499,17 @@ func validateMCPDependencies(mcp MCPConfig, postGIS PostGISConfig, superAgent Su } func validateChatDependencies(chat ChatConfig, superAgent SuperAgentConfig, mcp MCPConfig) error { + if chat.PageEnabled { + if !chat.Enabled { + return fmt.Errorf("%s must be true when %s is true", ChatEnabledEnv, ChatPageEnabledEnv) + } + if chat.CompatAppID == "" { + return fmt.Errorf("%s is required when %s is true", ChatCompatAppIDEnv, ChatPageEnabledEnv) + } + if len(chat.AllowedOrigins) == 0 { + return fmt.Errorf("%s must contain at least one exact HTTP(S) origin when %s is true", ChatAllowedOriginsEnv, ChatPageEnabledEnv) + } + } if !chat.Enabled { return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5b82783..8ed14bf 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -41,6 +41,9 @@ func TestLoadDefaults(t *testing.T) { if cfg.Chat.Enabled { t.Fatal("Chat.Enabled = true, want false") } + if cfg.Chat.PageEnabled { + t.Fatal("Chat.PageEnabled = true, want false") + } if cfg.Chat.AllowLegacyShortToken { t.Fatal("Chat.AllowLegacyShortToken = true, want false") } @@ -68,6 +71,7 @@ func TestLoadConfiguredChat(t *testing.T) { t.Setenv(SuperAgentBaseURLEnv, "https://superagent.example.test") t.Setenv(SuperAgentOpenAPIKeyEnv, "superagent-test-open-api-key") t.Setenv(ChatEnabledEnv, "true") + t.Setenv(ChatPageEnabledEnv, "true") t.Setenv(ChatAuthTokenEnv, "0123456789abcdef0123456789abcdef") t.Setenv(ChatSubjectIDEnv, " local-chat-test-subject ") t.Setenv(ChatCompatAppIDEnv, " fire-safety-public-app ") @@ -81,7 +85,7 @@ func TestLoadConfiguredChat(t *testing.T) { if err != nil { t.Fatalf("Load() error = %v", err) } - if !cfg.Chat.Enabled || cfg.Chat.AuthToken == "" || cfg.Chat.SubjectID != "local-chat-test-subject" || cfg.Chat.CompatAppID != "fire-safety-public-app" { + if !cfg.Chat.Enabled || !cfg.Chat.PageEnabled || cfg.Chat.AuthToken == "" || cfg.Chat.SubjectID != "local-chat-test-subject" || cfg.Chat.CompatAppID != "fire-safety-public-app" { t.Fatalf("unexpected Chat config: %#v", cfg.Chat) } if got, want := strings.Join(cfg.Chat.AllowedOrigins, ","), "http://localhost:5173,https://fire.example.test"; got != want { @@ -213,6 +217,7 @@ func TestLoadRejectsInvalidValues(t *testing.T) { {name: "probe timeout syntax", key: SuperAgentProbeTimeoutEnv, value: "forever"}, {name: "probe timeout non-positive", key: SuperAgentProbeTimeoutEnv, value: "0s"}, {name: "Chat boolean", key: ChatEnabledEnv, value: "sometimes"}, + {name: "Chat page boolean", key: ChatPageEnabledEnv, value: "sometimes"}, {name: "Chat legacy short token boolean", key: ChatAllowLegacyShortTokenEnv, value: "sometimes"}, {name: "Chat body syntax", key: ChatMaxBodyBytesEnv, value: "large"}, {name: "Chat body non-positive", key: ChatMaxBodyBytesEnv, value: "0"}, @@ -256,6 +261,61 @@ func TestLoadRejectsInvalidValues(t *testing.T) { } } +func TestLoadRequiresChatPageDependencies(t *testing.T) { + configureEnabledChat := func(t *testing.T) { + t.Helper() + t.Setenv(SuperAgentEnabledEnv, "true") + t.Setenv(SuperAgentBaseURLEnv, "https://superagent.example.test") + t.Setenv(SuperAgentOpenAPIKeyEnv, "superagent-open-api-key") + t.Setenv(ChatEnabledEnv, "true") + t.Setenv(ChatAuthTokenEnv, "0123456789abcdef0123456789abcdef") + } + + tests := []struct { + name string + configure func(*testing.T) + wantErrKey string + }{ + { + name: "chat disabled", + configure: func(t *testing.T) { + t.Setenv(ChatCompatAppIDEnv, "fire-safety-app") + t.Setenv(ChatAllowedOriginsEnv, "https://fire.example.test") + }, + wantErrKey: ChatEnabledEnv, + }, + { + name: "missing compatibility app ID", + configure: func(t *testing.T) { + configureEnabledChat(t) + t.Setenv(ChatAllowedOriginsEnv, "https://fire.example.test") + }, + wantErrKey: ChatCompatAppIDEnv, + }, + { + name: "missing allowed origin", + configure: func(t *testing.T) { + configureEnabledChat(t) + t.Setenv(ChatCompatAppIDEnv, "fire-safety-app") + }, + wantErrKey: ChatAllowedOriginsEnv, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearEnvironment(t) + t.Setenv(ChatPageEnabledEnv, "true") + tt.configure(t) + + _, err := Load() + if err == nil || !strings.Contains(err.Error(), tt.wantErrKey) { + t.Fatalf("Load() error = %v, want error mentioning %s", err, tt.wantErrKey) + } + }) + } +} + func TestLoadRequiresEnabledChatSettings(t *testing.T) { tests := []struct { name string @@ -521,6 +581,7 @@ func clearEnvironment(t *testing.T) { SuperAgentProbeSubjectIDEnv, SuperAgentProbeTimeoutEnv, ChatEnabledEnv, + ChatPageEnabledEnv, ChatAuthTokenEnv, ChatAllowLegacyShortTokenEnv, ChatSubjectIDEnv, diff --git a/internal/handler/chat_page.go b/internal/handler/chat_page.go new file mode 100644 index 0000000..57c170b --- /dev/null +++ b/internal/handler/chat_page.go @@ -0,0 +1,95 @@ +package handler + +import ( + "embed" + "errors" + "fmt" + "html/template" + "io/fs" + "net/http" + "path" + "strconv" +) + +//go:embed chatpage/* +var chatPageAssets embed.FS + +// ChatPageOptions configures the optional browser-based compatibility client. +// AppID is a public route identifier; callers must never pass an auth token here. +type ChatPageOptions struct { + AppID string +} + +// ChatPageHandler serves the self-contained browser client under /chat/. +type ChatPageHandler struct { + appID string + page *template.Template + assets fs.FS +} + +// NewChatPageHandler constructs the optional chat page. Whether it is mounted +// is intentionally left to application configuration. +func NewChatPageHandler(options ChatPageOptions) (*ChatPageHandler, error) { + if !validDashScopeAppID(options.AppID) { + return nil, errors.New("chat page app ID is invalid") + } + pageTemplate, err := template.ParseFS(chatPageAssets, "chatpage/index.html") + if err != nil { + return nil, fmt.Errorf("parse embedded chat page: %w", err) + } + assets, err := fs.Sub(chatPageAssets, "chatpage") + if err != nil { + return nil, fmt.Errorf("open embedded chat page assets: %w", err) + } + return &ChatPageHandler{appID: options.AppID, page: pageTemplate, assets: assets}, nil +} + +func (h *ChatPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.setSecurityHeaders(w) + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", http.MethodGet+", "+http.MethodHead) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + switch r.URL.Path { + case "/chat/": + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + return + } + if err := h.page.ExecuteTemplate(w, "index.html", struct{ AppID string }{AppID: h.appID}); err != nil { + http.Error(w, "chat page unavailable", http.StatusInternalServerError) + } + case "/chat/app.css", "/chat/app.js": + name := path.Base(r.URL.Path) + if name == "app.css" { + w.Header().Set("Content-Type", "text/css; charset=utf-8") + } else { + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + } + content, err := fs.ReadFile(h.assets, name) + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Length", strconv.Itoa(len(content))) + if r.Method == http.MethodGet { + _, _ = w.Write(content) + } + default: + http.NotFound(w, r) + } +} + +func (h *ChatPageHandler) setSecurityHeaders(w http.ResponseWriter) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") + w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") + w.Header().Set("Cross-Origin-Resource-Policy", "same-origin") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") +} diff --git a/internal/handler/chat_page_test.go b/internal/handler/chat_page_test.go new file mode 100644 index 0000000..9cdb1ab --- /dev/null +++ b/internal/handler/chat_page_test.go @@ -0,0 +1,93 @@ +package handler + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestNewChatPageHandlerValidatesPublicAppID(t *testing.T) { + for _, appID := range []string{"", "app/other", " + + + + + 此测试页面需要启用 JavaScript。请勿在脚本不可用时输入访问凭证或问题。 + + + + FIRE SAFETY · YMD + 森林防火演练助手 + 基于现有消防空间资料提供候选信息。结果需经现场核验,不能替代报警、撤离和现场指挥。 + + 新对话 + + + + + 访问凭证 + 凭证只保存在本页面内存中,刷新或关闭页面后即清除。 + + + xtoken + + 应用凭证 + 清除 + + 尚未设置凭证 + + + + + + 请先设置访问凭证,然后输入你的森林防火演练问题。 + + + + + 问题 + + + + + + +
此测试页面需要启用 JavaScript。请勿在脚本不可用时输入访问凭证或问题。
FIRE SAFETY · YMD
基于现有消防空间资料提供候选信息。结果需经现场核验,不能替代报警、撤离和现场指挥。
凭证只保存在本页面内存中,刷新或关闭页面后即清除。
尚未设置凭证
请先设置访问凭证,然后输入你的森林防火演练问题。