diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a2db73e..c6ae8a76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -163,6 +163,9 @@ ### 修复 Fixed +- **健康轮 SYNTH 全灭修复:CJK 检索词在 C locale 下被 ANSI-C 包裹致 rg 崩(2026-10-26;回归源=#196 C4 spawn env 白名单丢 LANG)**:线上 v1.4.2-721 起每个 health 轮 SYNTH 步必炸 `inject_precedents: rg 检索失败(rc=2)`——C4 白名单后轮子进程无 LANG→bash 退 C locale→`printf %q` 给 CJK 检索词(如「限流」)输出 `$'\351\…'` 字面包裹,而命令替换输出不再经词法解析→rg 模式含 `$'`/八进制转义被解析为反向引用→parse error rc=2;回归起点=2026-10-25-1900-health(10-24-1800 及之前全绿)。两层修复:①根治层=lib.sh inject_precedents 弃 `$(printf -- '-e %q ' …)` 命令替换拼参,改 argv 数组直传(`rg_args+=(-e "$kw")`)——任何 locale 下 CJK 原样字节进 argv,与 locale 彻底解耦(输出块格式零变);②环境层=_round_env() 桥注入 LANG(继承操作员值,缺省 C.UTF-8)防同类 locale 敏感行为复发。门:selftest.sh 119/0(基 116+新 C-locale 回归钉三断言;红证:旧 lib.sh 下恰该例两断言红 PASS=117 FAIL=2)、t4 63/0(golden 三方字节对拍照旧)、backend test_ops_rounds+test_ops_config 81 passed(基 80;白名单对拍集同步 LANG+LANG 继承/缺省两断言)、`env -i` 手工钉死复现消亡(限流 排他率→注入块 9 命中、exit 0) + - **Health-round SYNTH wipeout fixed: CJK keywords ANSI-C-wrapped under C locale crash rg (2026-10-26; regression source = the #196 C4 spawn-env allowlist dropping LANG)**: from v1.4.2-721 every health round's SYNTH step died with `inject_precedents: rg 检索失败(rc=2)` — post-allowlist the round child had no LANG → bash fell back to C locale → `printf %q` wrapped CJK keywords (e.g. 限流) as `$'\351\…'` literals, and command-substitution output is never re-parsed as quoting → rg received `$'`/octal-escape bytes in the pattern, parsed as backreferences → parse error rc=2; regression onset = 2026-10-25-1900-health (10-24-1800 and earlier all green). Two-layer fix: ① root layer — lib.sh inject_precedents drops the `$(printf -- '-e %q ' …)` command-substitution argv assembly for a direct argv array (`rg_args+=(-e "$kw")`) — CJK bytes reach argv verbatim under any locale, fully locale-decoupled (output block format unchanged); ② environment layer — _round_env() bridge-injects LANG (operator value inherited, default C.UTF-8) against locale-sensitive recurrences. Gates: selftest.sh 119/0 (base 116 + the new C-locale regression pin's three assertions; red proof: on the old lib.sh exactly that case reddens, PASS=117 FAIL=2), t4 63/0 (golden three-way byte comparison unchanged), backend test_ops_rounds + test_ops_config 81 passed (base 80; allowlist comparison set synced with LANG + two LANG inheritance/default assertions), `env -i` manual repro pinned dead (限流 排他率 → injection block with 9 hits, exit 0) + - **驾驶舱「展开全量日志」中途开页点击无感修复(2026-10-26,单写者直修)**:根因=task_log 事件只发不留——用户在轮中开页时订阅前已发的 stage 行全丢,日志面只剩订阅后 1 行,展开/收起视觉无差;修复三层:①后端 UpdateManager 增 ops stderr 日志尾(`_OPS_LOG_KEEP=200` deque,与前端 LOG_KEEP_LINES 同窗;start 建、下一轮 start 清扫、终态后仍随快照出——轮后开页也能播上一轮尾),snapshot() 增 `logs` 面(键=task_id,经 TasksSnapshotOut extra="allow" 透传);未知 task_id 照发事件不建尾(恢复轮替身不破);②前端 TaskProvider seedLogs 快照播种(SSE snapshot 事件+resync fetch 两口同接),合并律=尾更长才替换——同 task 的 append-only 流里更短的旧快照(fetch/SSE 竞态)不得缩水已收行;③驾驶舱 UX:展开钮带行数计数(单行时展开/收起也有可见差),新一轮起跑上升沿复位展开态(全新日志面不跨轮携带)。门:backend test_ops_rounds+test_tasks+pruning 90/0(新三例:尾随快照出+终态留+下轮清扫/205 行窗 200/未知 tid 只发不建),frontend vitest 437/437(新三例:快照播种+播种后追加/更短旧快照不缩水/计数+复位)+tsc+build 绿 - **Cockpit "show full log" mid-round open click-deadness fix (2026-10-26, single-writer direct fix)**: root cause = task_log events are fire-and-forget — a page opened mid-round loses every stage line emitted before subscribe, leaving a 1-line log face where expand/collapse looks identical; fix in three layers: ① backend UpdateManager gains an ops stderr tail (`_OPS_LOG_KEEP=200` deque, same window as the frontend LOG_KEEP_LINES; created at start_ops_round, swept by the next round's start, retained past terminal state so a post-round page open seeds the previous round's tail too), snapshot() gains a `logs` face (key=task_id, flowing through TasksSnapshotOut extra="allow"); unknown task_id still emits without a tail (recovery-round stand-ins don't break); ② frontend TaskProvider seedLogs seeds from the snapshot (both the SSE snapshot event and the resync fetch), merge rule = replace only if the tail is longer — a shorter stale snapshot (fetch/SSE race) never shrinks already-received lines on the same append-only stream; ③ cockpit UX: the expand button carries a line count (a visible difference even with a single line), and a new round's rising edge resets the expanded state (a fresh log face never carries over). Gates: backend test_ops_rounds + test_tasks + pruning 90/0 (three new: tail in snapshot + survives terminal + swept next round / 205 lines capped at 200 / unknown tid emits without tail), frontend vitest 437/437 (three new: snapshot seeding + post-seed append / shorter stale snapshot never shrinks / count + reset) + tsc + build green diff --git a/backend/ipdb/_ops_rounds.py b/backend/ipdb/_ops_rounds.py index a2879acb..1d62cc52 100644 --- a/backend/ipdb/_ops_rounds.py +++ b/backend/ipdb/_ops_rounds.py @@ -89,6 +89,8 @@ def _round_env() -> dict[str, str]: "DOCS_ROOT": str(root.parent), "PRECEDENTS_DIR": str(root / "precedents"), "LEDGER_FILE": str(root / "ledger.md"), + # 2026-10-26:C4 白名单剔 LANG→子进程 bash 退 C locale,%q 对 CJK 输出 ANSI-C 包裹致 rg 解析炸(健康轮 SYNTH 全灭回归);bash 面 locale 敏感行为需确定性:继承操作员 LANG,缺省 C.UTF-8。 + "LANG": os.environ.get("LANG") or "C.UTF-8", "OPS_EVAL_PY": sys.executable, "OPS_DATA_DIR": str(_registry.DATA_DIR), } diff --git a/backend/tests/test_ops_rounds.py b/backend/tests/test_ops_rounds.py index e277f022..4a70f612 100644 --- a/backend/tests/test_ops_rounds.py +++ b/backend/tests/test_ops_rounds.py @@ -521,10 +521,13 @@ def test_spawn_env_injects_ops_tree_and_fences(rounds, ops_env, fake_agent): # 契约单源 = ops/agent/agent.mjs SPAWN_CONTRACT_ENV(OPS_*/PI_* 前缀 + 精确名) # + 运行必需(PATH/HOME/TMPDIR/IP_RADAR_DATA_DIR)。IP_RADAR_ALERT_URLS 是 # #184 finding webhook 出口契约员(与 backend _alerts.push 双消费面),保留透传。 +# LANG = _round_env 桥注入面(2026-10-26 判例:C4 白名单剔 LANG→子进程 bash 退 +# C locale,%q 给 CJK 包 ANSI-C 字面量→rg rc=2 健康轮 SYNTH 全灭;继承操作员值 +# 缺省 C.UTF-8,DOCS_ROOT 族同型)。 SPAWN_ENV_EXACT = {"PATH", "HOME", "TMPDIR", "GH_TOKEN", "IP_RADAR_DATA_DIR", "IP_RADAR_ALERT_URLS", "ZAI_CODING_CN_API_KEY", "JINA_API_KEY", - "DOCS_ROOT", "PRECEDENTS_DIR", "LEDGER_FILE"} + "DOCS_ROOT", "PRECEDENTS_DIR", "LEDGER_FILE", "LANG"} def test_spawn_env_allowlist_excludes_secrets( @@ -556,6 +559,17 @@ def test_spawn_env_exactly_contract_face( assert extra == [], f"白名单外 env 透传子进程: {extra}" +def test_round_env_lang_locale_determinism(rounds, ops_env, monkeypatch): + """2026-10-26 判例:C4 白名单剔 LANG→子进程 bash 退 C locale,%q 给 CJK 检索词 + 包 ANSI-C 字面量→rg 反向引用 rc=2(线上 10-25-1900 起健康轮 SYNTH 全灭)—— + 桥注入 LANG 钉 bash 面 locale 确定性:os.environ 无 LANG 缺省 C.UTF-8, + 有值继承操作员原值。""" + monkeypatch.delenv("LANG", raising=False) + assert rounds._round_env()["LANG"] == "C.UTF-8" + monkeypatch.setenv("LANG", "zh_CN.UTF-8") + assert rounds._round_env()["LANG"] == "zh_CN.UTF-8" + + def test_round_env_secret_files_0600(rounds, ops_env, fake_agent): """C4 附带(#196):物化密钥件(auth.json/web-search.json)权限 0600—— mkstemp 面已隐式满足,显式钉住契约防实现漂移(arch 评审 C4 附带项)。""" diff --git a/ops/agent/lib.sh b/ops/agent/lib.sh index 128bba44..272e3afc 100644 --- a/ops/agent/lib.sh +++ b/ops/agent/lib.sh @@ -52,8 +52,11 @@ inject_precedents() { echo "### 注入判例(检索词: ${kws[*]})" local hits="" rg_out rg_rc=0 if (( ${#kws[@]} )); then + # 2026-10-26 判例:C4 白名单丢 LANG→C locale 下 %q 给 CJK 包 $'..' 字面量→rg 反向引用 rc=2(线上 10-25-1900 起健康轮 SYNTH 全灭);argv 直传与 locale 解耦 + local rg_args=() kw + for kw in "${kws[@]}"; do rg_args+=(-e "$kw"); done rg_out="$(rg -i -l -g '*.md' -g '!_*' \ - $(printf -- '-e %q ' "${kws[@]}") "$PRECEDENTS_DIR" 2>/dev/null)" || rg_rc=$? + "${rg_args[@]}" "$PRECEDENTS_DIR" 2>/dev/null)" || rg_rc=$? fi if (( rg_rc > 1 )); then # 评审 Minor-3:rc≥2 = 目录缺失/非法检索词等基础设施故障,必须报错, diff --git a/ops/agent/selftest.sh b/ops/agent/selftest.sh index ae0bf426..9ff240bf 100755 --- a/ops/agent/selftest.sh +++ b/ops/agent/selftest.sh @@ -92,6 +92,19 @@ OUT="$(inject_precedents 排他率 2>&1)" OUT="$(inject_precedents 不存在的词xyz 2>&1)" [[ "$OUT" == *"无判例(检索词: 不存在的词xyz)"* ]] && ok "inject:零命中显式无判例行" || bad "inject:零命中显式无判例行" +# C-locale 回归钉(2026-10-26 判例:C4 spawn 白名单丢 LANG→子进程 bash 退 C locale, +# %q 给 CJK 检索词包 $'..' ANSI-C 字面量→rg 反向引用 parse error rc=2 报「基础设施 +# 故障」,线上 10-25-1900 起健康轮 SYNTH 步全灭):env -i 子壳(仅 PATH)带 CJK 词 +# 调用必须正常检索命中,绝不 rc=2。 +OUT="$(env -i PATH="$PATH" PRECEDENTS_DIR="$FIXDIR" bash -c \ + ". \"$AGENT/lib.sh\"; inject_precedents 排他率 不存在的词xyz" 2>&1)"; CJK_RC=$? +[[ "$OUT" == "### 注入判例(检索词: 排他率 不存在的词xyz)"* ]] \ + && ok "inject:C-locale CJK 检索不炸(首行注入判例头)" || bad "inject:C-locale CJK 检索不炸(首行注入判例头)" +[[ "$OUT" == *"2026-01-01-fixture-aggregator.md"* ]] \ + && ok "inject:C-locale CJK 词真命中(判例行在场)" || bad "inject:C-locale CJK 词真命中(判例行在场)" +[[ "$OUT" != *"rg 检索失败"* && "$CJK_RC" == 0 ]] \ + && ok "inject:C-locale 零基础设施故障行且 rc=0" || bad "inject:C-locale 零基础设施故障行且 rc=0" + # 真实判例库冒烟(只读):frontmatter 解析对 12 条种子成立。子壳隔离:bash 对 # 「VAR=x 函数调用」的前缀赋值会在函数返回后泄漏到当前 shell(非 POSIX 模式)。 # docs 符号链接缺席(CI checkout)→ SKIP 显式计 PASS(T2 守卫,理由见 skipok 注释)。