Author SHA1 Message Date
AskaEth a0b4c941e4 fix(loop): keep cross-batch voice success and gate speak without dispatcher (M57)
两处 review 必修(review-m57 r1):

P2-1 跨批覆盖:applyBatchVoice 原先无条件用后一批的语音结论覆盖
前一批,于是「先成功合成 abc.wav、下一批换个参数再试却失败」会把
Result.Voice 退化成 tts_failed —— 音频已落盘,退回文本既无收益也
违背那次表达。改为守 captureVoice 的同一条优先级:已落盘的音频不被
后续批次的失败抹掉;先降级后成功仍然覆盖(后到的成功即最终语音)。

P2-2 nil-dispatcher 安全门:speak 由循环自己承载、无 dispatcher 也
能执行(invokeOne 短路),但 applyToolAdmissions 在 dispatcher 缺席
时整段跳过安全门,使「没配 dispatcher」成为绕过 design/11 §3 的
旁路。新增 applyDispatcherlessVoiceGate:与策略引擎对未登记工具的
口径一致(默认关闭、只有 owner 放行),拒绝走同一条 not_allowed
软结果与 tool.reject.not_allowed 审计通道;并改正该处过时注释。

两条回归测试都做过改前红/改后绿:
- TestApplyBatchVoice_KeepsCrossBatchSuccess:注释掉守卫即报
  「voice = ... FallbackReason:tts_failed, want abc.wav」
- TestApplyToolAdmissions_DispatcherlessSpeakGate:去掉 else 分支即报
  「env group_member run = true, want false」

验收:go build ./... / go vet ./... / go test ./... 全绿。
2026-10-27 22:00:18 +08:00
AskaEth c9da488749 refactor(loop): extract speak voice backfill into applyBatchVoice (M57) 2026-10-27 21:33:05 +08:00
AskaEth 39ae883a4e test(loop): cover the speak tool and Result.Voice (M57)
speak_tool_test.go pins the leaf behaviour: budget formula, extension
whitelist, argument validation, and the success/timeout/no-audio/store-failed
paths, plus the model-surface advertisement and append-once rule.

voice_result_test.go pins the outbound chain end to end: captureVoice's three
states and its last-success-wins rule, a full turn where speak is advertised,
executed through the injected synth/store and lands on Result.Voice, the
degraded turn that still answers in text, the unwired turn that keeps the
surface byte-identical, and the store adapter the wiring will use.
2026-10-27 21:22:20 +08:00
AskaEth 1347a77d33 feat(loop): wire speak tool into the loop (M57)
- speak_tool.go: the speak tool itself — rune-derived timeout budget, structured
  failure codes, persona/emotion stamped on the synthesis ctx, audio persisted
  through the store seam, structured result for the model.
- voice_deps.go: the wiring seam (VoiceSynth / VoiceStore + VoiceStoreFunc
  adapter), voiceEnabled, withSpeakSpec (appended after every schema shrink),
  and captureVoice (success beats failure, last one wins).
- options.go / loop.go: VoiceSynth, VoiceStore, SpeakBudget injection points.
- request.go: advertise speak on both toolSchemas return paths.
- tools.go / tools_invoke.go: carry the batch voice outcome; split invokeOne /
  emitToolExecution / spanStatusForToolPhase / invokeParallel into their own
  file (tools.go was at the 500-line ceiling) and let speak run without a
  dispatcher.
- run.go: keep the voice outcome across tool batches (a later batch without
  speak must not erase it).

Known, deliberate: speak bypasses the capacity allow-set and skill-round
narrowing (it belongs to no built-in toolset), so it is also advertised in
quiet/awake; execution still passes the tool security gate, where speak is not
registered and non-owner actors are denied fail-closed.
2026-10-27 21:22:14 +08:00
AskaEth f84a358e30 feat(loop): add OutboundVoice result type for speak tool 2026-10-27 20:22:12 +08:00
AskaEth 07df06b86d Merge branch 'feat/b2a-browserexit-redact-browser-exception' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 22:27:21 +08:00
AskaEth b040f588b2 fix(browser): M53 追加 Chrome 输出预览打码
preview() 出镜前过 trace.RedactTextLimited(600 rune),覆盖失败日志的 chrome_output 与 withChromeOutput 追加给调用方的错误串;补凭据形状/原话可见/有界三条测试,删实现可打红。
2026-10-26 22:19:31 +08:00
AskaEth e8bc8727da Merge branch 'feat/b2c-hostredact-vision-hostof-and-tts-red' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 22:15:21 +08:00
AskaEth b626b3f5c0 Merge branch 'feat/b2d-weixinname-send-md-name-redact-and-l' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 22:11:58 +08:00
AskaEth 50cc60d019 Merge branch 'feat/b2b-judgetext-redact-judge-reason-sensit' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 22:11:05 +08:00
AskaEth a1c9da6e8a test(voice/tts): pin fragment-before-query in redactURL fallback
Review r1 P2-01: the only fragment case had '?' before '#', so degrading
stripURLQuery to cut '?' alone stayed green and the '#'-first branch was
unpinned. Add a scheme-less '#access_token=...' case (OAuth implicit
flow shape) and correct the mutation comment to state which case each
mutation actually kills.
2026-10-26 22:11:01 +08:00
AskaEth e39cc5d096 fix(weixin_ilink): drop user file name from media failure log; fix TestLogin_LogRedaction data race 2026-10-26 22:00:09 +08:00
AskaEth 5a69d6e0ff fix(automations): redact reminder message before logging 2026-10-26 21:58:31 +08:00
AskaEth 8058660537 fix(browser): M53 两条页面侧自由文本出口收敛脱敏
- Page.navigate 的 errorText 是**协议枚举**面(net error 码):先剥 `net::`
  命名空间再交给 trace.ErrorToken,像枚举就原样保留、散文一律丢弃只留字节数;
- Runtime.evaluate 的 exceptionDetails.text 是**页面自由文本**面:压平空白
  (防日志行伪造)+ trace.RedactTextLimited 按 200 rune 截断(档位理由见
  errtext.go:80 会截断真实异常消息,4096 等于允许页面塞 4KB 正文);
- 两类守卫不互换(口径见 trace/errortoken.go 的按字段类别判定),清洗实现
  仍只有 trace 那一份,本片只做「选哪一道 + 补形状前提」。

测试:出口 A/B 各一条经 RenderText 的真实路径断言(含 URL/凭据形状的假文本),
外加 net::ERR_* 正向保留、上限截断/换行压平、netErrorCode 形状表。三条泄露断言
已验证「退回原样直吐即红」。
2026-10-26 21:57:48 +08:00
AskaEth e0793debc3 fix(restrict): redact sensitive param summary before it reaches audit/panel 2026-10-26 21:55:25 +08:00
AskaEth 28a6cd7e53 fix(voice/tts): strip query from scheme-less URLs in redactURL
redactURL's fallback returned the raw string when url.Parse found no host.
A scheme-less BaseURL (127.0.0.1:9880/v1?token=...) fails to parse, so the
signed query reached the WARN log and the error chain verbatim. Strip at
? and # in that path, keeping the '@' full-redaction rule first.

Test pins the scheme-less query/fragment cases and goes red if the fix
is reverted.
2026-10-26 21:54:21 +08:00
AskaEth 5099c21520 fix(audit): redact judge reason before it reaches logs and the panel
判定理理由(verdict.Reason)是模型自由文本,判定器会把它在参数摘要里看到的
凭据原样抄进理由,而这条理由要进审计日志与前端小字两个读面。cleanReason
在收敛空白/截断之后统一过 trace.RedactText(80 rune 档),与其余出图文本
走同一套清洗;maxReasonRunes 退化为「进入清洗前」的上限,避免在无界文本上
跑凭据匹配。
2026-10-26 21:52:44 +08:00
AskaEth a5b3d40a74 fix(vision): report only URL origin when an image source is rejected
fetchURL echoed the whole raw URL (path, query, userinfo) in the
ErrUnsupportedSource message, so a rejected source carrying a signed
query leaked it into logs and tickets. Route the URL exit through
trace.HostOf and fall back to a placeholder instead of the raw string.

Test pins origin-only output and goes red if the fix is reverted.
2026-10-26 21:52:19 +08:00
AskaEth d47185feb8 docs(contract): v0.97 voice WS frames + TTS autolaunch + upstream error safety
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- voice 进 message_persisted/channel_persisted 首帧与握手回放(M44 fb55769,
  INSERT→写列→notify;两条实现前提进契约)
- emotionTTS 自启默认关(M47,voice-tts-launch.json,probeUrl 单向兜底)
- UpstreamError 构造即安全:code/type 固定令牌守卫、message 默认缺席
  (M49/M50,一处行为变更如实标注)
- U6 双凭据门明示(Bearer JWT 或 X-API-Key,既有能力非新增)
- 改正 14 处「WS 帧不带 voice」假话 + 旧横幅加注;尾注 v0.82→v0.97
2026-10-26 21:44:44 +08:00
AskaEth 656c35f1e8 Merge branch 'feat/m51-lastexits-close-remaining-upstream-f' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
# Conflicts:
#	internal/runtime/trace/errortoken.go
2026-10-26 21:25:48 +08:00
AskaEth 76263d42ed fix(upstream): M51 复审 p2 三条 —— light.go 同形出口 / MCP SDK 日志面 / ErrorToken 口径
① light.go 的 2 处 "light judge: " + d.Reason 与 engage.go 同形(model reason →
返回值 → coalesce_prepare 的 Debug)。把上一轮的 engageReason 提成包内共用的
judgeReason(judge_reason.go,RedactTextLimited + strings.Fields,60 rune),
engage.go 7 处 + light.go 2 处 + notice.go 的 "notice review failed: "+err.Error()
统一走它。控制流零改动:只换「进去的那份文本」,判定条件与返回值拼接结构未动。

② MCP 的 Logger 面先探针后修:go-sdk v1.8.0 的 keepalive 会把对端 JSON-RPC error
原样打进日志(实测泄漏,见 sdklog_probe_test.go 的输出),tools/call 路径不打日志。
新增 sdklog.go:redactingLogger 交给 SDK,wire 错误(链上有 *jsonrpc.Error)只留
code + message_bytes(复用 upstreamError),本地错误保留原因,消息/属性压平+打码+300 rune。

③ errortoken.go 的口径注释纠正:从「包外不得调用」改为按字段类别判定(协议枚举可守、
自由文本默认不带),并把 cdp.go 这条 M51 用法作为例子写进去。
2026-10-26 20:00:57 +08:00
AskaEth 7fbb8d6232 fix(channelbridge): M51 追加 —— 参与度判定 reason 出镜前打码压平
Debug 日志与返回值共 7 处(engage.go 222/227/228/231/250/256/257 原行号)此前把
判定模型回的 d.Reason 原样写出:模型可以把 Authorization / sk-… 或换行塞进 reason,
前者进日志就是凭据泄漏,后者能按行聚合的日志里伪造一条新记录。

新增 engageReason(trace.RedactTextLimited + strings.Fields,上限 60 rune):
- 控制流零改动:分支条件、状态迁移、返回值拼接结构一个字未动,
  只把「进去的那份文本」从 d.Reason 换成 engageReason(d.Reason);
- 上限 60 rune 的理由写在常量注释里(reason 是一句短语,超过即是在复述群聊原文)。

用例 engage_reason_test.go:PROBING / 跟进 / 回避+无兴趣三条出口,
断言日志与返回值都不含 sk-live…、都含 [redacted]、都不含换行。
变异实测:摘 RedactText ⇒ 三条红;摘压平 ⇒ 三条红。
2026-10-26 19:47:35 +08:00
AskaEth d6493d79f4 fix(upstream): M51 收敛剩余上游自由文本出口 - CDP/MCP/flowgate/channelbridge WARN
- channelbridge: 拨号失败的 WARN 不再带原始 dial 错误(含 ?access_token=),
  URLCause 剥壳 + 内嵌地址 MaskQuery 打码 + HostOf 保留目标来源;控制流零改动
- browser/cdp.go: CDP error.message 只有像协议枚举(ErrorToken)才出镜,其余只留字节数
- mcp: 第三方 server 的错误文本默认不带,只留 server/工具/JSON-RPC 码/message 长度,
  分类链(errors.Is/As、context.Canceled)经 Unwrap 保留
- channel/adapter/flowgate: 审查输出摘要由 40 字节改 40 rune(防切 UTF-8)并过 RedactText

四处均补了会随回退变红的用例(变异已实测)。
2026-10-26 19:44:22 +08:00
53 changed files with 3620 additions and 213 deletions

No files matched your search

+41 -21
View File
@@ -6,7 +6,7 @@
> **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
> **维护铁律**:每实现/变更一个 REST 端点、WS 事件、admin API **立即改本文档**;契约变更必须版本化,不破坏既有客户端。
> **准确性 > 详尽度**:宁少写也别写错;**写不了确证的写「未确证」**,见 [§7](#7-在飞切片与未实现未确证)。
> ⚠️ **v0.67 是「契约先行」登记版**:本版新增的三批 —— **语音消息**(播放端点 + 消息 DTO `voice`)、**非消息事件查询面**、**账号在线状态** —— 登记时**都还没有实现**,形状**取自设计文档**(`design/24 §10.11`、`design/26 §4.5` / `§2.6`)**而非代码核对**。**已落地**:账号在线状态(`52dac45`)、非消息事件查询面(切片 E6)—— 见 §2.4;**语音消息的「用户可见面」已由 v0.79 落地**(切片 L45:DTO `voice` + `GET /api/v1/media/{name}` + 副本落盘;残留缺口——WS 实时帧/握手回放不带 `voice`、AMR 不落副本(**后者已由 v0.80 修复**)——见 §4.5 / §7.2 / §7.4-29)。已在 §7.2 逐条如实标注。**本文档其余内容仍以代码为真源**。
> ⚠️ **v0.67 是「契约先行」登记版**:本版新增的三批 —— **语音消息**(播放端点 + 消息 DTO `voice`)、**非消息事件查询面**、**账号在线状态** —— 登记时**都还没有实现**,形状**取自设计文档**(`design/24 §10.11`、`design/26 §4.5` / `§2.6`)**而非代码核对**。**已落地**:账号在线状态(`52dac45`)、非消息事件查询面(切片 E6)—— 见 §2.4;**语音消息的「用户可见面」已由 v0.79 落地**(切片 L45:DTO `voice` + `GET /api/v1/media/{name}` + 副本落盘;残留缺口——WS 实时帧/握手回放不带 `voice`、AMR 不落副本——**两者均已修复**:前者已由 v0.97(切片 M44)、后者已由 v0.80(切片 W4)——见 §4.5 / §7.2 / §7.4-29)。已在 §7.2 逐条如实标注。**本文档其余内容仍以代码为真源**。
> **v0.68 是纯增量版(已实现,非契约先行)**:新增 1 条管理只读端点 `GET /api/v1/admin/config-facts`(配置事实面,见 §2.2 B12),并给 `GET /api/v1/admin/backthink` 的条目加 2 个可选字段(`urgency` / `quietHoursBypassed`,见 §2.2 B8)。**无破坏性变更**,老客户端零影响。
> **v0.69 是缺口修复版(已实现)**:`PUT /api/v1/admin/channels/{platform}/config` 现在**接收并原样落盘**账号顶层 `directMsgPerMin`,`GET /api/v1/admin/channels` 的账号元素也**如实回显**它(§2.2 A5 / §2.3 C28 注)。修的是「面板保存一次渠道配置,就把主人手配的**关闭流控(-1)**静默重置回 `0` = 保护性默认 6 条/分」。**无破坏性变更**(老客户端不带该键,行为与 v0.68 相同)。
> **v0.70 是精确化版(已实现,纯增量)**:`GET /api/v1/admin/backthink` 的 `urgency` **默认就是该轮实际生效的精确档位**(随记录落库),另加可选 `urgencyLowerBound:true` 专门标记**旧行的下限推导值**;实时 `backthink` 帧自本版起也带记录里的 `urgency`(§2.2 B8 / §4.10(b),并更正 §4.10(b) 原「实时帧不带这两键」的表述)。**无破坏性变更**(新键可选,老客户端不读即零影响;`urgency` 的既有取值域与 `quietHoursBypassed` 语义都未改)。
@@ -37,9 +37,9 @@
> **v0.80 是三批增量版(均**已实现**,**纯增量、无破坏性变更**)**:① **链路图时间轴出口**(切片 W1,`design/19 §7`)—— 新增 1 条管理只读端点 **`GET /api/v1/admin/flow/timeline`**(§2.2 B18 / §4.11.1),把「链路图」从「**最近一轮** + 已落盘轮次的**键列表**」扩到**按时间正序的多轮时间轴**(每轮含该轮**每一路**),支持 `limit`(缺省 **50** / 硬上限 **200**)与 `before` 游标向**更早**翻页;响应另给 `retentionHours` / `retentionEnabled` / `truncated`,供面板**如实**标注「更早的轮次已过保留期」。修的是主人 2026-10-06 的原话「web 后台的全链路流程图我要的是**所有消息按时间顺序串起来**的流程图,而不是只有最新一个来回」。老端点 `GET /api/v1/admin/flow/graph`(B13)**逐字未改**(老客户端零改动)。② **搜索 provider 热重载 + 主 provider / 降级链**(切片 W3;裁决 2026-10-06 #1 / #2)—— `plugin.saveProvider` 成功落盘后,组合根**按落盘后的配置重建 web-search 工具并原子替换能力注册表里的条目**(`registry.ReplaceTool`,保留运行期策略)⇒ **下一轮对话即用新配置、不需要重启**;若这次改动会让 web-search **没有可用后端**(下次启动会 fail-fast)⇒ **文件按字节回滚** + **`409 config_unstartable`**。同一次提交可带两个**文件级**字段 `primary` / `fallback[]`(缺位 = 不改动);响应新增机器可读事实 **`hotReload:{applied,registered}`**;新增错误码 **`primary_not_configured`(400)** / **`config_unstartable`(409)**。🔴 **本版把三处已成假话的旧口径改正**(§4.14(b)(c) / §7.2)—— 此前写的「面板保存后**不**热替换运行中的工具、需重启」与「面板**无**主 provider / 降级链字段」**都不再成立**。⚠️ **未确证(如实)**:真机面板上「新增/改一家 provider → 下一次搜索真的走它」的端到端、以及**真机探针打真实上游**,都没跑。③ **入站语音 AMR 也可回放**(切片 W4;裁决 #11)—— 组合根补齐 `channelbridge.Options.VoiceTranscoder` 注入(`cmd/cyrene/wire_channels.go`)⇒ **不可播格式的入站语音在落盘之前先转成 `.wav` 再落副本**(`mime: audio/wav`;本期唯一必需的转码路 = QQ OneBot v11 的 `amr → wav`,见 `internal/runtime/voice/playable.go`),客户端从此能拿到可回放的字节;此前走 `category=transcode_not_wired` 的诚实 WARN、**不落副本**。**形状零变化**(DTO `voice` 的键集合与语义一字未改;可播格式仍**原样落、一次转码都不发生**)。🔴 **同批改正 §4.5 / §7.1 / §7.2 / §7.4 里已成假话的「组合根未注入 `VoiceTranscoder` ⇒ AMR 不落副本」**(`transcode_not_wired` 从此只剩「引擎未装配 / 构造失败」一档;旧版本横幅保留当时状态,另加「已由 v0.80 修复」注)。⚠️ **未确证(如实)**:③ 的**生产端到端未跑**(红线:不重启后端)⇒ 本期证据是 `channelbridge` 的行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**。详见 **§6**。
> **v0.79 是「面板动作真可用 + 记忆审查/语音用户可见面 + 链路图回合标识全覆盖」版(四批,均**已实现**;**唯一一处形状变化**)**:① **插件面板动作真可用**(切片 L1/PLG-2 的执行面接线)—— `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501**:web-search 的四个动作 + 下拉候选来源(`plugin.providers`)都已登记**生产处理器**,**真读写** `$DATA_DIR/config/plugins/web-search.json`、探针**真发一次 HTTP**;失败回**结构化非 2xx**(错误码/文案/状态由插件侧给出,宿主只透传),新增 `invalid_payload` / `unknown_provider` / `not_configured` / `config_unreadable` / `config_write_failed` 与一批探针码(502);**只有「已声明但宿主未登记处理器」才回 `501 action_not_implemented`**(不再是兜底档)。审计新增附加字段 `code`(**不进 DB 记录体、不含请求体**)。详见 **§2.1 A14 / §4.1 / §4.14 / §7.2**。② **「入库前审查」(D3)的管理面落地**(切片 L45)—— 新增 2 条 `writeAuth` 端点 **`GET /api/v1/admin/memories/reviews?limit=`**(留痕列表,**不含记忆正文**;`limit` 缺省 100 / `≤0` 同缺省 / >500 钳位 / 非整数 `400`)与 **`POST /api/v1/admin/memories/{id}/revert`**(撤销一次软合并,**无损可逆**,落审计 `memory.review.revert`);未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**假装没有留痕)。详见 **§2.2 B16/B17 / §4.1**。③ **语音消息的用户可见面落地**(切片 L45)—— 消息 DTO 的 **`voice`** 现在真的填充(REST 读取面 + `message_edited`),新增 **`GET /api/v1/media/{name}`**(200/206/304/416/`HEAD`;`audio/*` 内联 + `nosniff` + `Accept-Ranges`;**无 `ETag`**;名字非法 / 穿越 / 缺失统一 `404 media_not_found`),副本真落 `$DATA_DIR`(`documents.SaveAudio`,内容寻址、0600)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`**;组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **AMR 这一项已由 v0.80(切片 W4)修复**。详见 **§1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5 / §7.2**。④ 🔴 **链路图的权威回合标识覆盖到全部编排回合**(切片 CODE-TC-2)—— 编排器驱动的**每一路**(含**普通单路回合**与队首续跑路)的图现在都带权威 `turnId`,取值 = 该轮消息行的 `chat_messages.turn_id` ⇒ `GET /sessions/{id}/messages` 的 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)⇒ 客户端**一律使用响应里给出的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍整体缺席;**v0.79 之前的单路历史行不回填**。详见 **§4.4 / §4.11 / §7.2 / §7.4-23**。**无破坏性变更**(新端点 / 新可选键 / 新错误码;老客户端若不自己拼 `graph.id` 则零改动)。
> **v0.79 是「面板动作真可用 + 记忆审查/语音用户可见面 + 链路图回合标识全覆盖」版(四批,均**已实现**;**唯一一处形状变化**)**:① **插件面板动作真可用**(切片 L1/PLG-2 的执行面接线)—— `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501**:web-search 的四个动作 + 下拉候选来源(`plugin.providers`)都已登记**生产处理器**,**真读写** `$DATA_DIR/config/plugins/web-search.json`、探针**真发一次 HTTP**;失败回**结构化非 2xx**(错误码/文案/状态由插件侧给出,宿主只透传),新增 `invalid_payload` / `unknown_provider` / `not_configured` / `config_unreadable` / `config_write_failed` 与一批探针码(502);**只有「已声明但宿主未登记处理器」才回 `501 action_not_implemented`**(不再是兜底档)。审计新增附加字段 `code`(**不进 DB 记录体、不含请求体**)。详见 **§2.1 A14 / §4.1 / §4.14 / §7.2**。② **「入库前审查」(D3)的管理面落地**(切片 L45)—— 新增 2 条 `writeAuth` 端点 **`GET /api/v1/admin/memories/reviews?limit=`**(留痕列表,**不含记忆正文**;`limit` 缺省 100 / `≤0` 同缺省 / >500 钳位 / 非整数 `400`)与 **`POST /api/v1/admin/memories/{id}/revert`**(撤销一次软合并,**无损可逆**,落审计 `memory.review.revert`);未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**假装没有留痕)。详见 **§2.2 B16/B17 / §4.1**。③ **语音消息的用户可见面落地**(切片 L45)—— 消息 DTO 的 **`voice`** 现在真的填充(REST 读取面 + `message_edited`),新增 **`GET /api/v1/media/{name}`**(200/206/304/416/`HEAD`;`audio/*` 内联 + `nosniff` + `Accept-Ranges`;**无 `ETag`**;名字非法 / 穿越 / 缺失统一 `404 media_not_found`),副本真落 `$DATA_DIR`(`documents.SaveAudio`,内容寻址、0600)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`** —— ✅ **该缺口已由 v0.97(切片 M44)修复**(首帧即带,见 §6 v0.97);组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **AMR 这一项已由 v0.80(切片 W4)修复**。详见 **§1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5 / §7.2**。④ 🔴 **链路图的权威回合标识覆盖到全部编排回合**(切片 CODE-TC-2)—— 编排器驱动的**每一路**(含**普通单路回合**与队首续跑路)的图现在都带权威 `turnId`,取值 = 该轮消息行的 `chat_messages.turn_id` ⇒ `GET /sessions/{id}/messages` 的 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)⇒ 客户端**一律使用响应里给出的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍整体缺席;**v0.79 之前的单路历史行不回填**。详见 **§4.4 / §4.11 / §7.2 / §7.4-23**。**无破坏性变更**(新端点 / 新可选键 / 新错误码;老客户端若不自己拼 `graph.id` 则零改动)。
> **v0.78 是六面增量版(⚠️ **①~⑤ 已实现;⑥ 只到落库面**(用户可见面**已由 v0.79 补齐**);以纯增量为主,无破坏性变更)**:① **消息读取面 `toolCalls[]` 落地**(切片 CODE-TC)—— v0.76 ① 从「未实现」改为**已实现**(数据源 `flow_graphs` + 迁移 `0023` 的**按路查询键** `lane_turn_id`);🔴 **v0.78 时覆盖率受限(如实)**:只有**真并行路**的图带权威 `turnId`,普通单路回合**查不到图 ⇒ 字段缺席**(契约允许,但不是想要的覆盖率)—— **已由 v0.79 补齐**(见 §6 v0.79 ④)。详见 **§4.4 / §7.2 / §7.4-23**。② **插件管理面板接入点(管理面新增两条路由 + 一条 WS 帧)**(切片 PLG-2)—— 新增 `GET /api/v1/admin/panels`(**按调用者角色过滤**后的接入点快照,viewer 可读)与 `POST /api/v1/admin/plugins/{id}/actions/{action}`(**只接受该插件声明过的动作名**,未声明 ⇒ `404 action_not_declared`;写动作 `writeAuth` + 审计);WS 下行帧 **`panels_changed`**(插件生命周期变化后推,**只发 owner/admin**)。⚠️ **动作执行器在 v0.78 无生产实现 ⇒ 当时所有已声明动作都回 `501 action_not_implemented`** —— **已由 v0.79 接线**(见 §6 v0.79 ① 与 §4.14)。详见 **§2.1 A13/A14 / §3.4.5 / §4.14**。③ **搜索 provider 可变源**(切片 PLG-4)—— 插件 `web-search` 的配置从 `.env` 迁到 `$DATA_DIR/config/plugins/web-search.json`(0600,文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider` 四个动作。🔴 **不新增任何 env 键**(`.env` 已冻结为引导面)。详见 **§4.14(c)**。④ **账号上下线落事件 + 渠道事件实时帧**(切片 E7)—— 账号 online/offline 迁移落进 `internal/runtime/events`(复用迁移 `0021`,`tier=t2` / `category=lifecycle`),并经**既有进程内 bus** 推成 WS 下行帧 **`channel_event`**(**只发 owner/admin**,viewer 走 REST 只读档)。⚠️ **当前只覆盖账号状态迁移这一路**;覆盖其余全部渠道事件的装饰器**未接线**(见 §7.2)。详见 **§3.4.5**。⑤ **微信 iLink 账号状态三态**(切片 CODE-F3)—— `GET /api/v1/admin/channels/{platform}/login-status` 新增可选 `authState` / `authDetail`(**有效 / 被拒 / 未知**);🔴 **`errcode=-14` 是「被拒」不是「过期」**,**未知同样不许写成「过期」**。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。详见 **§2.4(c)**。⑥ **语音消息落库面**(切片 CODE-VOICE)—— 迁移 `0020` 给 `chat_messages` 加 `voice` 列(`TEXT NOT NULL DEFAULT ''`);**用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);残留缺口(WS 实时帧 / 握手回放不带 `voice`;AMR 那一项**已由 v0.80 修复**)见 **§4.5 / §7.2**。**无破坏性变更**(新端点 / 新可选键 / 新帧;老客户端零影响)。
> **v0.78 是六面增量版(⚠️ **①~⑤ 已实现;⑥ 只到落库面**(用户可见面**已由 v0.79 补齐**);以纯增量为主,无破坏性变更)**:① **消息读取面 `toolCalls[]` 落地**(切片 CODE-TC)—— v0.76 ① 从「未实现」改为**已实现**(数据源 `flow_graphs` + 迁移 `0023` 的**按路查询键** `lane_turn_id`);🔴 **v0.78 时覆盖率受限(如实)**:只有**真并行路**的图带权威 `turnId`,普通单路回合**查不到图 ⇒ 字段缺席**(契约允许,但不是想要的覆盖率)—— **已由 v0.79 补齐**(见 §6 v0.79 ④)。详见 **§4.4 / §7.2 / §7.4-23**。② **插件管理面板接入点(管理面新增两条路由 + 一条 WS 帧)**(切片 PLG-2)—— 新增 `GET /api/v1/admin/panels`(**按调用者角色过滤**后的接入点快照,viewer 可读)与 `POST /api/v1/admin/plugins/{id}/actions/{action}`(**只接受该插件声明过的动作名**,未声明 ⇒ `404 action_not_declared`;写动作 `writeAuth` + 审计);WS 下行帧 **`panels_changed`**(插件生命周期变化后推,**只发 owner/admin**)。⚠️ **动作执行器在 v0.78 无生产实现 ⇒ 当时所有已声明动作都回 `501 action_not_implemented`** —— **已由 v0.79 接线**(见 §6 v0.79 ① 与 §4.14)。详见 **§2.1 A13/A14 / §3.4.5 / §4.14**。③ **搜索 provider 可变源**(切片 PLG-4)—— 插件 `web-search` 的配置从 `.env` 迁到 `$DATA_DIR/config/plugins/web-search.json`(0600,文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider` 四个动作。🔴 **不新增任何 env 键**(`.env` 已冻结为引导面)。详见 **§4.14(c)**。④ **账号上下线落事件 + 渠道事件实时帧**(切片 E7)—— 账号 online/offline 迁移落进 `internal/runtime/events`(复用迁移 `0021`,`tier=t2` / `category=lifecycle`),并经**既有进程内 bus** 推成 WS 下行帧 **`channel_event`**(**只发 owner/admin**,viewer 走 REST 只读档)。⚠️ **当前只覆盖账号状态迁移这一路**;覆盖其余全部渠道事件的装饰器**未接线**(见 §7.2)。详见 **§3.4.5**。⑤ **微信 iLink 账号状态三态**(切片 CODE-F3)—— `GET /api/v1/admin/channels/{platform}/login-status` 新增可选 `authState` / `authDetail`(**有效 / 被拒 / 未知**);🔴 **`errcode=-14` 是「被拒」不是「过期」**,**未知同样不许写成「过期」**。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。详见 **§2.4(c)**。⑥ **语音消息落库面**(切片 CODE-VOICE)—— 迁移 `0020` 给 `chat_messages` 加 `voice` 列(`TEXT NOT NULL DEFAULT ''`);**用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);残留缺口(WS 实时帧 / 握手回放不带 `voice`——**已由 v0.97(切片 M44)修复**;AMR 那一项**已由 v0.80 修复**)见 **§4.5 / §7.2**。**无破坏性变更**(新端点 / 新可选键 / 新帧;老客户端零影响)。
> **v0.75 是记忆整理试算版(已实现,纯增量)**:新增 1 条管理端点 **`POST /api/v1/admin/memories/tidy/dry-run`**(**只读语义的 POST**,`writeAuth` = owner/admin)—— 跑**同一套**记忆整理规则(召回门 + 包含判定 + 规则否决,与低峰定时轮共用代码路径),把候选**直接回在响应里**,**一行都不写**(不写 `memory_tidy_candidates` / `memory_tidy_runs`,绝不改 `memory_items`)。修的是「整理器只在 23:00–07:00 跑(design/23 §4),在它第一次开跑前面板只能空着」——主人要「现在就想看到在 736 条上它会建议合并哪些」。**零副作用是硬约束**(后端有逐行快照单测钉死);失败**一律如实作答**(`tidy_unavailable` / `tidy_disabled` / `tidy_recall_unavailable` / `tidy_timeout`),**绝不回空候选数组假装「没有建议」**。**无破坏性变更**(新端点;既有端点/帧一字未改)。详见 **§2.2 B14 / §4.12**。
@@ -123,7 +123,7 @@
| U3 | GET | `/api/v1/personas` | 任意已认证 | 200 | 角色目录 `{data:[{id,name,character,isDefault,mode,memoryNamespace,avatar?,chatBackground?,mood}]}`。`mood` 为空 = 该角色暂无被跟踪情绪(不伪造)。**不含** admin 专属 `thinking` 旋钮。 |
| U4 | GET | `/api/v1/meta/enums` | 任意已认证 | 200 | 共享枚举权威表 `{data:{platforms,moods,modes,unknown}}`;每项 `{id,label}`。`unknown` 恒 `"display-raw"`。**未知取值原样显示,不要丢弃/报错**(服务端新增取值是非破坏性变更)。详见 [§4.7](#47-枚举权威表)。 |
| U5 | POST | `/api/v1/audio/transcribe` | **owner** | 200 / 400 / 500 | STT。body `{audio,format?,deviceId?}` → `{data:{text, provider?}}`。`provider` 只在结果为空/降级时出现(如 `"silent"`)。空 audio → `400`;未接语音 → `500 voice_unavailable`;识别失败 → `500 stt_failed`。**owner-only + 审计**。 |
| U6 | POST | `/api/v1/audio/speech` | **owner** | 200 / 400 / 500 | TTS。body `{text, voice?, format?, instruction?, sessionId?}` → **`200` 原始音频字节**(**非 JSON 信封**)。🔴 **v0.96 起发音人按 `sessionId` 的生效人格解析**(会话绑定 → 全局默认人格 → 空 ⇒ 配置的 `default`),**省略 `sessionId` 不等于没有角色**;请求体 `voice` 接受但不生效;产物恒 WAV,实际格式按字节嗅探(见 §6 v0.96)。空 text → `400`;未接语音 → `500 voice_unavailable`;失败 → `500 tts_failed`;人格无映射 → `500 tts_persona_unmapped`;连生效人格都没有且没配 default → `500 tts_no_character`;静默空音频 → `500 tts_empty` 且响应头 `X-Voice-Provider: silent`。**owner-only + 审计**。 |
| U6 | POST | `/api/v1/audio/speech` | **owner** | 200 / 400 / 500 | TTS。body `{text, voice?, format?, instruction?, sessionId?}` → **`200` 原始音频字节**(**非 JSON 信封**)。🔴 **鉴权 = 既有双凭据门(v0.97 明示)**:`Authorization: Bearer <owner JWT>` **或** `X-API-Key: <已签发 key>`,两分支同 `authorize(RoleOwner)`——Bearer 走完整 JWT 校验 + owner-only(**不因「带了 Bearer」就放行**);X-API-Key 一律 owner 级,未知/吊销 key 一律 `401` 且**不回落** Bearer(fail-closed);key 表未注入时只认 Bearer。无凭据/无效凭据 `401`,错误形状同 §4.1 信封。🔴 **v0.96 起发音人按 `sessionId` 的生效人格解析**(会话绑定 → 全局默认人格 → 空 ⇒ 配置的 `default`),**省略 `sessionId` 不等于没有角色**;请求体 `voice` 接受但不生效;产物恒 WAV,实际格式按字节嗅探(见 §6 v0.96)。空 text → `400`;未接语音 → `500 voice_unavailable`;失败 → `500 tts_failed`;人格无映射 → `500 tts_persona_unmapped`;连生效人格都没有且没配 default → `500 tts_no_character`;静默空音频 → `500 tts_empty` 且响应头 `X-Voice-Provider: silent`。**owner-only + 审计**。 |
| U7 | POST | `/api/v1/audio/voice-clone` | **owner** | 200 / 400 / 500 | 声音复刻建音色。body `{url,prefix?}` → `{data:{voiceId}}`。参考 URL **绝不进日志/审计**。空 url → `400`;失败 → `500 voice_clone_failed`。 |
| U8 | POST | `/api/v1/upload` | owner/admin | 201 / 400 / 413 / 415 / 500 | **图片上传**,`multipart/form-data` 字段 `file`(光栅图,**单文件字节 ≤ 5 MiB**,即 `maxUploadBytes = 5 << 20`)→ `{data:{url,size,mime,name}}`。`url` 是**绝对同源 URL**,可直接作 `chat.image`(该路径的 5 MiB 在**上传时**定死,与 `chat.image` 的字符长度判据无关,见 [§4.5](#45-图片--文档--语音链路))。嗅探裁决 png/jpeg/gif/webp/bmp(拒 SVG/HTML);原始文件名丢弃,用 128-bit 随机名。缺文件/空文件 `400`、超限 `413 payload_too_large`、非白名单 `415 unsupported_media_type`。**带审计**。 |
| U9 | GET | `/api/v1/files/{name}` | **无需**(能力 URL) | 200 / 404 | 上传文件读取。`name` 仅接受「32 位小写 hex + 白名单扩展名」;路径穿越全形态拒绝。**GET 不鉴权**是该 URL 为 128-bit 随机能力地址(`<img>`/provider 无法带 token);任何持有 URL 者可取,勿外泄。 |
@@ -375,7 +375,7 @@
{"type":"history","sessionId":"web","data":{"messages":[ <historyMessage> ... ]}}
```
- 内容 = 主会话最近 **50** 条(`wsReplayLimit`),`thinking` 恒缺席(见 §3.4.4 口径更正);每条**可能**带可选 `turnId`(v0.66,空则键省略)。🔴 **`voice` 不在握手回放里**(v0.67 登记时曾计划覆盖):v0.79 的 `voice` 只填在 4 个 REST 读取面 + `message_edited`,握手 `history` 回放**不带该键**(见 §6 v0.79 ③ / §4.4 / §7.2)。
- 内容 = 主会话最近 **50** 条(`wsReplayLimit`),`thinking` 恒缺席(见 §3.4.4 口径更正);每条**可能**带可选 `turnId`(v0.66,空则键省略)。✅ **`voice` 在握手回放里(v0.97 起,切片 M44)**:与 REST 读面同一收口点,形状与 §4.4 一致,非语音消息键缺席(v0.67~v0.96 期间曾长期缺席,那时的「不要在握手回放上等 `voice`」口径已作废)。
- **viewer 连接完成握手时完全不做回放**(其只读窗口是 REST 监控面)。
- 回放是尽力而为:存储未接或读出错静默跳过。
@@ -489,7 +489,7 @@
> **思维链的唯一出口是实时帧**:§3.4.2 的 `thinking.start` / `thinking.delta` / `thinking.end`(owner/admin 档)。客户端**不要**为「从历史里读回思维链」写任何代码。
> ⚠️ 本行**更正**本文档 v0.65 及更早的写法(旧版称「历史思维链只有 `GET /sessions/{id}/messages`(owner/admin)返回」)—— **那是文档写错了**,与实现不符。见 §7.3。
> 另注:管理面板人格清单里的 `thinking` 是**人格旋钮**(`GET /admin/personas` 的 `thinking` 块),与消息 DTO 的 `thinking` 不是同一个字段。
> **`voice` 覆盖同一批读取面(v0.67 登记;🔴 **v0.79 只落地了其中一部分**)**:消息 DTO 的可选 `voice` 对象**设计上**一次覆盖 `message_persisted` / `channel_persisted` / `message_edited` 与握手 `history` 回放(形状与语义见 §4.4)。🔴 **v0.79 实测覆盖**:只有 `message_edited`(与 §4.4 列出的 4 个 REST 读取面)会带它;**`message_persisted` / `channel_persisted` 与握手 `history` 回放仍不带 `voice`**(补齐需给 `history.Message` 加字段,**未做**)⇒ 客户端**不要在实时帧 / 握手回放上等 `voice`**。**WS 协议本身零变化**:无新帧、无新上行字段;`voice` 只是既有 DTO 的新可选键。
> **`voice` 覆盖同一批读取面(v0.67 登记;✅ **v0.79 落地 REST 面,v0.97 补齐 WS 面**)**:消息 DTO 的可选 `voice` 对象覆盖 `message_persisted` / `channel_persisted` / `message_edited` 与握手 `history` 回放(形状与语义见 §4.4)。🔴 **v0.97 起全部在读面上填充**:`message_edited` 与 4 个 REST 读取面自 v0.79 起;`message_persisted` / `channel_persisted` 帧与握手回放自 v0.97(切片 M44,落库顺序改为 INSERT→写列→notify,**首帧即带**)⇒ 客户端可以统一按「`voice` 键存在 = 语音消息」处理所有读面。**WS 协议本身零变化**:无新帧、无新上行字段;`voice` 只是既有 DTO 的可选键。
#### 3.4.5 子代理 / 任务 / 系统帧(owner+admin)
@@ -659,7 +659,7 @@
- 落库形状 = DTO 形状**减去** `url`(列 `chat_messages.voice TEXT NOT NULL DEFAULT ''`,JSON **单对象**;**用空串不用 NULL**,与 `turn_id` 同一口径)。音频原件**复用文档那一套存储 / 元数据表 / 引用计数 / GC**(同一 `<DataDir>/uploads/documents` 目录),但**不进文档的抽取 / 注入链路**:不被抽取、不进 `documents` DTO 数组、不占注入预算、不出现在附件清单行里。
- **一条消息只落一份音频副本**(= 被转写的那一条语音);同一条消息里的第 2..N 条语音既不转写也不落盘,DTO 里也没有它们(**刻意的边界**,不是遗漏)。
- ⚠️ **微信(`weixin-ilink`)语音没有音频副本、不可回放**:按既有裁决「平台自带转写就不白下载那份音频」⇒ `source:"platform"` 且**无 `name`/`mime`/`size`/`url`**,只保留 🎤 标记。
- ✅ **覆盖率(v0.79,如实)**:`voice` 已在**这 4 个读取面**填充 —— `GET /sessions/{id}/messages`、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`、`PATCH /messages/{id}` 的**响应与 `message_edited` 广播帧**。🔴 **尚未填充**:`message_persisted` / `channel_persisted`(`broadcast.go`)与握手 `history` 回放(`ws_history.go`)—— 需要给 `history.Message` 加 `voice` 字段(**未做**);且入站语音的 `voice` 列是**消息落库之后**回填的,`message_persisted` 那一帧在**落库那一刻**发出,即使加了字段也拿不到值。⇒ **客户端不要在实时帧上等 `voice`**(见 §7.2、§7.4-29)。
- ✅ **覆盖率(v0.97,如实)**:`voice` 已在**全部读面**填充 —— `GET /sessions/{id}/messages`、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`、`PATCH /messages/{id}` 的**响应与 `message_edited` 广播帧**(v0.79 起),以及 **`message_persisted` / `channel_persisted` 帧与握手 `history` 回放(v0.97,切片 M44)**。首帧即带的保证来自落库顺序 **INSERT → 写 `voice` 列 → notify**(两条实现前提见 §6 v0.97);帧内值是**本帧发出那一刻**的即时列读(每帧 +1 次读列、亚毫秒同步)。
- ⚠️ **列被外部改坏时可见地缺席**:非法 JSON / 闭集破坏时**不静默当「不是语音」** —— 记一条带来源与消息 id(**不含正文**)的 WARN,并让该字段缺席。契约的 `voice` 没有「读不出」这一态,因此缺席是唯一诚实的表达(与 `documents` 回一条 `unreadable` 不同)。
**`documents` 数组语义(核心)**:
@@ -713,10 +713,11 @@
- `/voice-clone`:`{url,prefix?}` → `{data:{voiceId}}`。⚠️ 真建音色需公网参考音频 URL(当前无,待主人提供)+ 北京地域 API Key。
- WS 语音回合(`voice_input`):**与文本回合走同一条路**(进编排器 → 落库 → 流式 → 跨端同步 → 忙碌/排队语义一致)。转写文本落库为用户消息、助手回复落库;`deviceId`/`clientMsgId` 与 `chat` 帧同形。
- `voice_output` 在**该回合结算时**才定向回发起连接(可能晚于若干状态帧,甚至由续跑回合或被 steer 注入的那一回合产生)。空正文 / TTS 失败/降级都走 `provider` 降级信号(`silent` 等),**不把 `stopReason` 当语音内容**;连接断开不补投,客户端带回同一 `clientMsgId` 可安全重试。
- **emotionTTS 自启(v0.97,切片 M47,默认关)**:后端启动时探活本机 emotionTTS,未起则异步拉起(失败只 WARN 不阻塞启动)。开关 = `$DATA_DIR/config/voice-tts-launch.json` 的 `{"enabled": true}`(面板配置文件、零新 `.env` 键;`probeUrl` 空 = `http://127.0.0.1:9880`,且**单向兜底**取 `voice-tts.json` 的 `baseUrl`)。⚠️ 快速 restart 两次会拉起两个 TTS 进程(跨进程去重不生效,记档不修)。见 §6 v0.97。
**入站语音的音频副本与播放端点(v0.67 登记;✅ **v0.79 已实现**,切片 L45;残留缺口见下)**:
> ✅ **v0.79 落地进展(切片 L45,如实登记)**:**已实现** —— ① `documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 同字节去重 / 目录 0700、**文件 0600** / 元数据写同一张 `documents` 表、引用计数并入同一套 `documents.ref_count`);② `history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列的**读写两条路**)⇒ channelbridge 的入站副本链**端到端可落地**;③ **网关 DTO `voice`(含唯一派生字段 `url`)** 与 **`GET /api/v1/media/{name}`**(§1.2 U19 / 下表)。🔴 **残留缺口(如实,不美化)**:① ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入** `VoiceTranscoder`(`cmd/cyrene/wire_channels.go` 的 `channelbridge.Options`)⇒ **不可播格式(`amr`)也落副本**(落盘前先转 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档。⚠️ **生产端到端仍未跑**;**可播格式(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍原样落、不转码**;② **WS 实时帧 / 握手回放不带 `voice`**(见 §3.4.4 与 §4.4 覆盖率);③ **消息 / 会话被删除时音频副本的引用计数不被释放**(`refs.go` 的增减点只认 `document` 列)⇒ 副本 `ref_count` **偏高**(后果是**晚回收 / 不回收**,绝不早删);④ **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上 0600 副本 → 客户端取到字节」只有替身 + 临时库/临时目录级证据。
> ✅ **v0.79 落地进展(切片 L45,如实登记)**:**已实现** —— ① `documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 同字节去重 / 目录 0700、**文件 0600** / 元数据写同一张 `documents` 表、引用计数并入同一套 `documents.ref_count`);② `history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列的**读写两条路**)⇒ channelbridge 的入站副本链**端到端可落地**;③ **网关 DTO `voice`(含唯一派生字段 `url`)** 与 **`GET /api/v1/media/{name}`**(§1.2 U19 / 下表)。🔴 **残留缺口(如实,不美化)**:① ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入** `VoiceTranscoder`(`cmd/cyrene/wire_channels.go` 的 `channelbridge.Options`)⇒ **不可播格式(`amr`)也落副本**(落盘前先转 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档。⚠️ **生产端到端仍未跑**;**可播格式(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍原样落、不转码**;② **WS 实时帧 / 握手回放不带 `voice`**(✅ **已由 v0.97(切片 M44)修复**,见 §3.4.4 与 §4.4 覆盖率);③ **消息 / 会话被删除时音频副本的引用计数不被释放**(`refs.go` 的增减点只认 `document` 列)⇒ 副本 `ref_count` **偏高**(后果是**晚回收 / 不回收**,绝不早删);④ **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上 0600 副本 → 客户端取到字节」只有替身 + 临时库/临时目录级证据。
> **v0.78 已落地的基础件(切片 CODE-VOICE;v0.79 已在其上补齐用户可见面)**:① 迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''` 列(JSON 单对象,形状 = 下面的 DTO `voice` **减去 `url`**);② `internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);③ `channelbridge` 入站语音「归一(最前端)→ 闸门**之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC,但**不进文档抽取/注入链路**)。唯一已生效且**客户端可见**的既有变化:IM 语音在「音频格式不在可转写白名单内」时正文诚实提示由「…(需要先转码,当前未做)」改为「…(服务端只把白名单内的音频送语音识别),这条语音没有转写。」—— **形状不变**(正文仍是转写文本或一行诚实提示;客户端本就**不该**按正文文案分支)。
@@ -1582,7 +1583,26 @@ curl -s -X POST -H "X-API-Key: $KEY" \
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
### v0.96(当前契约版本)
### v0.97(当前契约版本)
**「语音帧补全 + 上游错误出口收敛」版(M44 / M47 / M49 / M50;以纯增量为主,一处行为变更如实标注)**
- **`voice` 进 WS 实时帧与握手回放(M44,纯增量)**:`message_persisted` / `channel_persisted` 帧内的 `message` 对象、以及握手 `history` 回放的每条消息,现在携带可选 `voice`(形状与语义与 §4.4 的 DTO **完全一致**,同一收口点 `attachVoiceTo → historyVoiceOf`;非语音消息整键缺席)。🔴 **首帧就带**:入站语音的落库顺序已改为 **INSERT → 写 `voice` 列 → notify** ⇒ 这条语音消息的**第一条** `message_persisted` / `channel_persisted` 帧就带 `voice`,客户端不用等回读。
- 🔴 **两条实现前提(新写入路径必须保持,否则首帧拿不到值)**:① `voice` 列的写入必须**先于** notify;② 帧内 `voice` 是**本帧发出那一刻**的即时列读(每帧 +1 次读列,被写列同步阻塞,亚毫秒级),不是消息结构体的缓存字段。
- ✅ **本版改正正文中已成假话的「WS 实时帧 / 握手回放不带 `voice` ⇒ 客户端不要在实时帧上等 `voice`」**(§3.2 / §4.4 / §7.2 / §7.4-29 及 v0.79 / v0.80 版本横幅已加注)。安卓端为此做的容忍逻辑(回读兜底)可以撤了。
- ⚠️ **不在本批范围**:`voice_output`(语音回合的定向回帧)形状未动;删除消息 / 会话时音频副本引用计数不释放的旧缺口仍在(见 §7.2)。
- **emotionTTS 自启(M47,默认关,运维面)**:后端启动时探活本机 emotionTTS,未起则异步拉起(`一键启动.bat`),**失败只 WARN 不阻塞启动**;探活在线则不重复拉起。
- 开关 = `$DATA_DIR/config/voice-tts-launch.json` 的 `{"enabled": true}`(**面板配置文件,零新 `.env` 键**);键:`enabled`(bool,**缺省/文件缺失 = 不启用**)、`probeUrl`(探活地址,空 = `http://127.0.0.1:9880`)。
- `probeUrl` 为空时**单向兜底**取 `voice-tts.json` 的 `baseUrl`(只降级不回写,方向唯一)。非法探活地址 = 不启用(宁可不自启,不拉错目标)。
- ⚠️ **已知限制(记档不修)**:快速 restart 两次会拉起两个 TTS 进程(跨进程去重不生效——防得住本进程并发,防不住上一进程拉起的孤儿)。
- **上游错误出口「构造即安全」(M49 / M50 / M48 / M52;🔴 一处行为变更)**:`trace.UpstreamError` 构造函数本身就是清洗边界——`error.code` / `error.type` **固定过令牌守卫**(≤64 字节、仅 `[A-Za-z0-9_.-]`,构造后注入走 `SetCode`/`SetType` 同一道守卫);**上游响应体的任何字节都不进错误**(只留长度 + sha256 前缀,仍能做相关性比对)。
- 🔴 **行为变更(如实)**:上游错误的 `error.message` **默认缺席**(此前部分出口会带上游自由文本)。要它必须调用点显式 opt-in(`WithUpstreamMessage`),且仍过凭据打码 + 128 rune 截断。理由:`{"error":{"message":"Invalid value: <用户原文>"}}` 这类回显真实存在,默认带 = 把她的原话进日志。分类排障用 `status/code/type/bodyLen/sha256` 足够。
- `trace.URLCause` 统一剥 `*url.Error` 的「方法 + 地址段」因果链(**层数上限 8**,环不会栈溢出);全仓 19 处 `%v` 直打已清零。URL 查询串里的凭据从此不出现在错误出口。
- **契约形状零变化**:REST / WS 错误信封(`{error:{code,message}}`)的形状、错误码集合都没动;这是**内容脱敏口径**的收敛,客户端按既有信封编程即可。
- **同批已核实、无契约面变化的内部项**:M45 `temporal` 列只观测不判定(`IngestRuleVersion=d3.5`,`resolveRetention` 不读它);M51 MCP keepalive 日志面收口(redactingLogger);M46/M48 `URLCause` 五处收敛与防环。
- **U6 `/audio/speech` 鉴权双凭据明示(本版补写,既有能力非新增)**:该端点自诞生起挂的就是 `authorize(RoleOwner)` 双分支门——**owner Bearer JWT 与 `X-API-Key` 都能用**(与全部 owner 端点同一扇门,见 §1.2 U6 更新)。裁决 2026-10-26(主人选 B):Android 不内置 API Key 入口、用现有 owner JWT 调 TTS。⚠️ Bearer 路径的真机 curl 属「同门已验证」的结构性结论(`auth` 层 `WithAPIKey` 双分支有核心断言;`/audio/speech` 与聊天端点共用同一门),**独立真机 Bearer 样例待安卓首次接入时顺手确证**。
### v0.96
**「语音发音人绑人格 + 管理面脱敏投影」版(M35 / M42;纯增量,无破坏性变更)**
@@ -1837,7 +1857,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
- **③ 入站语音 AMR 也可回放(切片 W4;裁决 #11)**:组合根补齐 `channelbridge.Options.VoiceTranscoder`(`cmd/cyrene/wire_channels.go`)—— 注入的是 `wireTranscode` 装配的**同一个**引擎实例(与工具面 `transcode` 工具同源,不是第二条降级链)。⇒ **不可播格式的入站语音在落盘之前先转成 `.wav` 再落副本**(`mime: audio/wav`;本期唯一必需的转码路 = QQ OneBot v11 的 `amr → wav`),客户端的 `voice` 从此带副本事实(`name` / `mime` / `size` / `durationMs` / `url`);**此前这些键缺席**(走 `category=transcode_not_wired` 的诚实 WARN、不落副本)。**形状零变化**(DTO `voice` 的键集合与语义一字未改);**可播格式**(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍**原样落、一次转码都不发生**。稳定 `category` 列表**不变**(10 类),但 `transcode_not_wired` 在生产装配下**不再出现**(它现在只剩「引擎未装配 / 构造失败」这一档)。🔴 **本版改正 6 处已成假话的「组合根未注入 `VoiceTranscoder` ⇒ AMR 不落副本」**:§4.5(缺口① + 现状行)/ §7.1(对照注)/ §7.2(语音面行 ②)/ §7.4-16(前向提示)/ §7.4-29(②)/ 旧版本横幅与 §6 旧版条目(v0.67 / v0.78 / v0.79)。**§4.5 / §7.2 / §7.4-29**。
- **行为变更**:① 无(新端点;老端点逐字未改);② **有**(面板保存由「需重启生效」变为**下一轮即生效**;新增两个可配字段与两个错误码;`plugin.saveProvider` / `plugin.reauthProvider` 的响应形状**加键**);③ 无(DTO `voice` 的键集合与语义未变,只是**此前缺席的键现在会出现**)。
- **破坏性**:**无**(新端点 / 新可选键 / 新错误码;新增的响应键都是**加性**的,老客户端不读即行为不变)。
- ⚠️ **诚实边界**:① 时间轴的**前端渲染 / 真机面板联调**不在后端出口的证据内;`graph_json` 破损行的 `500` 路径**无测试**;游标精度到**毫秒**(同一毫秒起跑的两个不同轮次在极端情况下可能被一并跳过 —— 理论边界,已写进 §4.11.1(b)-4)。② 热重载的**真机面板端到端未跑**、**真机探针未打真实上游**;**启用态不跨重启持久化**。③ 语音**生产端到端未跑**(红线:不重启后端)⇒ 证据是 `channelbridge` 行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**;WS 实时帧 / 握手回放**仍不带 `voice`**;删除消息 / 会话时音频副本引用计数**仍不释放**。逐条见 **§7.2 / §7.3 / §7.4**。
- ⚠️ **诚实边界**:① 时间轴的**前端渲染 / 真机面板联调**不在后端出口的证据内;`graph_json` 破损行的 `500` 路径**无测试**;游标精度到**毫秒**(同一毫秒起跑的两个不同轮次在极端情况下可能被一并跳过 —— 理论边界,已写进 §4.11.1(b)-4)。② 热重载的**真机面板端到端未跑**、**真机探针未打真实上游**;**启用态不跨重启持久化**。③ 语音**生产端到端未跑**(红线:不重启后端)⇒ 证据是 `channelbridge` 行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**;WS 实时帧 / 握手回放**仍不带 `voice`** —— ✅ **已由 v0.97(切片 M44)修复**(首帧即带,见 §6 v0.97);删除消息 / 会话时音频副本引用计数**仍不释放**。逐条见 **§7.2 / §7.3 / §7.4**。
### v0.79(上一版)
@@ -1845,11 +1865,11 @@ curl -s -X POST -H "X-API-Key: $KEY" \
- **① 插件面板动作真可用(切片 L1/PLG-2)**:插件管理器新增**动作处理器登记面**(`Manager.RegisterActions` → `Panels.RegisterActions`,取代单一全局执行器;**声明仍是唯一闸门** —— 登记≠可执行);组合根在 `wirePanelHub` 里登记内置 `web-search` 的**四个动作 + 下拉候选来源**(`plugin.providers`)⇒ `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501** —— 真读写 `$DATA_DIR/config/plugins/web-search.json`(0600 + temp+rename,**重启仍生效**;**v0.80 起保存后即热重载**,见 §6 v0.80 ②)、探针**真发一次最小 HTTP 检索**。失败回**结构化非 2xx**(码/文案/状态由动作处理器给出,宿主只透传、状态越界回落 502):新增 `invalid_payload`(400) / `unknown_provider`(400) / `not_configured`(400) / `config_unreadable`(500) / `config_write_failed`(500),以及探针码 `unauthorized` / `rate_limited` / `quota_exceeded` / `network` / `upstream` / `bad_response` / `no_results` / `unknown`(502);**`501 action_not_implemented` 只在「已声明但宿主未登记处理器」时出现**(**不再是兜底档**);兜底执行失败仍是 `502 action_failed`。审计仍 `what="plugin.action"`,**服务端日志新增附加字段 `code`**(**不进 DB 记录体、不含请求体**)。**§2.1 A14 / §4.1 / §4.14**。
- **② 「入库前审查」(D3)的管理面(切片 L45)**:新增 2 条 `writeAuth`(owner\|admin)端点 —— `GET /api/v1/admin/memories/reviews?limit=`(留痕列表:`limit` 缺省 100、`≤0` 同缺省、>500 钳位、非整数 `400`;元素含 `decision ∈ skip\|merge\|modify\|create`、`mergedIds` 恒为非 null 数组、`createdAt` UTC RFC3339;🔴 **不含记忆正文**)与 `POST /api/v1/admin/memories/{id}/revert`(撤销一次**软合并**:被吸收行复原 `active`、从存活行 `merged_from` 摘除、`merge_count` 减 1;**行永不物理删除**)。未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**);回滚落审计新动词 **`memory.review.revert`**(只记 item id)。权限矩阵:`writeAuth` 14 → **16**、总数 68 → **70**。**§2.0 / §2.2 B16/B17 / §4.1**。
- **③ 语音消息的用户可见面(切片 L45)**:消息 DTO 的 **`voice`**(含服务端唯一派生的 `url`)在 **4 个 REST 读取面 + `message_edited`** 真实填充;新增 **`GET /api/v1/media/{name}`**(`writeAuth`;`audio/*` 内联 + `nosniff` + `Accept-Ranges` + `Cache-Control: private, max-age=86400, immutable`;**无 `ETag`**;`Range` ⇒ 206、条件请求 ⇒ 304、支持 `HEAD`、不可满足 `Range` ⇒ 416(**非 JSON 信封**);名字非法 / 穿越 / 缺失统一 `404 media_not_found`);副本落盘走新增 `documents.Store.SaveAudio`(内容寻址、0600、元数据进同一张 `documents` 表、并入同一套引用计数),`chat_messages.voice` 列读写两条路补齐(`history.Store.UpdateMessageVoice` / `VoiceColumns`)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`**(需给 `history.Message` 加字段,未做;且 `message_persisted` 在落库那一刻发出,天生拿不到落库后才回填的值);组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **该缺口已由 v0.80(切片 W4)修复**(组合根已注入,见 §6 v0.80 ③ / §4.5 / §7.2)。**§0.3 / §1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5**。
- **③ 语音消息的用户可见面(切片 L45)**:消息 DTO 的 **`voice`**(含服务端唯一派生的 `url`)在 **4 个 REST 读取面 + `message_edited`** 真实填充;新增 **`GET /api/v1/media/{name}`**(`writeAuth`;`audio/*` 内联 + `nosniff` + `Accept-Ranges` + `Cache-Control: private, max-age=86400, immutable`;**无 `ETag`**;`Range` ⇒ 206、条件请求 ⇒ 304、支持 `HEAD`、不可满足 `Range` ⇒ 416(**非 JSON 信封**);名字非法 / 穿越 / 缺失统一 `404 media_not_found`);副本落盘走新增 `documents.Store.SaveAudio`(内容寻址、0600、元数据进同一张 `documents` 表、并入同一套引用计数),`chat_messages.voice` 列读写两条路补齐(`history.Store.UpdateMessageVoice` / `VoiceColumns`)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`** —— ✅ **该缺口已由 v0.97(切片 M44)修复**(首帧即带,见 §6 v0.97);组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **该缺口已由 v0.80(切片 W4)修复**(组合根已注入,见 §6 v0.80 ③ / §4.5 / §7.2)。**§0.3 / §1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5**。
- **④ 🔴 链路图权威回合标识覆盖到全部编排回合(切片 CODE-TC-2)**:新增 `loop.FlowPublisher.StartRoundWithTurnID(ctx, owner, text)`(权威回合标识取自 ctx 的 `TurnIDFrom`),`run.go` 的**唯一生产建图点**改走它 ⇒ 编排器驱动的**每一路**(**普通单路回合**、队首续跑路、真并行路)的图都带与 `chat_messages.turn_id` **同值的 `turnId`** ⇒ 消息面 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{权威回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)—— 该键仍**不透明**、仍可能含 `#`;客户端**一律使用响应里的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`(自拼的老客户端**查不到新行**)。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍**整体缺席**(**绝不回空数组**);**v0.79 之前的单路历史行不回填**(`lane_turn_id` 为空且无可回填来源)。**§4.4 / §4.11 / §7.2 / §7.4-23**。
- **行为变更**:① **有**(面板动作由「全 501」变为**真执行**)+ 新增一批错误码;② 无(新端点;老客户端零影响);③ 无(`voice` 是既有 DTO 的可选键、`GET /media/{name}` 是新端点);④ **有(唯一一处形状变化)**:`graph.id` 对单路也追加后缀 ⇒ 自拼 id 的读取方须改用响应值(**其余形状与缺席语义一字未改**)。
- **破坏性**:**无**(新端点 / 新可选键 / 新错误码;④的形状变化只影响「自己拼 `graph.id`」的读取方,而本文档自 v0.73 起就写明该键**不透明、应从响应取**)。
- ⚠️ **诚实边界**:① 面板动作**热重载未做**(保存后运行中的 web-search 工具不换接线,需重启)—— ✅ **已由 v0.80(切片 W3)修复**(见 §6 v0.80 ②)、**探针只用 `httptest` 假上游**(未对真实上游连通,**仍未做**);② 回滚端点与 `design/23 §11` 的 owner-only 口径**有差异**(待裁决)、留痕列表**无分页游标**、`memory_ingest_reviews` 无 TTL/GC;③ 语音**生产端到端未跑**、WS 实时帧/握手回放不带 `voice`、AMR 不落副本(✅ **已由 v0.80 修复**)、删除消息/会话时音频副本引用计数不释放;④ 单路 `toolCalls[]` 的**HTTP 出口端到端未由本切片确证**(写侧已验,网关读侧由主会话补验)、`turns[]` 仍按**路**列条目、真机未验。逐条见 **§7.2 / §7.4**。
- ⚠️ **诚实边界**:① 面板动作**热重载未做**(保存后运行中的 web-search 工具不换接线,需重启)—— ✅ **已由 v0.80(切片 W3)修复**(见 §6 v0.80 ②)、**探针只用 `httptest` 假上游**(未对真实上游连通,**仍未做**);② 回滚端点与 `design/23 §11` 的 owner-only 口径**有差异**(待裁决)、留痕列表**无分页游标**、`memory_ingest_reviews` 无 TTL/GC;③ 语音**生产端到端未跑**、WS 实时帧/握手回放不带 `voice`(✅ **已由 v0.97(切片 M44)修复**)、AMR 不落副本(✅ **已由 v0.80 修复**)、删除消息/会话时音频副本引用计数不释放;④ 单路 `toolCalls[]` 的**HTTP 出口端到端未由本切片确证**(写侧已验,网关读侧由主会话补验)、`turns[]` 仍按**路**列条目、真机未验。逐条见 **§7.2 / §7.4**。
### v0.78(上一版)
@@ -1860,10 +1880,10 @@ curl -s -X POST -H "X-API-Key: $KEY" \
- **③ 搜索 provider 可变源(切片 PLG-4)**:插件 `web-search` 新增可变源 `$DATA_DIR/config/plugins/web-search.json`(**0600**、temp+rename、文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = 四个动作 `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider`(表格行 `{provider,state,enabled}`,`state ∈ {unknown,disabled,not_configured,ok}`;`testProvider` 的 `errorCode` 闭集十项,⚠️ **`no_results` 时「空结果 ≠ 不通」**)。🔴 **不新增任何 env 键**(`.env` 已冻结);**`.env` 的 `WEB_SEARCH_*` 自此只在文件缺失时生效**(运维视角的行为变更,如实登记)。**§4.14(c) / §5**。
- **④ 账号上下线落事件 + 渠道事件实时帧(切片 E7)**:`internal/runtime/events` 增「账号状态迁移」这一路 —— 三态归一(**未知也照常落行**,不整类丢弃)→ 落一行 `tier=t2` / `category=lifecycle`(**复用迁移 `0021`**)→ 经**既有进程内 bus** 的主题 `channel.event` 推成 WS 下行帧 **`channel_event`**(`data.event` 与 REST `events[]` **同一个对象**;⚠️ **实时帧的 `id` 键可能整缺**;**只发 owner/admin**,**viewer 走 REST 只读档**)。采样常驻(约 60s 一拍,**不看面板也在跑**)。⚠️ **当前只覆盖账号状态迁移这一路**(其余渠道事件的实时装饰器**未接线**,见 §7.2);采样通道目前唯一来源是 `qq-obv11` 的探活。**§3.4.5**。
- **⑤ 微信 iLink 凭据三态(切片 CODE-F3)**:`GET /api/v1/admin/channels/{platform}/login-status` 新增**可选** `authState` / `authDetail`(`"active"` 有效 / `"rejected"` **被拒且需重扫** / `"unknown"` 未知;**闭集**)。🔴 **文案口径写死**:**`errcode=-14` 是「被拒」不是「过期」**、**未知原因不许写成「过期」/「未连接」**(响应体全文由测试钉住)。**键缺席 = 该账号没有这条状态面**,≠ `unknown`。**无新端点、无新路由**(沿用 v0.39 起的端点)。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。**§2.4(c)**。
- **⑥ 语音消息落库面(切片 CODE-VOICE)**:迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''`(JSON 单对象,形状 = DTO `voice` 减去 `url`;**刻意不建索引**);`internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);`channelbridge` 入站语音「归一 → **闸门之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC、**不进文档抽取/注入链路**)。⚠️ **用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);**残留缺口**(WS 实时帧/握手回放不带 `voice`;AMR 那一项**已由 v0.80 修复**)见 §4.5 / §7.2。唯一已生效的**客户端可见**变化:IM 语音在「格式不在可转写白名单内」时正文诚实提示改口径(**形状不变**,客户端本就不该按正文文案分支)。**§4.5 / §7.2**。
- **⑥ 语音消息落库面(切片 CODE-VOICE)**:迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''`(JSON 单对象,形状 = DTO `voice` 减去 `url`;**刻意不建索引**);`internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);`channelbridge` 入站语音「归一 → **闸门之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC、**不进文档抽取/注入链路**)。⚠️ **用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);**残留缺口**(WS 实时帧/握手回放不带 `voice`——**已由 v0.97(切片 M44)修复**;AMR 那一项**已由 v0.80 修复**)见 §4.5 / §7.2。唯一已生效的**客户端可见**变化:IM 语音在「格式不在可转写白名单内」时正文诚实提示改口径(**形状不变**,客户端本就不该按正文文案分支)。**§4.5 / §7.2**。
- **行为变更**:① 无(新可选键 + 新迁移列/索引);② 无(新端点 / 新帧 / 新可选键;老客户端零影响)—— ⚠️ 但**新增了 `502` 状态码**(仅插件动作执行器失败路径)与一批插件动作错误码;③ **有**(运维视角:`.env` 的 `WEB_SEARCH_*` 仅在可变源文件缺失时生效);④ 无(新帧 + 新事件行;REST 形状未变);⑤ 无(两个可选键;`status`/`botId`/`botToken`/`err` 与 v0.39 起逐字一致);⑥ **有(轻微)**:IM 语音「不可转写格式」的正文诚实提示**改口径**(**形状不变**)。
- **破坏性**:**无**。
- ⚠️ **诚实边界**:① `toolCalls[]` **v0.78 时覆盖率只到真并行路**(普通单路回合查不到图 ⇒ 缺席)—— **已由 v0.79 补齐**(见 §6 v0.79 ④),且**未做真机端到端**(真并行路的 `lane_turn_id` 与 `chat_messages.turn_id` 逐字相等仍是**代码级**推论);② 插件动作**生产无执行器**(全回 501)—— **已由 v0.79 接线**(见 §6 v0.79 ①)、热装插件的声明来源缺、`panels_changed` 对 viewer 不可见、启用态**不跨重启持久化**、审计持久化记录**未含 pluginId/action**(都在 §7.2);③ `web-search` 声明已就绪但**「面板加一家 provider」未做真机端到端**;④ `channel_event` **只覆盖账号状态迁移这一路**(组合根装饰器未接线),且**未做真机端到端**;⑤ iLink `rejected` 分支**只有单测/httptest 证据**(真实 `errcode=-14` 至今 0 次观测);⑥ 语音**生产端到端未跑** —— **用户可见面已由 v0.79 落地**(见 §6 v0.79 ③),但 WS 帧/握手回放仍不带 `voice`;AMR 不落副本**已由 v0.80 修复**(见 §6 v0.80 ③)。
- ⚠️ **诚实边界**:① `toolCalls[]` **v0.78 时覆盖率只到真并行路**(普通单路回合查不到图 ⇒ 缺席)—— **已由 v0.79 补齐**(见 §6 v0.79 ④),且**未做真机端到端**(真并行路的 `lane_turn_id` 与 `chat_messages.turn_id` 逐字相等仍是**代码级**推论);② 插件动作**生产无执行器**(全回 501)—— **已由 v0.79 接线**(见 §6 v0.79 ①)、热装插件的声明来源缺、`panels_changed` 对 viewer 不可见、启用态**不跨重启持久化**、审计持久化记录**未含 pluginId/action**(都在 §7.2);③ `web-search` 声明已就绪但**「面板加一家 provider」未做真机端到端**;④ `channel_event` **只覆盖账号状态迁移这一路**(组合根装饰器未接线),且**未做真机端到端**;⑤ iLink `rejected` 分支**只有单测/httptest 证据**(真实 `errcode=-14` 至今 0 次观测);⑥ 语音**生产端到端未跑** —— **用户可见面已由 v0.79 落地**(见 §6 v0.79 ③),但 WS 帧/握手回放仍不带 `voice`(✅ **已由 v0.97(切片 M44)修复**);AMR 不落副本**已由 v0.80 修复**(见 §6 v0.80 ③)。
### v0.77(上一版)
@@ -1993,11 +2013,11 @@ curl -s -X POST -H "X-API-Key: $KEY" \
**三批已在设计文档定稿的新增面一次性登记**:语音消息(播放端点 + 消息 DTO `voice` + 音频副本口径)、非消息事件查询面、账号在线状态(依据 `design/24 §10.11`、`design/26 §4.5` / `§2.6`)。登记时三批**都还没有实现**;其中**账号在线状态**(`52dac45`)与**非消息事件查询面**(本切片 E6)**已落地**(见下)。
> ⚠️ **本版是「契约先行」登记**:登记的三批里,**账号在线状态**(见 §2.4(b))与**非消息事件查询面**(见 §2.4(a))**已落地**;**语音消息**当时未实现,那一批的形状**取自设计文档而非代码核对** —— **v0.79 已把用户可见面落地**(切片 L45,见 §6 v0.79 ③ / §4.5 / §7.2),**残留缺口**(WS 实时帧/握手回放不带 `voice`;AMR 的那一项**已由 v0.80 修复**)仍按「未确证/未实现」读。
> ⚠️ **本版是「契约先行」登记**:登记的三批里,**账号在线状态**(见 §2.4(b))与**非消息事件查询面**(见 §2.4(a))**已落地**;**语音消息**当时未实现,那一批的形状**取自设计文档而非代码核对** —— **v0.79 已把用户可见面落地**(切片 L45,见 §6 v0.79 ③ / §4.5 / §7.2),**残留缺口**(WS 实时帧/握手回放不带 `voice`——**已由 v0.97(切片 M44)修复**;AMR 的那一项**已由 v0.80 修复**)仍按「未确证/未实现」读。
- **纯增量**:
① 新端点 `GET /api/v1/media/{name}`(用户侧,**owner/admin 必须 Bearer**):`200` 字节流 / `206 Partial Content` / `304 Not Modified`;`Content-Type: audio/*`;`Content-Disposition: inline; filename="<name>"`;`nosniff`;`Accept-Ranges: bytes`;`Cache-Control: private, max-age=86400, immutable`;支持 `Range` 与 `HEAD`;`404 media_not_found`;`401`/`403` 由中间件给出。⚠️ **`416` 是标准库直写的非 JSON 响应**(见 §0.3、§4.1、§4.5)。
② 消息 DTO 增**可选** `voice` 对象(`omitempty`;**非语音消息键缺席**):`{source,status,name?,mime?,size?,durationMs?,url?}`;`source` ∈ `asr`/`platform`,`status` ∈ `transcribed`/`empty`/`failed`,`name`/`mime`/`size`/`url` **四者同生共死**(有副本时才在);`url` 是服务端相对路径 `/api/v1/media/<name>`。**🎤 判据 = `voice` 键存在**。⚠️ **v0.79 校正(实测覆盖)**:真正会带 `voice` 的读取面**只有** `GET /sessions/{id}/messages`、`GET /admin/channels[/{platform}]/messages`、`PATCH /messages/{id}` 响应与 `message_edited`;**`message_persisted` / `channel_persisted` 与握手 `history` 回放不带**(见 §3.2、§3.4.4、§4.4 覆盖率的「尚未填充」)。
② 消息 DTO 增**可选** `voice` 对象(`omitempty`;**非语音消息键缺席**):`{source,status,name?,mime?,size?,durationMs?,url?}`;`source` ∈ `asr`/`platform`,`status` ∈ `transcribed`/`empty`/`failed`,`name`/`mime`/`size`/`url` **四者同生共死**(有副本时才在);`url` 是服务端相对路径 `/api/v1/media/<name>`。**🎤 判据 = `voice` 键存在**。⚠️ **v0.79 校正(实测覆盖)**:真正会带 `voice` 的读取面**只有** `GET /sessions/{id}/messages`、`GET /admin/channels[/{platform}]/messages`、`PATCH /messages/{id}` 响应与 `message_edited`;**`message_persisted` / `channel_persisted` 与握手 `history` 回放不带**(✅ **已由 v0.97(切片 M44)补齐**,见 §3.2、§3.4.4、§4.4 覆盖率)。
③ 新错误码 **`media_not_found`(404)** → 加进 §4.1 的 404 行(与 `document_not_found` 并列;**刻意不复用**它,否则客户端分不清缺的是文档还是语音)。
④ 新错误形态 **`416`**(**非信封**,标准库直写)→ §0.3 / §4.1 / §4.5。
⑤ 新 admin 端点 **`GET /api/v1/admin/channels/events`**(非消息事件列表,倒序;`?platform=&tier=&category=&channelId=&since=&until=&limit=&offset=`;响应 `{data:{events:[…],total}}`;`since`/`until` 按 UTC ISO-8601 定宽字符串**字典序**比较、**跨度上限 90 天**;不含正文、不含媒体 URL、不含任何凭据 / `flag`)—— §2.4(a)。**已落地**(`owner|admin` 档;元素键名 camelCase,逐字见 §2.4(a);未给窗口 ⇒ 不注入窗口)。
@@ -2092,7 +2112,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
### 7.1 在飞切片
**v0.66 及更早登记的在飞项已全部冻结、客户端可直接依赖**(下表)。⚠️ **v0.67 新登记的三批中,账号在线状态(`accounts[].accountOnline`)与非消息事件查询面都已落地**(见 §7.2);**语音消息的用户可见面已由 v0.79 落地**(仍残留 WS 帧/握手回放不带 `voice`;AMR 不落副本**已由 v0.80 修复** —— 见 §7.2 / §7.4-29),**不在本表**,逐条见 §7.2。
**v0.66 及更早登记的在飞项已全部冻结、客户端可直接依赖**(下表)。⚠️ **v0.67 新登记的三批中,账号在线状态(`accounts[].accountOnline`)与非消息事件查询面都已落地**(见 §7.2);**语音消息的用户可见面已由 v0.79 落地**(WS 帧/握手回放不带 `voice` 的缺口**已由 v0.97(切片 M44)修复**;AMR 不落副本**已由 v0.80 修复** —— 见 §7.2 / §7.4-29),**不在本表**,逐条见 §7.2。
| 项 | 状态 | 冻结位置 |
|---|---|---|
@@ -2117,7 +2137,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
| 项 | 现状 |
|---|---|
| **✅ v0.67 登记的「语音消息面」**(`GET /api/v1/media/{name}` + 消息 DTO `voice` + 入站语音音频副本落盘) | **用户可见面已实现(v0.79,切片 L45)**:端点 / 字段 / 副本口径见 §1.2 U19、§4.4、§4.5。落地:`documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 0600)、`history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列读写两条路)、网关 DTO `voice`(含唯一派生 `url`)与 `GET /api/v1/media/{name}`。🔴 **残留缺口(如实)**:① **`message_persisted` / `channel_persisted` 帧与握手 `history` 回放仍不带 `voice`**(需给 `history.Message` 加字段,**未做**;且 `message_persisted` 在落库那一刻发出,天生拿不到落库后才回填的值)⇒ **客户端不要在实时帧/握手回放上等 `voice`**;② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ **AMR 也落副本**(落盘前转成 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档(**生产装配下不再出现**);③ **消息/会话被删除时音频副本引用计数不被释放**(`refs.go` 只认 `document` 列 ⇒ `ref_count` 偏高,后果是**晚回收**,绝不早删);④ **生产端到端未跑**(未重启后端)。⚠️ 同批的**口径变更**(`/admin/system/storage` 的 `documents` 段含语音副本)仍**尚未生效**;**已生效**的是 IM 语音「不可转写格式」正文提示改口径(**形状不变**,见 §4.5)。权威设计 `design/24 §10` |
| **✅ v0.67 登记的「语音消息面」**(`GET /api/v1/media/{name}` + 消息 DTO `voice` + 入站语音音频副本落盘) | **用户可见面已实现(v0.79,切片 L45)**:端点 / 字段 / 副本口径见 §1.2 U19、§4.4、§4.5。落地:`documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 0600)、`history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列读写两条路)、网关 DTO `voice`(含唯一派生 `url`)与 `GET /api/v1/media/{name}`。🔴 **残留缺口(如实)**:① ✅ **已修(v0.97,切片 M44)**:**`message_persisted` / `channel_persisted` 帧与握手 `history` 回放已带 `voice`**(落库顺序改 INSERT→写列→notify,**首帧即带**,见 §6 v0.97);② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ **AMR 也落副本**(落盘前转成 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档(**生产装配下不再出现**);③ **消息/会话被删除时音频副本引用计数不被释放**(`refs.go` 只认 `document` 列 ⇒ `ref_count` 偏高,后果是**晚回收**,绝不早删);④ ⚠️ **`weixin-ilink` 语音无副本是刻意的**(平台自带转写就不白下载音频),不受 M44 影响。⚠️ 同批的**口径变更**(`/admin/system/storage` 的 `documents` 段含语音副本)仍**尚未生效**;**已生效**的是 IM 语音「不可转写格式」正文提示改口径(**形状不变**,见 §4.5)。权威设计 `design/24 §10` |
| **✅ v0.67 登记的「非消息事件查询面」**(`GET /admin/channels/events`、`GET /admin/channels/events/counters`) | **已实现**(v0.67 登记项本切片落地):形状与错误码见 §2.4(a)(已按代码核对:元素键名 camelCase、`approx` 与 `counters` 同级、`400 bad_request`、`owner\|admin` 档);权威设计 `design/26 §4.5`。未接线存储 ⇒ `500 channel_events_unavailable`。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表并回填 §2.1/§2.2 的端点计数 |
| **✅ v0.67 登记的「账号在线状态」**(`GET /admin/channels` 的 `accounts[].accountOnline`) | **已实现**(v0.67 登记项本次落地;落地前基线 `f91a83f`,本切片 commit 由主会话补记):形状见 §2.4(b)。管理面按**可选接口断言**读 `AccountStatus()`(`plugins/channels/qq_obv11/probe.go`,`a79fe96`);适配器**没有**该状态面、或实现了但 `known=false` ⇒ **键缺席**(未知 ≠ 离线,绝不用 `false` 冒充)。当前只有 `qq-obv11` 会带该键。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表 |
| **✅ P1-T:`directMsgPerMin` 的 gateway 写入路径**(`GET /admin/channels` 的 `accounts[].directMsgPerMin` + `PUT .../config` 的顶层 `directMsgPerMin`) | **已实现**(v0.69):缺口 = gateway 面没让该字段通过 ⇒ **面板保存一次渠道配置就把主人手配的「关闭流控(-1)」静默重置回 `0` = 保护性默认 6 条/分**。现 5 处透传(`AdminChannelAccount` DTO / `channelConfigRequest` / `config.ChannelAccount` 拷贝 / `adminChannelAccountOf` / 微信扫码重建账号)。形状见 §2.2 A5 与 §2.3 C28 注 |
@@ -2161,7 +2181,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
- **账号级限流字段是「值语义」,不是三态(v0.69 明确)**:`groupMsgPerMin`/`msgPerMin`/`maxMessages`/`directMsgPerMin` 在请求体里**省略即 `0`(未设置)**—— 一次「只改 token」的 PUT 若不带这四个键,会把已设限额(含 `directMsgPerMin:-1`)重置为 `0`。要保住就必须回送(面板已如此做)。这是 v0.68 起既有的顶层字段口径,v0.69 **未改**。
- **后台思考行的实时帧只带记录里的字段(v0.70 边界)**:`urgency` 在实时帧上一定是**精确档位**(记录落库值),`urgencyLowerBound` / `quietHoursBypassed` **不在记录里**、因此实时帧不带 —— 它们由网关读历史时按安静时段窗口推导(见 §4.10(b))。⇒ 实时行的「穿透安静时段」标记要等重开页面/历史拉取才出现,**这不等于**「这一轮没有穿透」。
- **`toolCalls` 只在 `GET /sessions/{id}/messages` 填充(v0.78 边界;覆盖率 v0.79 已补齐)**:共享 DTO 上的新键,但**只有该 handler** 会填它;其余出口(`PATCH /messages/{id}` 响应、`GET /admin/channels[/{platform}]/messages`、`message_persisted` / `channel_persisted` / `message_edited`、握手 `history` 回放)**逐字不变**(有单测钉住)。⚠️ **v0.78 时覆盖率只到真并行路** —— **已由 v0.79(切片 CODE-TC-2)补齐至全部编排回合**;仍缺席的是后台思考会话与 v0.79 之前的单路历史行(见 §4.4 / §7.2)。
- **`voice` 只在 4 个 REST 读取面 + `message_edited` 填充(v0.79 边界)**:**`message_persisted` / `channel_persisted` 帧与握手 `history` 回放仍不带 `voice`**(需给 `history.Message` 加字段,未做);且入站语音的 `voice` 列是**落库之后**回填的 ⇒ 即使加了字段,`message_persisted`(在落库那一刻发出)也拿不到值。**客户端不要在实时帧 / 握手回放上等 `voice`**(见 §3.4.4 / §4.4 / §4.5)。
- **`voice` 已在全部读面填充(v0.97 边界)**:**`message_persisted` / `channel_persisted` 帧与握手 `history` 回放自 v0.97(切片 M44)带 `voice`,且首帧即带**(落库顺序 INSERT→写列→notify;即时列读,每帧 +1 次读列、亚毫秒)。`voice_output` 定向回帧不在该批,形状未动(见 §3.4.4 / §4.4 / §4.5)。
- **`memory.review.revert` 是新审计动词(v0.79)**:`design/23 §16.4` 的 `memory.*` 表里还没有它(需设计侧补记);回滚**只记 item id、不记正文**。
- **`web-search` 的「`.env` 只播种」(v0.78 行为变更)**:可变源文件 `$DATA_DIR/config/plugins/web-search.json` 一旦落盘,`.env` 的 `WEB_SEARCH_*` **只在文件缺失时**起作用 ⇒ **改 `.env` 不再影响运行中的部署**(这是 design/09 §5.7 E 要的「`.env` 只播种、面板是配置面」,但运维视角上是行为变更)。🔴 **不是** env 键新增/删除。
- **`channel_event` 是 best-effort 增量提示、且当前只覆盖一路(v0.78 边界)**:帧可能在 hub 发送缓冲满时**丢掉该连接的那一帧** ⇒ 客户端**必须**保留 REST 轮询兜底(以轮询结果为准重排)。且目前**只有账号状态迁移**会产帧(其余渠道事件的实时装饰器**未接线**,见 §7.2);帧里那行的 `id` **可能整缺**(刚落库、id 尚未回读)。
@@ -2215,7 +2235,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
28. **(v0.78 登记)渠道事件实时面未做真机端到端**:`channel_event` 的「采样 → 落行 → 推帧 → 只投 owner/admin」由**真 SQLite + 真 bus + 真 WS 连接**的单测/端到端用例覆盖,但**运行中的 `cyrene.exe` 未重启**(红线:主会话换产物)⇒ **没有真机日志证据**。且当前 QQ 已停连 ⇒ 若真机启动,`qq-obv11` 账号为 `known=false`,首拍会落一行 `账号状态未知(尚未探到)`。另:**心跳通道(`meta_event.heartbeat`/`lifecycle`)驱动的迁移仍未接**(需适配器把状态投影进状态面,属 `plugins/**`),当前**唯一**迁移来源是探活(`qq_obv11/probe.go`)。
29. **(v0.78 登记 · v0.79 更新)语音的端到端未确证**:`internal/runtime/voice/**` 与 `channelbridge` 入站副本链有**真 `transcode.Engine`**(真 AMR 夹具 ⇒ RIFF/WAVE + 时长可确证)的走通用例;**v0.79** 补齐了网关侧(`media_test.go` 用真 `httptest` 真发 `GET`/`Range`/`HEAD`/条件请求,断言响应头与字节逐字相符 ⇒ **端点可用是「真跑过」的**),并新增 `documents.SaveAudio`(含 0600 断言)+ `history` 的 `voice` 列读写。⚠️ **仍未确证**:① **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上有 0600 副本 → 客户端取到字节」只有**替身 + 临时库/临时目录**级证据;② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ AMR 转 `.wav` 后落副本 ⇒ 「Web 上能点开听 **AMR** 语音」的**接线**已成立(① 的**生产端到端仍未跑**,未重启后端);③ **`message_persisted` / `channel_persisted` / 握手 `history` 回放不带 `voice`**(见 §4.4 / §7.3);④ `amr` / `flac` / `opus` 在部分浏览器内核上**可播放性未逐一实测**(判据是设计登记的 MDN 规范级结论);⑤ **删除消息/会话时音频副本引用计数不释放**(`ref_count` 偏高 ⇒ 晚回收,绝不早删)。`weixin-ilink` 语音**无副本、不可回放**是**刻意**的(平台自带转写就不白下载音频)。
29. **(v0.78 登记 · v0.79 更新)语音的端到端未确证**:`internal/runtime/voice/**` 与 `channelbridge` 入站副本链有**真 `transcode.Engine`**(真 AMR 夹具 ⇒ RIFF/WAVE + 时长可确证)的走通用例;**v0.79** 补齐了网关侧(`media_test.go` 用真 `httptest` 真发 `GET`/`Range`/`HEAD`/条件请求,断言响应头与字节逐字相符 ⇒ **端点可用是「真跑过」的**),并新增 `documents.SaveAudio`(含 0600 断言)+ `history` 的 `voice` 列读写。⚠️ **仍未确证**:① **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上有 0600 副本 → 客户端取到字节」只有**替身 + 临时库/临时目录**级证据;② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ AMR 转 `.wav` 后落副本 ⇒ 「Web 上能点开听 **AMR** 语音」的**接线**已成立(① 的**生产端到端仍未跑**,未重启后端);③ ✅ **已修(v0.97,切片 M44)**:`message_persisted` / `channel_persisted` / 握手 `history` 回放**已带 `voice` 且首帧即带**(见 §4.4 / §6 v0.97 / §7.3);④ `amr` / `flac` / `opus` 在部分浏览器内核上**可播放性未逐一实测**(判据是设计登记的 MDN 规范级结论);⑤ **删除消息/会话时音频副本引用计数不释放**(`ref_count` 偏高 ⇒ 晚回收,绝不早删)。`weixin-ilink` 语音**无副本、不可回放**是**刻意**的(平台自带转写就不白下载音频)。
30. **(v0.82 登记)`tool_execution.end` 的 `resultSummary` 未做真机端到端**:证据止于**测试进程内**的「真 loop + 真工具 + 真 tracer/flow 图 → 真 bus → 真 relay → 真 hub → **真 WS 连接**」帧(`internal/services/gateway/ws_tool_result_summary_test.go` 用真 `httptest` + `websocket.Dialer` 读回帧),以及 loop 侧「帧上摘要是**从真图快照反读**的同一份文本」的同源钉(`TestRun_ToolEndFrameSummaryMatchesGraphResultSummary`)。⚠️ **仍未确证**:① 对 `:10942` 上的发布产物发一条**真会调工具**的消息、看客户端收到的 `.end` 帧是否带该键(换产物前不可能验);② **失败路径**上「实时帧的错误摘要」与「回看面的图上 `error` 摘要」是否**逐字一致**(同源链是代码级结论:两侧都过 `trace.RedactText`,但没有真机样例);③ 真机上「一次调用拿到超长结果」时帧上那份摘要的长度(代码级是 `RedactText` 的 ≤80 rune;测试造的是 >500 的假值,用来钉网关那道 ≤500 的兜底上限)。**为什么验不了**:本波禁换产物 / 禁重启后端(红线:后端常驻)。
@@ -2223,4 +2243,4 @@ curl -s -X POST -H "X-API-Key: $KEY" \
---
*本文件由「以代码为真源」维护(当前 `v0.82`;`v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` / `v0.70` / `v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.82`(三面 —— Y1 `resultSummary` / Y3 自排成本可见面(`?trigger=` + `/admin/backthink/self-wake`)/ Y4 `browser` 面板与六个动作)同步了 `internal/runtime/model/stream.go`、`internal/runtime/loop/**`(`tools.go` / `tool_frame_summary.go` / `tool_frame_summary_test.go`)、`internal/services/gateway/**`(`ws_bridge.go` / `ws_tool_result_summary_test.go` / `admin_backthink_filter.go` / `admin_backthink_filter_test.go` / `admin_backthink_selfwake.go` / `admin_query.go` / `routes.go`)、`internal/services/backthink/**`(`backthink.go` / `store.go` / `trigger.go` / `trigger_domain.go` / `selfwake_status.go` 及其测试)、`internal/config/runtimeconfig/backthink.go`、`plugins/tools/browser/**`(`panel.go` / `paneldata.go` / `attach.go` / `tool.go` 及测试)、`internal/capability/browser/**`(`state.go` / `manager.go`)、`internal/runtime/multimodal/vision/**`(`shot.go` / `resolver.go`)、`cmd/cyrene/**`(`wire_browser_panel.go` / `wire_browser_panel_data.go` / `wire_browser_context.go` 及测试)`frontend/web/src/admin/modules/Backthink.tsx`、`frontend/web/src/admin/plugin/nav.ts`、`frontend/web/src/lib/api/adminSystem.ts` 与本文件(§2.0 / §2.1 A13 / §2.2 B8·B19 / §3.4.2 / §4.10(d)(e) / §4.14(a)(d) / §6 / §7.1 / §7.2 / §7.4);`v0.81`(自排轮可见面 + 时间轴 `retentionBoundaryReached` + `round` 跨重启单调)同步了 `internal/services/backthink/**`(`trigger.go` / `selfwake*.go` / `store.go`)、`internal/runtime/flowgraph/**`(`timeline.go` / `timeline_test.go` / `flowgraph.go`)、`internal/services/gateway/**`(`admin_flow_timeline.go` 及测试 / `routes.go`)与本文件(§0.1 / §2.2 B18 / §4.10(d) / §4.11.1 / §6 / §7.1);`v0.80`(三批 —— W1 链路图时间轴出口 / W3 搜索 provider 热重载 + 主 provider·降级链 / W4 入站语音 AMR 转码落副本)同步了 `internal/runtime/flowgraph/**`(`timeline.go` / `timeline_test.go`)、`internal/services/gateway/**`(`admin_flow_timeline.go` / `admin_flow_timeline_test.go` / `history_toolcalls_gap_test.go` / `routes.go`)、`internal/capability/registry/**`(`registry.go` / `replacetool_test.go`)、`plugins/tools/io/search/**`(`panel.go` / `panel_test.go`)、`cmd/cyrene/**`(`websearch_reload.go` / `websearch_reload_test.go` / `websearch_selection.go` / `websearch_selection_test.go` / `websearch_testenv_test.go` / `websearch_action_data.go` / `wire_io_tools.go` / `wire_plugin_actions.go` / `wire_channels.go` / `wire_channels_test.go`)与本文件(§2.0 / §2.2 B18 / §4.1 / §4.5 / §4.11.1 / §4.14 / §6 / §7.1 / §7.2 / §7.3 / §7.4);`v0.79`(四批 —— L1/PLG-2 插件动作执行面接线 / L45 入库前审查管理面 / L45 语音用户可见面 / CODE-TC-2 链路图回合标识覆盖全部编排回合)同步了 `internal/capability/plugin/**`(`actions.go` / `panelactions.go` / `panels.go`)、`internal/services/gateway/**`(`admin_panels.go` / `admin_memory_review.go` / `history.go` / `history_write.go` / `history_voice.go` / `media.go` / `routes.go`)、`internal/runtime/voice/media.go`、`internal/runtime/documents/store_audio.go`、`internal/runtime/history/voice_column.go`、`internal/runtime/loop/**`(`flow.go` / `run.go`)、`internal/runtime/trace/graph.go`、`internal/runtime/flowgraph/flowgraph.go`、`plugins/tools/io/search/**`(`paneldata.go` / `probe.go`)、`cmd/cyrene/**`(`wire_plugin_actions.go` / `websearch_action_data.go` / `websearch_probe.go` / `wire_frontend.go`)与本文件(§0.3 / §1.2 U19 / §2.0 / §2.1 A13-A14 / §2.2 B16-B17 / §3.4.4 / §4.1 / §4.4 / §4.5 / §4.11 / §4.14 / §6 / §7.2 / §7.3 / §7.4);`v0.78`(六面 —— CODE-TC `toolCalls[]` / PLG-2 插件面板接入点 / PLG-4 搜索 provider 可变源 / E7 账号状态事件与 `channel_event` / CODE-F3 iLink 凭据三态 / CODE-VOICE 语音落库面)同步了 `internal/db/migrations/0023_flow_graphs_lane_turn_id.*`、`internal/db/migrations/0020_chat_message_voice.*`、`internal/runtime/flowgraph/**`、`internal/services/gateway/**`(`history.go` / `history_toolcalls.go` / `contracts.go` / `admin_panels.go` / `admin_channels_weixin.go` / `admin_channel_status.go` / `ws_channel_events.go` / `ws_bridge.go` / `ws_hub.go` / `routes.go`)、`internal/capability/plugin/**`(`panels.go` / `manager.go`)、`internal/capability/paneldecl/**`、`internal/config/runtimeconfig/websearch.go`、`internal/config/websearch.go`、`plugins/tools/io/search/**`(`panel.go` / `paneldata.go` / `probe.go`)、`plugins/channels/weixin_ilink/**`(`session_status.go` / `poll.go` / `send.go`)、`internal/runtime/events/**`、`interLine truncated
*本文件由「以代码为真源」维护(当前 `v0.97`;`v0.96` ~ `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.97`(三面 —— M44 `voice` 进 WS 实时帧/握手回放 / M47 emotionTTS 自启(默认关)/ M49+M50+M48+M52 上游错误出口「构造即安全」)同步了 `internal/services/gateway/**`(`broadcast.go` / `history_voice.go` / `ws_history.go` 及 `broadcast_voice_test.go`)、`internal/services/channelbridge/**`(`persist.go` / `inbound_voice_copy.go` / `coalesce_batch.go` 及 `inbound_voice_gateway_e2e_test.go`)、`cmd/cyrene/**`(`voice_tts_launch.go` 及测试 / `main.go`)、`internal/config/runtimeconfig/voice_tts_launch.go`、`internal/runtime/voice/ttslaunch/**`、`internal/runtime/trace/**`(`upstream.go` 等)与本文件(§3.2 / §4.4 / §4.5 / §6 / §7.2 / §7.4);`v0.96` ~ `v0.82` 各版的文件级范围见各自 §6 条目;更早版本(`v0.81` ~ `v0.68`)的文件级范围按原记录保留如下:`v0.81` 同步了 backthink/flowgraph/gateway 的自排轮可见面;`v0.80` 同步了 flowgraph timeline、websearch 热重载、wire_channels 的 VoiceTranscoder 注入;`v0.79` 同步了 plugin actions、memory review、history_voice、media、CODE-TC-2;`v0.78` 同步了迁移 0020/0023、voice 包、channelbridge inbound_voice、panels、iLink 三态;`v0.77` 同步了 trace graph 分桶、loop/flow、memory tidy、admin_flow;`v0.76` 的未读面同步了 broadcast/session_read;`v0.75` ~ `v0.71` 同步了 memory tidy、proactive、flowgraph、loop 各面(详见归档)。*
+12
View File
@@ -119,6 +119,18 @@ func portListening(ctx context.Context, port int) bool {
// 侧看到的工具结果(`browserError`)来自这条错误 —— 不上报原话,用户就只看到我们这一侧的猜测
// (真机踩过:窗口上跳出浏览器的抱怨,日志里像浏览器坏了,工具结果里什么都没有)。
// 没有输出时也如实点明,那本身是线索(参数被拒之类通常会带话)。
//
// 拼接后整条都是**调用方可见**的,因此两半各自的出镜口径必须交代清楚:
//
// - `preview` 是浏览器可控的自由文本 ⇒ 已在 preview() 里过了 chromeOutputPreview
// (凭据形状打码 + 600 rune 上限)。这里**不再**加一道带上限的守卫:同一份上限套在
// 整条上,会把下面这条错误自己的**可操作**建议截掉(`profileBusyHint` 里带 profileDir
// 路径,adopt_test 钉着它必须可见);
// - `err` 那一半是我们自己拼的(ErrLaunch 分类标记、endpointURL 回环地址、两条 hint 里
// 的文件系统路径),**不含对端文本** —— 对端说的话一律经 preview 走,没有第二条入口。
//
// 换言之:整条被覆盖,靠的是「对端文本只有一个入口(preview)」这个结构约束,而不是在
// 拼接处再洗一遍。
func withChromeOutput(err error, preview string) error {
if preview == "" {
return fmt.Errorf("%w;浏览器没有输出任何内容", err)
+25 -2
View File
@@ -5,10 +5,13 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// cdpCallTimeout 是单条 CDP 命令的兜底超时(handshake / 读超时)。正常调用还会被
@@ -30,8 +33,28 @@ type cdpError struct {
Message string `json:"message"`
}
// Error 实现 error 接口。
func (e *cdpError) Error() string { return fmt.Sprintf("CDP %d: %s", e.Code, e.Message) }
// Error 渲染协议错误(实现 error 接口)。
//
// 🔴 Message 是**上游自由文本**:Chrome 与各类兼容实现可以往里面放任何东西 —— 包括
// 它刚被要求操作的表达式、地址或页面内容。原样进错误等于把那段内容抄进日志与模型
// 可见视图,因此默认**不出镜**:
// - 只有像协议枚举的短串(trace.ErrorToken:≤64 字节、仅 [A-Za-z0-9_.-])才保留
// —— 真正的枚举(context_not_found 之类)能过,散文与回显一律过不了;
// - 其余只留**长度**(字节数),够判断「对端给了句话」而不带那句话。
//
// 方法名由调用点补齐(cdpClient.call / Call 的 %w 文案),诊断面并不只剩一个码。
func (e *cdpError) Error() string {
var b strings.Builder
fmt.Fprintf(&b, "CDP %d", e.Code)
switch token := trace.ErrorToken(e.Message); {
case token != "":
b.WriteString(": ")
b.WriteString(token)
case e.Message != "":
fmt.Fprintf(&b, " (message %d bytes withheld)", len(e.Message))
}
return b.String()
}
// cdpClient 是最小 CDP 客户端:一条 WebSocket、一个读协程、按 id 关联的待答表。
//
+52 -6
View File
@@ -3,6 +3,7 @@ package browser
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -105,11 +106,23 @@ func TestCDPClient_CallRoundTrip(t *testing.T) {
}
}
// TestCDPClient_ProtocolErrorIsWrapped 钉住协议错误被 %w 包成 ErrCDP(上层据此
// 判定「连接级问题 ⇒ 丢会话重起」)。
func TestCDPClient_ProtocolErrorIsWrapped(t *testing.T) {
// TestCDPClient_ProtocolErrorKeepsNoFreeText 钉住两件事:协议错误被 %w 包成 ErrCDP
// (上层据此判定「连接级问题 ⇒ 丢会话重起」),而 CDP 的 error.message **不出镜**。
//
// 为什么 message 不能出镜:它是**上游自由文本** —— Chrome 与各类兼容实现可以往里面
// 放它刚被要求操作的东西(表达式、地址、页面内容)。只有像协议枚举的短串
// (trace.ErrorToken)才保留,其余只留字节数。
//
// 夹具刻意用**带空格的散文 + 会被回显的页面文本**:真正的 CDP message 就长这样
// ("Cannot find context with specified id"),而像令牌的单字("boom")本来就会过
// 守卫 —— 用例不该拿它当「自由文本」。
//
// 变异判据:把 cdpError.Error() 换回 `fmt.Sprintf("CDP %d: %s", e.Code, e.Message)`
// ⇒ 哨兵文本出现在错误串里,本用例红。
func TestCDPClient_ProtocolErrorKeepsNoFreeText(t *testing.T) {
const message = "Cannot resolve context for <script>PAGE-ECHO-SECRET</script>"
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
return nil, &cdpError{Code: -32000, Message: "boom"}
return nil, &cdpError{Code: -32000, Message: message}
})
wsURL, _ := pageTargetWS(context.Background(), f.base)
c, err := newCDPClient(context.Background(), wsURL)
@@ -118,14 +131,47 @@ func TestCDPClient_ProtocolErrorIsWrapped(t *testing.T) {
}
defer func() { _ = c.Close() }()
err = c.Call(context.Background(), "Runtime.evaluate", nil, nil)
if err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("Call 的错误 = %v,应含 CDP 的 message", err)
if err == nil {
t.Fatal("协议错误应当返回 error")
}
if strings.Contains(err.Error(), "PAGE-ECHO-SECRET") {
t.Fatalf("CDP 的自由文本进了错误串:%v", err)
}
if !strings.Contains(err.Error(), "CDP -32000") {
t.Errorf("错误没带上协议码: %v", err)
}
if want := fmt.Sprintf("message %d bytes withheld", len(message)); !strings.Contains(err.Error(), want) {
t.Errorf("错误没带上被扣下文本的长度(期望 %q): %v", want, err)
}
// 方法名是代码给的名字,必须仍在 —— 扣掉 message 之后诊断面靠它定位。
if !strings.Contains(err.Error(), "Runtime.evaluate") {
t.Errorf("错误丢了方法名: %v", err)
}
if !strings.Contains(err.Error(), "CDP 调用失败") {
t.Errorf("错误没带上 ErrCDP 文案: %v", err)
}
}
// TestCDPClient_ProtocolErrorKeepsProtocolToken 是「协议枚举仍可读」的正向证据:
// 取值像令牌(≤64 字节、仅 [A-Za-z0-9_.-])的 message 原样保留。
//
// 变异判据:把 cdpError.Error() 改成「message 一律不带」⇒ 本用例红。
func TestCDPClient_ProtocolErrorKeepsProtocolToken(t *testing.T) {
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
return nil, &cdpError{Code: -32602, Message: "context_not_found"}
})
wsURL, _ := pageTargetWS(context.Background(), f.base)
c, err := newCDPClient(context.Background(), wsURL)
if err != nil {
t.Fatalf("newCDPClient: %v", err)
}
defer func() { _ = c.Close() }()
err = c.Call(context.Background(), "Runtime.evaluate", nil, nil)
if err == nil || !strings.Contains(err.Error(), "context_not_found") {
t.Fatalf("协议枚举形状的 message 应保留:%v", err)
}
}
// TestCDPClient_ContextCancelStopsWaiting 钉住「取消能立刻返回」,不是等对端回话。
func TestCDPClient_ContextCancelStopsWaiting(t *testing.T) {
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
@@ -0,0 +1,154 @@
package browser
// 本文件钉住「浏览器自己说的话」这条自由文本出口的两条约束 —— 既**有界**(600 rune +
// 12 行)又**打码**(凭据形状不外泄),而且两者都**不许**伤到排障信息:普通原话必须仍然
// 看得见(这是 launch_test / adopt_test 既有的「失败要如实报」口径)。
//
// 「删实现能打红」的落点:把 preview() 里的 chromeOutputPreview 调用退回原样直吐,本文件
// 的凭据断言(以及日志读面那条)会全红。
import (
"context"
"errors"
"log/slog"
"strings"
"testing"
"time"
"unicode/utf8"
)
// TestChromeOutputPreviewRedactsCredentials 是这条出口的**核心断言**:浏览器把带凭据的
// 地址/请求头打进 stderr 时,出镜的预览里只有 [redacted],没有凭据本体;同时排障用的
// 普通原话一个字都不许少。
func TestChromeOutputPreviewRedactsCredentials(t *testing.T) {
t.Parallel()
const (
complaint = "ERROR:process_singleton.cc(343)] 参数被拒: --remote-allow-origins=*"
credURL = "ERROR:navigation] 载入失败 https://example.com/cb?access_token=sk-live-ABCDEFGH12345"
bearer = "WARNING:net] 请求头 Authorization: Bearer abcdefghij1234567890"
)
var out chromeOutput
for _, line := range []string{complaint, credURL, bearer} {
if _, err := out.Write([]byte(line + "\n")); err != nil {
t.Fatalf("chromeOutput.Write 永不返回错误: %v", err)
}
}
got := out.preview()
t.Logf("实测预览=%q", got)
for _, secret := range []string{"sk-live-ABCDEFGH12345", "abcdefghij1234567890", "access_token="} {
if strings.Contains(got, secret) {
t.Errorf("凭据形状 %q 原样出镜了: %q", secret, got)
}
}
if !strings.Contains(got, "[redacted]") {
t.Errorf("凭据应被打成 [redacted](看得见的打码,不是静默删除): %q", got)
}
// 反面:打码不能顺手把「浏览器为什么拒绝」一起吃掉 —— 那正是这条出口存在的理由。
for _, want := range []string{complaint, "载入失败", "请求头 Authorization"} {
if !strings.Contains(got, want) {
t.Errorf("排障用的普通原话 %q 不该被洗掉: %q", want, got)
}
}
}
// TestChromeOutputPreviewIsBounded 钉住上限的两半:正文被截到 chromeOutputPreviewRunes
// 并留 `…`;而「其实说了多少字节」这条**我们自己的**说明必须活着(上限只压正文,不吃
// 量级)。没有它,读者会把半截输出误当成完整输出。
func TestChromeOutputPreviewIsBounded(t *testing.T) {
t.Parallel()
t.Run("单行超长只留上限并标截断", func(t *testing.T) {
t.Parallel()
var out chromeOutput
if _, err := out.Write([]byte(strings.Repeat("A", 4000) + "\n")); err != nil {
t.Fatalf("Write: %v", err)
}
got := out.preview()
if !strings.HasSuffix(got, "…") {
t.Errorf("超上限必须留可见的截断标记: 长度=%d 尾部=%q", utf8.RuneCountInString(got), tailRunes(got, 8))
}
if n := utf8.RuneCountInString(got); n != chromeOutputPreviewRunes+1 {
t.Errorf("正文应正好截到 %d rune + 一个 `…`,实际 %d rune", chromeOutputPreviewRunes, n)
}
})
t.Run("字节截断时仍报总量", func(t *testing.T) {
t.Parallel()
var out chromeOutput
// 5000 字节 > chromeOutputMaxBytes(4096)⇒ 缓存本身就被截,preview 必须点明总量。
if _, err := out.Write([]byte(strings.Repeat("B", 5000))); err != nil {
t.Fatalf("Write: %v", err)
}
got := out.preview()
if !strings.Contains(got, "已截断,共 5000 字节") {
t.Errorf("字节被截时必须报出真实总量(不能拿留下的那部分当全部): %q", tailRunes(got, 40))
}
})
}
// TestDial_ChromeOutputOnFailureIsRedacted 走真实失败路径(假浏览器 exit 0 ⇒
// profileBusyHint + withChromeOutput),断言**拼接后的整条**(hint 那半 + 浏览器原话那半)
// 在用户侧可见的错误串与失败日志里都没有凭据形状,且两半的信息都还在。
func TestDial_ChromeOutputOnFailureIsRedacted(t *testing.T) {
t.Parallel()
const (
complaint = "不支持的标记: --remote-allow-origins=*"
credLine = "载入失败 https://example.com/cb?access_token=sk-live-ABCDEFGH12345"
bearerLine = "请求头 Authorization: Bearer abcdefghij1234567890"
)
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{
Stderr: []string{complaint, credLine, bearerLine}, ExitCode: 0,
}, 5*time.Second, log)
_, stop, err := l.Dial(context.Background())
if stop != nil {
t.Error("失败路径不该交出停止函数")
}
if err == nil {
t.Fatal("进程 exit 0 却不写端口记录 ⇒ 必须报错")
}
if !errors.Is(err, ErrLaunch) {
t.Fatalf("仍须包住 ErrLaunch: %v", err)
}
msg := err.Error()
logs := buf.String()
t.Logf("实测 error=%v\n实测日志=%s", err, logs)
for _, secret := range []string{"sk-live-ABCDEFGH12345", "abcdefghij1234567890", "access_token="} {
if strings.Contains(msg, secret) {
t.Errorf("返回给调用方的错误里出现了凭据形状 %q(用户侧工具结果直接来自这条串): %v", secret, err)
}
if strings.Contains(logs, secret) {
t.Errorf("失败日志里出现了凭据形状 %q(密钥不落日志):\n%s", secret, logs)
}
}
if !strings.Contains(msg, "[redacted]") {
t.Errorf("打码应当看得见([redacted]): %v", err)
}
// 两半都得活着:hint 那半(可操作建议 + profileDir 路径)不能被上限截掉,
// 浏览器原话那半是这条出口存在的理由。
for _, want := range []string{
"绝不杀别人的浏览器", l.profileDir,
complaint, "载入失败",
} {
if !strings.Contains(msg, want) {
t.Errorf("整条错误里缺少 %q(过度打码/截断): %v", want, err)
}
}
if !strings.Contains(logs, complaint) {
t.Errorf("失败日志里丢了浏览器原话 %q(排障信息):\n%s", complaint, logs)
}
}
// tailRunes 返回 s 的末 n 个 rune,供失败断言打印尾部而不是把 4000 字符灌进报告。
func tailRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[len(r)-n:])
}
+124
View File
@@ -0,0 +1,124 @@
package browser
// 本文件是**对端自由文本出口**的收敛点:有三处会把页面/浏览器自己说的话原样交给我们,
// 它们出镜(进 error ⇒ 日志 + 模型可见视图)之前必须在这里过一道。
//
// 三处**不是同一类**,因此守卫也不同(口径见 internal/runtime/trace/errortoken.go
// 的「按字段类别判定」):
//
// - `Page.navigate` 的 `errorText` 是**协议枚举**:值域是 Chrome 的 net error 码
// (`net::ERR_NAME_NOT_RESOLVED` 之类),短、标识符形状 ⇒ 用 trace.ErrorToken 守:
// 像枚举就保留,散文一律丢弃;
// - `Runtime.evaluate` 的 `exceptionDetails.text` 是**页面自由文本**:页面的 JS 可以
// throw 任何东西 —— 包括它读到的 DOM 正文、地址栏里带 token 的 URL。⇒ 默认不带
// 全文,按「带上限地带」处理:压平空白 + trace.RedactTextLimited 截断;
// - 浏览器自己的 stdout/stderr 预览(launchoutput.go 的 `preview()`)也是自由文本:
// 参数被拒时 Chromium 会把**本次 argv** 与地址原话打出来(`chromeOutputPreview`)。
//
// 🔴 两类守卫**不能互换**:拿 ErrorToken 去「清洗」自由文本是错的口径(它只会把整段
// 话删掉,不是清洗);拿 RedactTextLimited 去守枚举字段也不对(它不判形状,挡不住
// 散文)。清洗实现仍然只有一份,本文件只做「选哪一道 + 补形状前提」。
import (
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// maxExceptionTextRunes 是 `exceptionDetails.text` 允许出镜的 rune 上限:**200**。
//
// 为什么不复用既有的两个档位:
// - **80**(`trace.RedactText` 的摘要档)会把真实的异常消息拦腰截断 ——
// `TypeError: Cannot read properties of undefined (reading 'addEventListener')`
// 已经 73 rune,再带上半句调用点就过 80,而调用点是唯一的定位线索;
// - **4096**(网关日志页 `maxLogMsgRunes` 那一档)是给「运维要读全文」的日志行用的;
// 这个字段是**页面自己说的话**,4096 等于允许页面把 4KB 正文塞进一条错误串 ——
// 而这条错误串同时进日志与模型可见视图。
//
// 200 容得下「异常消息 + 一段调用点」,又把页面可控文本压在「一句话」量级。超限时
// trace.RedactTextLimited 会补 `…`,因此截断本身是看得见的,不会被误读成全文。
const maxExceptionTextRunes = 200
// netErrorNamespace 是 Chrome net error 码的命名空间前缀(`net::ERR_...`)。
//
// 为什么要先剥掉它再交给 ErrorToken:ErrorToken 的字符集是 `[A-Za-z0-9_.-]`,**不含
// `:`**,整串直接送进去会把合法的网络错误码一起判掉 —— 而「合法网络错误码形态保留」
// 是这条出口的硬要求(上层与工具都在断言它,见 render_test ③)。这个前缀是**协议
// 常量**,不是从对端抄来的文本,因此剥它是安全的。
const netErrorNamespace = "net::"
// netErrorCodePrefix 是 net error 枚举的名字前缀(`ERR_`):所有真实取值都以它开头。
// 于是这一条额外把「恰好是单个令牌的自由文本」(`hunter2` 之类)也挡掉 —— 形状判定
// 仍由 trace.ErrorToken 做,本文件只补这一个前提,不复制第二份守卫实现。
const netErrorCodePrefix = "ERR_"
// netErrorCode 只放行「像 net error 枚举」的 errorText:`[net::]ERR_<标识符>`。
// 其余(散文、被回显的 URL、带凭据的文本)返回空串,由调用方按「已隐去」处理。
func netErrorCode(raw string) string {
text := strings.TrimSpace(raw)
namespace := ""
if rest, ok := strings.CutPrefix(text, netErrorNamespace); ok {
namespace, text = netErrorNamespace, rest
}
if !strings.HasPrefix(text, netErrorCodePrefix) {
return ""
}
token := trace.ErrorToken(text)
if token == "" {
return ""
}
return namespace + token
}
// navigateCause 把 `Page.navigate` 的 errorText 变成可出镜的原因子句。
// 调用前提:raw 非空(navigate 只在 errorText 非空时判定为失败)。
//
// 不是枚举时一个字符的自由文本都不带,只留**字节数** —— 够判断「对端说了句话」,
// 不带那句话(与 cdpError.Error 同一口径)。
func navigateCause(raw string) string {
if code := netErrorCode(raw); code != "" {
return code
}
return fmt.Sprintf("网络错误文本已隐去(%d 字节)", len(raw))
}
// pageExceptionText 是 `exceptionDetails.text` 出镜前的唯一处理:压平空白(一个换行
// 就能在按行聚合的日志里伪造出「另一条记录」,与 sdklog / judgeReason 同一做法)+
// 凭据形状打码 + 截断(清洗实现只有一份:trace.RedactTextLimited)。
func pageExceptionText(raw string) string {
return trace.RedactTextLimited(strings.Join(strings.Fields(raw), " "), maxExceptionTextRunes)
}
// evaluateCause 把 `Runtime.evaluate` 的 exceptionDetails.text 变成可出镜的原因子句。
func evaluateCause(raw string) string {
if text := pageExceptionText(raw); text != "" {
return text
}
// 压平之后什么都不剩(对端只给了空白):如实说「没给文本」,不假装异常无因。
return "页面异常(对端未给文本)"
}
// chromeOutputPreviewRunes 是浏览器 stdout/stderr 预览出镜时的 rune 上限:**600**
// (= 12 行 × 50 rune)。
//
// 为什么是这个量级:预览的**行**预算已经是 12 行(chromeOutputMaxLines),50 rune 大致
// 是一个短分句 —— Chromium 的抱怨句式是「原因在前」(`ERROR:...] 参数被拒:...`),
// 所以 600 保得住原因那一句,又把「一整块转储」挡在模型可见的错误串之外。
//
// 为什么不沿用另外两档:80(`trace.RedactText` 摘要档)连**一行**浏览器日志都放不下;
// 4096(`chromeOutputMaxBytes` 缓存档、网关日志页 maxLogMsgRunes)是「运维读全文」的
// 量级,对浏览器可控的字段等于允许把 4KB 正文塞进工具结果。
//
// 为什么 600 不会把「到底说了多少」藏起来:本上限只压在**预览正文**上,调用方
// (launchoutput.go)随后仍会追加「已截断,共 N 字节」——读者看到的永远是「这里有 600
// rune 的原话 + 它其实说了 N 字节」,截断与量级都看得见。
const chromeOutputPreviewRunes = 600
// chromeOutputPreview 是浏览器 stdout/stderr 预览出镜前的唯一处理:凭据形状打码 +
// 截断(清洗实现只有一份:trace.RedactTextLimited)。它服务**两个**读面 —— 失败日志的
// `chrome_output` 字段与返回给调用方的错误串(launch.go 的 launchOnce),因此收敛在
// preview() 里,两个读面不会判得不一致。
func chromeOutputPreview(text string) string {
return trace.RedactTextLimited(text, chromeOutputPreviewRunes)
}
+200
View File
@@ -0,0 +1,200 @@
package browser
import (
"context"
"errors"
"strconv"
"strings"
"testing"
"unicode/utf8"
)
// navigateErrorCDP 造一个「导航必失败、errorText 是给定文本」的假 CDP:只应答 Page.enable
// 与 Page.navigate,导航之后一律拒绝(失败之后不该再有求值)。
func navigateErrorCDP(t *testing.T, errorText string) *fakeCDP {
t.Helper()
return newFakeCDP(t, func(method string, _ map[string]any) (any, *cdpError) {
switch method {
case "Page.enable":
return map[string]any{}, nil
case "Page.navigate":
return map[string]any{"errorText": errorText}, nil
}
return nil, &cdpError{Code: -32601, Message: "导航失败后不该再发 " + method}
})
}
// evaluateExceptionCDP 造一个「导航与就绪都正常、取正文那一次求值抛异常」的假 CDP:
// 异常文本由调用方给(模拟页面 JS 往 exceptionDetails.text 里塞任何东西)。
func evaluateExceptionCDP(t *testing.T, exceptionText string) *fakeCDP {
t.Helper()
return newFakeCDP(t, func(method string, params map[string]any) (any, *cdpError) {
switch method {
case "Page.enable", "Page.navigate":
return map[string]any{}, nil
case "Page.getFrameTree":
return map[string]any{"frameTree": map[string]any{"frame": map[string]any{"id": "frame-1"}}}, nil
case "Page.createIsolatedWorld":
return map[string]any{"executionContextId": 1}, nil
case "Runtime.evaluate":
expr, _ := params["expression"].(string)
if strings.Contains(expr, "readyState") {
return map[string]any{"result": map[string]any{"value": map[string]any{
"url": "https://example.com/", "title": "t", "readyState": "complete",
}}}, nil
}
return map[string]any{"exceptionDetails": map[string]any{"text": exceptionText}}, nil
}
return nil, &cdpError{Code: -32601, Message: "unexpected " + method}
})
}
// 出口 A(`Page.navigate` 的 errorText):散文形状的值一个字符都不许出镜 —— 代理与网关
// 会把它回显成任意文本,包括它刚收到的那条带 token 的 URL。判据是「原文本里每个有辨识度
// 的片段都不在错误串里」,且出镜的是**字节数**(够判断对端说了句话,不带那句话)。
//
// 删掉 navigateCause 的调用(退回 `fmt.Errorf("%w: %s", ErrNavigate, text)`)时本用例必红。
func TestManager_RenderTextNavigateErrorTextNeverEchoesFreeText(t *testing.T) {
t.Parallel()
const raw = "Failed loading https://example.com/?access_token=SUPERSECRET123 (via proxy)"
m := renderTestManager(t, cdpEndpointAvail(navigateErrorCDP(t, raw)))
_, err := m.RenderText(context.Background(), "https://example.com/")
if !errors.Is(err, ErrNavigate) {
t.Fatalf("err = %v, want ErrNavigate", err)
}
msg := err.Error()
for _, leak := range []string{"SUPERSECRET123", "access_token", "https://example.com/?", "Failed loading", "proxy"} {
if strings.Contains(msg, leak) {
t.Errorf("错误串泄露了 errorText 的自由文本片段 %q: %q", leak, msg)
}
}
if !strings.Contains(msg, "已隐去") {
t.Errorf("应点明文本已隐去(否则读起来像对端没说话): %q", msg)
}
if !strings.Contains(msg, strconv.Itoa(len(raw))) {
t.Errorf("应带出被隐去文本的字节数 %d(唯一的排查线索): %q", len(raw), msg)
}
}
// 出口 A 的正向:合法的 net error 枚举**必须原样保留** —— 它是调用方与工具分类失败的
// 唯一依据(render_test ③ 也在断言它)。这条同时钉住「net:: 前缀要先剥掉再交给
// ErrorToken」这个实现前提:整串直接送进 ErrorToken 会因 `:` 被判空,正向就丢了。
func TestManager_RenderTextNavigateErrorTextKeepsNetErrorCode(t *testing.T) {
t.Parallel()
for _, code := range []string{
"net::ERR_NAME_NOT_RESOLVED",
"net::ERR_CONNECTION_REFUSED",
"net::ERR_TIMED_OUT",
"ERR_ABORTED",
} {
m := renderTestManager(t, cdpEndpointAvail(navigateErrorCDP(t, code)))
_, err := m.RenderText(context.Background(), "https://example.com/")
if !errors.Is(err, ErrNavigate) {
t.Fatalf("%s: err = %v, want ErrNavigate", code, err)
}
if !strings.Contains(err.Error(), code) {
t.Errorf("%s 是合法的网络错误码,必须原样保留,实得 %q", code, err.Error())
}
}
}
// 出口 B(`Runtime.evaluate` 的 exceptionDetails.text):页面 JS 可以 throw 任何东西,
// 包括它读到的凭据。判据是「凭据形状被打码、消息本体仍可排障」—— 不是整段丢弃(自由
// 文本的处置口径是「带上限地带」,见 errtext.go)。
//
// 删掉 evaluateCause 的调用(退回原样直吐)时本用例必红。
func TestManager_RenderTextExceptionTextRedactsCredentials(t *testing.T) {
t.Parallel()
const raw = "TypeError: fetch failed https://api.example.com/v1?api_key=sk-live-ABCDEFGH12345"
m := renderTestManager(t, cdpEndpointAvail(evaluateExceptionCDP(t, raw)))
_, err := m.RenderText(context.Background(), "https://example.com/")
if !errors.Is(err, ErrEvaluate) {
t.Fatalf("err = %v, want ErrEvaluate", err)
}
msg := err.Error()
for _, leak := range []string{"ABCDEFGH12345", "sk-live"} {
if strings.Contains(msg, leak) {
t.Errorf("错误串泄露了凭据 %q: %q", leak, msg)
}
}
if !strings.Contains(msg, "[redacted]") {
t.Errorf("凭据形状应被打成 [redacted](看得见的打码,不是静默删除): %q", msg)
}
if !strings.Contains(msg, "TypeError") {
t.Errorf("异常消息本体是唯一的定位线索,不该被整段丢掉: %q", msg)
}
}
// 出口 B 的上限:页面可以把 4KB 正文塞进异常文本,而这条错误串同时进日志与模型可见视图。
// 判据:超限时截断看得见(`…`),且超出上限的尾部**确实没出镜**;换行被压平(一个换行
// 就能在按行聚合的日志里伪造出另一条记录)。
func TestManager_RenderTextExceptionTextIsBoundedAndFlattened(t *testing.T) {
t.Parallel()
const tail = "TAILMARKER"
raw := strings.Repeat("A", maxExceptionTextRunes) + "\n" + tail
m := renderTestManager(t, cdpEndpointAvail(evaluateExceptionCDP(t, raw)))
_, err := m.RenderText(context.Background(), "https://example.com/")
if !errors.Is(err, ErrEvaluate) {
t.Fatalf("err = %v, want ErrEvaluate", err)
}
msg := err.Error()
if strings.Contains(msg, tail) {
t.Errorf("超出上限的尾部不该出镜: %q", msg)
}
if !strings.HasSuffix(msg, "…") {
t.Errorf("截断必须看得见(尾部补省略号),不许让半截文本读起来像全文: %q", msg)
}
if strings.ContainsAny(msg, "\r\n") {
t.Errorf("异常文本里的换行必须压平(否则可在按行聚合的日志里伪造记录): %q", msg)
}
}
// pageExceptionText 的档位就是 maxExceptionTextRunes:超限出 maxExceptionTextRunes 个
// rune 加一个省略号,一个不多。
func TestPageExceptionTextLimitsRunes(t *testing.T) {
t.Parallel()
got := pageExceptionText(strings.Repeat("长", maxExceptionTextRunes+50))
if n, want := utf8.RuneCountInString(got), maxExceptionTextRunes+1; n != want {
t.Errorf("rune 数 = %d, want %d(%d + 省略号)", n, want, maxExceptionTextRunes)
}
if !strings.HasSuffix(got, "…") {
t.Errorf("截断应补省略号: %q", got)
}
// 未超限时原样返回,不补省略号(截断标记只表示「确实被截了」)。
short := pageExceptionText("TypeError: body is gone")
if short != "TypeError: body is gone" {
t.Errorf("未超限不该改动文本: %q", short)
}
// 只给空白 ⇒ 空串,由 evaluateCause 如实说「对端未给文本」。
if got := evaluateCause(" \n\t "); got != "页面异常(对端未给文本)" {
t.Errorf("空白异常文本的处理 = %q", got)
}
}
// netErrorCode 只放行「像 net error 枚举」的值:形状判定仍由 trace.ErrorToken 做,本
// 函数只补「net:: 命名空间可剥 + 必须以 ERR_ 开头」这两个前提 —— 于是「恰好是单个令牌
// 的自由文本」(`hunter2`)不会被误当成错误码保留。
func TestNetErrorCodeOnlyAcceptsEnumerations(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want string
}{
{"net::ERR_NAME_NOT_RESOLVED", "net::ERR_NAME_NOT_RESOLVED"},
{" net::ERR_ABORTED ", "net::ERR_ABORTED"},
{"ERR_FAILED", "ERR_FAILED"},
{"hunter2", ""},
{"Failed loading https://example.com/", ""},
{"net::ERR_NOT_A_CODE because spaces", ""},
{"net::hunter2", ""},
{"", ""},
}
for _, c := range cases {
if got := netErrorCode(c.in); got != c.want {
t.Errorf("netErrorCode(%q) = %q, want %q", c.in, got, c.want)
}
}
}
+13 -2
View File
@@ -20,10 +20,12 @@ const (
// 的落地前提是「看得见失败」—— 启动失败时浏览器把原因写在自己的 stdout/stderr 上
// (参数被拒、profile 锁、崩溃),不接住它,日志里就只剩我们这一侧的猜测。
//
// 两条口径:
// 三条口径:
//
// - **有界**:只留前 N 字节 / 上报前 N 行。正常启动时 Chrome 也会往 stderr 写常规
// 信息,全打进日志就是刷屏;缓存也无上限地吃内存。
// - **打码**:这是浏览器可控的自由文本,出镜前必过 chromeOutputPreview(凭据形状
// 打码 + rune 上限,见 errtext.go)。它进的不只是日志,还有用户侧的工具结果。
// - **不主动打**:本类型只缓存,**不输出**。成功时只报字节数(Debug),失败时由
// `localChrome.Dial` 一次性把前 N 行与本次 argv 一起报出来(见 launch.go)。
type chromeOutput struct {
@@ -65,6 +67,13 @@ func (o *chromeOutput) totalBytes() int {
// preview 返回失败上报用的「前 N 行」:每行内的空白压成单空格、空行去掉;被截断时
// 在末尾点明**截断与总字节数**(绝不把半截输出说得像完整输出)。没有输出时返回空串,
// 调用方据此判断「浏览器一个字都没说」。
//
// 🔴 正文出镜前必过 chromeOutputPreview(凭据形状打码 + 600 rune 上限,见 errtext.go):
// 这个字符串**是浏览器可控的自由文本**,而它会同时进失败日志与**返回给调用方的错误串**
// (launchOnce ⇒ 用户侧工具结果)。真机上 Chromium 会把本次 argv、被拒的参数、地址原话
// 打出来 —— 一旦里面出现 `?access_token=` 或 `Bearer ...`,原样透出就是凭据落进日志与
// 模型上下文。上限只压正文:下面的「已截断,共 N 字节」后缀是我们自己的定长说明,不进
// 这份预算,因此截断与量级都照实可见。
func (o *chromeOutput) preview() string {
o.mu.Lock()
raw := truncateRune(o.head)
@@ -84,7 +93,9 @@ func (o *chromeOutput) preview() string {
break
}
}
text := strings.Join(kept, " | ")
// 先拼再清洗(而不是逐行清洗):跨行的凭据形状(`access_token=` 与值被换行拆开)
// 只有拼起来之后才看得见。
text := chromeOutputPreview(strings.Join(kept, " | "))
if truncated && text != "" {
text += " | ...(已截断,共 " + strconv.Itoa(total) + " 字节)"
}
+5 -2
View File
@@ -100,7 +100,8 @@ func (s *session) navigate(ctx context.Context, url string) (pageState, error) {
return pageState{}, err
}
if text := strings.TrimSpace(res.ErrorText); text != "" {
return pageState{}, fmt.Errorf("%w: %s", ErrNavigate, text)
// errorText 是**协议枚举**面:像 net error 码就保留,散文一律丢弃(见 errtext.go)。
return pageState{}, fmt.Errorf("%w: %s", ErrNavigate, navigateCause(text))
}
if err := s.waitReady(ctx); err != nil {
return pageState{}, err
@@ -163,7 +164,9 @@ func (s *session) evaluate(ctx context.Context, expr string, out any) error {
return err
}
if res.ExceptionDetails != nil {
return fmt.Errorf("%w: %s", ErrEvaluate, res.ExceptionDetails.Text)
// text 是**页面自由文本**面:页面 JS 可以 throw 任何东西(含 DOM 正文、带 token
// 的 URL)⇒ 压平空白 + 凭据打码 + 截断后再出镜(见 errtext.go)。
return fmt.Errorf("%w: %s", ErrEvaluate, evaluateCause(res.ExceptionDetails.Text))
}
if out == nil || len(res.Result.Value) == 0 {
return nil
+13 -7
View File
@@ -33,7 +33,6 @@ func (c *Client) Connect(ctx context.Context) error {
if err != nil {
return fmt.Errorf("mcp connect %q: %w", c.cfg.Name, wrapConnectErr(err))
}
info := serverInfoFrom(session, c.hostFeatures())
c.mu.Lock()
c.session = session
@@ -62,7 +61,7 @@ func (c *Client) Close() error {
if session != nil {
if err := session.Close(); err != nil {
return fmt.Errorf("mcp close %q: %w", c.cfg.Name, err)
return fmt.Errorf("mcp close %q: %w", c.cfg.Name, upstreamError(err))
}
}
return nil
@@ -82,7 +81,7 @@ func (c *Client) ListTools(ctx context.Context) ([]ToolInfo, error) {
out := make([]ToolInfo, 0, 8)
for tool, err := range session.Tools(ctx, nil) {
if err != nil {
return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, err)
return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, upstreamError(err))
}
out = append(out, toolInfoFrom(tool))
}
@@ -112,7 +111,7 @@ func (c *Client) callTool(ctx context.Context, name string, args map[string]any,
}
res, err := session.CallTool(ctx, &mcpsdk.CallToolParams{Name: name, Arguments: prepared})
if err != nil {
return ToolResult{}, fmt.Errorf("mcp call tool %q: %w", name, err)
return ToolResult{}, fmt.Errorf("mcp call tool %q on %q: %w", name, c.cfg.Name, upstreamError(err))
}
return ToolResult{
Content: toContentBlocks(res.Content),
@@ -165,7 +164,9 @@ func (c *Client) handleToolListChanged(context.Context, *mcpsdk.ToolListChangedR
// newSDKClient 按配置构建官方 SDK 客户端:只宣告宿主真正具备的客户端能力。
func (c *Client) newSDKClient() *mcpsdk.Client {
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: clientName, Version: clientVersion}, &mcpsdk.ClientOptions{
Logger: c.log,
// 🔴 交给 SDK 的 Logger 必须过 redactingLogger(sdklog.go):SDK 自己会把对端
// 错误原样打进日志(keepalive 那条已实测),那条路绕开了本包 upstreamError 的出口。
Logger: redactingLogger(c.log),
Capabilities: c.clientCapabilities(),
CreateMessageHandler: c.wrapSampling(),
ElicitationHandler: c.wrapElicitation(),
@@ -188,12 +189,17 @@ func (c *Client) sessionOptions() *mcpsdk.ClientSessionOptions {
}
// wrapConnectErr 把 SDK 报的「协议版本不支持」映射到本包哨兵错误,便于调用方判定。
//
// 它同时是握手失败的**脱敏点**:原始 SDK 错误(第三方 server 的自由文本)只经
// upstreamError 留下 code 与长度,绝不被 %w 带进文案 —— 而哨兵映射仍然成立,因为
// upstreamFailure 的 cause 保留了整条链,errors.Is 不受影响。
func wrapConnectErr(err error) error {
safe := upstreamError(err)
var wire *sdkjsonrpc.Error
if errors.As(err, &wire) && wire.Code == mcpsdk.CodeUnsupportedProtocolVersion {
return fmt.Errorf("%w: %w", ErrUnsupportedProtocolVersion, err)
return fmt.Errorf("%w: %w", ErrUnsupportedProtocolVersion, safe)
}
return err
return safe
}
// normalizeArgs 把 agent 传来的参数值规整成 MCP arguments 对象(必须是 JSON 对象)。
+71
View File
@@ -0,0 +1,71 @@
package mcp
import (
"errors"
"fmt"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
)
// 本文件是「第三方 MCP server 的错误怎么写进本包错误」的**唯一**收敛点。
//
// 为什么需要它:MCP server 是第三方进程/远端服务,它的错误文本是**上游自由文本** ——
// 它可以把刚收到的工具参数、读到的文件内容、甚至是它自己的系统提示词塞进
// `error.message`。原样经 SDK error 进本包错误,那条文本就会随 `errors.Is` 分类链
// 一路进日志、进管理面板、进模型可见视图。与 trace.UpstreamError 同一口径:
// **默认不带自由文本**,只留可分类的骨架。
//
// 留下的骨架(够定位,不够回显):
// - 哪台 server / 哪把工具 —— 由调用点的 %w 文案给(名字来自配置与 tools/list,
// 不是错误文本);
// - JSON-RPC code —— 协议枚举(-32601 method not found、-32602 invalid params …),
// 是本文件唯一允许出镜的「对端给的值」,且以数字渲染;
// - message 的**字节数** —— 能区分「对端给了句话」与「对端什么都没说」,不带那句话。
// upstreamFailure 是一次第三方 server 失败在本包错误链里的形态。它的 Error() 只
// 渲染 code 与长度;原始原因挂在 cause 上,只供 errors.Is / errors.As 分类。
//
// 为什么不直接把 SDK 的 err 用 %w 包上:%w 只加前缀,被包的那条文本仍然在
// err.Error() 里 —— 包一层不是修复,只是换个位置泄漏(同 trace.URLCause 的口径)。
type upstreamFailure struct {
// code 是 JSON-RPC 错误码;hasCode 为 false 表示这条错误不是线上响应里的
// 结构化错误(传输层失败、SDK 内部错误、ctx 取消等),此时不带任何码。
code int64
hasCode bool
// length 是被扣下的自由文本的字节数。
length int
// cause 只用于分类,绝不参与 Error()。
cause error
}
// Error 渲染一行只含 code / 长度的诊断;自由文本一个字节都不出。
func (f *upstreamFailure) Error() string {
if f.hasCode {
return fmt.Sprintf("mcp server error: code=%d message_bytes=%d", f.code, f.length)
}
return fmt.Sprintf("mcp server error: %T message_bytes=%d", f.cause, f.length)
}
// Unwrap 让 errors.Is/As 仍然看得见原始原因(context.Canceled、
// context.DeadlineExceeded、*jsonrpc.Error 的码…),而 Error() 绝不渲染它。
// 少了它,取消会被上层当成普通工具失败继续跑。
func (f *upstreamFailure) Unwrap() error { return f.cause }
// upstreamError 把一次第三方 server 失败收敛成**不带自由文本**的错误。
//
// 收 code 用的 errors.As 会遍历整条链:SDK 常把线上错误再包一层(fmt 包装、
// JSON-RPC 信封),所以不能只断言最外层。
func upstreamError(err error) error {
if err == nil {
return nil
}
failure := &upstreamFailure{cause: err, length: len(err.Error())}
var wire *sdkjsonrpc.Error
if errors.As(err, &wire) {
failure.code = wire.Code
failure.hasCode = true
// WireError.Error() 就是 Message 本身,所以这里量的正是被扣下的那段文本。
failure.length = len(wire.Message)
}
return failure
}
+94
View File
@@ -0,0 +1,94 @@
package mcp
// 本文件钉住「第三方 MCP server 的错误文本不进错误与日志」这条口径(errors.go)。
//
// 生产风险:MCP server 是第三方进程/远端服务,它可以把刚收到的工具参数、读到的文件
// 内容、甚至自己的系统提示词塞进 error.message。原样经 SDK error 进本包错误,那条
// 文本就会随分类链一路进日志、进管理面板、进模型可见视图。
//
// 变异判据(每条都对应一个会红的实现):
// - 把 upstreamError 换成裸 err(或 `%w` 包原始 err)⇒ 哨兵文本出现在错误串里;
// - 把 upstreamFailure 的 Error() 改成渲染 cause ⇒ 同上;
// - 摘掉 Unwrap ⇒ 「分类仍成立」那条断言红(取消会被当成普通工具失败)。
import (
"context"
"errors"
"fmt"
"strings"
"testing"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
)
// errSentinel 是「绝不能出现在错误串里」的第三方自由文本标记。
const errSentinel = "MCP-UPSTREAM-SENTINEL-原文回显"
// sentinelServer 起一台会在 tools/call 里返回带哨兵文本的 JSON-RPC 错误的 server。
func sentinelServer() *mcpsdk.Server {
srv := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "sentinel-mcp", Version: "1.0.0"}, nil)
srv.AddTool(&mcpsdk.Tool{
Name: "boom",
InputSchema: map[string]any{"type": "object"},
}, func(context.Context, *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) {
return nil, &sdkjsonrpc.Error{Code: sdkjsonrpc.CodeInternalError, Message: errSentinel}
})
return srv
}
// TestCallTool_KeepsServerErrorTextOut 是 CallTool 的正面证据:错误里只剩
// server / 工具 / 协议码 / 长度,哨兵文本一个字节都没有。
func TestCallTool_KeepsServerErrorTextOut(t *testing.T) {
t.Parallel()
c := connectFakeClient(t, sentinelServer())
_, err := c.CallTool(context.Background(), "boom", map[string]any{})
if err == nil {
t.Fatal("server 返回协议错误时 CallTool 应当报错")
}
if strings.Contains(err.Error(), errSentinel) {
t.Fatalf("第三方 server 的自由文本进了错误串:%v", err)
}
for _, want := range []string{"boom", "fake", "code=-32603"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("错误里应保留可分类的骨架 %q:%v", want, err)
}
}
}
// TestUpstreamError_KeepsClassificationButNotText 直接钉住 errors.go 的契约:
// Error() 只有骨架,errors.Is/As 仍然看得见原始原因。
func TestUpstreamError_KeepsClassificationButNotText(t *testing.T) {
t.Parallel()
cause := fmt.Errorf("sdk wrapper: %w", &sdkjsonrpc.Error{Code: -32601, Message: errSentinel})
err := upstreamError(cause)
if strings.Contains(err.Error(), errSentinel) {
t.Fatalf("自由文本进了错误串:%v", err)
}
if !strings.Contains(err.Error(), "code=-32601") {
t.Errorf("错误里丢了 JSON-RPC 码:%v", err)
}
// 分类面:WireError 的 Is 比的是码,链不能断。
if !errors.Is(err, &sdkjsonrpc.Error{Code: -32601}) {
t.Errorf("errors.Is 丢失了 JSON-RPC 码分类:%v", err)
}
var wire *sdkjsonrpc.Error
if !errors.As(err, &wire) || wire.Code != -32601 {
t.Errorf("errors.As 取不到原始 JSON-RPC 错误:%v", err)
}
}
// TestUpstreamError_KeepsContextCancellation 钉住「取消仍可分类」:上层靠
// errors.Is(err, context.Canceled) 区分「用户取消」与「工具失败」,
// 收敛文本时绝不能把这条链切断。
func TestUpstreamError_KeepsContextCancellation(t *testing.T) {
t.Parallel()
err := upstreamError(fmt.Errorf("mcp: %w", context.Canceled))
if !errors.Is(err, context.Canceled) {
t.Fatalf("errors.Is(err, context.Canceled) = false,取消被吞掉了:%v", err)
}
if strings.Contains(err.Error(), "context canceled") {
t.Errorf("取消的原文也被带进了错误串:%v", err)
}
}
+124
View File
@@ -0,0 +1,124 @@
package mcp
// 本文件是「交给官方 SDK 的 Logger」的唯一收敛点。
//
// 为什么需要它(探针证据见 sdklog_probe_test.go):官方 SDK v1.8.0 的客户端侧会自己
// 打日志,其中 keepalive 那条把**对端错误原样**交给 logger:
//
// logger.Error("keepalive ping failed; closing session", "error", err, …)
//
// 而 err 是 SDK 自己的包装(`calling "ping": <对端 error.message>`),**不是**本包
// upstreamError 的产物 —— 于是第三方 server 的自由文本可以借 SDK 的手进我们的日志,
// 绕开 client.go 里那条「默认不带自由文本」的出口。探针实测:未加本文件时日志里出现
// 了对端回显的整句话。
//
// 收敛口径(与 client.go 同一刻画,不做第二套):
// - **线上错误**(链上有 *jsonrpc.Error):自由文本一律不带,只留
// `upstreamError` 渲染的 code + message_bytes;
// - **本地错误**(context deadline / canceled、传输错误):文本是代码自己产生的,
// 保留并以 maxSDKLogRunes 截断 —— 运维靠它判断「为什么 session 被关掉」;
// - 消息本身与所有字符串属性同样过 trace.RedactTextLimited(抹凭据形状 + 截断)。
//
// 为什么不是「一律 RedactText」:RedactText 只抹**凭据形状**,不删「她的话」——
// 对端把用户正文回显进 error.message 时,凭据清洗一个字符都不会动它。要挡住这一类,
// 判据必须是「这条文本来自线上」而不是「它像不像凭据」。
import (
"context"
"errors"
"log/slog"
"strings"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// maxSDKLogRunes 是 SDK 日志行(消息与字符串属性)允许出镜的 rune 上限。
//
// 取 300 的理由:这些行是「方法名 + 原因」的合成串(`calling "ping": …`),
// 300 rune 容得下任何真实诊断,同时挡住 MiB 级对象把日志行撑爆;与网关日志页
// 的 maxLogMsgRunes(4096,整页一行要留全文)刻意不同 —— 这里的行是我们自己
// 产出的诊断,不需要全文,只需要「哪一步失败、为什么」。
const maxSDKLogRunes = 300
// redactingLogger 返回交给 SDK 的日志器:同一底层 handler,包一层日志面收敛。
// 它不改变级别与输出格式(Enabled 原样委托),因此宿主注入的 Logger 行为不变。
func redactingLogger(l *slog.Logger) *slog.Logger {
return slog.New(&redactingHandler{inner: l.Handler()})
}
// redactingHandler 是 redactingLogger 的 handler 形态。
type redactingHandler struct {
inner slog.Handler
}
// Enabled 原样委托:收敛发生在写出时,不影响任何级别判定。
func (h *redactingHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.inner.Enabled(ctx, level)
}
// Handle 先收敛一条记录(消息 + 全部属性),再交给底层 handler。
func (h *redactingHandler) Handle(ctx context.Context, r slog.Record) error {
out := slog.NewRecord(r.Time, r.Level, scrubSDKText(r.Message), r.PC)
r.Attrs(func(a slog.Attr) bool {
out.AddAttrs(scrubSDKAttr(a))
return true
})
return h.inner.Handle(ctx, out)
}
// WithAttrs 让预置属性(logger.With(...))同样过收敛 —— 否则「先 With 再打」的
// 记录会绕过本文件。
func (h *redactingHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
scrubbed := make([]slog.Attr, len(attrs))
for i, a := range attrs {
scrubbed[i] = scrubSDKAttr(a)
}
return &redactingHandler{inner: h.inner.WithAttrs(scrubbed)}
}
// WithGroup 透传分组,组内属性仍由 Handle 逐条收敛。
func (h *redactingHandler) WithGroup(name string) slog.Handler {
return &redactingHandler{inner: h.inner.WithGroup(name)}
}
// scrubSDKText 是一条自由文本进入日志前唯一处理:压平空白(一个换行就能在按行聚合
// 的日志里伪造出「另一条记录」)+ 凭据形状清洗 + 截断。
func scrubSDKText(s string) string {
return trace.RedactTextLimited(strings.Join(strings.Fields(s), " "), maxSDKLogRunes)
}
// scrubSDKAttr 收敛一条属性:字符串与 error 是自由文本面,分组递归,其余(数字、
// 时间、布尔)原样保留 —— 它们是体积与状态,没有内容。
func scrubSDKAttr(a slog.Attr) slog.Attr {
switch a.Value.Kind() {
case slog.KindString:
a.Value = slog.StringValue(scrubSDKText(a.Value.String()))
case slog.KindAny:
if err, ok := a.Value.Any().(error); ok && err != nil {
a.Value = slog.StringValue(scrubSDKError(err))
}
case slog.KindGroup:
group := a.Value.Group()
scrubbed := make([]slog.Attr, len(group))
for i, item := range group {
scrubbed[i] = scrubSDKAttr(item)
}
a.Value = slog.GroupValue(scrubbed...)
}
return a
}
// scrubSDKError 区分两类错误来源(口径见文件头):
// - 链上有 *jsonrpc.Error ⇒ 文本来自对端,走 upstreamError 的骨架(code +
// message_bytes),一个字节的自由文本都不留;
// - 否则是本地产生的错误(context deadline / canceled、传输层、SDK 自身),
// 保留原因(运维要靠它判断失败性质),只做凭据清洗与截断。
func scrubSDKError(err error) string {
var wire *sdkjsonrpc.Error
if errors.As(err, &wire) {
return upstreamError(err).Error()
}
return scrubSDKText(err.Error())
}
@@ -0,0 +1,175 @@
package mcp
// 本文件是「SDK 自己的 Logger 日志面会不会漏第三方 server 的自由文本」的**探针**,
// 由塔 2026-10-26 复审 p2 要求:先证明有没有泄漏,再决定要不要上基础设施。
//
// 探针覆盖 go-sdk v1.8.0 客户端侧两处会打日志的点:
// 1. 正常的方法调用失败(tools/call 返回 JSON-RPC error)—— SDK 是否记录它;
// 2. keepalive ping 被对端用 JSON-RPC error 拒绝 —— SDK 明确会
// `logger.Warn("keepalive ping failed;…", "error", err)`,err 就是**对端的自由文本**。
//
// 结论(v1.8.0 实测):第 1 条不打日志;第 2 条会,且原样带出 message —— 因此
// newSDKClient 交给 SDK 的 Logger 必须经过 redactingLogger(见 sdklog.go)。
//
// 变异判据:把 newSDKClient 的 redactingLogger 摘掉 ⇒ 本文件的哨兵断言红。
import (
"bytes"
"context"
"strings"
"sync"
"testing"
"time"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// sdkLogSentinel 是第三方 server 塞进 JSON-RPC error.message 的哨兵(模拟「她刚才
// 说的那句话」被对端回显)。
const sdkLogSentinel = "SENTINEL-MCPLOG 她刚才说的那句话"
// pingErrorTransport 包一层传输:把客户端的 `ping` 拦下来,直接用一条**带哨兵文本的
// JSON-RPC error** 回答它(不转发给真 server),从而稳定复现「对端 free text 进
// keepalive 日志」这条路径。
//
// 为什么要在传输层拦 ping:SDK server 的 ping 应答是内置的(总回空 result),无法让
// 它回错误;而 ping 失败正是客户端侧唯一一处「把对端 error 原样交给 logger」的地方。
type pingErrorTransport struct {
inner mcpsdk.Transport
}
func (t *pingErrorTransport) Connect(ctx context.Context) (mcpsdk.Connection, error) {
conn, err := t.inner.Connect(ctx)
if err != nil {
return nil, err
}
return newPingErrorConn(conn), nil
}
// pingErrorConn 把内层连接的消息泵到一个自己的 channel,好让拦下的 ping 能**插队**
// 回一条错误响应(直接在 Read 里塞响应不行:读循环已经阻塞在内层 Read 上)。
type pingErrorConn struct {
inner mcpsdk.Connection
msgs chan sdkjsonrpc.Message
done chan struct{}
once sync.Once
}
func newPingErrorConn(inner mcpsdk.Connection) *pingErrorConn {
c := &pingErrorConn{
inner: inner,
msgs: make(chan sdkjsonrpc.Message, 16),
done: make(chan struct{}),
}
go c.pump()
return c
}
// pump 把内层消息搬进 msgs;连接结束就关掉 msgs(读侧据此如实报错)。
func (c *pingErrorConn) pump() {
defer close(c.msgs)
for {
msg, err := c.inner.Read(context.Background())
if err != nil {
return
}
select {
case c.msgs <- msg:
case <-c.done:
return
}
}
}
func (c *pingErrorConn) Write(ctx context.Context, msg sdkjsonrpc.Message) error {
req, ok := msg.(*sdkjsonrpc.Request)
if !ok || req.Method != "ping" {
return c.inner.Write(ctx, msg)
}
select {
case c.msgs <- &sdkjsonrpc.Response{
ID: req.ID,
Error: &sdkjsonrpc.Error{Code: sdkjsonrpc.CodeInternalError, Message: sdkLogSentinel},
}:
return nil
case <-c.done:
return context.Canceled
}
}
func (c *pingErrorConn) Read(ctx context.Context) (sdkjsonrpc.Message, error) {
select {
case msg, ok := <-c.msgs:
if !ok {
return nil, context.Canceled
}
return msg, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (c *pingErrorConn) Close() error {
c.once.Do(func() { close(c.done) })
return c.inner.Close()
}
// SessionID 满足 mcpsdk.Connection(SDK 把它当作传输的身份标签,探针原样透传)。
func (c *pingErrorConn) SessionID() string { return c.inner.SessionID() }
// TestSDKLogger_ToolCallErrorIsNotLogged 证明探针不是空转:tools/call 的 JSON-RPC
// 错误**确实**会被我们的(redacted)日志器看到 —— 只是 SDK 并不记录它。
func TestSDKLogger_ToolCallErrorIsNotLogged(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := logger.New(logger.Options{Format: "text", Level: "debug", Out: &buf})
c := New(MCPConfig{Name: "fake"},
WithTransport(startFakeServer(t, sentinelServer())),
WithLogger(log))
if err := c.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
defer func() { _ = c.Close() }()
if _, err := c.CallTool(context.Background(), "boom", map[string]any{}); err == nil {
t.Fatal("server 返回协议错误时 CallTool 应当报错")
}
if got := buf.String(); strings.Contains(got, errSentinel) {
t.Fatalf("tools/call 路径的 SDK 日志里出现哨兵(原先认为它不打日志,结论要改):\n%s", got)
}
t.Logf("tools/call 路径 SDK 日志(预期不含哨兵):\n%s", buf.String())
}
// TestSDKLogger_KeepaliveErrorTextStaysOut 是探针的主断言:keepalive 被对端用
// JSON-RPC error 拒绝时,SDK 会把 err 原样打进 WARN —— 哨兵绝不能出现在日志里。
func TestSDKLogger_KeepaliveErrorTextStaysOut(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := logger.New(logger.Options{Format: "text", Level: "debug", Out: &buf})
tr := &pingErrorTransport{inner: startFakeServer(t, newFakeServer())}
c := New(MCPConfig{Name: "fake"},
WithTransport(tr),
// 固定到 keepalive 生效的旧协议版本(同 keepalive_test.go 的口径)。
WithProtocolVersion("2025-11-25"),
WithKeepAlive(20*time.Millisecond, 1),
WithLogger(log))
if err := c.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
defer func() { _ = c.Close() }()
waitFor(t, 5*time.Second, "keepalive 报出 ping 失败", func() bool {
return strings.Contains(buf.String(), "keepalive ping failed")
})
logged := buf.String()
// 探针本身要可证伪:日志里确实有那条 keepalive 记录,否则「没哨兵」是空转。
if !strings.Contains(logged, "keepalive ping failed") {
t.Fatalf("探针没触发 keepalive 日志,结论不可用:\n%s", logged)
}
if strings.Contains(logged, sdkLogSentinel) {
t.Fatalf("SDK 的 keepalive 日志把对端自由文本原样打出来了:\n%s", logged)
}
t.Logf("keepalive 路径 SDK 日志(预期含记录、不含哨兵):\n%s", logged)
}
+59
View File
@@ -0,0 +1,59 @@
package mcp
// 本文件钉住 sdklog.go 的两条分支(线上错误 / 本地错误),sdklog_probe_test.go 则
// 用真 server 证明这条日志面确实走过收敛。
//
// 变异判据:把 scrubSDKError 的分支判据摘掉(一律保留原文)⇒ 第一条子用例红;
// 反过来「一律只留骨架」⇒ 第二、三条子用例红(运维会丢掉「为什么 session 被关掉」)。
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"testing"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
)
// TestScrubSDKError_SplitsWireFromLocal 是分类判据的正面证据。
func TestScrubSDKError_SplitsWireFromLocal(t *testing.T) {
t.Parallel()
// 1. 线上错误(链上有 *jsonrpc.Error):自由文本一个字节都不留,只留码与长度。
wire := fmt.Errorf("calling %q: %w", "ping",
&sdkjsonrpc.Error{Code: sdkjsonrpc.CodeInternalError, Message: "SENTINEL-MCPLOG 她刚才说的话"})
got := scrubSDKError(wire)
if strings.Contains(got, "SENTINEL-MCPLOG") {
t.Fatalf("线上错误的自由文本进了日志:%q", got)
}
if !strings.Contains(got, "code=-32603") {
t.Fatalf("线上错误的协议码被丢掉了:%q", got)
}
// 2. 本地错误:原因必须保留(运维靠它判断失败性质)。
if got := scrubSDKError(context.DeadlineExceeded); !strings.Contains(got, "deadline exceeded") {
t.Fatalf("本地错误的原因被吞掉了:%q", got)
}
// 3. 本地错误里的凭据形状仍要打码,换行仍要压平。
leaky := errors.New("dial failed: Authorization: Bearer sk-live-0123456789abcdef\nsecond line")
got = scrubSDKError(leaky)
if strings.Contains(got, "sk-live") {
t.Fatalf("本地错误里的凭据没被打码:%q", got)
}
if strings.Contains(got, "\n") || !strings.Contains(got, "second line") {
t.Fatalf("本地错误的换行没被压平(或压平把内容吞了):%q", got)
}
}
// TestScrubSDKAttr_KeepsScalars 钉住「只收敛自由文本面」:数字/布尔/时间是体积与
// 状态,不该被字符串化或截断。
func TestScrubSDKAttr_KeepsScalars(t *testing.T) {
t.Parallel()
if got := scrubSDKAttr(slog.Int("consecutiveFailures", 3)); got.Value.Kind().String() != "Int64" {
t.Fatalf("数字属性被改成了 %s(应保持数字)", got.Value.Kind())
}
}
+11 -2
View File
@@ -3,6 +3,8 @@ package restrict
import (
"strings"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// SensitiveOp 是 design/11 §4「敏感清单」里的操作类别。它只用于审计与前端
@@ -45,7 +47,8 @@ var SensitiveOps = []SensitiveOp{
}
// sensitiveSummaryRunes 截断动作摘要:代审小字与审计只需一眼看出「想干什么」,
// 同时限制进入前端/日志的文本块大小(也降低把长参数里的秘密整段搬进日志的机会)。
// 同时限制进入前端/日志的文本块大小。它只管**长度** —— 秘密由 summarize 里的
// trace.RedactText 负责打码,不靠截断「顺手甩掉」。
const sensitiveSummaryRunes = 80
// SensitiveCall 判定一次工具调用是否属敏感操作(design/11 §4)。
@@ -135,8 +138,14 @@ func httpCall(args map[string]any) (SensitiveOp, string, bool) {
// summarize 拼接「动作 + 目标」摘要并按 sensitiveSummaryRunes 截断(不切开
// 多字节字符);目标为空时只返回动作。
//
// 目标先过 trace.RedactText 打码:这个摘要会进审计与前端小字两个读面,而
// terminal 的 command / http 的 url 里常常就带着凭据(`curl -H 'api_key=…'`、
// `?token=…`),截断并不能让它们变得安全。RedactText 自身已按同一档(80 rune)
// 截断,后面的 clipRunes 保留是为了让长度上限继续由本包的
// sensitiveSummaryRunes 掌握 —— 两处同档,叠加不改变结果。
func summarize(action, target string) string {
target = strings.TrimSpace(target)
target = trace.RedactText(strings.TrimSpace(target))
if target == "" {
return action
}
@@ -77,3 +77,41 @@ func TestSensitiveCall_SummaryIsClipped(t *testing.T) {
t.Fatalf("被截断的摘要应以省略号结尾: %q", detail)
}
}
// TestSensitiveCall_SummaryIsRedacted 锁定「参数摘要进审计与前端小字之前必须
// 打码」:terminal 的 command、http 的 url 里常常就带着凭据,截断不是脱敏。
func TestSensitiveCall_SummaryIsRedacted(t *testing.T) {
t.Parallel()
const secret = "sk-live-9f8e7d6c5b4a3210"
cases := []struct {
name string
args map[string]any
start string // 摘要应保留的开头(打码不能把摘要打没)
}{
{
name: "terminal",
args: map[string]any{"command": "curl -H 'api_key=" + secret + "' https://x"},
start: "terminal ",
},
{
name: "http",
args: map[string]any{"method": "POST", "url": "https://x/hook?token=" + secret},
start: "http POST ",
},
}
for _, tc := range cases {
_, detail, sensitive := SensitiveCall(tc.name, tc.args)
if !sensitive {
t.Fatalf("%s 应判定为敏感", tc.name)
}
if strings.Contains(detail, secret) {
t.Fatalf("%s 摘要泄露了凭据原文: %q", tc.name, detail)
}
if !strings.Contains(detail, "[redacted]") {
t.Fatalf("%s 摘要应带打码标记: %q", tc.name, detail)
}
if !strings.HasPrefix(detail, tc.start) {
t.Fatalf("%s 摘要应以 %q 开头(动作不能被一起打掉): %q", tc.name, tc.start, detail)
}
}
}
+14 -8
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"sort"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// utf8BOM 是 UTF-8 的字节序标记。对端(审查模型网关)会把它原样回带,而
@@ -13,9 +15,13 @@ import (
// 并把「每窗口一次」的节流打穿(见 DevDocs/audit/群聊排队与回旧话-2026-10-24.md)。
const utf8BOM = "\ufeff"
// reviewExcerptLen 是解析失败时附带在错误里的模型输出长度(字节)。它只含模型
// reviewExcerptLen 是解析失败时附带在错误里的模型输出长度(**rune**)。它只含模型
// 返回的编号数组(数字/方括号/逗号/围栏),足以定位「又换了什么壳」,不至于把
// 消息内容带进日志。
//
// 🔴 单位是 rune 不是字节:审查模型回的中文/全角标点每个字 3 字节,按 40 **字节**
// 截会把其中一个字切成半个 —— 错误串变成非法 UTF-8,终端显示 U+FFFD、JSON 客户端
// 各自乱码。带上限出镜是刻意的(失败原因对排障有用),但上限必须以字符计。
const reviewExcerptLen = 40
// ParseReviewIndices decodes the keep-index array a review model returned, rising
@@ -83,12 +89,12 @@ func unwrapCodeFence(s string) string {
return s
}
// reviewExcerpt truncates text for an error message so the next parse failure is
// diagnosable without logging the whole completion.
// reviewExcerpt 把模型输出裁成一段可以进错误串的摘要。两步都不是可选的:
//
// - trace.RedactTextLimited 先清洗凭据形状(审查网关偶有把 Authorization 回显进
// 输出的),再按 **rune** 截到 reviewExcerptLen —— 按字节截会把多字节字符切成
// 半个,错误串因此变成非法 UTF-8;
// - 压平空白:一条诊断只占一行,也挡掉换行注入。
func reviewExcerpt(s string) string {
s = strings.TrimSpace(s)
if len(s) <= reviewExcerptLen {
return s
}
return s[:reviewExcerptLen] + "…"
return trace.RedactTextLimited(strings.Join(strings.Fields(s), " "), reviewExcerptLen)
}
@@ -9,6 +9,7 @@ import (
"encoding/json"
"strings"
"testing"
"unicode/utf8"
)
// bomReviewFixture 是审查模型网关的实际回包形状:UTF-8 BOM(EF BB BF)紧接一个
@@ -126,3 +127,45 @@ func TestParseReviewIndices_RejectsGarbage(t *testing.T) {
}
}
}
// TestReviewExcerpt_TruncatesByRune 钉住「上限按字符计,不按字节计」:审查模型回的中文
// 每个字 3 字节,按 40 **字节**截会把其中一个字切成半个,错误串因此成为非法 UTF-8
// (终端显示 U+FFFD、JSON 客户端各自乱码)。
//
// 变异判据:把 reviewExcerpt 换回 `s[:reviewExcerptLen] + "…"` 的字节截断 ⇒ 本用例红
// (utf8.ValidString 为 false)。
func TestReviewExcerpt_TruncatesByRune(t *testing.T) {
t.Parallel()
got := reviewExcerpt(strings.Repeat("中", reviewExcerptLen*2))
if !utf8.ValidString(got) {
t.Fatalf("截断后不是合法 UTF-8(按字节截断了半个汉字):%q", got)
}
body := strings.TrimSuffix(got, "…")
if n := utf8.RuneCountInString(body); n != reviewExcerptLen {
t.Fatalf("保留 %d 个字符,期望 %d:%q", n, reviewExcerptLen, got)
}
if !strings.HasSuffix(got, "…") {
t.Errorf("超限时应当带省略号:%q", got)
}
}
// TestReviewExcerpt_RedactsCredentialShapes 钉住「带上限出镜 ≠ 原样出镜」:摘要仍要
// 过 trace.RedactText,网关回显的凭据形状(sk-… / token=… 等)必须被打码。
//
// 变异判据:让 reviewExcerpt 只做截断、不过 trace.RedactTextLimited ⇒ 本用例红。
func TestReviewExcerpt_RedactsCredentialShapes(t *testing.T) {
t.Parallel()
const secret = "sk-abcdefghijklmnop"
got := reviewExcerpt("[0, 1] Authorization: Bearer " + secret)
if strings.Contains(got, secret) {
t.Fatalf("凭据形状没被打码:%q", got)
}
if !strings.Contains(got, "[redacted]") {
t.Errorf("打码标记缺失:%q", got)
}
if !strings.Contains(got, "[0, 1]") {
t.Errorf("摘要丢了诊断价值(编号数组):%q", got)
}
}
+15 -1
View File
@@ -85,6 +85,11 @@ type Result struct {
// 用户消息的 document 列或随响应回传,使客户端能看到「这份附件读到了 /
// 读不到」—— 没有它,抽取失败对客户端就是不可见的。
Documents []history.DocumentRef
// Voice 是本回合的**出站语音结论**:nil 表示本回合没有请求语音(纯文本);
// Name 非空表示合成成功且音频副本已落盘;Requested 为真但 Name 为空、
// FallbackReason 非空表示她请求了语音但降级为文本(如 tts_timeout)。
// 字段语义详见 OutboundVoice。
Voice *OutboundVoice
// docSeen 是 Documents 的去重索引(存储名 → 下标),作用域限于本次
// Result,绝不跨 Run 共享。
docSeen map[string]int
@@ -138,7 +143,13 @@ type Loop struct {
// 逐字节一致。它由装配期一次性挂上,之后只读;每会话的状态在 SkillFace 内部
// 按会话分桶并自带锁,因此循环的并发 Run 契约不变。
skillFace *SkillFace
log *slog.Logger
// voiceSynth / voiceStore 是 speak 的注入依赖(M57)。两者都非 nil 时
// 循环向模型广告 speak 并自行承载它的执行(见 voice_deps.go);
// speakBudget 是超时预算的替换公式,nil 用内置公式。
voiceSynth VoiceSynth
voiceStore VoiceStore
speakBudget func(int) int
log *slog.Logger
}
// New 由 opts 构建 Loop 并应用默认值。MaxRounds 非正时
@@ -211,6 +222,9 @@ func New(opts Options) *Loop {
imageResolver: opts.Vision,
documentResolver: opts.DocumentResolver,
documentInjectChars: docInjectChars,
voiceSynth: opts.VoiceSynth,
voiceStore: opts.VoiceStore,
speakBudget: opts.SpeakBudget,
log: sourcedLog(opts.Log),
}
}
+9
View File
@@ -214,6 +214,15 @@ type Options struct {
// 或重排模型所见的消息。它是循环中与
// OpenClaw 的 transformContext 等价之物(design §1.3)。为 nil = 不做投影。
TransformContext func(ctx context.Context, messages []model.Message, round int) []model.Message
// VoiceSynth 与 VoiceStore 一起接线时(**两个都非 nil**),循环向模型
// 广告 speak 工具(M57):她可以自己决定这一条回复用语音还是文本。
// 任一为 nil = 不广告、speak 不可调用,模型面与接线前逐字节一致。
VoiceSynth VoiceSynth
VoiceStore VoiceStore
// SpeakBudget 可替换 speak 的超时预算公式(文本 rune 数 → 毫秒)。
// 为 nil 或返回值非正时回落内置公式 speakBudgetMs。它是给测试与
// 部署调优用的注入点,不是能力开关(开关是上面两个依赖)。
SpeakBudget func(runes int) int
// Log 是基础 logger。为 nil 时替换为标记来源
// "runtime/loop" 的丢弃 logger。
Log *slog.Logger
+2 -2
View File
@@ -87,7 +87,7 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode
}
schemas := d.ToolSchemas()
if l.capacity == nil || !l.capacity.Enabled() {
return l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, schemas))
return l.withSpeakSpec(l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, schemas)))
}
names := make([]string, 0, len(schemas))
@@ -124,7 +124,7 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode
out = append(out, s)
}
}
return l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, out))
return l.withSpeakSpec(l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, out)))
}
// capacityTexts 为 capacity 判定器提取近期对话文本:
+1
View File
@@ -187,6 +187,7 @@ func (l *Loop) driveTurn(ctx context.Context, sessionID string, msgs []model.Mes
}
msgs = batch.messages
res.ToolCallsExecuted += batch.executed
applyBatchVoice(res, &batch)
l.saveCheckpoint(ctx, sessionID, step, msgs, agent.StopReasonToolCalls)
// 关键工具循环安全网(或终止钩子)不等真正的最终答案
+336
View File
@@ -0,0 +1,336 @@
package loop
// 本文件实现模型侧的 speak 工具(M57)。主人 2026-10-27 的口径是「他可以自己
// 选择某次消息是拿文字发 还是去请求 TTS 接口发音频」——**决策是她的**,因此这里
// 只提供一个可调用的工具,绝不替她决定什么时候用语音(那会变成幕后置规则)。
// 调了 speak = 这条想用语音;不调 = 发文本。
//
// 三条执行口径:
// - 超时预算按**文本 rune 数**推导(speakBudgetMs):短句拿最低预算,长文拿
// 上限,中间线性 —— 越长合成越慢,但绝不无限等;
// - 成功 = 音频副本**已落盘**(经 VoiceStore 接缝,接线方走 documents.SaveAudio
// 的内容寻址 0600 口径)+ 结构化结果(name/mime/size/durationMs/budgetMs/costMs);
// - 超时/失败 = 结构化错误(code + 预算 + 实际耗时),**回合绝不因此失败**,
// 她能在后续输出里看到「这次语音没成」,客户端也能据此特殊显示。
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
const (
// speakToolName 是模型侧工具名。它同时是 Result.Voice 回填时的匹配键。
speakToolName = "speak"
// speakBudgetBaseMs 是任何一次合成的最低预算:即使一个字的请求也要给
// 上游留出建连 + 首包的时间,低于它的预算只会把"网络慢"误判成"内容长"。
speakBudgetBaseMs = 3000
// speakBudgetPerRuneMs 是每个 rune 追加的预算(毫秒)。25ms/rune 约等于
// 每秒 40 个字符的合成速度,比实测慢一截,留出余量。
speakBudgetPerRuneMs = 25
// speakBudgetMaxMs 是预算上限:再长的文本也等这么久,超了就退回文本,
// 绝不让她的一条消息把整个回合挂住。
speakBudgetMaxMs = 20000
// speakMaxRunes 是文本长度硬上限。按上面的公式,约 680 rune 就顶到预算
// 上限,再长的文本必然超时 —— 早拒绝能省一次上游调用与一次落盘。
speakMaxRunes = 1200
// speakAudioOrigName 是落盘时给 documents.SaveAudio 的原始名,扩展名
// 由上游实际格式拼接(白名单见 voiceref.MediaExtAllowed)。
speakAudioOrigName = "speak."
// 降级/失败原因码(Result.Voice.FallbackReason 与错误正文共用同一套取值,
// 客户端据此决定怎么显示「这条本来想用语音」)。
speakReasonTimeout = "tts_timeout"
speakReasonFailed = "tts_failed"
speakReasonNoAudio = "tts_no_audio"
speakReasonStoreFailed = "tts_store_failed"
speakReasonTooLong = "text_too_long"
speakReasonInvalidArgs = "invalid_args"
)
// speakTool 实现 sdk.Tool。依赖全部由循环注入(不自己 new),因此叶子测试
// 可以塞桩:合成桩决定成功/超时,落盘桩决定存储名,预算桩决定超时长短。
type speakTool struct {
synth VoiceSynth
store VoiceStore
// budget 把 rune 数映射为预算(毫秒);nil 用内置公式。
budget func(runes int) int
// persona 解析本回合的生效人格 id,供 TTS 的发音人解析链使用
// (发音人绑人格,链本身不动)。
persona func(context.Context) string
log *slog.Logger
}
// 编译期断言:speak 就是一个普通的模型侧工具,接线方式与其他工具一致。
var _ sdk.Tool = speakTool{}
// Name 返回工具标识。
func (speakTool) Name() string { return speakToolName }
// Description 说明**何时**用语音。它是能力描述,不是身份/权限判定 ——
// 谁能用语音仍由渠道层 actor 元数据 + 工具可达性标记决定。
func (speakTool) Description() string {
return "用语音(合成音频)把这段话念出来,让这一条回复变成声音。" +
"适合用语音:轻声细语、撒娇、唱歌、长一点的心里话或独白、需要语气温度的表达。" +
"适合用文本:代码、列表、链接、报错原文,以及需要被复制或搜索的内容。" +
"语音只改变这一条回复的呈现方式,不改变你的能力或身份。" +
"合成失败或超时会自动退回文本,调用一次就够,不要为同一句话反复重试。"
}
// Parameters 返回工具输入的 JSON Schema。
func (speakTool) Parameters() (map[string]any, error) {
return map[string]any{
"type": "object",
"properties": map[string]any{
"text": map[string]any{
"type": "string",
"description": "要念出来的话。写成可直接朗读的口语,不要带 Markdown 标记、代码块或链接。",
},
"emotion": map[string]any{
"type": "string",
"description": "可选语气微调(如「温柔」「轻声」「欢快」)。默认语气由你的人格决定," +
"这个字段只做额外微调,不会换掉发音人。",
},
},
"required": []string{"text"},
}, nil
}
// speakArgs 是解析后的调用参数。runes 只算一次,预算与日志共用。
type speakArgs struct {
text string
emotion string
runes int
}
// Execute 合成一段语音并落盘副本。成功返回结构化结果;失败返回 *speakError,
// 它同时是模型能读懂的错误正文与 Result.Voice 的降级依据。
func (t speakTool) Execute(ctx context.Context, args any) (any, error) {
in, err := parseSpeakArgs(args)
if err != nil {
return nil, err
}
if t.synth == nil || t.store == nil {
return nil, &speakError{Code: speakReasonFailed,
Err: errors.New("speak: voice synthesis is not configured")}
}
budget := t.budgetMs(in.runes)
synthCtx, cancel := context.WithTimeout(ctx, time.Duration(budget)*time.Millisecond)
defer cancel()
synthCtx = t.stampVoiceContext(synthCtx, ctx, in.emotion)
started := time.Now()
audio, provider, err := t.synth.TTSWithProvider(synthCtx, in.text)
cost := int(time.Since(started) / time.Millisecond)
if err != nil {
return nil, t.synthesisFailed(synthCtx, err, budget, cost)
}
ext, extOK := speakExt(audio.Format)
if len(audio.Data) == 0 || !extOK {
return nil, &speakError{Code: speakReasonNoAudio, BudgetMs: budget, CostMs: cost,
Err: fmt.Errorf("speak: unusable audio (format %q, %d bytes)", audio.Format, len(audio.Data))}
}
// 落盘用**父 ctx**:预算只约束上游合成,不该让已经合成好的音频因为预算
// 到点而丢掉(写盘是本地动作,毫秒级,且副本本身就是交付物)。
ref, err := t.store.SaveAudio(ctx, speakAudioOrigName+ext, audio.Data)
if err != nil {
return nil, &speakError{Code: speakReasonStoreFailed, BudgetMs: budget, CostMs: cost, Err: err}
}
t.logSynthesized(provider, in.runes, budget, cost, len(audio.Data))
// durationMs 恒为 0:本项目**绝不估算时长**(与 voice.Copy.DurationMs 同一
// 口径),而解析 WAV 头不在本切片范围内。0 表示「未解析」,不是零长度。
return map[string]any{
"name": ref.Name,
"mime": ref.MIME,
"size": ref.Size,
"durationMs": int64(0),
"budgetMs": budget,
"costMs": cost,
}, nil
}
// stampVoiceContext 把本回合的生效人格与可选语气挂到 ctx 上:TTS 侧按人格
// 解析发音人(链不动),emotion 只作为逐请求的语气指令透传到底层链。
func (t speakTool) stampVoiceContext(synthCtx, turnCtx context.Context, emotion string) context.Context {
if t.persona != nil {
if pid := t.persona(turnCtx); pid != "" {
synthCtx = voicetts.WithPersonaID(synthCtx, pid)
}
}
if emotion != "" {
synthCtx = mvoice.WithTTSInstruction(synthCtx, emotion)
}
return synthCtx
}
// budgetMs 返回本次合成的超时预算;注入值非正时回落内置公式。
func (t speakTool) budgetMs(runes int) int {
if t.budget != nil {
if ms := t.budget(runes); ms > 0 {
return ms
}
}
return speakBudgetMs(runes)
}
// synthesisFailed 把上游错误归一化成带原因码的结构化失败。超时判定看三处:
// 上游自己报的 deadline、我们给的预算 ctx 已过期、或实际耗时已经吃满预算
// (上游把超时包成自己的错误类型时只有最后一处能认出来)。
func (t speakTool) synthesisFailed(synthCtx context.Context, err error, budget, cost int) error {
code := speakReasonFailed
if errors.Is(err, context.DeadlineExceeded) ||
errors.Is(synthCtx.Err(), context.DeadlineExceeded) || cost >= budget {
code = speakReasonTimeout
}
if t.log != nil {
// 只记长度与耗时,**绝不记正文**(与网关出站 TTS 同一口径)。
t.log.Warn("speak degraded to text",
"reason", code, "budget_ms", budget, "cost_ms", cost, "err", err)
}
return &speakError{Code: code, BudgetMs: budget, CostMs: cost, Err: err}
}
// logSynthesized 记一条成功合成。只带来源、级别名、长度与耗时,绝不带正文。
func (t speakTool) logSynthesized(provider string, runes, budget, cost, size int) {
if t.log == nil {
return
}
t.log.Info("speak synthesized",
"provider", provider, "runes", runes, "budget_ms", budget, "cost_ms", cost, "bytes", size)
}
// speakError 是 speak 的结构化失败。Code 进 Result.Voice.FallbackReason,让
// 「想用语音但没成」与「本来就用文本」在调用方与客户端都能区分开;预算与实际
// 耗时让模型下一轮能判断是"该说短点"还是"上游坏了"。
type speakError struct {
Code string
BudgetMs int
CostMs int
Err error
}
// Error 渲染成一句可读正文,随 RoleTool 消息回灌给模型。
func (e *speakError) Error() string {
if e.Err == nil {
return fmt.Sprintf("speak failed: %s (budget_ms=%d cost_ms=%d)", e.Code, e.BudgetMs, e.CostMs)
}
return fmt.Sprintf("speak failed: %s (budget_ms=%d cost_ms=%d): %v", e.Code, e.BudgetMs, e.CostMs, e.Err)
}
// Unwrap 让 errors.Is 能穿透到原因(含 tool.ErrInvalidArgs 的参数校验分类)。
func (e *speakError) Unwrap() error { return e.Err }
// parseSpeakArgs 解析并校验参数。参数错误包 tool.ErrInvalidArgs,使循环把它
// 归到 argument-validation 而不是执行失败。
func parseSpeakArgs(args any) (speakArgs, error) {
m, err := speakArgMap(args)
if err != nil {
return speakArgs{}, err
}
text, _ := m["text"].(string)
text = strings.TrimSpace(text)
if text == "" {
return speakArgs{}, &speakError{Code: speakReasonInvalidArgs,
Err: fmt.Errorf("speak: text is required: %w", tool.ErrInvalidArgs)}
}
runes := utf8.RuneCountInString(text)
if runes > speakMaxRunes {
return speakArgs{}, &speakError{Code: speakReasonTooLong,
Err: fmt.Errorf("speak: text has %d runes, limit is %d: %w", runes, speakMaxRunes, tool.ErrInvalidArgs)}
}
emotion, _ := m["emotion"].(string)
return speakArgs{text: text, emotion: strings.TrimSpace(emotion), runes: runes}, nil
}
// speakArgMap 把调用参数归一化成 map;nil 参数视为空对象(缺 text 由调用方报)。
func speakArgMap(args any) (map[string]any, error) {
if args == nil {
return map[string]any{}, nil
}
m, ok := args.(map[string]any)
if !ok {
return nil, &speakError{Code: speakReasonInvalidArgs,
Err: fmt.Errorf("speak: arguments must be an object: %w", tool.ErrInvalidArgs)}
}
return m, nil
}
// speakBudgetMs 把文本长度(rune 数)映射为合成超时预算:
// clamp(speakBudgetBaseMs + speakBudgetPerRuneMs×runes, base, max)。
func speakBudgetMs(runes int) int {
if runes < 0 {
runes = 0
}
ms := speakBudgetBaseMs + speakBudgetPerRuneMs*runes
if ms > speakBudgetMaxMs {
return speakBudgetMaxMs
}
if ms < speakBudgetBaseMs {
return speakBudgetBaseMs
}
return ms
}
// speakExt 把上游实际格式映射成落盘扩展名。空格式按 wav 命名 —— TTS 上游
// 产物恒为 WAV(见 voice/tts/audio.go 的口径);白名单外的格式一律判不可用,
// 绝不拿一个读不出 Content-Type 的名字落盘。
func speakExt(format string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(format)) {
case "":
return "wav", true
case "wav", "mp3", "ogg", "flac", "m4a", "webm", "amr":
return strings.ToLower(strings.TrimSpace(format)), true
default:
return "", false
}
}
// speakSpec 返回 speak 的模型面声明(工具未接线时为 nil)。
func (l *Loop) speakSpec() *model.ToolSpec {
if !l.voiceEnabled() {
return nil
}
t := l.speakTool()
params, err := t.Parameters()
if err != nil {
return nil
}
return &model.ToolSpec{Name: t.Name(), Description: t.Description(), Parameters: params}
}
// speakTool 用循环注入的依赖装配一个 speak 工具值(每次装配都是廉价的)。
func (l *Loop) speakTool() speakTool {
return speakTool{
synth: l.voiceSynth,
store: l.voiceStore,
budget: l.speakBudget,
persona: l.personaFor,
log: l.log,
}
}
// invokeTool 执行单个工具调用。speak 由循环自己承载(它需要本回合的人格与
// 预算口径,且未注册进能力登记表),其余一律交给 dispatcher —— 它的超时、
// panic 兜底与允许集软判对普通工具仍然生效。
func (l *Loop) invokeTool(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) (any, error) {
if tc.Name == speakToolName && l.voiceEnabled() {
return l.speakTool().Execute(ctx, tc.Args)
}
if d == nil {
return nil, fmt.Errorf("no tool dispatcher configured for %q", tc.Name)
}
return d.Invoke(tool.WithLegID(ctx, ToolLegIDFrom(ctx)), tc.Name, tc.Args)
}
+334
View File
@@ -0,0 +1,334 @@
package loop
import (
"context"
"errors"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// 本文件是 speak 工具的叶子测试(M57):预算公式、扩展名白名单、
// 参数校验、成功/超时/无音频/落盘失败四条执行路径,以及模型面的
// 广告与追加。依赖全部用桩,绝不真连 TTS、绝不真写盘。
// stubSynth 是可配置的合成桩:delay 制造超时,err 制造失败,
// audio 决定成功时的产物,provider 供日志断言。
type stubSynth struct {
audio mvoice.Audio
provider string
err error
delay time.Duration
gotText string
}
func (s *stubSynth) TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error) {
s.gotText = text
if s.delay > 0 {
select {
case <-time.After(s.delay):
case <-ctx.Done():
return mvoice.Audio{}, "", ctx.Err()
}
}
if s.err != nil {
return mvoice.Audio{}, "", s.err
}
return s.audio, s.provider, nil
}
// stubStore 是可配置的落盘桩,同时记录收到的原始名与字节。
type stubStore struct {
ref VoiceRef
err error
gotName string
gotData []byte
}
func (s *stubStore) SaveAudio(_ context.Context, origName string, data []byte) (VoiceRef, error) {
s.gotName = origName
s.gotData = data
if s.err != nil {
return VoiceRef{}, s.err
}
return s.ref, nil
}
// newSpeakTool 装配一个注入了桩的 speak 工具。
func newSpeakTool(synth VoiceSynth, store VoiceStore) speakTool {
return speakTool{synth: synth, store: store}
}
// speakCodeOf 取出 speak 失败的结构化原因码。
func speakCodeOf(t *testing.T, err error) *speakError {
t.Helper()
var se *speakError
if !errors.As(err, &se) {
t.Fatalf("error %v is not *speakError", err)
}
return se
}
// TestSpeakBudgetMs 锁住预算公式的边界:短句拿最低预算、线性增长、
// 长文封顶,负数按 0 处理(绝不返回负预算)。
func TestSpeakBudgetMs(t *testing.T) {
t.Parallel()
cases := []struct {
runes int
want int
}{
{-5, speakBudgetBaseMs},
{0, speakBudgetBaseMs},
{1, speakBudgetBaseMs + speakBudgetPerRuneMs},
{100, speakBudgetBaseMs + 100*speakBudgetPerRuneMs},
{680, speakBudgetMaxMs},
{speakMaxRunes, speakBudgetMaxMs},
}
for _, c := range cases {
if got := speakBudgetMs(c.runes); got != c.want {
t.Errorf("speakBudgetMs(%d) = %d, want %d", c.runes, got, c.want)
}
}
}
// TestSpeakExt 锁住落盘扩展名:空格式按 wav(TTS 产物恒为 WAV)、
// 白名单内小写化、白名单外判不可用(绝不拿读不出 Content-Type 的
// 名字落盘)。
func TestSpeakExt(t *testing.T) {
t.Parallel()
cases := []struct {
format string
want string
ok bool
}{
{"", "wav", true},
{" ", "wav", true},
{"WAV", "wav", true},
{"mp3", "mp3", true},
{" Opus ", "", false},
{"exe", "", false},
}
for _, c := range cases {
got, ok := speakExt(c.format)
if got != c.want || ok != c.ok {
t.Errorf("speakExt(%q) = (%q,%v), want (%q,%v)", c.format, got, ok, c.want, c.ok)
}
}
}
// TestParseSpeakArgs_Validation 锁住参数校验:缺 text、非对象、超长
// 都归 argument-validation(errors.Is(tool.ErrInvalidArgs)),并且
// 长度按 **rune** 算而不是字节(多字节文本不会因字节数被误判超长)。
func TestParseSpeakArgs_Validation(t *testing.T) {
t.Parallel()
if _, err := parseSpeakArgs(nil); !errors.Is(err, tool.ErrInvalidArgs) {
t.Fatalf("nil args err = %v, want ErrInvalidArgs", err)
}
if _, err := parseSpeakArgs(map[string]any{"text": " "}); !errors.Is(err, tool.ErrInvalidArgs) {
t.Fatalf("blank text err = %v, want ErrInvalidArgs", err)
}
if _, err := parseSpeakArgs([]string{"a"}); !errors.Is(err, tool.ErrInvalidArgs) {
t.Fatalf("non-object args err = %v, want ErrInvalidArgs", err)
}
if _, err := parseSpeakArgs(map[string]any{"text": strings.Repeat("あ", speakMaxRunes+1)}); !errors.Is(err, tool.ErrInvalidArgs) {
t.Fatalf("too-long text err = %v, want ErrInvalidArgs", err)
} else if code := speakCodeOf(t, err).Code; code != speakReasonTooLong {
t.Fatalf("too-long code = %q, want %q", code, speakReasonTooLong)
}
// 恰好上限、且是多字节:按 rune 计数,应当放行。
in, err := parseSpeakArgs(map[string]any{
"text": strings.Repeat("あ", speakMaxRunes),
"emotion": " 温柔 ",
})
if err != nil {
t.Fatalf("at-limit runes rejected: %v", err)
}
if in.runes != speakMaxRunes {
t.Fatalf("runes = %d, want %d(必须按 rune 而非字节计数)", in.runes, speakMaxRunes)
}
if in.emotion != "温柔" {
t.Fatalf("emotion = %q, want trimmed 温柔", in.emotion)
}
}
// TestSpeakTool_ExecuteSuccess 锁住成功路径的交付物:音频落盘用
// 上游实际格式的扩展名,返回值带齐 name/mime/size/budgetMs/costMs,
// 且 durationMs 恒为 0(未解析,不是零长度)。
func TestSpeakTool_ExecuteSuccess(t *testing.T) {
t.Parallel()
synth := &stubSynth{
audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2, 3}},
provider: "emotiontts",
}
store := &stubStore{ref: VoiceRef{Name: "abc123.wav", MIME: "audio/wav", Size: 3}}
tk := newSpeakTool(synth, store)
tk.budget = func(int) int { return 777 }
out, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if synth.gotText != "晚安" {
t.Fatalf("synth text = %q, want 晚安", synth.gotText)
}
if store.gotName != "speak.wav" {
t.Fatalf("origName = %q, want speak.wav", store.gotName)
}
if len(store.gotData) != 3 {
t.Fatalf("stored bytes = %d, want 3", len(store.gotData))
}
m, ok := out.(map[string]any)
if !ok {
t.Fatalf("result type = %T, want map[string]any", out)
}
if m["name"] != "abc123.wav" || m["mime"] != "audio/wav" || m["size"] != int64(3) {
t.Fatalf("result = %v, want the stored ref's name/mime/size", m)
}
if m["durationMs"] != int64(0) {
t.Fatalf("durationMs = %v, want int64(0)(未解析)", m["durationMs"])
}
if m["budgetMs"] != 777 {
t.Fatalf("budgetMs = %v, want the injected 777", m["budgetMs"])
}
if _, ok := m["costMs"].(int); !ok {
t.Fatalf("costMs = %v, want int", m["costMs"])
}
}
// TestSpeakTool_ExecuteTimeout 锁住超时路径:预算到点即放弃合成,
// 原因码是 tts_timeout,且预算/实际耗时都回传给调用方(她下一轮
// 据此判断是「说短点」还是「上游坏了」)。这里用 1ms 预算 +
// 5ms 延迟制造真超时,不靠断言硬编码的时间差。
func TestSpeakTool_ExecuteTimeout(t *testing.T) {
t.Parallel()
tk := newSpeakTool(
&stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, delay: 5 * time.Millisecond},
&stubStore{},
)
tk.budget = func(int) int { return 1 }
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
se := speakCodeOf(t, err)
if se.Code != speakReasonTimeout {
t.Fatalf("code = %q, want %q", se.Code, speakReasonTimeout)
}
if se.BudgetMs != 1 {
t.Fatalf("BudgetMs = %d, want 1", se.BudgetMs)
}
if se.CostMs < 0 {
t.Fatalf("CostMs = %d, want >= 0", se.CostMs)
}
}
// TestSpeakTool_ExecuteNoAudio 锁住「上游说成功但没给可用音频」:
// 空字节与白名单外格式都归 tts_no_audio,绝不落一个空文件。
func TestSpeakTool_ExecuteNoAudio(t *testing.T) {
t.Parallel()
cases := []struct {
name string
audio mvoice.Audio
}{
{"empty bytes", mvoice.Audio{Format: "wav"}},
{"unknown format", mvoice.Audio{Format: "exe", Data: []byte{1}}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
store := &stubStore{ref: VoiceRef{Name: "x.wav"}}
tk := newSpeakTool(&stubSynth{audio: c.audio, provider: "p"}, store)
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
if code := speakCodeOf(t, err).Code; code != speakReasonNoAudio {
t.Fatalf("code = %q, want %q", code, speakReasonNoAudio)
}
if store.gotName != "" {
t.Fatalf("store was called with %q, want no write at all", store.gotName)
}
})
}
}
// TestSpeakTool_ExecuteStoreFailed 锁住落盘失败:合成成功但副本存不下
// 时不谎报成功 —— 原因是 tts_store_failed,音频字节不会被当作交付物。
func TestSpeakTool_ExecuteStoreFailed(t *testing.T) {
t.Parallel()
tk := newSpeakTool(
&stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2}}, provider: "p"},
&stubStore{err: errors.New("disk full")},
)
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
se := speakCodeOf(t, err)
if se.Code != speakReasonStoreFailed {
t.Fatalf("code = %q, want %q", se.Code, speakReasonStoreFailed)
}
if !errors.Is(err, se.Err) {
t.Fatalf("error chain lost the store error: %v", err)
}
}
// TestSpeakTool_NotConfigured 锁住「合成或落盘缺一个」:不 panic,
// 以结构化失败返回,使回合仍能继续。
func TestSpeakTool_NotConfigured(t *testing.T) {
t.Parallel()
for _, tk := range []speakTool{
newSpeakTool(nil, &stubStore{}),
newSpeakTool(&stubSynth{}, nil),
} {
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
if code := speakCodeOf(t, err).Code; code != speakReasonFailed {
t.Fatalf("code = %q, want %q", code, speakReasonFailed)
}
}
}
// TestSpeakSpec 锁住模型面的接线判定:两个依赖缺任一都不广告
// (模型面与接线前逐字节一致),都在时声明带参数 schema 的 speak。
func TestSpeakSpec(t *testing.T) {
t.Parallel()
if spec := New(Options{}).speakSpec(); spec != nil {
t.Fatalf("unwired speakSpec = %+v, want nil", spec)
}
if spec := New(Options{VoiceSynth: &stubSynth{}}).speakSpec(); spec != nil {
t.Fatalf("half-wired speakSpec = %+v, want nil", spec)
}
spec := New(Options{VoiceSynth: &stubSynth{}, VoiceStore: &stubStore{}}).speakSpec()
if spec == nil {
t.Fatal("wired speakSpec = nil, want a spec")
}
if spec.Name != speakToolName {
t.Fatalf("spec name = %q, want %q", spec.Name, speakToolName)
}
if spec.Parameters == nil {
t.Fatal("spec parameters = nil, want the JSON schema")
}
}
// TestWithSpeakSpec 锁住追加语义:未接线原样返回、接线后追加到末尾
// (因此不受前面任何收缩影响)、已有同名不重复追加。
func TestWithSpeakSpec(t *testing.T) {
t.Parallel()
base := []model.ToolSpec{{Name: "terminal"}, {Name: "web-search"}}
unwired := New(Options{})
if got := unwired.withSpeakSpec(base); len(got) != len(base) {
t.Fatalf("unwired appended: %d tools, want %d", len(got), len(base))
}
wired := New(Options{VoiceSynth: &stubSynth{}, VoiceStore: &stubStore{}})
got := wired.withSpeakSpec(base)
if len(got) != len(base)+1 {
t.Fatalf("wired tools = %d, want %d", len(got), len(base)+1)
}
if last := got[len(got)-1].Name; last != speakToolName {
t.Fatalf("last tool = %q, want %q(speak 必须在末尾)", last, speakToolName)
}
again := wired.withSpeakSpec(got)
if len(again) != len(got) {
t.Fatalf("duplicate speak appended: %d tools, want %d", len(again), len(got))
}
}
+32
View File
@@ -3,6 +3,7 @@ package loop
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
@@ -128,6 +129,37 @@ func notAllowedReason(name string) string {
return "not_allowed: tool " + name + " is not allowed in a group/stranger conversation"
}
// applyDispatcherlessVoiceGate 是 dispatcher 缺席时对 speak 的兜底安全门
// (design/11 §3)。正常接线恒有 dispatcher,安全门由 applyToolSecurityGate
// 走策略引擎;但 speak 由循环自己承载,无 dispatcher 也能执行(invokeOne
// 的短路),若此处不拦,退化配置就会让群聊/陌生人(甚至模型幻觉出的)
// speak 调用真的去合成音频并落盘 —— 绕过身份判定,也白烧一次上游调用。
//
// 口径与策略引擎对未登记工具的处理一致:**默认关闭**,只有主人环境放行。
// 拒绝走与安全门相同的软结果与审计通道(not_allowed + auditToolRejectNotAllowed),
// 使模型看到的原因、前端小字与审计轨迹在不同配置下逐字同源。
func (l *Loop) applyDispatcherlessVoiceGate(ctx context.Context, calls []agent.ToolCall, actions []toolCallAction) {
if !l.voiceEnabled() {
return
}
env := sessionEnvFrom(ctx)
if env.ActorEnv == restrict.ActorOwner {
return
}
for i := range calls {
if !actions[i].run || calls[i].Name != speakToolName {
continue
}
reason := notAllowedReason(calls[i].Name)
actions[i] = toolCallAction{run: false, reason: reason}
l.emitAudit(AuditEvent{
Who: string(env.ActorEnv),
What: auditToolRejectNotAllowed,
Result: reason,
})
}
}
// emitAudit 把 AuditEvent 转发给配置的 OnAudit sink。未接线
// sink 时为空操作,因此在未挂审计 sink 的叶子测试中
// 安全门仍能工作(软 not_allowed 结果)。
@@ -8,7 +8,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
@@ -235,6 +237,75 @@ func TestRun_Stranger_NotAllowed_Blocked(t *testing.T) {
}
}
// TestApplyToolAdmissions_DispatcherlessSpeakGate 锁住退化配置下的同一道
// 安全门:speak 由循环自己承载,没有 dispatcher 也能执行(invokeOne 的
// 短路),所以「没配 dispatcher」绝不能变成绕过 design/11 §3 的旁路 ——
// 群聊/陌生人的 speak 必须在这里被拦成 not_allowed 软结果并留下审计,
// 主人环境照常放行。
func TestApplyToolAdmissions_DispatcherlessSpeakGate(t *testing.T) {
t.Parallel()
var mu sync.Mutex
var audited []AuditEvent
l := New(Options{
VoiceSynth: &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, provider: "p"},
VoiceStore: &stubStore{ref: VoiceRef{Name: "a.wav", MIME: "audio/wav", Size: 1}},
OnAudit: func(ev AuditEvent) {
mu.Lock()
audited = append(audited, ev)
mu.Unlock()
},
})
cases := []struct {
env restrict.ActorEnv
allow bool
}{
{restrict.ActorGroupMember, false},
{restrict.ActorStranger, false},
{restrict.ActorOwner, true},
}
for _, tc := range cases {
mu.Lock()
audited = nil
mu.Unlock()
calls := []agent.ToolCall{{Name: speakToolName, Args: map[string]any{"text": "晚安"}}}
actions := []toolCallAction{{run: true}}
l.applyToolAdmissions(WithSessionEnv(context.Background(), SessionEnv{ActorEnv: tc.env}), nil, calls, actions)
if actions[0].run != tc.allow {
t.Fatalf("env %s run = %v, want %v (reason %q)", tc.env, actions[0].run, tc.allow, actions[0].reason)
}
mu.Lock()
got := append([]AuditEvent(nil), audited...)
mu.Unlock()
if tc.allow {
if len(got) != 0 {
t.Fatalf("env %s audited %+v, want none", tc.env, got)
}
continue
}
if !strings.Contains(actions[0].reason, "not_allowed") {
t.Fatalf("env %s reason = %q, want a not_allowed soft result", tc.env, actions[0].reason)
}
if len(got) != 1 || got[0].What != auditToolRejectNotAllowed || got[0].Who != string(tc.env) {
t.Fatalf("env %s audited %+v, want one %s by %s", tc.env, got, auditToolRejectNotAllowed, tc.env)
}
}
// speak 未接线时没有可执行的旁路,门不该动任何调用。
actions := []toolCallAction{{run: true}}
New(Options{}).applyToolAdmissions(
WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember}),
nil,
[]agent.ToolCall{{Name: speakToolName}},
actions,
)
if !actions[0].run {
t.Fatalf("unwired speak blocked (reason %q), want untouched", actions[0].reason)
}
}
// mustSetPolicy 把 ToolPolicy 应用到具名工具,出错即让测试失败。
func mustSetPolicy(t *testing.T, reg *registry.Registry, name string, pol registry.ToolPolicy) {
t.Helper()
+17 -93
View File
@@ -5,12 +5,10 @@ import (
"encoding/json"
"errors"
"fmt"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// Options.ToolExecution 接受的 ToolExecutionMode 取值。为空时选用
@@ -77,6 +75,9 @@ type toolRunResult struct {
messages []model.Message
executed int
terminate bool
// voice 是本批 speak 调用的结论(M57):nil = 这一批没有要求语音。
// 由 runToolCalls 在结局钩子之后提取,供 run.go 回填 Result.Voice。
voice *OutboundVoice
}
// toolLoopState 携带关键工具循环安全网的每次 run 状态。
@@ -156,6 +157,10 @@ func (l *Loop) runToolCalls(ctx context.Context, messages []model.Message, calls
// 结果落定后的两类观察/改写钩子见 applyToolOutcomeHooks。
l.applyToolOutcomeHooks(ctx, calls, outcomes)
// 语音结论必须在钩子之后提取:AfterOutcome 可以改写 speak 的结果,
// 出站结论要看**模型最终看到的那份**。
res.voice = l.captureVoice(calls, outcomes)
// 钩子可以把每个调用都标记为 terminate 来要求终止本轮
// (OpenClaw 的 shouldTerminateToolBatch)。
if !res.terminate && shouldTerminateBatch(outcomes) {
@@ -231,11 +236,19 @@ func classifyToolBatch(st *toolLoopState, calls []agent.ToolCall) ([]toolCallAct
// 不被允许的调用绝不执行,以 not_allowed 软结果回灌并记录审计;
// 它是该批的最终权威,后续钩子可以收紧但绝不重新放行。②
// BatchAdmit 批次钩子(审计 / 政治审查)拦截整批。③ CallAdmit
// 逐调用否决。dispatcher 缺席时安全门跳过(runToolCalls 已保证
// 此时整批以错误回灌)。
// 逐调用否决。
//
// dispatcher 缺席时安全门走不了(它需要 dispatcher 的策略引擎),
// 其余工具由 runToolCalls 保证以「没有 dispatcher」错误回灌;但
// speak 由循环自己承载、无 dispatcher 也能执行(见 invokeOne 的
// 短路),因此这条退化配置下必须由 applyDispatcherlessVoiceGate
// 补上同一道硬门 —— 否则「没配 dispatcher」就成了绕过 design/11 §3
// 的旁路。
func (l *Loop) applyToolAdmissions(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, actions []toolCallAction) {
if d != nil {
l.applyToolSecurityGate(ctx, d, calls, actions)
} else {
l.applyDispatcherlessVoiceGate(ctx, calls, actions)
}
if l.hooks.BatchAdmit != nil {
if ok, reason := l.hooks.BatchAdmit(ctx, calls); !ok {
@@ -287,95 +300,6 @@ func (l *Loop) applyToolOutcomeHooks(ctx context.Context, calls []agent.ToolCall
}
}
// invokeOne 在其每工具截止时间下执行单个工具调用,发出一个
// tool span、以 tool_execution.start/end 事件流式输出执行进度
// 并归一化结果。执行全程由单一 phase 决策驱动:start 帧带
// running,Invoke 返回后按成败取 done/error,同一取值既决定
// end 帧的 phase,也决定 span 终态(design/19 的 tool 节点
// running/done/error 状态即由此驱动)。
func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) ToolOutcome {
if d == nil {
return ToolOutcome{Err: fmt.Errorf("no tool dispatcher configured for %q", tc.Name), ErrKind: tool.KindExecution}
}
_, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool)
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionStart, model.ToolPhaseRunning, "")
// 路标识桥(design/15 §2.5.7、主人裁决 3):网关在**每一条在途路**上把互斥
// 身份经 loop.WithToolLegID 挂在 ctx 上(普通回合 / 并行路 / 队首续跑路都挂,
// 取值 = 该路的 turnID),这里把它桥接成工具层的 tool.WithLegID —— 工具侧
// 凭 tool.LegIDFrom 读到「我是哪一路」做文件级互斥;未挂载 ⇒ 空串 ⇒ 完全
// 不做互斥,行为逐字不变(兼容硬要求)。
//
// 刻意**不**读 ParallelLegFrom:那条通道只有真并行路才有、且只决定 checkpoint
// 归属(checkpoint.go 的 checkpointAgentID),拿它当互斥身份会让普通回合在
// 「1 普通回合 + 1 并行路」时完全不受保护。
result, err := d.Invoke(tool.WithLegID(ctx, ToolLegIDFrom(ctx)), tc.Name, tc.Args)
phase := model.ToolPhaseDone
if err != nil {
phase = model.ToolPhaseError
}
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionEnd, phase, toolEndFrameSummary(result, err))
out := ToolOutcome{Result: result, Err: err, ErrKind: errorKind(err)}
l.spanProp(toolSpan, "phase", phase)
l.recordToolDone(toolSpan, tc, out)
if err != nil {
l.spanProp(toolSpan, "error", err.Error())
l.spanProp(toolSpan, "error_kind", out.ErrKind)
} else {
recordToolResult(toolSpan, result)
}
l.finishSpan(toolSpan, spanStatusForToolPhase(phase))
return out
}
// emitToolExecution 在循环处于流式路径(配置了 OnStream)时把
// tool_execution 三事件(start / update / end)转发给前端;否则为
// 空操作(被静音的内部流程也会跳过它)。phase 是结构化字段
// (running/done/error),绝不塞进 Delta;工具名走 Tool 字段,
// ID 复用 provider 的 tool call id,使执行帧能与其 tool_call.* 帧关联;
// resultSummary 只在 end 帧、且只在结果真正拿到时非空(口径见 tool_frame_summary.go)。
func (l *Loop) emitToolExecution(ctx context.Context, tc agent.ToolCall, kind model.StreamKind, phase, resultSummary string) {
if l.onStream == nil || isMutedBroadcast(ctx) {
return
}
ev := model.StreamEvent{Kind: kind, Phase: phase, Tool: tc.Name, ID: tc.ID, ResultSummary: resultSummary}
ev.SessionID = loopSession(ctx)
ev.TurnID = loopTurn(ctx)
l.onStream(ev)
}
// spanStatusForToolPhase 把工具执行的终态 phase 映射为 span 终态,
// 使 trace/流程图与 WS 帧从同一个 phase 取值得出一致结论。
// 非终态 phase(running)不会走到这里 —— span 只在执行结束时收尾。
func spanStatusForToolPhase(phase string) trace.SpanStatus {
if phase == model.ToolPhaseError {
return trace.SpanStatusError
}
return trace.SpanStatusOK
}
// invokeParallel 并发运行 idxs 处的调用,上限为循环的
// 并行度,并把每个结果写回 out 的对应调用索引处,使调用方
// 能按原始顺序渲染结果。信号量限制并发,在途
// goroutine 完成时释放其槽位,因此被取消的 context
// (它使每个 Invoke 迅速返回)不会泄露 goroutine。
func (l *Loop) invokeParallel(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, idxs []int, out []ToolOutcome) {
if len(idxs) == 0 {
return
}
sem := make(chan struct{}, l.toolParallelism)
var wg sync.WaitGroup
for _, i := range idxs {
wg.Add(1)
sem <- struct{}{}
go func(i int) {
defer wg.Done()
defer func() { <-sem }()
out[i] = l.invokeOne(ctx, d, calls[i])
}(i)
}
wg.Wait()
}
// 由 classifyToolBatch 为两种跳过原因返回。
func duplicateCallReason() string {
return "duplicate call with identical arguments already issued in this batch"
+107
View File
@@ -0,0 +1,107 @@
package loop
// 本文件承载**单个/一批工具调用的执行**(span、流式帧、结果归一化、
// 有界并行),与 tools.go 的分类、准入与回渲职责分开 —— 从 tools.go
// 抽出,使两边都留在 500 行以内(AGENTS.md §10)。
import (
"context"
"fmt"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// invokeOne 在其每工具截止时间下执行单个工具调用,发出一个
// tool span、以 tool_execution.start/end 事件流式输出执行进度
// 并归一化结果。执行全程由单一 phase 决策驱动:start 帧带
// running,Invoke 返回后按成败取 done/error,同一取值既决定
// end 帧的 phase,也决定 span 终态(design/19 的 tool 节点
// running/done/error 状态即由此驱动)。
func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) ToolOutcome {
// speak 由循环自己承载(见 invokeTool),没有 dispatcher 也能执行;其余
// 工具缺 dispatcher 时仍按原样短路,不为此多开 span 或流式帧。
if d == nil && !(l.voiceEnabled() && tc.Name == speakToolName) {
return ToolOutcome{Err: fmt.Errorf("no tool dispatcher configured for %q", tc.Name), ErrKind: tool.KindExecution}
}
_, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool)
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionStart, model.ToolPhaseRunning, "")
// 分派见 invokeTool:路标识桥(tool.WithLegID)与 speak 的循环内承载都在
// 那里。路标识(design/15 §2.5.7、主人裁决 3):网关在**每一条在途路**上把
// 互斥身份经 loop.WithToolLegID 挂在 ctx 上(普通回合 / 并行路 / 队首续跑路
// 都挂,取值 = 该路的 turnID),工具层凭 tool.LegIDFrom 读到「我是哪一路」
// 做文件级互斥;未挂载 ⇒ 空串 ⇒ 完全不做互斥,行为逐字不变(兼容硬要求)。
//
// 刻意**不**读 ParallelLegFrom:那条通道只有真并行路才有、且只决定 checkpoint
// 归属(checkpoint.go 的 checkpointAgentID),拿它当互斥身份会让普通回合在
// 「1 普通回合 + 1 并行路」时完全不受保护。
result, err := l.invokeTool(ctx, d, tc)
phase := model.ToolPhaseDone
if err != nil {
phase = model.ToolPhaseError
}
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionEnd, phase, toolEndFrameSummary(result, err))
out := ToolOutcome{Result: result, Err: err, ErrKind: errorKind(err)}
l.spanProp(toolSpan, "phase", phase)
l.recordToolDone(toolSpan, tc, out)
if err != nil {
l.spanProp(toolSpan, "error", err.Error())
l.spanProp(toolSpan, "error_kind", out.ErrKind)
} else {
recordToolResult(toolSpan, result)
}
l.finishSpan(toolSpan, spanStatusForToolPhase(phase))
return out
}
// emitToolExecution 在循环处于流式路径(配置了 OnStream)时把
// tool_execution 三事件(start / update / end)转发给前端;否则为
// 空操作(被静音的内部流程也会跳过它)。phase 是结构化字段
// (running/done/error),绝不塞进 Delta;工具名走 Tool 字段,
// ID 复用 provider 的 tool call id,使执行帧能与其 tool_call.* 帧关联;
// resultSummary 只在 end 帧、且只在结果真正拿到时非空(口径见 tool_frame_summary.go)。
func (l *Loop) emitToolExecution(ctx context.Context, tc agent.ToolCall, kind model.StreamKind, phase, resultSummary string) {
if l.onStream == nil || isMutedBroadcast(ctx) {
return
}
ev := model.StreamEvent{Kind: kind, Phase: phase, Tool: tc.Name, ID: tc.ID, ResultSummary: resultSummary}
ev.SessionID = loopSession(ctx)
ev.TurnID = loopTurn(ctx)
l.onStream(ev)
}
// spanStatusForToolPhase 把工具执行的终态 phase 映射为 span 终态,
// 使 trace/流程图与 WS 帧从同一个 phase 取值得出一致结论。
// 非终态 phase(running)不会走到这里 —— span 只在执行结束时收尾。
func spanStatusForToolPhase(phase string) trace.SpanStatus {
if phase == model.ToolPhaseError {
return trace.SpanStatusError
}
return trace.SpanStatusOK
}
// invokeParallel 并发运行 idxs 处的调用,上限为循环的
// 并行度,并把每个结果写回 out 的对应调用索引处,使调用方
// 能按原始顺序渲染结果。信号量限制并发,在途
// goroutine 完成时释放其槽位,因此被取消的 context
// (它使每个 Invoke 迅速返回)不会泄露 goroutine。
func (l *Loop) invokeParallel(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, idxs []int, out []ToolOutcome) {
if len(idxs) == 0 {
return
}
sem := make(chan struct{}, l.toolParallelism)
var wg sync.WaitGroup
for _, i := range idxs {
wg.Add(1)
sem <- struct{}{}
go func(i int) {
defer wg.Done()
defer func() { <-sem }()
out[i] = l.invokeOne(ctx, d, calls[i])
}(i)
}
wg.Wait()
}
+40
View File
@@ -0,0 +1,40 @@
package loop
// OutboundVoice 是一次 Run 的**出站语音结论**:模型在回合内调用 speak 工具
// 请求合成语音时,循环把这次请求的结局归一化到本结构,随 Result 交还调用方
// (网关据此决定这条回复是发音频还是发文本,持久化层据此记下「这条是语音」
// 与降级原因)。它是进程内类型,不直接序列化给客户端。
//
// 三种状态互斥且由字段组合判定:
//
// - Result.Voice == nil —— 本回合**没有**请求语音,回复就是纯文本;
// - Name != "" —— 合成成功,且音频副本**已经落盘**(Name 即服务端存储名,
// Mime/Size 描述该副本);
// - Requested && Name == "" && FallbackReason != "" —— 她**请求了**语音,
// 但合成降级为文本(如 tts_timeout);FallbackReason 是机器可读的降级码,
// 客户端可以据此特殊显示「这条本来想用语音」。
//
// 语音是她的表达选择,不是身份或权限判定 —— 谁能用语音仍由渠道层 actor
// 元数据 + 工具可达性标记决定,本结构只记录结局。
type OutboundVoice struct {
// Requested 报告本回合是否发起过语音合成请求(成功与降级都为 true)。
Requested bool
// Name 是落盘音频副本的服务端存储名;空表示没有可用音频(降级或未请求)。
Name string
// Mime 是音频副本的媒体类型(如 audio/wav),随 Name 一起有意义。
Mime string
// Size 是音频副本的字节数。
Size int64
// DurationMs 是音频时长(毫秒)。**不知道就是 0** —— 本项目绝不用比特率
// 或字数估算时长(与 voice.Copy.DurationMs 同一口径);0 表示「未解析」,
// 不表示零长度音频。
DurationMs int64
// CostMs 是这次合成实际耗时(毫秒),用于对照预算看是否接近超时。
CostMs int
// BudgetMs 是本次合成被允许的超时预算(毫秒),由文本 rune 数推导
// (见 speakBudgetMs);未请求时为 0。
BudgetMs int
// FallbackReason 是降级原因码(如 tts_timeout),仅在「请求了但没有音频」
// 时非空;它让调用方与客户端能把「想用语音但没成」与「本来就用文本」区分开。
FallbackReason string
}
+172
View File
@@ -0,0 +1,172 @@
package loop
// 本文件是 speak 的接线面(M57):两个注入依赖的接口、接线判定、
// 以及「本回合的出站语音结论」从工具结局到 Result.Voice 的提取。
// 它们刻意留在循环里而不是工具包里 —— speak 需要本回合的人格与
// 预算口径,而这两样只有循环持有。
import (
"context"
"errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
)
// VoiceSynth 是 speak 需要的合成能力(*tts.Service 天然满足)。
// 接口收窄到**一个方法**:循环只需要「给定文本拿回音频与提供者」,
// 发音人解析、降级链与配额全在实现内部,循环不重复实现它们。
type VoiceSynth interface {
// TTSWithProvider 合成文本并返回音频与**实际**提供者标识
// (降级后是备用的那个),供日志与可观测性使用。
TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error)
}
// VoiceRef 是音频副本落盘后的交付描述:循环只需要「叫什么、什么类型、
// 多大」,与存储层的哈希/去重标记/磁盘路径无关。单独定义而不复用
// documents.Ref 是为了让循环不 import documents 包 —— 该包的测试会
// import 循环,反向依赖构成测试期的 import cycle。
type VoiceRef struct {
// Name 是服务端存储名(客户端凭它取回音频)。
Name string
// MIME 是存储层字节嗅探所得,不是合成侧的声明。
MIME string
// Size 是该副本的字节数。
Size int64
}
// VoiceStore 是 speak 需要的落盘能力。只要一个方法:音频副本必须走文档
// 存储的内容寻址 + 权限口径,而不是循环自己写文件。*documents.Store 经
// VoiceStoreFunc 适配即可接上(见该类型的用法说明)。
type VoiceStore interface {
// SaveAudio 落盘一份音频副本并返回其交付描述。
SaveAudio(ctx context.Context, origName string, data []byte) (VoiceRef, error)
}
// VoiceStoreFunc 把函数适配为 VoiceStore(与 ImageResolveFunc 同一惯例)。
// 接线方用它把存储层接到本包的接缝上,避免循环反向依赖 documents:
//
// loop.VoiceStoreFunc(func(ctx context.Context, name string, data []byte) (loop.VoiceRef, error) {
// ref, err := docs.SaveAudio(ctx, name, data)
// return loop.VoiceRef{Name: ref.Name, MIME: ref.MIME, Size: ref.Size}, err
// })
type VoiceStoreFunc func(ctx context.Context, origName string, data []byte) (VoiceRef, error)
// SaveAudio 实现 VoiceStore。
func (f VoiceStoreFunc) SaveAudio(ctx context.Context, origName string, data []byte) (VoiceRef, error) {
return f(ctx, origName, data)
}
// voiceEnabled 报告 speak 是否已接线。两个依赖**都**非 nil 才算 ——
// 只接了合成却没有存储时,调用必然在落盘一步失败,不如干脆不广告,
// 让模型面与接线前逐字节一致。
func (l *Loop) voiceEnabled() bool {
return l != nil && l.voiceSynth != nil && l.voiceStore != nil
}
// withSpeakSpec 把 speak 追加到模型面的工具声明末尾。它由 toolSchemas
// 在**所有收缩之后**调用,因此 speak 不受能力允许集(quiet/awake)与
// 技能轮次的 allowed-tools 裁剪 —— speak 不在任何内置 toolset 里,
// 走那些过滤就等于永远不广告。代价(群聊/陌生人也能看到 speak)由
// 执行侧的工具安全门兜住:speak 未注册进能力登记表,非主人环境下
// Policy.Allow 以「未知工具」拒绝,fail-closed。
func (l *Loop) withSpeakSpec(schemas []model.ToolSpec) []model.ToolSpec {
spec := l.speakSpec()
if spec == nil {
return schemas
}
for i := range schemas {
if schemas[i].Name == spec.Name {
return schemas
}
}
out := make([]model.ToolSpec, 0, len(schemas)+1)
out = append(out, schemas...)
return append(out, *spec)
}
// applyBatchVoice 把一批工具调用的语音结论回填进本回合的 Result。
// 后面批次里没有 speak 时保留前面那批的结论 —— 「这条回复想用语音」
// 发生在哪一批都算数。
//
// 跨批也守 captureVoice 的同一条优先级:**已落盘的音频绝不被后续批次的
// 失败抹掉**。同一批里由 captureVoice 自己保证(成功优先于失败),但批次
// 之间是两次独立结论,若这里无条件覆盖,则「先成功合成 abc.wav、下一轮
// 换个参数再试却失败」会把交付物退化成 tts_failed —— 音频已经存在,退回
// 文本既没有收益也违背她那次表达的意愿。反过来(先降级后成功)仍然覆盖:
// 后到的成功就是这条消息最终的语音。
func applyBatchVoice(res *Result, batch *toolRunResult) {
if batch.voice == nil {
return
}
if res.Voice != nil && res.Voice.Name != "" && batch.voice.Name == "" {
return
}
res.Voice = batch.voice
}
// captureVoice 从一批调用的结局里提取本回合的出站语音结论。返回 nil
// 表示「这一批没有要求语音」,Result.Voice 因此保持 nil,客户端按纯
// 文本呈现 —— 与没有 speak 之前逐字节一致。
//
// 多个 speak 调用时取**最后一个成功的**:成功意味着音频真的落了盘,
// 后续的重试即使失败也不该让已经交付的音频消失。全都失败时取最后
// 一个失败的原因,使客户端仍能区分「想用语音但没成」与「本来就用
// 文本」。
func (l *Loop) captureVoice(calls []agent.ToolCall, outcomes []ToolOutcome) *OutboundVoice {
var lastOK, lastFail *OutboundVoice
n := len(calls)
if len(outcomes) < n {
n = len(outcomes)
}
for i := 0; i < n; i++ {
if calls[i].Name != speakToolName || outcomes[i].Skipped {
continue
}
if outcomes[i].Err == nil {
lastOK = synthesizedVoice(outcomes[i].Result)
continue
}
lastFail = degradedVoice(outcomes[i].Err)
}
if lastOK != nil {
return lastOK
}
return lastFail
}
// synthesizedVoice 把 speak 成功时的结构化结果翻成 OutboundVoice。
// 结果被钩子改写成非对象时返回 nil:拿不到存储名就播放不了,此时
// 按文本呈现是唯一诚实的选择(DurationMs 保持 0 = 未解析)。
func synthesizedVoice(result any) *OutboundVoice {
m, ok := result.(map[string]any)
if !ok {
return nil
}
v := &OutboundVoice{Requested: true}
v.Name, _ = m["name"].(string)
v.Mime, _ = m["mime"].(string)
v.Size, _ = m["size"].(int64)
v.DurationMs, _ = m["durationMs"].(int64)
v.CostMs, _ = m["costMs"].(int)
v.BudgetMs, _ = m["budgetMs"].(int)
return v
}
// degradedVoice 把 speak 的失败翻成「想用语音但退回了文本」。原因码
// 优先取结构化错误的 Code(超时/无音频/落盘失败/文本过长),非结构化
// 失败一律归 tts_failed,绝不把空原因码交给客户端。
func degradedVoice(err error) *OutboundVoice {
v := &OutboundVoice{Requested: true, FallbackReason: speakReasonFailed}
var se *speakError
if !errors.As(err, &se) {
return v
}
if se.Code != "" {
v.FallbackReason = se.Code
}
v.BudgetMs = se.BudgetMs
v.CostMs = se.CostMs
return v
}
+296
View File
@@ -0,0 +1,296 @@
package loop
import (
"context"
"errors"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// 本文件锁住「speak 调用 → Result.Voice」这条出站链(M57):
// 成功、降级、没调三态在叶子层,加上一次端到端回合,证明模型面真的
// 被广告了 speak、调用真的被执行、结论真的落在 Result 上。
// TestCaptureVoice_States 锁住三态:成功带交付物、失败带原因码与
// 预算/耗时、没调(或只调了别的工具)返回 nil。
func TestCaptureVoice_States(t *testing.T) {
t.Parallel()
l := New(Options{})
if v := l.captureVoice(
[]agent.ToolCall{{Name: "terminal"}},
[]ToolOutcome{{Result: "ok"}},
); v != nil {
t.Fatalf("non-speak batch voice = %+v, want nil", v)
}
ok := l.captureVoice(
[]agent.ToolCall{{Name: speakToolName}},
[]ToolOutcome{{Result: map[string]any{
"name": "abc.wav", "mime": "audio/wav", "size": int64(9),
"durationMs": int64(0), "budgetMs": 3025, "costMs": 120,
}}},
)
if ok == nil || !ok.Requested {
t.Fatalf("success voice = %+v, want Requested", ok)
}
if ok.Name != "abc.wav" || ok.Mime != "audio/wav" || ok.Size != 9 {
t.Fatalf("success voice = %+v, want the stored ref", ok)
}
if ok.BudgetMs != 3025 || ok.CostMs != 120 || ok.DurationMs != 0 {
t.Fatalf("success voice = %+v, want budget/cost and unparsed duration", ok)
}
if ok.FallbackReason != "" {
t.Fatalf("success FallbackReason = %q, want empty", ok.FallbackReason)
}
bad := l.captureVoice(
[]agent.ToolCall{{Name: speakToolName}},
[]ToolOutcome{{Err: &speakError{Code: speakReasonTimeout, BudgetMs: 3000, CostMs: 3100}}},
)
if bad == nil || !bad.Requested {
t.Fatalf("failure voice = %+v, want Requested", bad)
}
if bad.FallbackReason != speakReasonTimeout || bad.BudgetMs != 3000 || bad.CostMs != 3100 {
t.Fatalf("failure voice = %+v, want the timeout code and budget/cost", bad)
}
if bad.Name != "" {
t.Fatalf("failure name = %q, want empty(没有交付物)", bad.Name)
}
// 非结构化失败也要有原因码,绝不把空串交给客户端。
raw := l.captureVoice(
[]agent.ToolCall{{Name: speakToolName}},
[]ToolOutcome{{Err: errors.New("boom")}},
)
if raw == nil || raw.FallbackReason != speakReasonFailed {
t.Fatalf("raw failure voice = %+v, want %q", raw, speakReasonFailed)
}
}
// TestCaptureVoice_LastSuccessWins 锁住多次 speak 的取用规则:成功优先
// 于失败(音频已经落盘就交付),同类取最后一个(这条消息最后的意愿)。
func TestCaptureVoice_LastSuccessWins(t *testing.T) {
t.Parallel()
l := New(Options{})
calls := []agent.ToolCall{{Name: speakToolName}, {Name: speakToolName}, {Name: speakToolName}}
// 先成功、后失败:交付已落盘的那份。
got := l.captureVoice(calls[:2], []ToolOutcome{
{Result: map[string]any{"name": "first.wav", "mime": "audio/wav", "size": int64(1)}},
{Err: &speakError{Code: speakReasonFailed}},
})
if got == nil || got.Name != "first.wav" {
t.Fatalf("voice = %+v, want first.wav(成功优先于后续失败)", got)
}
// 两次都成功:取后者。
got = l.captureVoice(calls[:2], []ToolOutcome{
{Result: map[string]any{"name": "first.wav"}},
{Result: map[string]any{"name": "second.wav"}},
})
if got == nil || got.Name != "second.wav" {
t.Fatalf("voice = %+v, want second.wav(后者覆盖前者)", got)
}
// 被跳过的调用不算数。
if v := l.captureVoice(calls[:1], []ToolOutcome{{Skipped: true, Reason: "duplicate"}}); v != nil {
t.Fatalf("skipped voice = %+v, want nil", v)
}
}
// TestRun_VoiceResult 是一次端到端回合:speak 出现在模型面里、被调用
// 后真的走了注入的合成与落盘,结论落在 Result.Voice 上。
func TestRun_VoiceResult(t *testing.T) {
t.Parallel()
call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{
ID: "call_speak_1",
Name: speakToolName,
Args: map[string]any{"text": "晚安", "emotion": "温柔"},
}}}
ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "晚安"}}}}
td := dispatcherWith(t, &mockTool{name: "terminal", result: "ok"})
synth := &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2, 3}}, provider: "emotiontts"}
store := &stubStore{ref: VoiceRef{Name: "abc.wav", MIME: "audio/wav", Size: 3}}
l := New(Options{
Agent: newTestAgent(t, ad, td),
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
VoiceSynth: synth,
VoiceStore: store,
})
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.ToolCallsExecuted != 1 {
t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted)
}
// ① 模型面确实被广告了 speak(否则模型不可能调它)。
if len(ad.streamReqs) == 0 {
t.Fatal("no streaming request captured")
}
if set := toolSet(ad.streamReqs[0].Tools); !set[speakToolName] {
t.Fatalf("advertised tools = %v, want %q", toolNames(ad.streamReqs[0].Tools), speakToolName)
}
// ② 调用真的走了注入的合成与落盘。
if synth.gotText != "晚安" {
t.Fatalf("synth text = %q, want 晚安", synth.gotText)
}
if store.gotName != "speak.wav" {
t.Fatalf("origName = %q, want speak.wav", store.gotName)
}
// ③ 结论落在 Result.Voice 上。
if res.Voice == nil {
t.Fatal("Result.Voice = nil, want the synthesized audio")
}
if !res.Voice.Requested || res.Voice.Name != "abc.wav" || res.Voice.Mime != "audio/wav" || res.Voice.Size != 3 {
t.Fatalf("Result.Voice = %+v, want the stored ref", res.Voice)
}
if res.Voice.FallbackReason != "" {
t.Fatalf("FallbackReason = %q, want empty", res.Voice.FallbackReason)
}
}
// TestRun_VoiceDegraded 锁住端到端的降级:合成失败时回合照常结束
// (绝不因语音失败而失败),Result.Voice 带上原因码让客户端特殊显示。
func TestRun_VoiceDegraded(t *testing.T) {
t.Parallel()
call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{
ID: "call_speak_2",
Name: speakToolName,
Args: map[string]any{"text": "晚安"},
}}}
ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "晚安"}}}}
l := New(Options{
Agent: newTestAgent(t, ad, dispatcherWith(t)),
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
VoiceSynth: &stubSynth{err: errors.New("upstream down")},
VoiceStore: &stubStore{},
})
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Text != "晚安" {
t.Fatalf("Text = %q, want the text answer(语音失败不得拖垮回合)", res.Text)
}
if res.Voice == nil || res.Voice.FallbackReason != speakReasonFailed {
t.Fatalf("Result.Voice = %+v, want %q", res.Voice, speakReasonFailed)
}
if res.Voice.Requested != true {
t.Fatalf("Result.Voice.Requested = %v, want true(确实想用语音)", res.Voice.Requested)
}
}
// TestRun_NoSpeakNoVoice 锁住「不接线就没有语音面」:未注入依赖时
// 模型面里没有 speak,Result.Voice 保持 nil —— 与接线前逐字节一致。
func TestRun_NoSpeakNoVoice(t *testing.T) {
t.Parallel()
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Model: "m", Content: "晚安"}}}}
l := New(Options{
Agent: newTestAgent(t, ad, dispatcherWith(t, &mockTool{name: "terminal", result: "ok"})),
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
})
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Voice != nil {
t.Fatalf("Result.Voice = %+v, want nil", res.Voice)
}
if len(ad.streamReqs) == 0 {
t.Fatal("no streaming request captured")
}
if set := toolSet(ad.streamReqs[0].Tools); set[speakToolName] {
t.Fatalf("advertised tools = %v, must not contain %q", toolNames(ad.streamReqs[0].Tools), speakToolName)
}
}
// TestInvokeTool_SpeakWithoutDispatcher 锁住 speak 不依赖 dispatcher:
// 未接线时普通工具仍按原样报「没有 dispatcher」,接线后 speak 照常执行。
func TestInvokeTool_SpeakWithoutDispatcher(t *testing.T) {
t.Parallel()
wired := New(Options{
VoiceSynth: &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, provider: "p"},
VoiceStore: &stubStore{ref: VoiceRef{Name: "a.wav", MIME: "audio/wav", Size: 1}},
})
out := wired.invokeOne(context.Background(), nil, agent.ToolCall{
Name: speakToolName, Args: map[string]any{"text": "晚安"},
})
if out.Err != nil {
t.Fatalf("speak without dispatcher err = %v, want nil", out.Err)
}
plain := New(Options{})
out = plain.invokeOne(context.Background(), nil, agent.ToolCall{Name: "terminal"})
if out.Err == nil || out.ErrKind != tool.KindExecution {
t.Fatalf("nil dispatcher outcome = %+v, want an execution error", out)
}
}
// TestApplyBatchVoice_KeepsCrossBatchSuccess 锁住跨批的语音优先级:批次之间
// 是两次独立结论,后一批换个参数失败时**绝不能**把前一批已落盘的音频抹成
// tts_failed —— 交付物已经存在,退回文本既没有收益也违背她那次表达的意愿。
// 反过来(先降级后成功)后到的成功仍然覆盖;后一批没有 speak 时保留结论。
func TestApplyBatchVoice_KeepsCrossBatchSuccess(t *testing.T) {
t.Parallel()
delivered := &OutboundVoice{Requested: true, Name: "abc.wav", Mime: "audio/wav", Size: 3}
degraded := &OutboundVoice{Requested: true, FallbackReason: speakReasonFailed}
res := &Result{}
applyBatchVoice(res, &toolRunResult{voice: delivered})
applyBatchVoice(res, &toolRunResult{voice: degraded})
if res.Voice == nil || res.Voice.Name != "abc.wav" || res.Voice.FallbackReason != "" {
t.Fatalf("success then cross-batch failure voice = %+v, want the delivered abc.wav", res.Voice)
}
// 先降级后成功:后到的成功就是这条消息最终的语音。
res = &Result{}
applyBatchVoice(res, &toolRunResult{voice: degraded})
applyBatchVoice(res, &toolRunResult{voice: delivered})
if res.Voice == nil || res.Voice.Name != "abc.wav" || res.Voice.FallbackReason != "" {
t.Fatalf("degraded then success voice = %+v, want abc.wav", res.Voice)
}
// 后一批没有 speak:前面那批的结论照旧算数。
res = &Result{}
applyBatchVoice(res, &toolRunResult{voice: delivered})
applyBatchVoice(res, &toolRunResult{})
if res.Voice == nil || res.Voice.Name != "abc.wav" {
t.Fatalf("batch without voice = %+v, want the earlier abc.wav", res.Voice)
}
}
// TestVoiceStoreFunc 锁住接线方用的函数适配器:它满足 VoiceStore,且
// 原样转发原始名与字节(接线方正是靠它把 documents.Store 接到接缝上,
// 而不让循环反向 import documents)。
func TestVoiceStoreFunc(t *testing.T) {
t.Parallel()
var gotName string
var gotData []byte
var store VoiceStore = VoiceStoreFunc(func(_ context.Context, origName string, data []byte) (VoiceRef, error) {
gotName, gotData = origName, data
return VoiceRef{Name: "saved.wav", MIME: "audio/wav", Size: int64(len(data))}, nil
})
ref, err := store.SaveAudio(context.Background(), "speak.wav", []byte{7, 8})
if err != nil {
t.Fatalf("SaveAudio: %v", err)
}
if gotName != "speak.wav" || len(gotData) != 2 {
t.Fatalf("forwarded (%q, %d bytes), want (speak.wav, 2 bytes)", gotName, len(gotData))
}
if ref.Name != "saved.wav" || ref.Size != 2 {
t.Fatalf("ref = %+v, want the adapter's own result", ref)
}
}
+14 -1
View File
@@ -119,7 +119,7 @@ func decodeBase64(src string) (string, []byte, error) {
// 网关/PROXY 返回的 HTML 错误页绝不会被当成 png 送进模型。
func fetchURL(ctx context.Context, rawURL string, client *http.Client) (string, []byte, error) {
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
return "", nil, fmt.Errorf("vision: %w: %q", ErrUnsupportedSource, rawURL)
return "", nil, fmt.Errorf("vision: %w: %q", ErrUnsupportedSource, sourceOrigin(rawURL))
}
u, err := url.Parse(rawURL)
if err != nil {
@@ -242,6 +242,19 @@ func fileExists(path string) bool {
return err == nil && info.Mode().IsRegular()
}
// sourceOrigin 给出可进错误串的「来源」形态:只有 scheme://host(见
// trace.HostOf),路径、查询串与 userinfo 一律不出 —— 一个被拒的来源地址同样
// 可能带签名 query,原样回显等于把它写进日志与工单。
//
// 解析不出主机(`file:///…`、相对路径、畸形串)时回占位符而**绝不回落原串**:
// 回落就是泄漏,而空串会让错误文案失去「这次拒绝的是哪一类地址」的信息。
func sourceOrigin(rawURL string) string {
if origin := trace.HostOf(rawURL); origin != "" {
return origin
}
return "<unknown source>"
}
// defaultHTTPClient 构造 DevDocs/02-development-constraints.md §2.3
// 所述的 HTTP client。这里每次调用返回一个新 client,
// 而不是包级全局变量——后者为约束所禁止。
@@ -78,3 +78,56 @@ func TestDescribeDropsURLFromBuildRequestError(t *testing.T) {
t.Fatalf("err = %v, want ErrUpstream(分类不许被带偏)", err)
}
}
// TestFetchURLUnsupportedSourceReportsOriginOnly 钉住 M55 的那处 URL 出口:
// fetchURL 拒绝非 http(s) 来源时,错误串里只许有**来源**(scheme://host,
// 见 trace.HostOf),不得带路径、查询串与 userinfo —— 被拒的地址同样可能带
// 签名 query,而这条错误会进日志与工单。
//
// 变异:把 `sourceOrigin(rawURL)` 改回 `rawURL` ⇒ 本用例必红(secret、路径、
// 用户名都会出现);把兜底改回空串 ⇒ 第三条用例失去 "<unknown source>"。
func TestFetchURLUnsupportedSourceReportsOriginOnly(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
wantOrigin string
leaks []string
}{
{
name: "带 query 的 ftp",
in: "ftp://files.example.com/a/b.png?access_token=" + urlCauseQuerySecret,
wantOrigin: "ftp://files.example.com",
leaks: []string{urlCauseQuerySecret, "access_token", "/a/b.png"},
},
{
name: "带 userinfo 的 ftp",
in: "ftp://" + urlCauseUser + ":svc-pass@files.example.com/p.png",
wantOrigin: "ftp://[redacted]@files.example.com",
leaks: []string{urlCauseUser, "svc-pass", "/p.png"},
},
{
name: "没有主机的 file",
in: "file:///etc/passwd?x=1",
wantOrigin: "<unknown source>",
leaks: []string{"/etc/passwd", "x=1"},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, _, err := fetchURL(context.Background(), c.in, nil)
if !errors.Is(err, ErrUnsupportedSource) {
t.Fatalf("err = %v, want ErrUnsupportedSource", err)
}
msg := err.Error()
for _, leak := range c.leaks {
if strings.Contains(msg, leak) {
t.Fatalf("错误串带出了地址细节 %q:%v", leak, err)
}
}
if !strings.Contains(msg, c.wantOrigin) {
t.Fatalf("错误串没给出可读来源 %q:%v", c.wantOrigin, err)
}
})
}
}
+13 -6
View File
@@ -7,6 +7,8 @@ import (
"time"
"unicode"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 本文件是 design/11 §4「敏感操作代审」的**实质判定**侧(主人 2026-10-05 裁决的口径 A):
@@ -28,8 +30,9 @@ const judgeTimeout = 5 * time.Second
// 不需要整段派发文本(成本受控 + 降低把长文本搬进判定问句的机会)。
const maxTaskRunes = 1000
// maxReasonRunes 是判定理由可进入审计/前端小字的字符上限(同样是为了成本与
// 不把长文本搬进呈现层)。超出按 rune 截断。
// maxReasonRunes 是判定理由**进入清洗前**的字符上限:理由是模型自由文本,先按
// rune 截断,避免在无界文本上跑凭据匹配(成本受控)。最终出日志/小字的长度由
// trace.RedactText 的 80 rune 档决定——两处都拦,短的那档生效。
const maxReasonRunes = 200
// 放行被拒时的理由口径。判不出来(未装配判定器 / 调用失败 / 超时 / 输出不可解析)
@@ -176,9 +179,13 @@ func (a *Approver) logf(msg string, ap *Approval, err error) {
a.log.Warn(msg, fields...)
}
// cleanReason 把判定器给的理由收敛成可进日志与前端的一行:压掉控制字符与连续空白,
// 截到 maxReasonRunes。全空时回落到 reasonOutOfScope,使拒绝**绝不静默**(人总能看到
// 一条理由)。
// cleanReason 把判定器给的理由收敛成可进日志与前端的一行:压掉控制字符与连续空白、
// 截到 maxReasonRunes,最后过 trace.RedactText(80 rune 档)。
//
// 打码这一步是**必须**的:理由是**模型自由文本**,判定器完全可能把它在参数摘要里
// 看到的凭据原样抄进理由(「不该把 Authorization: Bearer … 发出去」),而这条理由
// 要进审计日志与前端小字两个读面。它与其余出图文本走同一套清洗,绝不原样落日志。
// 全空时回落到 reasonOutOfScope,使拒绝**绝不静默**(人总能看到一条理由)。
func cleanReason(reason string) string {
var b strings.Builder
space := false
@@ -198,7 +205,7 @@ func cleanReason(reason string) string {
if out == "" {
return reasonOutOfScope
}
return clip(out, maxReasonRunes)
return trace.RedactText(clip(out, maxReasonRunes))
}
// clip 把 s 截到至多 max 个 rune,超出时追加省略号(max 非正时原样返回)。
@@ -320,6 +320,44 @@ func TestReviewAndApprove_WritesJudgementReasonToAuditLog(t *testing.T) {
}
}
// TestJudgeCall_ReasonIsRedactedBeforeLogging 钉住「判定理由出日志前打码」:理由是
// **模型自由文本**,判定器完全可能把它在参数摘要里看到的凭据原样抄进理由,而这条
// 理由要进审计日志与前端小字两个读面(M54)。
//
// 🔴 断言打在**日志输出字节**上(真 slog handler → 缓冲区):删掉 cleanReason 里的
// trace.RedactText,raw 凭据就会原样出现在审计行里,本用例随即变红。
func TestJudgeCall_ReasonIsRedactedBeforeLogging(t *testing.T) {
t.Parallel()
const rawSecret = "sk-live-9f8e7d6c5b4a3210"
var buf bytes.Buffer
ap := NewApprover(logger.New(logger.Options{Level: "debug", Format: "json", Out: &buf}),
WithSubagentOrigin(subagentOrigin("整理导出文件")),
WithCallJudge(&stubJudge{verdict: JudgeVerdict{
Allowed: false,
Reason: "越界:任务不需要带着 api_key=" + rawSecret + " 去请求上游",
}}),
)
got, err := ap.ReviewAndApprove(context.Background(), "昔涟", "执行命令:terminal curl -H 'x'", "curl")
if err != nil {
t.Fatalf("ReviewAndApprove: %v", err)
}
// 日志面是主断言:审计行里只能出现打码后的形态。
out := buf.String()
if strings.Contains(out, rawSecret) {
t.Fatalf("审计日志里出现了原样凭据: %s", out)
}
for _, want := range []string{`"reason":`, "[redacted]", "越界:任务不需要带着"} {
if !strings.Contains(out, want) {
t.Fatalf("审计日志缺少 %q(打码不能把理由打没): %s", want, out)
}
}
// 同一条理由也进前端小字(Approval.Reason),因此读面之外的返回值同样不许带原文。
if strings.Contains(got.Reason, rawSecret) {
t.Fatalf("Approval.Reason 里带着原样凭据(会进前端小字): %q", got.Reason)
}
}
// TestNewApprover_WarnsWhenJudgeHasNoOrigin:接线错误(装配了判定器却没有来源解析 ⇒
// 判定永不触发)必须显式告警,不能静默空转。
func TestNewApprover_WarnsWhenJudgeHasNoOrigin(t *testing.T) {
+27 -24
View File
@@ -2,38 +2,41 @@ package trace
import "strings"
// 本文件是「上游错误字段能不能出镜」的唯一守卫。
// 本文件是「上游错误里的某个字段能不能出镜」的守卫之一:只放行「像协议枚举」的短串。
//
// 背景:UpstreamErrorInfo 只解析 error.code / error.type 两个字段,而它们**可能**是
// 自由文本面 —— 主流上游里它们确实是枚举(invalid_request_error、overloaded_error、
// 背景:UpstreamErrorInfo 只解析 error.code / error.type 两个字段,而它们是**自由
// 文本面** —— 主流上游里它们确实是枚举(invalid_request_error、overloaded_error、
// Throttling.RateQuota …),但网关、代理与兼容层可以往里面塞任何东西,包括回显的
// 用户原文。因此这两个字段出镜前必须像令牌:短、只含标识符字符。
//
// 丢弃的代价只是少一条分类提示;保留一个不受控字段的代价是她的话进日志。这道守卫
// 原先在 runtime/model(safeErrorToken)与 runtime/multimodal/voice
// (dashscopeErrorToken)各有一份逐字同形的实现,M46 把同一份收敛到这里。
// 丢弃的代价只是少一条分类提示;保留一个不受控字段的代价是她的话进日志。
//
// 🔴 M49 收口:守卫的**应用点**同样收进本包 —— UpstreamError 构造时对 error.code /
// error.type 各调一次(构造即安全,调用点不必也不该再守),构造后的注入走
// UpstreamErrorInfo.SetCode / SetType(同一道守卫)。
// 🔴 M49 收口的是**应用点**,不是调用权:UpstreamError 构造时对 error.code /
// error.type 各守一次(构造即安全),构造后的注入走 SetCode / SetType(同一道守
// 卫)—— 这样一来「新调用点默认安全」,不必也不该在 model/voice 里各抄一份。
//
// 🔴 M50 修正口径(**取代**原先「本包之外的 Go 代码里不应出现 ErrorToken 调用」的说法;
// 那条已被证伪:M50 之后本包外确有正当调用点)。判据是**字段的类别**,不是调用点的位置:
// 🔴 M51 纠正口径(2026-10-26,复审 p2 裁定):本文件旧注释写的是「包外不应出现
// ErrorToken 调用」。那句话把 M48 的真实教训(**调用方逐点补守卫**会漏)错译成了
// 「只有本包能守」,于是把「按**字段类别**判定」这条正确口径一并否掉,而现实里包外
// 一直有正当调用。正确口径按字段类别分:
//
// - **协议枚举字段**(信封的 status / code / type、DashScope 的 request_id 这类
// 「一格一个值」的字段)**可以**在渲染点调用本守卫后再出镜。真实取值是短令牌 ⇒
// 原样通过;上游若在枚举位上塞散文 ⇒ 整体变空串。现行调用点:
// `plugins/channels/qq_obv11`(信封 status,两处)、`plugins/channels/weixin_ilink`
// (扫码 status 与 redirect_host)、`runtime/multimodal/voice/dashscope_error.go`
// (request_id —— M50 之前就存在的正当例外)。
// - **自由文本字段**(message / description / msg / wording / 服务端错误说明头 …)
// **不得**靠本守卫出镜。它们的问题不是形状(短的人话照样像令牌),而是取值的
// 控制权:上游想写什么就写什么,而它写的常常是被拒的请求正文 —— 也就是她的话。
// 这类字段的口径是**默认不带**;要带必须显式 opt-in,并在保留时过 RedactText
// (见 trace.UpstreamError 的 WithUpstreamMessage 与
// internal/channel/adapter/httpclient 的 WithEnvelopeMessage)。
// - **协议枚举字段** —— error.code / error.type、CDP `error.message` 里的协议错误
// 名、各类状态码:值域是协议定义的短标识符 ⇒ 用 ErrorToken 守。像枚举就保留,
// 散文(带空格、标点、超长)一律丢弃;
// - **自由文本字段** —— error.message、模型/对端「说的话」、页面异常文本:**默认
// 不带**;确实要带时按「带上限地带」处理(rune 上限 + RedactText,见
// channel/adapter 的 reviewExcerpt、services/channelbridge 的 judgeReason、trace.UpstreamError 的 WithUpstreamMessage 与 httpclient 的 WithEnvelopeMessage),
// 绝不拿 ErrorToken 当清洗(它只会把整段话删掉,不是清洗)。
//
// 简言之:本守卫回答「这个令牌形状的枚举值能不能露」,不回答「这段人话能不能露」。
// 包外调用的现存例子(都是枚举口径,属正当用法):
// - `internal/capability/browser/cdp.go` 的 cdpError.Error():CDP error.message
// 只有像协议枚举才出镜,其余只留字节数(M51);
// - `internal/runtime/multimodal/voice/dashscope_error.go`:DashScope 的 code/type
// (M46 起就在包外调用)。
//
// 因此现在的约束只有一条:**调用前先确认那个字段是枚举口径**;自由文本字段不许用
// 它冒充清洗,也不许为了「新调用点安全」而在本文件之外复制一份守卫实现 —— 守卫本身
// 仍然只有这一份。
// maxErrorTokenBytes 是 error.code / error.type / request_id 允许出镜的长度上限。
// 64 字节足够容纳任何真实的枚举与 UUID 形状,又远小于「一段话」。
+22 -9
View File
@@ -306,16 +306,29 @@ type Audio struct {
// redactURL 把 baseURL 收敛成**可打日志**的形态:只保留 scheme 与 host,
// 丢掉 userinfo/query(若运维在地址里塞了凭据,别把它写进日志)。
//
// 解析失败或没有 host 时**不能**原样返回:`http://user:pass@` 这种不合法地址会
// 把凭据整个写进日志。因此兜底判据是"含 `@` 就整体打码"(`@` 是 userinfo 的
// 分隔符,出现在地址里只可能是凭据)。
// 解析不出 host 时**不能**原样返回,两条兜底各挡一类泄漏:
// - 含 `@`(userinfo 的分隔符,出现在地址里只可能是凭据)⇒ 整体打码。
// `http://user:pass@` 这种不合法地址因此不会把凭据整个写进日志。
// - 其余多半是**漏了 scheme** 的地址(`127.0.0.1:9880/v1?token=…`:`url.Parse`
// 在首段的 `:` 上报错,host 为空)。漏 scheme 不是"原样返回"的理由 ——
// 签名与 token 就在 query 里,因此按 `?`/`#` 切掉查询串与片段再返回。
func redactURL(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
if strings.Contains(raw, "@") {
return "<redacted>"
}
return raw
if err == nil && u.Host != "" {
return u.Scheme + "://" + u.Host
}
return u.Scheme + "://" + u.Host
if strings.Contains(raw, "@") {
return "<redacted>"
}
return stripURLQuery(raw)
}
// stripURLQuery 切掉 raw 里 `?` 或 `#` 起的部分。它是 redactURL 在「解析不出
// host」那条路径上的兜底:那里拿不到 url.URL 的 RawQuery/Fragment,只能按
// 分隔符切。没有分隔符时原样返回(不重新编码、不规范化,便于肉眼比对)。
func stripURLQuery(raw string) string {
if i := strings.IndexAny(raw, "?#"); i >= 0 {
return raw[:i]
}
return raw
}
+41 -1
View File
@@ -6,7 +6,8 @@ package tts
// 里的 `input` 整段回显,所以「把错误体前 512 字节打进日志」= 把她的原话写进
// 日志。这里用一台**回显正文**的假上游 + 一个接日志缓冲区的 logger 来抓它。
// 2. `redactURL` 的兜底路径:地址里含 `@`(userinfo)时必须整体打码,哪怕 URL
// 本身不合法 —— 否则 `user:pass@` 会把凭据原样写进日志。
// 本身不合法 —— 否则 `user:pass@` 会把凭据原样写进日志;而**漏了 scheme** 的
// 地址(`127.0.0.1:9880/v1?token=…`)走的是同一条兜底,query 必须被切掉。
import (
"bytes"
@@ -121,3 +122,42 @@ func TestRedactURLCredentials(t *testing.T) {
})
}
}
// TestRedactURLNoSchemeDropsQuery 钉住 M55 的缺口:**漏了 scheme** 的地址
// (`127.0.0.1:9880/v1?token=…`)在 `url.Parse` 里是错误 —— 首段带 `:` ——
// 于是 host 为空、走兜底。旧兜底把原串连 query 一起返回,签名/token 就这么进了
// 日志与工单。现在兜底只按 `?`/`#` 切掉查询串与片段。
//
// 变异(两种,各自被不同的子用例杀灭):
// - 兜底改回 `raw` ⇒ 带 query 的三条用例全红(secret / `token=` 会出现在结果里);
// - `stripURLQuery` 退化成**只切 `?`** ⇒ 只有「片段先于查询」那条红
// (`#access_token=…` 原样留在结果里)。「query 与片段」那条**打不红** ——
// `?` 在 `#` 之前,只切 `?` 会顺带把片段带走 —— 所以 `#` 先行这条分支必须由
// 「片段先于查询」独立钉住(review M55 r1 的 P2-01)。
func TestRedactURLNoSchemeDropsQuery(t *testing.T) {
const secret = "TTS-SECRET-7b3e"
cases := []struct {
name string
in string
want string
}{
{"无 scheme 带 query", "127.0.0.1:9880/v1/audio/speech?token=" + secret, "127.0.0.1:9880/v1/audio/speech"},
{"无 scheme 带 query 与片段", "tts.internal:9880/v1?token=" + secret + "#frag", "tts.internal:9880/v1"},
{"无 scheme 片段先于查询", "127.0.0.1:9880/v1#access_token=" + secret, "127.0.0.1:9880/v1"},
{"无 scheme 无 query 原样", "127.0.0.1:9880", "127.0.0.1:9880"},
{"无 scheme 带 userinfo ⇒ 整体打码", "user:pass@host/v1?token=" + secret, "<redacted>"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := redactURL(c.in)
if got != c.want {
t.Fatalf("redactURL(%q) = %q, want %q", c.in, got, c.want)
}
for _, leak := range []string{secret, "token=", "pass"} {
if strings.Contains(got, leak) {
t.Fatalf("可打日志的地址里出现了 %q: %q", leak, got)
}
}
})
}
}
+2 -1
View File
@@ -9,6 +9,7 @@ import (
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
const (
@@ -206,6 +207,6 @@ func (rs *ReminderStore) fireDue(ctx context.Context) {
rs.log.Error("publish reminder", "id", r.ID, "err", err)
continue
}
rs.log.Info("reminder fired", "id", r.ID, "at", r.At, "message", r.Message)
rs.log.Info("reminder fired", "id", r.ID, "at", r.At, "message", trace.RedactText(r.Message))
}
}
@@ -1,8 +1,11 @@
package automations
import (
"bytes"
"context"
"errors"
"log/slog"
"strings"
"testing"
"time"
@@ -166,3 +169,70 @@ func TestService_ReminderLifecycle(t *testing.T) {
t.Fatalf("CancelReminder: %v", err)
}
}
// TestReminder_FireLogIsRedacted 钉住提醒触发日志的出文本口径:Message 是模型/
// 用户自由文本,可能顺手带着凭据(「把 api_key=… 发我」)。日志面必须打码,
// 而总线上的 payload 是**投递面**,必须原样保留 —— 打码不能改投递内容。
func TestReminder_FireLogIsRedacted(t *testing.T) {
t.Parallel()
const secret = "sk-live-9f8e7d6c5b4a3210"
msg := "把 api_key=" + secret + " 发到群里"
var logBuf bytes.Buffer
b := bus.New()
defer b.Close()
got := make(chan bus.Event, 1)
unsub, err := b.Subscribe(context.Background(), SubjectTrigger, func(_ context.Context, ev bus.Event) error {
got <- ev
return nil
})
if err != nil {
t.Fatalf("subscribe: %v", err)
}
defer unsub()
rs, err := NewReminder(Config{ReminderInterval: 10 * time.Millisecond}, Deps{
Bus: b,
Log: slog.New(slog.NewTextHandler(&logBuf, nil)),
})
if err != nil {
t.Fatalf("NewReminder: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := rs.Add(ctx, Reminder{ID: "leak", At: time.Now().Add(10 * time.Millisecond), Message: msg}); err != nil {
t.Fatalf("Add: %v", err)
}
if err := rs.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
select {
case ev := <-got:
rem, ok := ev.Payload.(Reminder)
if !ok {
t.Fatalf("payload type = %T, want Reminder", ev.Payload)
}
if rem.Message != msg {
t.Fatalf("总线 payload 的 message 被改动: %q, want %q", rem.Message, msg)
}
case <-time.After(3 * time.Second):
t.Fatal("reminder did not fire")
}
// Stop 等轮询 goroutine 退出,触发日志必然已写完,避免与后台写缓冲竞争。
if err := rs.Stop(ctx); err != nil {
t.Fatalf("Stop: %v", err)
}
out := logBuf.String()
if strings.Contains(out, secret) {
t.Fatalf("提醒日志里出现了凭据原文:%s", out)
}
for _, want := range []string{"reminder fired", "[redacted]"} {
if !strings.Contains(out, want) {
t.Fatalf("提醒日志 = %q,want 含 %q(打码不能把日志变哑)", out, want)
}
}
}
@@ -0,0 +1,112 @@
package channelbridge
import (
"errors"
"net/url"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 本文件是「适配器拨号失败怎么写进日志」的**唯一**收敛点。
//
// 为什么需要它:Connect 失败的错误串里几乎总带着**拨号地址** ——
// qq-obv11 的 OneBot 端点按惯例把凭据写成 `?access_token=…`(也可以写成
// `user:pass@` 或别的键名),而 *url.Error 的 Error() 会把整条地址渲染进错误串:
// `dial tcp wss://h/?access_token=…: …`。这条错误串原样进 WARN,就等于把平台凭据
// 写进日志 —— 而日志会被采集、tail、粘进工单,是最容易扩散的面。
//
// 收敛口径与 trace 包完全一致(那里是**唯一一份**实现,本包只调用、不复制):
// - trace.URLCause 剥掉 *url.Error 的外壳 —— 它的 Error() 就是整条地址;
// 「包一层 %w 不是修复,只是换个位置泄漏」,剥壳才是;
// - 残留文本里**内嵌**的地址(适配器自己用 %s 把地址拼进文案,例如 qq-obv11 的
// `dial onebot ws <remote_url>`)逐个换成 trace.MaskQuery 的形态:凭据类
// query/fragment 值与 userinfo 打码,host 与路径保留;
// - trace.HostOf 单独给出目标来源 —— 打码之后「打到哪」这条诊断信息仍在;
// - trace.RedactText 收尾:不在 URL 里的凭据形状(`Authorization: Bearer …` 这类
// 握手失败常见回显)也一并抹掉,并把整行压回一屏。
//
// 🔴 本文件只影响**日志文案**:connectAndRun 的判定与分支一行未动 —— 谁连、连不上
// 之后留不留在连接表、要不要继续重试,全都不归本文件管(channelbridge 是历史敏感面)。
// connectFailureAttrs 把一次拨号失败整理成可以安全落日志的键值对:目标来源
// (host)+ 打过码的原因。它替代直接写 `"error", err` —— 后者会把整条拨号地址
// (含 access_token)原样带进 WARN。
func connectFailureAttrs(channel string, err error) []any {
host := urlErrorHost(err)
text, embeddedHost := maskEmbeddedURLs(trace.URLCause(err).Error())
if host == "" {
host = embeddedHost
}
return []any{"channel", channel, "host", host, "error", trace.RedactText(text)}
}
// urlErrorHost 取 *url.Error 里那条地址的来源(scheme://host[:port],用户信息
// 已整段打码)。err 不是 *url.Error 时返回空串 —— 绝不回落到原串。
func urlErrorHost(err error) string {
var uerr *url.Error
if !errors.As(err, &uerr) {
return ""
}
return trace.HostOf(uerr.URL)
}
// maskEmbeddedURLs 把自由文本里内嵌的地址逐个换成 trace.MaskQuery 的形态,并返回
// 第一个地址的来源(供日志的 host 字段)。解析不出 host 的地址整段替换成占位符:
// 宁可丢掉那条地址,也不回落到原串(回落就是泄漏)。
func maskEmbeddedURLs(text string) (masked, host string) {
var b strings.Builder
rest := text
for {
i := strings.Index(rest, "://")
if i < 0 {
b.WriteString(rest)
return b.String(), host
}
start := i
for start > 0 && isSchemeByte(rest[start-1]) {
start--
}
end := i + len("://")
for end < len(rest) && !isURLTailByte(rest[end]) {
end++
}
raw := rest[start:end]
safe := trace.MaskQuery(raw)
if safe == "" {
safe = "[redacted-url]"
}
if host == "" {
host = trace.HostOf(raw)
}
b.WriteString(rest[:start])
b.WriteString(safe)
rest = rest[end:]
}
}
// isSchemeByte 报告该字节能否出现在 scheme 里(RFC 3986:ALPHA / DIGIT / "+" /
// "-" / ".")。回退扫描到 scheme 起点,才不会把 `ws://` 里的 `ws` 丢掉。
func isSchemeByte(c byte) bool {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
return true
case c == '+' || c == '-' || c == '.':
return true
default:
return false
}
}
// isURLTailByte 报告该字节是否**终止**一段地址(日志里内嵌地址的常见收尾:空白、
// 引号、括号、逗号/分号/花括号)。`]` 刻意不算终止符:IPv6 字面量
// (`http://[::1]:8080/x`)会被切成半截而解析不出 host。>= 0x7f 的字节也终止:那是
// 多字节字符的首字节,地址里不会出现裸中文,跟着它一起扫会把后面的整段话当成地址。
func isURLTailByte(c byte) bool {
switch c {
case ' ', '\t', '\n', '\r', '"', '\'', ')', '>', ',', ';', '}':
return true
default:
return c >= 0x7f || c == 0
}
}
@@ -0,0 +1,114 @@
package channelbridge
// 本文件钉住「拨号失败的 WARN 不带凭据」这条红线(connectlog.go)。
//
// 生产症状:qq-obv11 的 OneBot 端点按惯例把凭据写成 `?access_token=…`,拨号失败时
// 错误串里就是整条地址;原样 `"error", err` 落 WARN 等于把平台凭据写进日志。
//
// 变异判据(每条都对应一个会红的实现):
// - 把 connectFailureAttrs 换回 `"error", err` ⇒ SECRET 出现在日志里,用例红;
// - 只做 trace.RedactText(不做 MaskQuery)⇒ 短 token 用例红(形状级匹配要求
// 赋值 ≥6 字符,3 字符的 token 逃得过去);
// - 把 host 字段摘掉 ⇒ 「目标 host 仍在」断言红(打码不能把诊断一起打没)。
import (
"bytes"
"context"
"errors"
"net/url"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// dialFailureAdapter 是「Connect 以给定错误失败」的适配器:其余能力沿用 fakeAdapter。
type dialFailureAdapter struct {
*fakeAdapter
err error
}
func (a *dialFailureAdapter) Connect(context.Context) error { return a.err }
// newLogBridge 构建一个把日志写进 out 的桥接器(其余依赖用测试桩)。
func newLogBridge(t *testing.T, out *bytes.Buffer) *Bridge {
t.Helper()
b, err := New(Options{
Log: logger.New(logger.Options{Format: string(logger.FormatJSON), Out: out}),
Loop: &stubChatter{res: &loop.Result{Text: "hi"}},
Sessions: &stubSessions{sess: session.Session{SessionID: "s", Owner: true, MemWritable: true}},
Owner: &user.OwnerProfile{},
})
if err != nil {
t.Fatalf("New: %v", err)
}
t.Cleanup(func() { b.Stop(context.Background()) })
return b
}
// qqDialAdapter 返回一个「配置完整、拨号必失败」的 qq-obv11 适配器。
func qqDialAdapter(err error) *dialFailureAdapter {
return &dialFailureAdapter{
fakeAdapter: &fakeAdapter{
name: "qq-obv11:bot1",
cfg: map[string]any{"mode": "client", "remoteURL": "wss://onebot.example.test/open"},
},
err: err,
}
}
// startAndCapture 启动桥接器(连接同步完成)并返回它写下的日志。
func startAndCapture(t *testing.T, err error) string {
t.Helper()
var out bytes.Buffer
b := newLogBridge(t, &out)
b.Add(qqDialAdapter(err))
b.Start()
return out.String()
}
// assertConnectWarn 断言 WARN 的三件事:仍然说「连不上」、目标 host 仍在、凭据不在。
func assertConnectWarn(t *testing.T, logged, secret string) {
t.Helper()
if !strings.Contains(logged, "channel connect failed, leaving disconnected") {
t.Fatalf("日志里没有「连不上」这条 WARN:%s", logged)
}
if !strings.Contains(logged, "onebot.example.test") {
t.Fatalf("日志丢了目标 host(打码不该连诊断一起打没):%s", logged)
}
if strings.Contains(logged, secret) {
t.Fatalf("日志里出现了拨号凭据 %q:%s", secret, logged)
}
}
// TestConnectFailureWarn_MasksURLErrorToken 覆盖 *url.Error 形状(net/http 与
// gorilla dial 的真实形态:Error() 会渲染整条地址)。
func TestConnectFailureWarn_MasksURLErrorToken(t *testing.T) {
t.Parallel()
const secret = "SECRET-TOKEN-VALUE"
dialErr := &url.Error{
Op: "dial",
URL: "wss://onebot.example.test/open?access_token=" + secret,
Err: errors.New("websocket: bad handshake"),
}
assertConnectWarn(t, startAndCapture(t, dialErr), secret)
}
// TestConnectFailureWarn_MasksEmbeddedURLToken 覆盖「适配器自己把地址拼进文案」的
// 形状(qq-obv11 的 `dial onebot ws <remote_url>: <cause>`):这里没有 *url.Error
// 外壳可剥,只能靠地址级打码 —— 因此 token 刻意取得**短于**形状级匹配的长度,
// 只做 RedactText 的实现会在这里漏。
func TestConnectFailureWarn_MasksEmbeddedURLToken(t *testing.T) {
t.Parallel()
const secret = "abc"
dialErr := errors.New("qq-obv11: dial onebot ws wss://onebot.example.test/open?access_token=" +
secret + ": websocket: bad handshake")
logged := startAndCapture(t, dialErr)
assertConnectWarn(t, logged, secret)
if !strings.Contains(logged, "access_token=***") {
t.Fatalf("凭据参数的值应被打码成 ***(保留参数名便于排障):%s", logged)
}
}
+7 -7
View File
@@ -219,16 +219,16 @@ func (b *Bridge) engageProbe(ctx context.Context, msg adapter.UnifiedMessage, ac
}
if d.AvoidTopic {
b.toIdle(st)
b.log.Debug("engage probe avoid topic, staying idle", "group", key, "reason", d.Reason)
b.log.Debug("engage probe avoid topic, staying idle", "group", key, "reason", judgeReason(d.Reason))
return false, "engage: avoid topic"
}
if d.WorthReply || d.Interesting {
b.enterActive(st, cfg, now, msg)
b.log.Debug("engage probing → active (respond)", "group", key, "reason", d.Reason)
return true, "engage: " + d.Reason
b.log.Debug("engage probing → active (respond)", "group", key, "reason", judgeReason(d.Reason))
return true, "engage: " + judgeReason(d.Reason)
}
b.toIdle(st)
b.log.Debug("engage probing → idle, no interest", "group", key, "reason", d.Reason)
b.log.Debug("engage probing → idle, no interest", "group", key, "reason", judgeReason(d.Reason))
return false, "engage: no interest"
}
@@ -247,14 +247,14 @@ func (b *Bridge) engageFollowUp(ctx context.Context, msg adapter.UnifiedMessage,
}
if d.AvoidTopic || (!d.WorthReply && !d.Interesting) {
b.countNoInterest(st)
b.log.Debug("engage follow-up no interest / avoid", "group", key, "reason", d.Reason)
b.log.Debug("engage follow-up no interest / avoid", "group", key, "reason", judgeReason(d.Reason))
return b.engageAfterFollowUp(st, cfg, now, key)
}
st.lastInteract = now
st.lastTopic = contentSnippet(msg.Content)
st.consecutiveNoInterest = 0
b.log.Debug("engage follow-up respond", "group", key, "reason", d.Reason)
return true, "engage follow-up: " + d.Reason
b.log.Debug("engage follow-up respond", "group", key, "reason", judgeReason(d.Reason))
return true, "engage follow-up: " + judgeReason(d.Reason)
}
// engageAfterFollowUp 判定一次无兴趣/回避跟进探测的结果。
@@ -0,0 +1,159 @@
package channelbridge
// 本文件钉住「参与度判定器的 reason 是模型自由文本,不得原样出镜」(engage.go 的 7 处
// 出口,统一走 judge_reason.go 的 judgeReason):Debug 日志与返回值必须同时满足
// 「凭据打码」与「压平成一行的短句」。
//
// 变异判据(每条都对应一个会红的实现):
// - 把 judgeReason 换成恒等返回(或摘掉 trace.RedactTextLimited)⇒ 日志与返回值里
// 都能搜到 sk-live…,红;
// - 只做 RedactText 不做 strings.Fields 压平 ⇒ 换行仍在,红(见下面两条换行断言);
// - 只改日志、不改返回值(`"engage: " + d.Reason` 保持原样)⇒ 返回值那条断言红。
import (
"bytes"
"context"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// engageReasonSecret 是判定器「回显」进 reason 的凭据形状(sk-… 形状级匹配能认)。
const engageReasonSecret = "sk-live-0123456789abcdef"
// engageReasonRaw 是判定器回的原始 reason:一句正常短语 + 回显的凭据 + 一个换行
// (换行是想在按行聚合的日志里伪造一条新记录)。
const engageReasonRaw = "第一行:群里有 " + engageReasonSecret + "\n第二行:伪造一条日志"
// engageReasonWant 是 engageReason 的期望产物:凭据被打码成 [redacted]、两行被压成
// 一行(空格分隔)、其余原文保留(诊断价值仍在)。
const engageReasonWant = "第一行:群里有 [redacted] 第二行:伪造一条日志"
// engageReasonBridge 构建一条装配了参与度引擎与 LightJudge 的桥接器,
// 并把 Debug 级日志写进 out(Debug 是 reason 的唯一落点,级别必须降到 debug)。
func engageReasonBridge(t *testing.T, judge LightJudge, out *bytes.Buffer) *Bridge {
t.Helper()
b, err := New(Options{
Log: logger.New(logger.Options{Format: string(logger.FormatJSON), Level: "debug", Out: out}),
Loop: &stubChatter{res: &loop.Result{Text: "参与"}},
Sessions: &stubSessions{sess: nonOwnerSession()},
Owner: &user.OwnerProfile{},
Light: judge,
TriggerWords: []string{"昔涟"},
Engage: DefaultEngageConfig(),
EngageRand: func() float64 { return 0.5 }, // 永不命中概率触发,只走角色名提及
Now: dayNow(),
})
if err != nil {
t.Fatalf("New: %v", err)
}
t.Cleanup(func() { b.Stop(context.Background()) })
return b
}
// assertNoCredentialNoNewline 断言一段出镜文本既不含凭据原文,也不含换行
// (换行会按行聚合的日志里伪造出「另一条记录」)。
func assertNoCredentialNoNewline(t *testing.T, what, got string) {
t.Helper()
if strings.Contains(got, "sk-live") {
t.Fatalf("%s 里出现了判定器回显的凭据:%q", what, got)
}
if !strings.Contains(got, "[redacted]") {
t.Errorf("%s 里没有打码标记(Reason 应当仍带上限出镜):%q", what, got)
}
if strings.Contains(got, "\n") || strings.Contains(got, "\\n") {
t.Fatalf("%s 里出现了换行(应被压平):%q", what, got)
}
}
// TestEngageReason_ProbeLogAndReturnAreSanitized 覆盖 PROBING 判定(→ ACTIVE):
// 日志与返回值都只带打码压平后的短句。
func TestEngageReason_ProbeLogAndReturnAreSanitized(t *testing.T) {
t.Parallel()
var out bytes.Buffer
judge := &engageScriptJudge{responses: []LightDecision{
{Interesting: true, WorthReply: true, Reason: engageReasonRaw},
}}
b := engageReasonBridge(t, judge, &out)
msg := nonOwnerGroupMsg("大家在聊昔涟的事")
respond, reason := b.engageDecision(context.Background(), msg, msg.Sender, nonOwnerSession(), false)
if !respond {
t.Fatalf("判定器说值得回,engageDecision 却回了 false(reason=%q)", reason)
}
assertNoCredentialNoNewline(t, "PROBING 返回值", reason)
if !strings.Contains(reason, engageReasonWant) {
t.Fatalf("返回值 = %q,期望含 %q(打码 + 压平后的短句)", reason, engageReasonWant)
}
logged := out.String()
if !strings.Contains(logged, engageReasonWant) {
t.Fatalf("Debug 日志里没有打码压平后的 reason(原文换行未压平?):%s", logged)
}
if strings.Contains(logged, "sk-live") {
t.Fatalf("Debug 日志里出现了判定器回显的凭据:%s", logged)
}
}
// TestEngageReason_FollowUpLogAndReturnAreSanitized 覆盖 ACTIVE 跟进复审(→ 继续参与):
// 第二处返回值拼接(`"engage follow-up: " + …`)同样必须打码压平。
func TestEngageReason_FollowUpLogAndReturnAreSanitized(t *testing.T) {
t.Parallel()
var out bytes.Buffer
judge := &engageScriptJudge{responses: []LightDecision{
{Interesting: true, WorthReply: true, Reason: "先进入活跃窗口"},
{Interesting: true, WorthReply: true, Reason: engageReasonRaw},
}}
b := engageReasonBridge(t, judge, &out)
msg := nonOwnerGroupMsg("大家在聊昔涟的事")
ctx := context.Background()
if respond, _ := b.engageDecision(ctx, msg, msg.Sender, nonOwnerSession(), false); !respond {
t.Fatal("第一次探测应当进入 ACTIVE")
}
respond, reason := b.engageDecision(ctx, msg, msg.Sender, nonOwnerSession(), false)
if !respond {
t.Fatalf("跟进判定说继续参与,engageDecision 却回了 false(reason=%q)", reason)
}
assertNoCredentialNoNewline(t, "跟进返回值", reason)
if !strings.Contains(reason, "engage follow-up: "+engageReasonWant) {
t.Fatalf("跟进返回值 = %q,期望含 %q", reason, "engage follow-up: "+engageReasonWant)
}
logged := out.String()
if !strings.Contains(logged, engageReasonWant) {
t.Fatalf("跟进 Debug 日志里没有打码压平后的 reason:%s", logged)
}
if strings.Contains(logged, "sk-live") {
t.Fatalf("跟进 Debug 日志里出现了判定器回显的凭据:%s", logged)
}
}
// TestEngageReason_AvoidTopicAndNoInterestAreSanitized 覆盖另外两条 Debug 出口
// (回避话题 / 无兴趣),它们同样带 reason。
func TestEngageReason_AvoidTopicAndNoInterestAreSanitized(t *testing.T) {
t.Parallel()
var out bytes.Buffer
judge := &engageScriptJudge{responses: []LightDecision{
{Interesting: false, WorthReply: false, AvoidTopic: true, Reason: engageReasonRaw},
{Interesting: false, WorthReply: false, Reason: engageReasonRaw},
}}
b := engageReasonBridge(t, judge, &out)
msg := nonOwnerGroupMsg("大家在聊昔涟的事")
ctx := context.Background()
if respond, reason := b.engageDecision(ctx, msg, msg.Sender, nonOwnerSession(), false); respond {
t.Fatalf("回避话题不应回复(reason=%q)", reason)
}
if respond, reason := b.engageDecision(ctx, msg, msg.Sender, nonOwnerSession(), false); respond {
t.Fatalf("无兴趣不应回复(reason=%q)", reason)
}
logged := out.String()
if strings.Contains(logged, "sk-live") {
t.Fatalf("回避/无兴趣的 Debug 日志里出现了凭据:%s", logged)
}
if n := strings.Count(logged, engageReasonWant); n < 2 {
t.Fatalf("回避与无兴趣两条日志各应带一次打码压平后的 reason,实际 %d 次:%s", n, logged)
}
}
@@ -0,0 +1,43 @@
package channelbridge
// 本文件是「判定模型/上游回来的自由文本怎么写进返回值与日志」的唯一收敛点。
//
// 它的三个使用者都是同一类出口:模型(或上游)给一句 reason,我们把它拼进返回值、
// 再被上层落进日志 ——
// - engage.go 的参与度判定(5 处 Debug + 2 处返回值);
// - light.go 的旧轻量判定(2 处返回值,落到 coalesce_prepare.go 的 Debug);
// - notice.go 的系统通知剔噪失败原因(拼进 noticeOutcome.Reason,落到事件 Debug)。
//
// 这类文本的两种病害都真实存在:模型可以把 Authorization / sk-… 回显进 reason(凭据),
// 也可以塞一个换行(在按行聚合的日志里伪造出「另一条记录」)。因此出镜前必须
// 「压平 + 打码 + 截断」,而且**只有这一份实现** —— 三处各写一份必然漂移,漂移的方向
// 总是「其中一处为了多带点诊断把原文留下来」。
import (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// judgeReasonRunes 是这类 reason 允许出镜的 rune 上限。
//
// 取 60 的理由:这些 reason 由「只输出 JSON 对象」的轻量判定器给出,实际值是一句短语
// (「群里在聊周末安排,与角色无关」≈ 16 字),60 rune 容得下三四句这样的短语;一旦
// 超过 60 rune,说明它在**复述群聊原文** —— 那部分对排障没有价值(要的是「判定方向 +
// 方向词」),却正是最不该进日志的那段话。
const judgeReasonRunes = 60
// judgeReason 把一段判定/上游 reason 整理成可以出镜的短句(返回值与日志共用这同一份,
// 口径不会分叉):
//
// - 压平空白:一个换行就能在按行聚合的日志里伪造出「另一条记录」;返回值也会流进
// 日志(coalesce_prepare 的 Debug),所以同样要压;
// - 凭据清洗走 trace.RedactTextLimited(与 trace.RedactText 同一实现,不是第二套
// 脱敏路径):模型可能把 Authorization / sk-… 之类原样回显进 reason;
// - 截到 judgeReasonRunes(见上)。
//
// 🔴 本函数只决定「进去的那份文本」,不参与任何判定:调用点的分支条件、状态迁移与
// 返回值拼接结构一个字都不动(2026-10-26 塔口径)。
func judgeReason(raw string) string {
return trace.RedactTextLimited(strings.Join(strings.Fields(raw), " "), judgeReasonRunes)
}
@@ -0,0 +1,114 @@
package channelbridge
// 本文件钉住 judgeReason 的三个使用者(engage.go / light.go / notice.go)都真的走它。
// engage.go 那 7 处由 engage_reason_test.go 覆盖;这里覆盖后两处与 helper 本身。
//
// 变异判据(每条都对应一个会红的实现):
// - 把 judgeReason 换成恒等返回(摘掉 trace.RedactTextLimited)⇒ 三条用例都红;
// - 只做 RedactText 不做 strings.Fields 压平 ⇒ helper 那条与 light 那条红(换行仍在);
// - 把 light.go / notice.go 的调用点改回原文(`d.Reason` / `err.Error()`)⇒ 对应用例红。
import (
"context"
"errors"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// judgeReasonSecret 是模型/上游「回显」进 reason 的凭据形状。
const judgeReasonSecret = "sk-live-0123456789abcdef"
// judgeReasonRaw 是原始 reason:短语 + 回显的凭据 + 换行(想在按行聚合的日志里伪造
// 一条新记录)。
const judgeReasonRaw = "第一行:群里有 " + judgeReasonSecret + "\n第二行:伪造一条日志"
// judgeReasonWant 是 judgeReason 的期望产物。
const judgeReasonWant = "第一行:群里有 [redacted] 第二行:伪造一条日志"
// assertSanitizedReason 断言一段出镜文本既不含凭据原文,也不含换行。
func assertSanitizedReason(t *testing.T, what, got string) {
t.Helper()
if strings.Contains(got, "sk-live") {
t.Fatalf("%s 里出现了回显的凭据:%q", what, got)
}
if !strings.Contains(got, "[redacted]") {
t.Errorf("%s 里没有打码标记:%q", what, got)
}
if strings.Contains(got, "\n") {
t.Fatalf("%s 里出现了换行(应被压平):%q", what, got)
}
}
// TestJudgeReason_RedactsFlattensAndBounds 钉住 helper 本身的三件事。
func TestJudgeReason_RedactsFlattensAndBounds(t *testing.T) {
t.Parallel()
if got := judgeReason(judgeReasonRaw); got != judgeReasonWant {
t.Fatalf("judgeReason = %q,期望 %q", got, judgeReasonWant)
}
// 上限按 rune 计:60 个汉字必须原样保留(不能被截),61 个才截断。
if got := judgeReason(strings.Repeat("中", judgeReasonRunes)); got != strings.Repeat("中", judgeReasonRunes) {
t.Fatalf("恰好 %d rune 不该被截断:%q", judgeReasonRunes, got)
}
if got := judgeReason(strings.Repeat("中", judgeReasonRunes+1)); len([]rune(got)) != judgeReasonRunes+1 {
t.Fatalf("超限时应保留 %d rune + 省略号,实际 %q", judgeReasonRunes, got)
}
}
// TestLegacyLightJudge_ReasonSanitized 覆盖 light.go 的两处返回值(旧轻量判定车道):
// reason 出镜前必须打码压平(它随后被 coalesce_prepare 的 Debug 落日志)。
func TestLegacyLightJudge_ReasonSanitized(t *testing.T) {
t.Parallel()
for _, respond := range []bool{true, false} {
judge := &stubLightJudge{resp: LightDecision{Respond: respond, Reason: judgeReasonRaw}}
b, err := New(Options{
Log: logger.New(logger.Options{Level: "debug", Out: &strings.Builder{}}),
Loop: &stubChatter{res: &loop.Result{Text: "x"}},
Sessions: &stubSessions{sess: nonOwnerSession()},
Owner: &user.OwnerProfile{},
Light: judge,
TriggerWords: []string{"昔涟"},
})
if err != nil {
t.Fatalf("New: %v", err)
}
msg := nonOwnerGroupMsg("提一句昔涟")
got, reason := b.legacyLightJudge(context.Background(), msg, msg.Sender, false)
b.Stop(context.Background())
if got != respond {
t.Fatalf("legacyLightJudge respond = %v, 期望 %v", got, respond)
}
assertSanitizedReason(t, "light judge 返回值", reason)
if !strings.Contains(reason, "light judge: "+judgeReasonWant) {
t.Fatalf("返回值 = %q,期望含 %q", reason, "light judge: "+judgeReasonWant)
}
}
}
// TestNoticeReviewFailureReasonSanitized 覆盖 notice.go 的失败原因:
// 审查模型(或它背后的上游)的 error 文本进 noticeOutcome.Reason 前同样要打码压平。
func TestNoticeReviewFailureReasonSanitized(t *testing.T) {
t.Parallel()
review := func(context.Context, []adapter.UnifiedMessage) ([]int, error) {
return nil, errors.New(judgeReasonRaw)
}
ad := &noticeAdapter{notice: adapter.SystemNotice{Kind: adapter.NoticeOther, SafeText: "群/客户端系统事件"}, ok: true}
b, _, _, _ := noticeFixture(t, ad, review)
msg := groupNoticeMessage()
out := b.preEntryReview(context.Background(), &msg, ad)
if out.Proceed {
t.Fatalf("审查失败必须走 fail-safe 静默,实际放行了:reason=%q", out.Reason)
}
if !strings.HasPrefix(out.Reason, "notice review failed: ") {
t.Fatalf("原因前缀被改动了:%q", out.Reason)
}
assertSanitizedReason(t, "notice 审查失败原因", out.Reason)
}
+2 -2
View File
@@ -279,9 +279,9 @@ func (b *Bridge) legacyLightJudge(ctx context.Context, msg adapter.UnifiedMessag
})
if err == nil {
if d.Respond {
return true, "light judge: " + d.Reason
return true, "light judge: " + judgeReason(d.Reason)
}
return false, "light judge: " + d.Reason
return false, "light judge: " + judgeReason(d.Reason)
}
b.log.Warn("light judge failed; silencing", "error", err)
}
+1 -1
View File
@@ -101,7 +101,7 @@ func (b *Bridge) preEntryReview(ctx context.Context, msg *adapter.UnifiedMessage
default:
keep, err := b.reviewNoticeNoise(ctx, *msg, notice)
if err != nil {
out.Reason = "notice review failed: " + err.Error()
out.Reason = "notice review failed: " + judgeReason(err.Error())
out.Proceed = noticeFailSafeProceeds
return out
}
+3 -1
View File
@@ -66,8 +66,10 @@ func (b *Bridge) connectAndRun(a adapter.PlatformAdapter) {
err := a.Connect(connCtx)
cancel()
if err != nil {
// 原始 dial 错误里带着整条拨号地址(`?access_token=…`),不能原样落 WARN;
// connectFailureAttrs 只改文案,不动这里的判定与分支(见 connectlog.go)。
b.log.Warn("channel connect failed, leaving disconnected",
"channel", a.Name(), "error", err)
connectFailureAttrs(a.Name(), err)...)
return
}
@@ -1,7 +1,6 @@
package weixin_ilink
import (
"bytes"
"context"
"fmt"
"log/slog"
@@ -102,7 +101,7 @@ func TestStartLogin_WaitingPastWindowReMints(t *testing.T) {
case "/ilink/bot/get_bot_qrcode":
mintCalls++
return handlerResult{body: jsonBody(map[string]any{
"qrcode": fmt.Sprintf("qr-w%d", mintCalls),
"qrcode": fmt.Sprintf("qr-w%d", mintCalls),
"qrcode_img_content": fmt.Sprintf("https://img/w%d", mintCalls),
})}
case "/ilink/bot/get_qrcode_status":
@@ -148,19 +147,30 @@ func TestStartLogin_WaitingPastWindowReMints(t *testing.T) {
// TestLogin_LogRedaction 验证扫码登录路径只记录已脱敏的标识:可渲染的二维码 URL 与
// bot id 会出现,但原始 qrcode 标识符、bot_token 以及主人的 user_id 绝不写入。
//
// ⚠️ 日志缓冲必须是**并发安全**的 syncBuffer(M56 修掉既有 DATA RACE):写侧是
// StartLogin 起的登录 goroutine(completeLogin 里的 `login confirmed`),读侧是测试
// goroutine 的 buf.String(),两侧之间没有 happens-before 边 —— 裸 bytes.Buffer 在
// -race 下必报。等待条件也必须等**那一行日志**落盘,而不只是 ad.IsConnected():
// completeLogin 先在 a.mu 下置 connected、**之后**才打日志,只等 IsConnected 会
// 偶发地读到还没有 `login confirmed` 的缓冲(同一处竞态的另一种表现:抖动)。
func TestLogin_LogRedaction(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
statusCalls := 0
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
var buf syncBuffer
rec := &reqRecorder{}
// 用 reqRecorder 数状态轮询次数(它自带互斥),而不是在处理器里 += 一个裸 int:
// 处理器跑在 httptest 的服务端 goroutine 上,任何无保护的共享计数器都是同一类
// 竞态的下一个落点。
srv := newJSONServer(t, rec, func(req recordedReq) handlerResult {
switch req.path {
case "/ilink/bot/get_bot_qrcode":
return handlerResult{body: jsonBody(map[string]any{
"qrcode": "qr-redact", "qrcode_img_content": "https://img/redact",
})}
case "/ilink/bot/get_qrcode_status":
statusCalls++
if statusCalls == 1 {
// rec.add 在处理器被调用之前完成,所以这里数到的已包含本次请求。
calls := len(rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_qrcode_status" }))
if calls == 1 {
return handlerResult{body: jsonBody(map[string]any{"status": "scaned"})}
}
return handlerResult{body: jsonBody(map[string]any{
@@ -180,7 +190,9 @@ func TestLogin_LogRedaction(t *testing.T) {
if _, err := ad.StartLogin(context.Background()); err != nil {
t.Fatalf("StartLogin: %v", err)
}
waitFor(t, func() bool { return ad.IsConnected() })
waitFor(t, func() bool {
return ad.IsConnected() && strings.Contains(buf.String(), "login confirmed")
})
logs := buf.String()
for _, want := range []string{
+17 -4
View File
@@ -93,8 +93,9 @@ func (a *Adapter) sendGuard() error {
//
// **单条媒体的失败绝不连累文本**:媒体只是附件,回复的主体是文字。真机实测
// (2026-10-04)就是「媒体项构建失败 ⇒ 整条回复被判失败 ⇒ 她的中文文本一起没了」。
// 因此这里逐条构建,失败的那条跳过并留一条带来源的 Warn(kind/name/原因),文本项
// 照常发出;全部媒体都失败也仍然发文本。失败必须可诊断,但绝不等于整条回复失败。
// 因此这里逐条构建,失败的那条跳过并留一条带来源的 Warn(kind/size/序号/原因),
// 文本项照常发出;全部媒体都失败也仍然发文本。失败必须可诊断,但绝不等于整条回复
// 失败。
func (a *Adapter) sendToOne(ctx context.Context, peer string, m adapter.UnifiedMessage) error {
if peer == "" {
return ErrNoContextToken
@@ -109,14 +110,26 @@ func (a *Adapter) sendToOne(ctx context.Context, peer string, m adapter.UnifiedM
items = append(items, nativeTextItem(m.Content))
}
var mediaErr error
for _, md := range m.Media {
for i, md := range m.Media {
item, err := a.buildMediaItem(ctx, peer, md)
if err != nil {
if mediaErr == nil {
mediaErr = err
}
// 🔴 `md.Name` **整字段不进日志**(M56)。它是**用户侧自由文本**:出站媒体名
// 来自调用方给的 adapter.Media.Name,入站侧就是用户自己起的文件名,可以是
// 「身份证-张三.jpg」这类内容 —— 与同一行的 peer/kind/size 不是一类东西。
//
// 为什么删字段、而不是打码或截断:包内可复用的打码(trace.RedactTextLimited)
// 是**按形状**认凭据的,对「长得像人话的文件名」完全无效;截断则必然留下前 N
// 个字符,而中文文件名的头几个字往往就是全部有意义的部分 —— 两种修法都只是
// 把泄露面缩小一点,并没有关上。而这个字段对排障**零贡献**:失败原因在 err
// 里,出问题的是哪一条由 kind + size + 下标(index)唯一确定。删字段因此是
// 严格更强、又不损失诊断力的选择。包内已有同一口径的先例:入站取证行
// (inbound_debug.go 的 inboundMediaShape)同样明确「正文、文件名、URL 原串
// 与一切密钥都不取」—— 日志面从不承载用户侧的正文/文件名,两个方向一致。
a.log.Warn("outbound media item skipped",
"peer", peer, "kind", md.Kind, "name", md.Name, "size", md.Size, "err", err)
"peer", peer, "kind", md.Kind, "size", md.Size, "index", i, "err", err)
continue
}
items = append(items, item)
@@ -6,6 +6,9 @@ package weixin_ilink
// 真机现场是她的中文文本本身完好,却因为一个媒体项构建失败而整条没发出去;
// B3 getuploadurl 的请求必须带上官方字段、响应必须认官方键名,且「键缺失」
// 与「会话被拒」必须分开报(后者已有 markRejected 路径)。
//
// M56 又追加了一条**日志内容**边界:失败告警里绝不出现 md.Name(它是用户侧自由
// 文本),可诊断性改由 kind/size/index/err 承担。
import (
"bytes"
@@ -117,8 +120,51 @@ func TestSendMessage_MediaFailureStillSendsText(t *testing.T) {
if !strings.Contains(logs, "source="+source) {
t.Fatalf("告警没有标注来源:\n%s", logs)
}
if !strings.Contains(logs, "kind=file") || !strings.Contains(logs, "name=1.txt") {
t.Fatalf("告警缺少 kind/name:\n%s", logs)
if !strings.Contains(logs, "kind=file") || !strings.Contains(logs, "index=0") {
t.Fatalf("告警缺少 kind/序号(媒体名的位置由它们承担):\n%s", logs)
}
}
// TestSendMessage_MediaFailureLogOmitsUserFileName 钉住失败告警的**内容边界**(M56):
// md.Name 是用户侧自由文本(入站侧就是用户自己起的文件名),所以失败日志里一个字都
// 不留 —— 连截断后的前缀也不行,断言因此拿「文件名的开头」当哨兵,把「截断到 N 个
// 字符」这类半吊子修法一起打红。可诊断性由 kind/size/index/err 承担,这里也逐个断言:
// 否则「把整行日志删掉」同样能让泄露消失。
func TestSendMessage_MediaFailureLogOmitsUserFileName(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
srv := mediaSendServer(t, rec, handlerResult{body: jsonBody(map[string]any{})})
var buf bytes.Buffer
ad := newLoggingSendAdapter(t, srv, &buf)
ad.recordInbound("tok-1", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()})
// 用户侧自由文本的典型形态:中文姓名 + 证件号。两者都不该进日志。
const userFileName = "身份证正面-张三-110101199003071234.jpg"
payload := []byte("user-bytes")
if err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{
SenderID: "u-1",
Content: "文本照发",
Media: []adapter.Media{{
Kind: adapter.MediaFile, Data: payload, Name: userFileName, Size: int64(len(payload)),
}},
}},
}); err != nil {
t.Fatalf("媒体失败连累了整条回复:%v", err)
}
logs := buf.String()
// 先确认告警本身还在:否则「没泄露」只是因为什么都没记。
for _, want := range []string{"outbound media item skipped", "kind=file", "size=10", "index=0", "err="} {
if !strings.Contains(logs, want) {
t.Fatalf("失败告警缺少 %q(诊断力不能被一起删掉):\n%s", want, logs)
}
}
// 泄露面:整名、名字开头、证件号 —— 任一段出现即红。
for _, leak := range []string{userFileName, "身份证", "110101199003071234"} {
if strings.Contains(logs, leak) {
t.Fatalf("用户文件名泄露 %q:\n%s", leak, logs)
}
}
}