Skip to content

feat: add Insight Flow Agent chat template - #5

Merged
xiongxz merged 6 commits into
mainfrom
codex/insight-flow-agent-chat-template
Aug 28, 2026
Merged

feat: add Insight Flow Agent chat template#5
xiongxz merged 6 commits into
mainfrom
codex/insight-flow-agent-chat-template

Conversation

@xiongxz

@xiongxz xiongxz commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add a ChatGPT-style Insight Flow Agent chat surface with a familiar sidebar, centered transcript, Markdown replies, mobile drawer, and bottom composer
  • move Base URL, API Key, and model/agent controls out of chat into a dedicated /settings page
  • add InsForge email OTP auth and an owner-only RLS configuration table
  • keep the stored API key masked by default; an explicit authenticated eye-button action reveals the current user's complete key
  • stream /v1/chat/completions through an authenticated InsForge Edge Function, preserving cancellation, [DONE], X-GoClaw-Session-Key, and tool_choice: none
  • retain the session key across cancellation/errors, reject truncated streams, and warn instead of silently starting a disconnected follow-up

Built 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.sql creates the per-user config table, owner-only RLS, constrained grants, and an atomic ON CONFLICT save RPC
  • the API key is stored as plaintext in the user's backend row to keep template deployment simple; no extra encryption secret is required
  • masking prevents accidental display and shoulder surfing; it is not a security boundary against authenticated same-origin code
  • insight-flow-config authenticates every request, returns a fixed mask on normal reads, and returns the full key only for explicit POST { action: "reveal" }
  • insight-flow-chat authenticates the user, revalidates the stored outbound URL, blocks local/non-public IPv4, IPv6, mapped IPv4 and CGNAT literals, and forwards only to HTTPS
  • INSIGHT_FLOW_ALLOWED_HOSTS remains the recommended production boundary against DNS-based SSRF/rebinding
  • API keys are never stored in browser persistence, public env vars, logs, URLs, or frontend source; the current user and project database administrators can read plaintext values
  • local auth failures use AUTH_UNAUTHORIZED for SDK refresh; upstream Insight Flow failures are wrapped as 502 with upstreamStatus
  • both Functions preserve machine-readable error codes and add localized message text; the frontend also falls back from an empty SDK message to known error codes

Validation

  • frontend streaming and error suite: 10 tests passed, including truncated EOF and abort-after-delta session retention
  • Function suite: 4 tests passed, including auth refresh codes, mapped IPv6/CGNAT rejection, masking/reveal/save, upstream status isolation, session forwarding, and SSE pass-through
  • npm run typecheck and production build passed
  • deno check passed for both Functions and tests
  • migration executed successfully against a disposable PostgreSQL 16 instance
  • template dependency audit: 0 vulnerabilities
  • repository validator suite: 17 tests passed
  • browser interaction: configured field starts masked, eye action reveals, second eye action restores password mode

@xiongxz

xiongxz commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review(首轮)

先说结论:整体结构干净,凭据不落前端这条主线做对了,PR 描述里列的验证我本地全部复跑通过(vitest 3/3、deno test --allow-env 3/3、tsc -bnode scripts/validate-registry.mjs → Registry OK (11 templates))。下面按严重度列问题,头号那条建议合并前修。


P1|私有地址闸有洞:::ffff:127.0.0.1 直穿到 loopback

functions/insight-flow-chat.ts / insight-flow-config.ts 的 IPv6 判定只认 ::1 / fc* / fd* / fe8-b*。WHATWG URL 会把 IPv4-mapped 地址归一化成 ::ffff:7f00:1,四个前缀一个都不匹配,于是整条检查放行。

本地实测(Deno,起了一个 127.0.0.1:8899 的 http server):

URL hostname -> [::ffff:7f00:1]
reached loopback, status 200

顺带把常见变体都跑了一遍,好消息是 127.1 / 2130706433 / 0177.0.0.1 / 0x7f.0.0.1 都被 WHATWG 归一化成点分四段后拦住了,真正漏的是:

输入 结果
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 永远走不到,ChatPagesessionKey 保持旧值/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-KeyX-InsightFlow-Session-Key 这一跳。Access-Control-Expose-Headers 你们加了,这点没问题,但只要 InsForge functions 网关、任何一层 CDN/反代把这个自定义响应头吃掉,表现就是「每轮都失忆」,而且和 P2-1 一样是静默的。

建议要么让 Insight Flow 侧把 session key 也放进 SSE 事件体里(比 header 稳),要么在 key 缺失时降级为把 transcript 作为 messages 发上去。至少加个可观测点:拿不到 key 时在 UI 上标一下。

P3

  1. 401 的 body 用了 error: 'unauthorized',SDK 只对 AUTH_UNAUTHORIZED / PGRST301 触发自动刷新(REFRESHABLE_AUTH_ERROR_CODES),所以 access token 过期后聊天会直接死掉而不是静默续期。仓库里 web-research-agent / website-change-monitor 是同款写法,属既有约定,但这个模板是长时流式场景,受影响更明显,值得单独改。
  2. 上游非 2xx 时原样透传 upstream.status:实测不会误触 SDK 自动登出(错误码不在刷新集合里),但前端无法区分「我的 InsForge 会话过期」和「你填的 Insight Flow key 不对」——两者都是 401。建议统一成 502 + upstreamStatus 字段。
  3. 流被截断时静默当正常结束consumeInsightFlowSSE 没等到 [DONE] 就 EOF 的话直接 resolve,用户看到半截回复以为说完了。建议记录是否见过 [DONE],没见过就报一句。
  4. 两个 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 也过,这块没问题。

@xiongxz

xiongxz commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

已按首轮 review 处理,修复在 11fd3e4。感谢探针和分级,P1 的判断确实成立。

已修:

  • P1 SSRF:两支 Function 现在都会拦截 ::ffff:127.0.0.1 经 WHATWG 归一化后的 ::ffff:7f00:1、IPv4-compatible 形式、::、CGNAT、link-local、私网、benchmark、multicast/reserved 等非公网 IP。测试同时覆盖 config/chat 两条校验路径。DNS rebinding 仍按 README 的建议通过 INSIGHT_FLOW_ALLOWED_HOSTS 收紧。
  • P2-1 session key 丢失streamAgentReply 在消费 SSE 前通过回调发布 response session key;abort/解析异常/上游 error 不再丢 key。补了“首个 delta 后 abort”的回归测试。
  • P2-2 脱敏边界:选择低成本路线,README/AGENTS/PRODUCT 已明确:mask 仅防误显示和肩窥,不抵御 authenticated same-origin code;当前用户和数据库管理员都可读取明文。
  • P2-3 静默失忆:保留 header 协议,不重发 transcript(避免改变 Agent session 语义和 Token 成本);首次响应拿不到 session key 时 UI 明确警告。已有 session 时 header 缺失会沿用请求中的 key。
  • P3 auth/error/截断:本地 401 改为 AUTH_UNAUTHORIZED 以触发 SDK refresh;Insight Flow 上游非 2xx 统一包装为 502 并携带 upstreamStatus;EOF 未见 [DONE] 现在报截断错误并保留已收到内容。
  • 其他:clipboard rejection 已捕获;registry 的 Continuous Sessions 改成 In-page Session Continuity;移除未使用的 delete policy/grant;migration 去掉误导性的 if not exists;首次并发 PUT 改为 SQL ON CONFLICT 原子保存 RPC。

暂不做:

  • URL 校验仍在两个单文件 Function 中各保留一份,因为模板手动部署契约是 functions deploy ... --file <single-file>;用同一组矩阵测试锁住两份行为,避免引入额外部署资产。
  • 不做 transcript 持久化/跨刷新历史,这个模板现在明确只承诺页面存续期间的 session continuity。

验证:Vitest 5/5、Deno 4/4、typecheck/build、audit 0 vulnerabilities、registry 17/17;另外在一次性 PostgreSQL 16 容器里实际执行 migration 成功,容器已删除。

@xiongxz

xiongxz commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review(二轮 · 11fd3e4

首轮 6 条我逐条复核了,修复方向全部正确,回归测试也都是真测(把 receivedDone/onSessionKey/AUTH_UNAUTHORIZED 分别改回旧行为,对应用例确实变红)。你列的验证我也全部复跑通过:Vitest 5/5、deno test --allow-env 4/4、tsc -bvite buildscripts 17/17、validate-registry → Registry OK (11 templates)。

不过 isBlockedHostname 这次重写引入了一个新的功能性回归,另外首轮 P1 还剩一个同类的绕过没关上。两个都在同一个函数里,一起改代价很小。


P1(新引入)|fc* / fd* / fe8-fef* 前缀被套到了 DNS 名上,正常域名直接存不进去

const bare = host.replace(/^\[|\]$/g, '');
const privateIPv6 = bare === '::' || bare === '::1' || bare.startsWith('fc') || bare.startsWith('fd') ||
  /^fe[89a-f]/.test(bare);

bare 在 hostname 不是 IPv6 字面量时就是普通域名,于是任何以 fc / fd / fe8fef 开头的域名都被当成 ULA / link-local 拦掉。而且这次把 fe[89ab] 放宽成 fe[89a-f],把 fed* / fee* / fef* 也一并卷进来了——这一档是这次新增的

走真 handler 的端到端探针(mock 掉 auth/db,其余走原逻辑):

200  flow.example.com           {"configured":true,...}
422  feed.example.com           {"error":"invalid_base_url"}
422  fedex-agents.example.com   {"error":"invalid_base_url"}
422  fcm.example.com            {"error":"invalid_base_url"}

影响:用户的 Insight Flow 域名只要撞上这几个前缀(feed. fedex. feature. fcm. fd-agents. 都实测中招),设置页永远保存失败,而且报的是 invalid_base_url——normalizeBaseUrlprivate_base_urlinvalid_base_url 两种异常都塌成同一条文案,用户完全没法从提示里看出发生了什么,只会以为自己 URL 写错了。

P1(首轮残留)|带尾点的 FQDN localhost. 仍然直穿 loopback

WHATWG 保留 hostname 的尾点,localhost. 既不等于 'localhost',也不 endsWith('.localhost')

hostname -> localhost. | isBlockedHostname -> false
reached loopback via trailing-dot FQDN, status 200

而且它能一路存进配置:

200  localhost.                 {"configured":true,"insightFlowBaseUrl":"https://localhost.",...}

foo.localhost. 同理。纯 IP 字面量没事(https://127.0.0.1./ 会被 WHATWG 归一化掉尾点),漏的只有名字形式。

建议一起改(两条同源)

把 IPv6 的前缀判定限定在真的是 IPv6 字面量时,并在进判定前先剥掉尾点:

export function isBlockedHostname(hostname: string) {
  const host = hostname.toLowerCase().replace(/\.$/, '');        // ← 关掉尾点绕过
  const isIPv6Literal = host.startsWith('[') && host.endsWith(']');
  const bare = isIPv6Literal ? host.slice(1, -1) : host;
  const privateIPv6 = isIPv6Literal && (                          // ← 别再套到 DNS 名上
    bare === '::' || bare === '::1' ||
    bare.startsWith('fc') || bare.startsWith('fd') || /^fe[89ab]/.test(bare)
  );
  return host === 'localhost' || host.endsWith('.localhost') || privateIPv6 ||
    isNonPublicIPv4(ipv4Parts(host)) ||
    (isIPv6Literal && isNonPublicIPv4(embeddedIPv4(bare)));
}

fe80::/10 精确对应就是 fe[89ab];一旦门被收到字面量里,写 fe[89a-f] 也无所谓。)

尾点还有第二个落点:safeBaseUrl / normalizeBaseUrlallowedHosts.includes(host) 用的是同一个 host,所以 https://flow.example.com./ 会在配了 allowlist 的环境里被判成 host_not_allowed。建议直接在这两处把 const host = url.hostname.toLowerCase() 改成 .toLowerCase().replace(/\.$/, ''),让 isBlockedHostname 和 allowlist 用同一个归一化结果。

顺带:你新加的那张 host 矩阵测试正是应该逮住这两条的地方,但它现在只有阳性样本。建议补上 'localhost.''foo.localhost.' 作为必须 BLOCK,'feed.example.com''fcm.example.com''fedex.example.com' 作为必须 ALLOW——有了阴性样本,这一类才不会再回来。


已确认关闭

  • P2-1 session keyonSessionKey 在消费流之前发布,|| request.sessionKey 兜住了 header 缺失的续轮。新增的 "abort after first delta" 用例和我首轮那个探针是同一形状,撤掉回调即变红。✅
  • P2-2 脱敏边界:README / AGENTS / PRODUCT 三处措辞都改准了,"not a security boundary against authenticated same-origin code" 这句是我想要的那句。✅
  • P2-3:保留 header 协议不重发 transcript 我认同(改 session 语义 + Token 成本),首轮拿不到 key 时给了明确警告,可观测性这条满足了。✅
  • P3-1 / P3-2 / P3-3AUTH_UNAUTHORIZED、502 + upstreamStatus、EOF 无 [DONE] 报截断,三条都带了断言。✅
  • P3-4 不 dedup:单文件部署契约这个理由成立,用矩阵测试锁住两份行为是对的做法——前提是矩阵得覆盖到位(见上)。
  • nit 五条(clipboard / Continuous SessionsIn-page Session Continuity / 多余的 delete policy+grant / if not exists / 并发首次 PUT 改 ON CONFLICT RPC)全部处理。✅

顺便确认一件事:新的 save_insight_flow_agent_config 是 SECURITY INVOKER(没写 security definer),RLS 照常生效,这点没问题。前端确实可以绕过 Function 直接 rpc() 写进任意 base_url,但 insight-flow-chat 出站前会重新跑一遍 safeBaseUrl——这条重校验是承重的,后面别顺手删掉。(在此之前用表级 grant 也能直接写,所以这次改动没有扩大面。)

剩下的小事(不挡合并)

  1. normalizeBaseUrl 的 catch 把 private_base_url / host_not_allowed / invalid_base_url 三种原因塌成一条 invalid_base_url。上面那个 feed.example.com 之所以难查,一半是这个原因。分开返回会省很多排查时间。
  2. upstreamStatus 加进了 502 响应体,但前端 streamAgentReply 只取 detail || error,这个字段目前没有消费方。
  3. ChatPage 里回调形参和外层 const 同名(都叫 nextSessionKey)造成遮蔽,读起来容易误会;另外「未收到会话标识」是个警告,却走的是 chat-error 那个 role="alert" 的红条。
  4. isNonPublicIPv4(a === 192 && (b === 0 || b === 168)) 把整个 192.0.0.0/16 都拦了,实际保留的只有 192.0.0.0/24192.0.2.0/24。要严谨可以收到这两个 /24。

@xiongxz

xiongxz commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

二轮指出的两条都成立,已在 6974e1a 修复。

  • IPv6 前缀检查现在只作用于带 [] 的真实 IPv6 字面量,不再误拦 feed.*fcm.*fedex.*feature.* 等普通 DNS 名。
  • hostname 在本地主机判断和 allowlist 判断前统一去掉单个尾点;localhost. / foo.localhost. 会被拦,feed.example.com. 可正确匹配 allowlist 中的 feed.example.com
  • host 矩阵增加了 BLOCK/ALLOW 双向样本,并通过真实 config handler 验证了尾点 FQDN + allowlist 保存路径以及 localhost.private_base_url 返回。

顺手处理了不挡合并的 4 点:

  1. config endpoint 保留并返回 private_base_url / host_not_allowed,不再全部塌成 invalid_base_url
  2. 前端消费 upstreamStatus,例如明确显示 Insight Flow HTTP 401
  3. 去掉 session key 回调变量遮蔽;缺少 session key 改为黄色 role=status 提示,不再走红色 error alert。
  4. 192.0.0.0/16 的过度拦截收窄到 192.0.0.0/24192.0.2.0/24192.168.0.0/16

验证:Vitest 6/6、Deno 4/4、typecheck/build、audit 0 vulnerabilities、registry 17/17,git diff --check 通过。

@xiongxz

xiongxz commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review(三轮 · 6974e1a

二轮两条都关掉了,修法和我贴的补丁基本一致,新加的测试也都是真测——三发 mutation 全红:

撤回的改动 变红的用例
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 -bvite buildscripts 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 全套。ChatPageloadAgentConfig() 的 catch 同款。

所以本轮把 private_base_urlhost_not_allowedinvalid_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(首轮残留):尾点在 isBlockedHostnamesafeBaseUrl/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)全部处理。✅

两条我自己证伪的,记一下省得后面有人再走一遍

  1. 双尾点 localhost.. 不是洞replace(/\.$/, '') 只剥一个点,我一开始测出它"能连上 loopback"——那是本机 HTTP_PROXY=127.0.0.1:7897 造成的假阳性(NO_PROXY 没覆盖这个写法,请求被代理转发了)。清掉代理环境变量后直接 client error (Connect)getaddrinfo 也是 label empty 的 IDNA 报错,根本不解析。不用管。
  2. 存进去的 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,代码侧我这边没有别的意见了。

@xiongxz

xiongxz commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

三轮 comment 指出的 P2 成立,已在 82fe08f 处理。

  • insight-flow-config 的所有错误响应现在同时返回稳定机器码 error 和本地化的 messagefunctions.invoke 生成的 InsForgeError.message 不再为空。
  • 前端增加第二层兼容逻辑:老 Function 或 SDK 仍返回“空 message + error code”时,设置读取、保存和 API Key reveal 也会翻译成可读提示,不会静默。
  • insight-flow-chat 也为所有 JSON 错误补了 message;聊天端优先展示 detail || message || error,不再把 agent_not_configured / empty_stream 等机器码直接给用户。
  • 成功响应、HTTP 状态码、原有 error 码和 SSE 透传协议均未改变,旧调用方保持兼容。

回归:Vitest 10/10、Deno 4/4、typecheck/build、audit 0 vulnerabilities、registry 17/17、git diff --check 全部通过。

@xiongxz

xiongxz commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

Review(四轮 · 82fe08f)— LGTM,可以合了

三轮那条 P2 关掉了,而且是端到端验过的关,不是看代码觉得对。

我把真 handler 的响应体原样喂进模板自己 node_modules 里的真 SDK,再把 SettingsPage 那行 catch 表达式原样求值(三轮我就是用这条链路证明它是空白的):

https://localhost.       | SDK error.message = "该地址指向本地或内网,请填写公网 HTTPS 地址。" | UI renders = 同上
https://10.0.0.5         | SDK error.message = "该地址指向本地或内网,请填写公网 HTTPS 地址。" | UI renders = 同上
http://flow.example.com  | SDK error.message = "Base URL 必须是不带查询参数的 HTTPS 地址。"     | UI renders = 同上

三轮那个 error.message -> ""UI renders -> "" 的链路已经不复现。第二层 functionErrorMessage 兜底也是对的设计——存量部署的旧 Function 不返回 message 时,前端还能靠 error.error 翻出人话,不会退回静默。

新加的 4 条断言全是真测,三发 mutation 全红:

撤回的改动 变红的用例
json() 里的 message 注入 config error did not include a user-facing message
functionErrorMessagecandidate.error 兜底层 translates an SDK error code when its message is empty
stream.tspayload?.message 的优先级 prefers a user-facing Function message over its machine error code

你列的验证我也全部复跑通过:Vitest 10/10、Deno 4/4、deno check 两个 Function、tsc -bvite buildscripts 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 的债):

  1. 两个 Function 各一份 URL 校验器 + 前后端各一份错误文案表,一共三处重复。单文件部署契约的取舍我认可,但等模板长起来可以考虑一个共享的 functions/_shared
  2. 会话只活在当前页面,刷新即丢 transcript 和 session key(上游 session 变孤儿)。这一轮已经把 registry 的措辞改成 In-page Session Continuity,口径是诚实的;真要做持久化就是下一个 PR 的事了。

@xiongxz
xiongxz merged commit 1edc135 into main Aug 28, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant