feat: add Insight Flow Agent chat template - #5
Conversation
Review(首轮)先说结论:整体结构干净,凭据不落前端这条主线做对了,PR 描述里列的验证我本地全部复跑通过( P1|私有地址闸有洞:
|
| 输入 | 结果 |
|---|---|
https://[::ffff:127.0.0.1] |
放行 → loopback |
https://100.64.0.1(CGNAT) |
放行 |
https://127.0.0.1.nip.io |
放行(DNS 类,README 已承认) |
影响面比"盲打"更大一点:上游非 2xx 时 limitedError() 会把响应体前 2000 字回显给调用方,SSE 响应体更是直接透传,所以这是有回显的 SSRF;端口也没限制,可以拿来探内网端口。
另外 PR 描述和 README 现在写的是「Function 拒绝 HTTP、本地主机和私有 IP 字面量」——::ffff:127.0.0.1 就是私有 IP 字面量,这句话目前不成立,修完代码顺手校准一下措辞。
建议修法:拿到 hostname 后先把 v4-mapped / v4-compatible 形式折回 IPv4 再进已有的 IPv4 判定,并补上 ::、100.64.0.0/10:
const bare = host.replace(/^\[|\]$/g, '').toLowerCase();
const mapped = /^::(ffff:)?(\d{1,3}(\.\d{1,3}){3})$/.exec(bare)
?? /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(bare); // ::ffff:7f00:1 这种归一化后的形态
// → 折回点分四段后走同一套 privateIPv4 判定P2-1|点「停止生成」后 session key 丢失,下一条消息静默开新会话
src/lib/stream.ts:
const sessionKey = response.headers.get('X-InsightFlow-Session-Key');
await consumeInsightFlowSSE(response.body, onDelta); // ← 这里一抛,下面就不执行了
return sessionKey;consumeInsightFlowSSE 在 abort、SSE 解析失败、上游 error 事件三种情况下都会抛,于是 return sessionKey 永远走不到,ChatPage 的 sessionKey 保持旧值/null。
而 ChatPage.sendMessage 的 catch 里,abort 分支只在助手消息没有内容时才替换成「已停止生成。」——已经流出来的半截回复会原样留在 transcript 里。所以用户看到的是:上文还在、对话继续,实际上下一条消息在上游是全新 session,Agent 完全失忆,界面零提示。
写了个探针实证(mock 掉 ../src/lib/insforge,响应头带 X-InsightFlow-Session-Key: sess-42,收到第一个 delta 就 abort):streamAgentReply reject,sess-42 拿不到。
建议:把 session key 在消费流之前就交出去(多加一个 onSessionKey 回调),或者在 catch 里包一个带 key 的 error 抛出去。
P2-2|脱敏是 UX,不是安全边界;但 AGENTS.md 把它写成了边界
migration 里是整表授权:
grant select, insert, delete on public.insight_flow_agent_configs to authenticated;select 不带列清单,api_key 在内。前端拿的是同一个 SDK、同一个 authenticated 角色,所以一行就能绕过整套 reveal 仪式:
await insforge.database.from('insight_flow_agent_configs').select('api_key').maybeSingle()也就是说 Function 侧「默认只返回 ••••••••••••、只有显式 reveal 才给全量」这层,对 XSS / 控制台 / 任何跑在页面里的第三方脚本都是不设防的。key 本来就是用户自己的,不构成越权,但 AGENTS.md 现在把它列在 "Credential boundary" 标题下,读起来像一道防线。
两条路选一条:
- 便宜:README / AGENTS.md 把话说准——脱敏只防肩窥和误显示,不防同源脚本;
- 彻底:
grant select (user_id, base_url, target_mode, target, disable_tools, created_at, updated_at)做列级授权,把api_key的读取收进 Function(这需要 Function 侧改用 service 角色读那一列,不是一行改动,看你们取舍)。
P2-3|多轮上下文 100% 悬在一个响应头上,没有兜底
insight-flow-chat 每次只发 messages: [{ role: 'user', content: message }],不带历史,多轮完全依赖 X-GoClaw-Session-Key → X-InsightFlow-Session-Key 这一跳。Access-Control-Expose-Headers 你们加了,这点没问题,但只要 InsForge functions 网关、任何一层 CDN/反代把这个自定义响应头吃掉,表现就是「每轮都失忆」,而且和 P2-1 一样是静默的。
建议要么让 Insight Flow 侧把 session key 也放进 SSE 事件体里(比 header 稳),要么在 key 缺失时降级为把 transcript 作为 messages 发上去。至少加个可观测点:拿不到 key 时在 UI 上标一下。
P3
- 401 的 body 用了
error: 'unauthorized',SDK 只对AUTH_UNAUTHORIZED/PGRST301触发自动刷新(REFRESHABLE_AUTH_ERROR_CODES),所以 access token 过期后聊天会直接死掉而不是静默续期。仓库里web-research-agent/website-change-monitor是同款写法,属既有约定,但这个模板是长时流式场景,受影响更明显,值得单独改。 - 上游非 2xx 时原样透传
upstream.status:实测不会误触 SDK 自动登出(错误码不在刷新集合里),但前端无法区分「我的 InsForge 会话过期」和「你填的 Insight Flow key 不对」——两者都是 401。建议统一成 502 +upstreamStatus字段。 - 流被截断时静默当正常结束:
consumeInsightFlowSSE没等到[DONE]就 EOF 的话直接 resolve,用户看到半截回复以为说完了。建议记录是否见过[DONE],没见过就报一句。 - 两个 Function 各抄了一份逐字相同的 URL 校验器(
safeBaseUrl/normalizeBaseUrl)。P1 那个洞要改两处,漂移风险实打实。
nit
- 侧栏「今天」只有一个假条目,刷新即丢全部消息和 session key(上游 session 就此变孤儿)。registry 里 features 写了
Continuous Sessions,对照仓库里chatbot模板是真持久化会话的,这里的措辞略微超前。 copyMessage直接navigator.clipboard.writeText且是void调用,非安全上下文会是 unhandled rejection。- migration 里
create table if not exists表明了可重入意图,但下面的create policy/create trigger没有drop ... if exists前置,重复应用会直接报错。 grant delete给了,但 UI 没有清除配置的入口。- 无行时两个并发 PUT 会双 insert → PK 冲突返回 500
config_save_failed,用 upsert 更稳。
cover 路径用 <slug>/public/template-cover.png 和最近两个模板(web-research-agent / website-change-monitor)一致,validator 也过,这块没问题。
|
已按首轮 review 处理,修复在 已修:
暂不做:
验证:Vitest 5/5、Deno 4/4、typecheck/build、audit 0 vulnerabilities、registry 17/17;另外在一次性 PostgreSQL 16 容器里实际执行 migration 成功,容器已删除。 |
Review(二轮 ·
|
|
二轮指出的两条都成立,已在
顺手处理了不挡合并的 4 点:
验证:Vitest 6/6、Deno 4/4、typecheck/build、audit 0 vulnerabilities、registry 17/17, |
Review(三轮 ·
|
| 撤回的改动 | 变红的用例 |
|---|---|
isBlockedHostname 回退到 11fd3e4 版本 |
host 矩阵 + handler 级保存路径(2 个) |
config catch 塌回单一 invalid_base_url |
private host rejection reason was collapsed |
去掉 upstreamStatus 拼接 |
labels an upstream authentication failure separately |
你列的验证我也全部复跑通过:Vitest 6/6、Deno 4/4、tsc -b、vite build、scripts 17/17、validate-registry → Registry OK (11 templates)。
没有新的安全问题。 但这一轮我顺着你新加的错误码区分往前端走了一遍,发现那部分工作用户一个字也看不到。
P2|config Function 的所有错误在设置页渲染成空白,本轮新增的错误码区分不可见
两个 Function 的错误响应体是 { error: 'private_base_url' },没有 message 字段。而 SDK 的 InsForgeError.fromApiError(data) 用的是 apiError.message 当 Error message,apiError.error 只存到 error.error 属性上。
拿模板自己 node_modules 里的 SDK 实测(mock 掉 fetch,返回 422 + {error:'private_base_url'}):
error instanceof Error: true
error.message -> ""
error.error -> "private_base_url"
error.statusCode -> 422
SettingsPage would render: ""
SettingsPage.submit 的 catch 是 setError(reason instanceof Error ? reason.message : '保存失败。')——reason 确实是 Error,于是走 reason.message,拿到空串,{error ? <p className="form-error"> : null} 直接不渲染。
用户看到的是:点「保存设置」→ 按钮从「保存中…」跳回「保存设置」→ 没有报错,也没有「已保存」,什么都没发生。这对所有 config 错误都成立:invalid_base_url / private_base_url / host_not_allowed / invalid_target / invalid_api_key / api_key_required / config_read_failed / config_save_failed 全套。ChatPage 里 loadAgentConfig() 的 catch 同款。
所以本轮把 private_base_url 和 host_not_allowed 从 invalid_base_url 里拆出来这件事,链路到 UI 就断了;二轮我说的「用户查不出原因」并没有真正解决。
顺带说明为什么聊天面没这个问题:streamAgentReply 走的是 rawFetch,自己解 payload.detail || payload.error,所以聊天错误是能显示的。只有走 functions.invoke 的 config 两条路径是哑的。
修法二选一(都很短):
// A. Function 侧补 message,让 SDK 能取到(顺带把机器码翻成人话)
const REASONS: Record<string, string> = {
private_base_url: '该地址指向本地或内网,请填写公网 HTTPS 地址。',
host_not_allowed: '该 Host 不在 INSIGHT_FLOW_ALLOWED_HOSTS 允许列表内。',
invalid_base_url: 'Base URL 必须是不带查询参数的 HTTPS 地址。',
};
return json(422, { error, message: REASONS[error] ?? error });
// B. 或者前端兜底,别只信 message
const detail = (reason as { message?: string; error?: string })?.message
|| (reason as { error?: string })?.error || '保存失败。';建议 A,顺手把 message 补齐;B 只是止血,用户还是会看到 private_base_url 这种机器码。
已确认关闭
- 二轮 P1(新引入的回归):IPv6 前缀判定现在被
isIPv6Literal门住,fe[89a-f]也收回fe[89ab]。实测feed./fcm./fedex./feature./fd-agents.example.com全部放行,[::1]/[::ffff:7f00:1]/[::7f00:1]/100.64.0.1仍拦。✅ - 二轮 P1(首轮残留):尾点在
isBlockedHostname和safeBaseUrl/normalizeBaseUrl两处统一剥掉,localhost./foo.localhost.拦住,allowlist 也用同一个归一化结果。✅ - host 矩阵补阴性样本:
feed/fcm/fedex/feature.example.com+192.1.2.3都进了 ALLOW 断言,handler 级还加了尾点 FQDN 过 allowlist 和localhost.返回private_base_url两条。这正是我想要的形状。✅ - 4 条小事(错误原因分开、
upstreamStatus前端消费、变量遮蔽 + 黄色role="status"提示、192.0.0.0/16收窄到两个 /24)全部处理。✅
两条我自己证伪的,记一下省得后面有人再走一遍
- 双尾点
localhost..不是洞。replace(/\.$/, '')只剥一个点,我一开始测出它"能连上 loopback"——那是本机HTTP_PROXY=127.0.0.1:7897造成的假阳性(NO_PROXY没覆盖这个写法,请求被代理转发了)。清掉代理环境变量后直接client error (Connect),getaddrinfo也是label empty的 IDNA 报错,根本不解析。不用管。 - 存进去的
https://feed.example.com.保留尾点不影响出站。我担心过 rustls 的 SNI 会拒带点的域名,实测https://example.com./正常 200。不是问题。
nit
聊天面的错误条会把 agent_not_configured / invalid_stored_base_url / empty_stream / unexpected_response 这些机器码原样显示(这几个响应没有 detail)。如果按上面 A 方案补 message,这几条也能一并解决。
除了上面那条 P2,代码侧我这边没有别的意见了。
|
三轮 comment 指出的 P2 成立,已在
回归:Vitest 10/10、Deno 4/4、typecheck/build、audit 0 vulnerabilities、registry 17/17、 |
Review(四轮 ·
|
| 撤回的改动 | 变红的用例 |
|---|---|
json() 里的 message 注入 |
config error did not include a user-facing message |
functionErrorMessage 的 candidate.error 兜底层 |
translates an SDK error code when its message is empty |
stream.ts 里 payload?.message 的优先级 |
prefers a user-facing Function message over its machine error code |
你列的验证我也全部复跑通过:Vitest 10/10、Deno 4/4、deno check 两个 Function、tsc -b、vite build、scripts 17/17、validate-registry → Registry OK (11 templates)。CI 三项也全绿,MERGEABLE / CLEAN。
顺带确认两处容易写错的地方你没踩:json() 只在 body 带 error 字段时才注入 message,成功响应(publicConfig / reveal)不受影响;insight_flow_error 仍然优先展示上游的 detail,没有被通用文案盖掉。
四轮总账
| 轮次 | 提出 | 状态 |
|---|---|---|
| 一轮 | ::ffff:127.0.0.1 有回显 SSRF、停止生成丢 session key、脱敏被写成安全边界、401 不触发 SDK refresh、上游状态码透传、流截断静默、5 条 nit |
全部关闭 |
| 二轮 | fc/fd/fe8-fef 前缀误杀正常域名(新引入回归)、尾点 FQDN localhost. 仍绕过、4 条建议 |
全部关闭 |
| 三轮 | config 错误在设置页渲染成空白 | 本轮关闭 |
| 四轮 | — | 无 |
LGTM,从我这边没有任何挡合并的问题了。 合并动作留给你。
合并后如果还想继续打磨,这两条挂账即可(都不是这个 PR 的债):
- 两个 Function 各一份 URL 校验器 + 前后端各一份错误文案表,一共三处重复。单文件部署契约的取舍我认可,但等模板长起来可以考虑一个共享的
functions/_shared。 - 会话只活在当前页面,刷新即丢 transcript 和 session key(上游 session 变孤儿)。这一轮已经把 registry 的措辞改成
In-page Session Continuity,口径是诚实的;真要做持久化就是下一个 PR 的事了。
Summary
/settingspage/v1/chat/completionsthrough an authenticated InsForge Edge Function, preserving cancellation,[DONE],X-GoClaw-Session-Key, andtool_choice: noneBuilt against the API introduced by insight-flow#666. The UI and README describe the exact semantics: Insight Flow emits finalized, delivery-safe content as SSE chunks; this does not claim model first-token latency.
Backend and security
migrations/20260828054000_create-insight-flow-agent-configs.sqlcreates the per-user config table, owner-only RLS, constrained grants, and an atomicON CONFLICTsave RPCinsight-flow-configauthenticates every request, returns a fixed mask on normal reads, and returns the full key only for explicitPOST { action: "reveal" }insight-flow-chatauthenticates the user, revalidates the stored outbound URL, blocks local/non-public IPv4, IPv6, mapped IPv4 and CGNAT literals, and forwards only to HTTPSINSIGHT_FLOW_ALLOWED_HOSTSremains the recommended production boundary against DNS-based SSRF/rebindingAUTH_UNAUTHORIZEDfor SDK refresh; upstream Insight Flow failures are wrapped as 502 withupstreamStatusmessagetext; the frontend also falls back from an empty SDK message to known error codesValidation
npm run typecheckand production build passeddeno checkpassed for both Functions and tests