@sema-agent/client-core 0.42.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -81,7 +81,7 @@ import { DEFAULT_DENY_REASON, DecideTransportRetryExhaustedError, HitlBridge, Hi
81
81
  import { hostLog } from '../host.js';
82
82
  import { createSessionSlot, DEFAULT_SESSION_KEY } from '../sessionSlot.js';
83
83
  import { readEngineActiveBgTasks } from '../fleet/fleetLedger.js';
84
- import { observeCancelByDeny, surfaceRememberNotApplied, surfaceEditNotForwarded } from './hitlHostSurface.js';
84
+ import { observeCancelByDeny, surfaceRememberNotApplied, surfaceEditNotForwarded, surfaceRuleArmNotSent, surfaceRuleArmRejected } from './hitlHostSurface.js';
85
85
  import { approvalCallKey, liveFrameCallKey } from './gateIdentity.js';
86
86
  /** fs 写权限 gate 判定:未来的一等 kind(tool_approval)或按 toolName(server 桥首批=fs 写三件,
87
87
  * [820] 表)。AskUserQuestion 永不进这里(ask 桥先判)。 */
@@ -269,10 +269,13 @@ parkGatedCallId) {
269
269
  // [C170] 答问②半场(0.29.0):durable 富行的两个展示键随卡透传 —— 修前这里是四位闭集,
270
270
  // server 7.16.0 起行上就有的 ruleSuggestions/governanceForced 在「行 → 卡」重铸处整段丢失
271
271
  // (feed 行原样透传零丢失,丢的只有这处)。governanceForced 条件 stamp 只认 === true(缺席
272
- // 纪律与活卡腿同款:缺席=无治理来源证据,绝不写 false);候选走 readRuleSuggestions 同一把
273
- // 合形窄化,落**只读键**(红线见 ApprovalCardRequest.ruleSuggestionsReadOnly 顶注:/decide
272
+ // 纪律与活卡腿同款:缺席=无治理来源证据,绝不写 false);候选走 readOfferSupply 同一把
273
+ // 合形窄化,落**只读键**(红线见 ApprovalCardRequest.ruleOffersReadOnly 顶注:/decide
274
274
  // 无规则位,落可兑付位=假 affordance)。
275
- const ruleSuggestionsReadOnly = readRuleSuggestions(pending.ruleSuggestions);
275
+ // #334(0.43.0):行上的活键换成 `ruleOffers`(server ≥7.46.0 `PendingCheckpoint.ruleOffers`);
276
+ // SDK 7.2.0 的 `PendingCheckpoint` 声明尚无此键 ⇒ **结构视图读**(与本函数下方 `riskDescriptor.probeCause`
277
+ // 同款姿势),旧键 `ruleSuggestions` 保读兼容(7.44 及更旧引擎仍在场),两键经同一把窄读器归一。
278
+ const ruleOffersReadOnly = readOfferSupply(pending.ruleOffers, pending.ruleSuggestions);
276
279
  // #280 件1(0.30.4):durable 行腿的探针因由载体 = `riskDescriptor.probeCause`(server 对
277
280
  // riskDescriptor 整体透传,与活卡帧同值)。SDK 6.17.2 的 riskDescriptor 声明尚无此键 ⇒ 结构
278
281
  // 视图读(与 caps 防御读同款姿势),类型半场候 SDK 班车;载体形不合 ⇒ 不铸键(内部结构不校,
@@ -290,7 +293,7 @@ parkGatedCallId) {
290
293
  callKey,
291
294
  ...(signal ? { signal } : {}),
292
295
  ...(pending.governanceForced === true ? { governanceForced: true } : {}),
293
- ...(ruleSuggestionsReadOnly !== undefined ? { ruleSuggestionsReadOnly } : {}),
296
+ ...(ruleOffersReadOnly !== undefined ? { ruleOffersReadOnly } : {}),
294
297
  ...(isWireRecordCarrier(durableProbeCause) ? { probeCause: durableProbeCause } : {}),
295
298
  });
296
299
  switch (card.kind) {
@@ -387,7 +390,22 @@ export const TOOL_APPROVAL_FRAME_KEYS_MIRROR = [
387
390
  'governanceForced',
388
391
  // sdk 6.14.0 追平(#225 件1,client-core 0.26.0):server 7.12.0 起真发规则候选 —— 门
389
392
  // (run-approval-frame-keys-test.mjs)在 bump SDK 当天恰红抓获本键缺席,本次补齐。
393
+ // ⚠️ **退役键**(server 7.46.0 起同文件 0 命中):留在镜像里是因为 sdk 7.2.0 的运行期锚仍含它,
394
+ // 删了对账门的「SDK 锚的每个键本仓都有」当场红;本包对它只做归一读(见帧上同名键的 JSDoc)。
390
395
  'ruleSuggestions',
396
+ // #334/[5223](client-core 0.43.0):server 7.46.0 起把规则候选整体换成判别联合 `ruleOffers`
397
+ // (core 5.58.0 design/375;engine 7.46.0 fixture 的 `@sema-agent/server/dist/tool-approval.d.ts`
398
+ // 直证,同文件旧键 0 命中)。sdk 7.2.0 锚**尚无**本键 ⇒ 与 `requiresRealApproval`/窗三键同形=
399
+ // **领先**,进对账门 AHEAD_OF_ANCHOR 带退出条件登记。
400
+ 'ruleOffers',
401
+ // #341/[5214]③(client-core 0.43.0):server 7.46.0 起真发 `inputHasBidi`(E-14 Trojan Source 族;
402
+ // 7.44 fixture 同文件 0 命中)。同样**领先** sdk 7.2.0 锚 ⇒ AHEAD_OF_ANCHOR 登记。
403
+ 'inputHasBidi',
404
+ // 🔴 **同形族扫的产物**(client-core 0.43.0,[same-shape-residue-constitution]):本批的病形是
405
+ // 「镜像滞后于 server 实发键」。族扫 = 把 engine 7.46.0 fixture 的 `ToolApprovalFrame` 23 个声明键
406
+ // 与本镜像逐一对表,而不是只补触发本批的那两个 —— `parked`(#329,server 7.44.0 及更早就在,
407
+ // `tool_approval_complete` only)就是这次捞出来的**存量**漏键。镜像补齐,消费面另立项(见帧上注)。
408
+ 'parked',
391
409
  // #144(client-core 0.28.0):server 7.13.0 起真发 `persistedRuleShadowed`(证据=engine fixture
392
410
  // 的 `@sema-agent/server/dist/tool-approval.d.ts`)。⚠️ 本键**领先** SDK 运行期锚一代
393
411
  // (sdk 6.14.0/6.15.0 的 TOOL_APPROVAL_FRAME_KEYS 都还没有它)⇒ 对账门里有一条**带退出条件**的
@@ -500,7 +518,7 @@ export function readToolApprovalRespondAck(v) {
500
518
  return undefined;
501
519
  if (!isRespondDecision(o.decision))
502
520
  return undefined;
503
- return {
521
+ const out = {
504
522
  approvalId: o.approvalId,
505
523
  delivery: 'applied',
506
524
  decision: o.decision,
@@ -515,7 +533,37 @@ export function readToolApprovalRespondAck(v) {
515
533
  // #229(sdk 6.16.0):这次回决的 note 有没有**真落进持久行**。缺席 ≠ false(未带 note 的回决/
516
534
  // 老 server 字段省略);非布尔降缺席 —— 它决定「审计理由记没记上」的诚实告知。
517
535
  ...(typeof o.noteRecorded === 'boolean' ? { noteRecorded: o.noteRecorded } : {}),
536
+ // #340 编辑臂回显(server ≥7.44):**真正落盘**的规范文本(可能与人敲的原字节不同)。
537
+ // 非空串才收 —— 空串是坏值不是「空规则」,回显一格空白比不回显更坏。
538
+ ...(typeof o.persistedRule === 'string' && o.persistedRule !== '' ? { persistedRule: o.persistedRule } : {}),
539
+ // #334 批臂回执(server ≥7.46.0):落地的全体成员规范文本,展示序。
540
+ // 🔴 **整只判形**(不逐条丢):合取批的回执是一份清单,漏掉一条就是对人少报了一次授权 ——
541
+ // 非数组 / 空 / 含非串或空串成员 ⇒ 整只降缺席(诚实「不知道」优先于半份清单)。
542
+ ...(isNonEmptyStringArray(o.persistedRules) ? { persistedRules: o.persistedRules } : {}),
518
543
  };
544
+ // 🔴 **两位规范文本回显的「同份 ack 内矛盾形」**(异源对抗复审 [high] 采纳,0.43.0)——
545
+ // 这与本函数不做的**相关性**判定(「这个 ack 是不是这一次审批的回执」,归编排层)是两件事:
546
+ // 下面判的全是**这一个对象自己**说不通的话,属于形的范畴。
547
+ // ① 单数与复数**同场**:server 的两个回显是互斥臂的产物(`editedArmEcho` 只在 single 成功时
548
+ // 铸、`persistedRules` 只在 batch 成功时铸),同场 = 这份回执在说两次不同的授权;
549
+ // ② 回显在场而 `rulePersisted !== true`:server 的成功回显恒随 `withFlag(true, …)` 一起铸,
550
+ // 「没存上」却报出一条「存成了什么」是自相矛盾 —— 照收会让端渲一句「你的规则已存为 X」,
551
+ // 而 `rulePersisted` 那一位同时在说没存上。
552
+ // 两形都**只丢这两位**(不牵连整份 ack):`rememberApplied`/`updatedInputForwarded` 是更重的
553
+ // 安全告知位,不该被一条规则回显的坏形连坐掉。
554
+ if (out.persistedRule !== undefined && out.persistedRules !== undefined) {
555
+ delete out.persistedRule;
556
+ delete out.persistedRules;
557
+ }
558
+ else if ((out.persistedRule !== undefined || out.persistedRules !== undefined) && out.rulePersisted !== true) {
559
+ delete out.persistedRule;
560
+ delete out.persistedRules;
561
+ }
562
+ return out;
563
+ }
564
+ /** {@link ToolApprovalRespondAckView.persistedRules} 的整只判形(见该位注:半份清单比没有更坏)。 */
565
+ function isNonEmptyStringArray(v) {
566
+ return Array.isArray(v) && v.length > 0 && v.every(x => typeof x === 'string' && x !== '');
519
567
  }
520
568
  /**
521
569
  * 从 respond 抛出来的东西上读 {@link ToolApprovalRespondRefusal}。**永不抛、永不造**。
@@ -651,28 +699,136 @@ function isNonNegativeFinite(v) {
651
699
  function isWireRecordCarrier(v) {
652
700
  return typeof v === 'object' && v !== null && !Array.isArray(v);
653
701
  }
654
- function readRuleSuggestions(v) {
702
+ /**
703
+ * 一条 offer / 一个 batch 成员共用的三位窄化(`rule` 非空串 + `match` 二词闭集 + `command` 非空串)。
704
+ * 任一不合形 ⇒ `undefined`(半条候选渲出来是一格没有正文、按下去必被 server 拒的选项)。
705
+ */
706
+ function readRuleTriple(v) {
707
+ if (v === null || typeof v !== 'object')
708
+ return undefined;
709
+ const o = v;
710
+ if (typeof o.rule !== 'string' || o.rule === '')
711
+ return undefined;
712
+ if (o.match !== 'exact' && o.match !== 'prefix')
713
+ return undefined;
714
+ // 上游形上 `command` 必填(渲染用的命令模式)——缺席/非串 ⇒ 整项不合形丢弃。
715
+ if (typeof o.command !== 'string' || o.command === '')
716
+ return undefined;
717
+ return { rule: o.rule, match: o.match, command: o.command };
718
+ }
719
+ /** offer **条数**的坏形帽。server 侧执法帽是 `MAX_RULE_OFFERS`=4(core 契约本就 ≤2),本包留 8 ——
720
+ * [4050] 随批立的纪律:**窄读域只许等于或宽于铸点域,绝不更窄**;超帽 = 坏形/注入,整只降缺席
721
+ * (截了不留痕会掩盖注入)。 */
722
+ const MAX_RULE_OFFERS_TOLERATED = 8;
723
+ /** 一条 `batch` 的**成员**条数坏形帽(core 契约 1..5,server 容忍帽 8)。与上面那个刻意分家:
724
+ * 两者数的是不同的东西(几个选项 vs 一个选项里几条规则),混成一个数会让上游放宽任一维时误伤另一维。 */
725
+ const MAX_RULE_OFFER_BATCH_MEMBERS_TOLERATED = 8;
726
+ /**
727
+ * `ruleOffers`(server ≥7.46.0 的判别联合)的结构读。
728
+ *
729
+ * 🔴 **逐条丢坏、原始下标不前移**:坏 offer 逐条丢弃(一条坏的不该让另一条真的消失,与 server
730
+ * `boundedRuleOffers` 同向),但留下来的每一条都带**原始 wire 下标** {@link RuleOffer.offerIndex} ——
731
+ * core 的契约原话:做不到保住原始下标的消费端「must suppress its persistence actions entirely」,
732
+ * 因为 `batch` 臂的兑付键就是下标。压紧 = 人点的第 k 个与服务端兑的第 k 个指向两条不同规则。
733
+ * 🔴 `kind` 是**闭集判别位**:不认识的 kind ⇒ 丢这一条(不猜、不降级成 single)。
734
+ * 全部不合形/非数组/超帽 ⇒ 整体缺席(卡不渲「不再询问」档)。
735
+ */
736
+ function readRuleOffers(v) {
655
737
  if (!Array.isArray(v))
656
738
  return undefined;
657
- if (v.length === 0 || v.length > 8)
739
+ if (v.length === 0 || v.length > MAX_RULE_OFFERS_TOLERATED)
658
740
  return undefined;
659
741
  const out = [];
660
- for (const item of v) {
742
+ for (let i = 0; i < v.length; i++) {
743
+ const item = v[i];
661
744
  if (item === null || typeof item !== 'object')
662
745
  continue;
663
746
  const o = item;
664
- if (typeof o.rule !== 'string' || o.rule === '')
747
+ if (o.kind === 'single') {
748
+ const triple = readRuleTriple(item);
749
+ if (triple === undefined)
750
+ continue;
751
+ out.push({ kind: 'single', offerIndex: i, ...triple });
665
752
  continue;
666
- if (o.match !== 'exact' && o.match !== 'prefix')
753
+ }
754
+ if (o.kind === 'batch') {
755
+ if (!Array.isArray(o.rules))
756
+ continue;
757
+ if (o.rules.length === 0 || o.rules.length > MAX_RULE_OFFER_BATCH_MEMBERS_TOLERATED)
758
+ continue;
759
+ // `uncoveredSegments` 是**诚实余量披露**,不是装饰位:非有限非负整数 ⇒ 整条 batch 不合形。
760
+ // 渲一个编出来的 0 等于对用户说「兑完这批就全覆盖了」,而那句话没有证据
761
+ // ([honest-absence-not-fabricated-zero])。
762
+ const uncovered = o.uncoveredSegments;
763
+ if (typeof uncovered !== 'number' || !Number.isSafeInteger(uncovered) || uncovered < 0)
764
+ continue;
765
+ const members = [];
766
+ let memberShapeOk = true;
767
+ for (const raw of o.rules) {
768
+ const triple = readRuleTriple(raw);
769
+ const seg = raw?.segment;
770
+ // 🔴 batch 成员**不逐条丢**:合取批是「对全体成员一次说是」,少一条成员就是另一次授权 ——
771
+ // 渲一个缺了成员的批,人点的与引擎兑的不是同一批规则。任一成员不合形 ⇒ 整条 batch 丢。
772
+ if (triple === undefined || typeof seg !== 'string' || seg === '') {
773
+ memberShapeOk = false;
774
+ break;
775
+ }
776
+ members.push({ ...triple, segment: seg });
777
+ }
778
+ if (!memberShapeOk)
779
+ continue;
780
+ out.push({ kind: 'batch', offerIndex: i, rules: members, uncoveredSegments: uncovered });
667
781
  continue;
668
- // SDK 形上 `command` 必填(渲染用的命令模式)——缺席/非串 ⇒ 整项不合形丢弃(半条候选
669
- // 渲出来是一格没有正文的选项)
670
- if (typeof o.command !== 'string' || o.command === '')
782
+ }
783
+ // 不认识的 kind(含 kind 缺席)⇒ 丢这一条,原始下标照旧不给后面的成员用。
784
+ }
785
+ return out.length > 0 ? out : undefined;
786
+ }
787
+ /**
788
+ * 退役键 `ruleSuggestions`(server ≤7.45)的**归一读** —— 每一项都是「整条命令一条规则」,
789
+ * 逐字对应新形的 `kind:'single'` 臂。7.44 及更旧引擎仍在场,所以这条兼容臂不是包袱。
790
+ * 🔴 归一后仍带原始下标:旧形没有 batch 臂 ⇒ 下标不会被当选择键用,但保住它让两代素材在
791
+ * 卡面/对账面上是**同一个形**(消费端零分支)。
792
+ */
793
+ function readLegacyRuleSuggestions(v) {
794
+ if (!Array.isArray(v))
795
+ return undefined;
796
+ if (v.length === 0 || v.length > MAX_RULE_OFFERS_TOLERATED)
797
+ return undefined;
798
+ const out = [];
799
+ for (let i = 0; i < v.length; i++) {
800
+ const triple = readRuleTriple(v[i]);
801
+ if (triple === undefined)
671
802
  continue;
672
- out.push({ rule: o.rule, match: o.match, command: o.command });
803
+ out.push({ kind: 'single', offerIndex: i, ...triple });
673
804
  }
674
805
  return out.length > 0 ? out : undefined;
675
806
  }
807
+ /**
808
+ * 两代 wire 供给 → **包内单一形**(#334/[5223],0.43.0):新键优先,新键整只读不出来才看旧键。
809
+ *
810
+ * 🔴 **新键在场即定局,绝不混编**:新键**有载体**而读出空(数组在但全条坏形、或压根不是数组)
811
+ * 也**不**回落旧键 —— 一台 7.46 引擎不会同时按两代形铸候选,拿旧键顶上去等于把一份异源素材
812
+ * 冒充成这次 ask 的候选。
813
+ *
814
+ * 🔴 **`null` 与 `undefined` 同视为「新键缺席」**(异源对抗复审 [medium] 追问后的**明示裁定**,
815
+ * 不是漏判):判据是**代价不对称**——
816
+ * · 认 null 为缺席的失效面:一台 **7.46** 引擎把新键发成 `null` **且**同时发了旧键。这不可能
817
+ * 发生:7.46 的三腿上旧键一个字都不铸(engine fixture 直证,同文件 0 命中)⇒ 回落读到的
818
+ * 只会是 `undefined`,结果与「整只缺席」逐字节相同,没有异源素材可混;
819
+ * · 认 null 为坏形的失效面:任何把「缺席」序列化成 `null` 的中转层(JSON 规范化包装器、
820
+ * 某些 SQL/JSONB 读面、mock)会让**所有 ≤7.45 引擎**的「不再询问」档整段消失 —— 那正是
821
+ * [5223] 这一批要修的病本身,只是换了个触发条件。
822
+ * ⇒ 取「null == 缺席」。安全面上它**不新增**任何攻击面:一个能塞 `{ruleOffers:null, ruleSuggestions:[…]}`
823
+ * 的注入面,同样能只塞 `{ruleSuggestions:[…]}`,而后者为了兼容 7.44 本来就必须收。
824
+ */
825
+ function readOfferSupply(offers, legacy) {
826
+ // 🔴 `null` 与 `undefined` 同视为缺席(理由见顶注的代价不对称段);其余一切载体 = 新键在场,
827
+ // 读出什么就是什么,**绝不**再看旧键。
828
+ if (offers !== undefined && offers !== null)
829
+ return readRuleOffers(offers);
830
+ return readLegacyRuleSuggestions(legacy);
831
+ }
676
832
  /** 子代审批帧的归属徽章([1535] cli 半场):名一手源=帧上 sourceAgentName(core 1.378 RB-39②
677
833
  * 显式展示身份,server redact 后透传,候 server 班车到货即亮);缺席退 fleet 在飞台账行名
678
834
  * (`${agentType}: ${description}` 合成形;⚠️ sourceTaskId 实测=子代 sessionId 非 a… handle,
@@ -736,7 +892,9 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
736
892
  // 🔴 治理位与委派链透传(2026-08-08):两键在帧上**只在为真/在场时**出现,所以这里也按在场性
737
893
  // 条件 stamp —— 绝不 `governanceForced: frame.governanceForced === true`(那会把「缺席」折成
738
894
  // 显式 `false`,而缺席的语义是「没有治理来源的证据」,不是「这门可以被表态掀掉」)。
739
- const ruleSuggestions = readRuleSuggestions(frame.ruleSuggestions);
895
+ // #334(0.43.0):新键 `ruleOffers`(server ≥7.46.0 判别联合)优先,旧键 `ruleSuggestions`
896
+ // (≤7.45)归一成 `kind:'single'` 兜底 —— 两代经同一把窄读器,包内出口只有一个形。
897
+ const ruleOffers = readOfferSupply(frame.ruleOffers, frame.ruleSuggestions);
740
898
  const card = await surfaceApprovalCard({
741
899
  toolName,
742
900
  args: args,
@@ -774,8 +932,9 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
774
932
  ...(isFromSubagent(frame) && isToolApprovalDelegation(frame.delegation)
775
933
  ? { delegation: frame.delegation }
776
934
  : {}),
777
- // #225 件1:规则候选透传(合形项;缺席/坏形 ⇒ 键不 stamp,卡形与 0.25.0 字节不变)。
778
- ...(ruleSuggestions !== undefined ? { ruleSuggestions } : {}),
935
+ // #225 件1 / #334 换形(0.43.0):规则候选透传(合形项;缺席/坏形 ⇒ 键不 stamp,卡形不渲该档)。
936
+ // 落位是**包内单一形出口** `ruleOffers`,两代 wire 键同经 readOfferSupply 归一。
937
+ ...(ruleOffers !== undefined ? { ruleOffers } : {}),
779
938
  // #144:被越级的持久规则原文(UNTRUSTED-for-display)。窄化=**非空白串才 stamp**,坏形降缺席
780
939
  // (server 明说空串不铸键:「空串是坏值不是『空规则』」)—— 一格空白的规则解释比没有解释更坏。
781
940
  // 🔴 stamp 的是**原文字节**(不 trim、不截、不改写):展示值一改写就不再是「你那条规则」的原文;
@@ -797,6 +956,11 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
797
956
  // 「这不是一次安全类 ask」,把它折成显式 false 会让宿主把「没有证据」读成「已确认可自动放行」,
798
957
  // 而本键存在的全部理由正是在这种门上把自动放行(记住的规则 / allow_session / bypass 姿态)拦下。
799
958
  ...(frame.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
959
+ // #341/[5214]③(0.43.0):bidi 披露位透传。条件 stamp **只认严格 true**,与 governanceForced /
960
+ // requiresRealApproval 同一条纪律 —— 缺席同时覆盖「真的没有」与「扫不了」,折成显式 false 会把
961
+ // 「没扫到」渲成「已确认干净」,而这一位存在的全部理由就是「人眼读到的顺序 ≠ 真跑的字节顺序」。
962
+ // 🔴 披露不清洗:args 字节在本层一个都不改(清洗会让卡上显示的与真跑的不是同一个东西)。
963
+ ...(frame.inputHasBidi === true ? { inputHasBidi: true } : {}),
800
964
  });
801
965
  const decision = card.kind === 'allow' ? (card.allowSession ? 'allow_session' : 'allow') : 'deny';
802
966
  if (card.kind === 'failed') {
@@ -845,9 +1009,70 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
845
1009
  // 🔴 能力位闸(`lane.respondFreeFormRulesCapable`)与 note 那条**同一条纪律**:位缺席就别发。
846
1010
  // 编辑臂在老 server 上会诚实降级 `rule_not_offered`(不是静默 200),但那仍然是一次
847
1011
  // 「人以为存上了、其实拿回一个拒绝」的往返 —— 能提前知道这台不供,就别把人的编辑送出去。
1012
+ //
1013
+ // ── #334 批臂(0.43.0,design/377 wire 形):选择键=**原始 wire 下标**,表核锚「该下标真是
1014
+ // 一条 batch offer」──────────────────────────────────────────────────────────────────
1015
+ // 🔴 **三臂互斥先判**(server `PERSIST_RULE_BATCH_EXCLUSIVE_ERROR`:同场响亮 400 且**连决断一起拒**)。
1016
+ // 包边界丢整条持久臂而不是让 400 打掉决断 —— 与 note 超限那条同一条纪律:附带愿望的失败
1017
+ // 绝不许升级成主动作的失败。也绝不替人挑一个臂(两臂说的是两次不同的授权)。
848
1018
  let persistRule;
849
1019
  let persistRuleEdited;
850
- if (card.kind === 'allow' && typeof card.persistRule === 'string' && card.persistRule !== '' && decision !== 'deny') {
1020
+ let persistRuleBatchOfferIndex;
1021
+ /** 真发出去的那条 batch offer(供下方 ack 的**基数**相关性核对;缺席 = 没发批臂)。 */
1022
+ let batchArmTarget;
1023
+ /** 人选了持久臂、而**能力位没确认**所以整条没发(两臂共用)。通知发在 respond 成功之后。 */
1024
+ let ruleArmDroppedForCaps = false;
1025
+ /** 人选了持久臂、而那次选择**没过包内表核/互斥核**(表外文本 / 坏下标 / 两臂同场)。
1026
+ * 与上一位**刻意分两个标记**:对用户的下一步建议不同(见两条文案常量的顶注)。同样发在
1027
+ * respond 成功之后 —— 文案里有「审批本身过了」。 */
1028
+ let ruleArmDroppedForCheck = false;
1029
+ const wantsTextArm = card.kind === 'allow' && typeof card.persistRule === 'string' && card.persistRule !== '';
1030
+ const wantsBatchArm = card.kind === 'allow' && card.persistRuleBatchOfferIndex !== undefined;
1031
+ if (wantsTextArm && wantsBatchArm) {
1032
+ hostLog('error', `liveToolApprovalWire: DROPPING the whole persistRule arm for ${frame.approvalId} — the card returned BOTH a rule ` +
1033
+ 'text and a batchOfferIndex; the server rejects that combination with a 400 that would take the decision down ' +
1034
+ 'with it, and picking one arm here would be deciding on the user\'s behalf (the decision itself still goes through)');
1035
+ // 🔴 十轮 [medium]:人的明确选择被丢了,`hostLog` 那条是给开发者的诊断,用户还需要一句答复。
1036
+ ruleArmDroppedForCheck = true;
1037
+ }
1038
+ else if (card.kind === 'allow' &&
1039
+ card.persistRuleBatchOfferIndex !== undefined &&
1040
+ decision !== 'deny') {
1041
+ // 🔴 表核:下标必须在**本帧**归一后的 offers 里真指向一条 `kind:'batch'`。
1042
+ // 越界 / 指向 single / 负数 / 非整数一律丢键留痕 —— server 对坏下标响亮 400
1043
+ // (`persistRule.batchOfferIndex must be a non-negative integer when present`),
1044
+ // 而一个被 `|0` 折过的下标指向的是**另一条** offer。
1045
+ const idx = card.persistRuleBatchOfferIndex;
1046
+ const target = Number.isSafeInteger(idx) && idx >= 0
1047
+ ? ruleOffers?.find(o => o.offerIndex === idx)
1048
+ : undefined;
1049
+ if (target !== undefined && target.kind === 'batch') {
1050
+ // 🔴 能力位闸(异源对抗复审 [high]):位缺席就别发 —— 见 `respondBatchRuleOffersCapable`
1051
+ // 顶注。「帧上有 batch offer」只证明出帧端认识新形,不证明收 respond 的那台认识它;
1052
+ // 旧解析器对本键响亮 400 且**连决断一起拒**。
1053
+ if (lane?.respondBatchRuleOffersCapable === true) {
1054
+ persistRuleBatchOfferIndex = idx;
1055
+ batchArmTarget = target;
1056
+ }
1057
+ else {
1058
+ hostLog('debug', `liveToolApprovalWire: DROPPING persistRuleBatchOfferIndex(${idx}) for ${frame.approvalId} — the ` +
1059
+ 'respondBatchRuleOffers capability is not confirmed for this engine, so the batch arm is not sent at all ' +
1060
+ '(the decision itself still goes through unchanged; the offer stays on the card for local/display use)');
1061
+ // 🔴 **人按下的意图被丢了就必须上屏**(异源对抗复审三轮 [high]):只记 debug =
1062
+ // 「用户明确选了『不再询问』,系统静默丢掉,下次照旧弹卡」。见 surfaceRuleArmNotSent 顶注。
1063
+ // ⚠️ 但**不在这里发** —— 文案里有「审批本身过了」,而此刻 respond 还没送出去(四轮 [medium]:
1064
+ // respond 随后抛错 ⇒ 决断没落定,用户却已读到「过了」)。只置标记,发在成功之后。
1065
+ ruleArmDroppedForCaps = true;
1066
+ }
1067
+ }
1068
+ else {
1069
+ hostLog('error', `liveToolApprovalWire: DROPPING persistRuleBatchOfferIndex(${String(idx)}) for ${frame.approvalId} — that wire ` +
1070
+ 'index is not a batch offer on this frame (out of range, a single offer, or not a non-negative integer); ' +
1071
+ 'never redeeming an index the engine did not offer as a batch');
1072
+ ruleArmDroppedForCheck = true; // 见上:表核丢键同样要给用户一句答复
1073
+ }
1074
+ }
1075
+ else if (wantsTextArm && card.kind === 'allow' && typeof card.persistRule === 'string' && decision !== 'deny') {
851
1076
  const edited = card.persistRuleEdited === true;
852
1077
  if (edited) {
853
1078
  if (lane?.respondFreeFormRulesCapable === true) {
@@ -858,13 +1083,17 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
858
1083
  hostLog('debug', `liveToolApprovalWire: DROPPING edited persistRule for ${frame.approvalId} (len=${card.persistRule.length}) — the ` +
859
1084
  'respondFreeFormRules capability is not confirmed for this engine, so the free-form arm is not sent at all ' +
860
1085
  '(the decision itself still goes through unchanged)');
1086
+ // 🔴 同形存量一并清(0.43.0):这一臂此前也只写 debug —— 与批臂同一个病,共用同一条通知,
1087
+ // 也共用同一条时序契约(发在 respond 成功之后,理由见批臂那处)。
1088
+ ruleArmDroppedForCaps = true;
861
1089
  }
862
1090
  }
863
- else if (ruleSuggestions?.some(sug => sug.rule === card.persistRule) === true) {
1091
+ else if (ruleOffers?.some(o => o.kind === 'single' && o.rule === card.persistRule) === true) {
864
1092
  persistRule = card.persistRule;
865
1093
  }
866
1094
  else {
867
- hostLog('error', `liveToolApprovalWire: DROPPING persistRule for ${frame.approvalId} — the card returned a rule that is not among the frame's suggestions (never sending un-offered text to the rule store)`);
1095
+ hostLog('error', `liveToolApprovalWire: DROPPING persistRule for ${frame.approvalId} — the card returned a rule that is not among the frame's SINGLE offers (a batch member's text is not a selectable candidate either: a conjunction batch is redeemed by index, all-or-nothing) — never sending un-offered text to the rule store`);
1096
+ ruleArmDroppedForCheck = true; // 同形存量一并清(0.26.0 起这条只写 error 日志)
868
1097
  }
869
1098
  }
870
1099
  const raw = await respond(frame.approvalId, decision, {
@@ -872,6 +1101,7 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
872
1101
  ...(card.kind === 'allow' && card.updatedInput !== undefined ? { updatedInput: card.updatedInput } : {}),
873
1102
  ...(persistRule !== undefined ? { persistRule } : {}),
874
1103
  ...(persistRuleEdited !== undefined ? { persistRuleEdited } : {}),
1104
+ ...(persistRuleBatchOfferIndex !== undefined ? { persistRuleBatchOfferIndex } : {}),
875
1105
  ...(note !== undefined ? { note } : {}),
876
1106
  });
877
1107
  // 🔴 相关性门(对抗复审二轮):ack 必须是**这一次**审批的回执 —— id 与决断词都要对上。
@@ -886,6 +1116,62 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
886
1116
  'treating as "no ack" (unknown) — never surfacing a safety notice off an unrelated receipt');
887
1117
  ack = undefined;
888
1118
  }
1119
+ // 🔴 **臂相关性门**(异源对抗复审 [high] 采纳,0.43.0):两位规范文本回显必须与**这一次真发出去
1120
+ // 的持久臂**对得上 —— 没发持久臂却拿到「你的规则已存为 X」,或发的是文本臂却拿到一份批清单,
1121
+ // 都是这份回执在说一次**没有发生过**的授权。端拿它渲的是强断言(「已存」+ 具体存了什么),
1122
+ // 据一个对不上的回显去渲就是替引擎编一次授权。
1123
+ // 🔴 **只丢这两位、不牵连整份 ack**(与上面那道 id/decision 相关性门刻意不同姿势):那道门管的是
1124
+ // 「整份回执根本不是我的」,这道门管的是「回执是我的,但其中一格与我发出的臂矛盾」——
1125
+ // 整份丢会把 `rememberApplied`/`updatedInputForwarded` 这两个更重的安全告知位一起连坐掉。
1126
+ if (ack !== undefined && (ack.persistedRule !== undefined || ack.persistedRules !== undefined)) {
1127
+ // 🔴 单数回显**只属编辑臂**(异源对抗复审二轮 [medium] 采纳,server `editedArmEcho` 逐字:
1128
+ // `persistRule.kind !== 'text' || !persistRule.edited || …` ⇒ 返 `{}`)——候选臂成功时
1129
+ // server **不铸**这一位,所以候选臂上收到它同样是「一次没发生过的授权」。
1130
+ const sentEditedArm = persistRule !== undefined && persistRuleEdited === true;
1131
+ // 🔴 复数回显要过**基数**相关性:server 契约逐字「全体成员落地(persisted/deduped 都计)+
1132
+ // `rules` = 规范文本,**展示序**(= batch offer 的成员序,壳逐条回显)」⇒ 一份合规回执的
1133
+ // 条数恒等于所选 batch 的成员数。多报 = 声称存了用户在这只批里**没看见**的规则(授权范围
1134
+ // 被放大);少报 = 这份清单不是它自称的「全体」。两向都不许当成「实际存了这些」渲上屏。
1135
+ // ⚠️ 只做**基数**相关性,**不复判文本**:规范化是引擎的活(`Bash(adb *)` → `Bash(adb:*)`),
1136
+ // 在这里比对文本就是装第二个判官。
1137
+ const expectedBatchCount = batchArmTarget?.rules.length;
1138
+ const batchEchoOk = ack.persistedRules !== undefined &&
1139
+ expectedBatchCount !== undefined &&
1140
+ ack.persistedRules.length === expectedBatchCount;
1141
+ const strayEchoes = [];
1142
+ if (ack.persistedRule !== undefined && !sentEditedArm)
1143
+ strayEchoes.push('persistedRule');
1144
+ if (ack.persistedRules !== undefined && !batchEchoOk)
1145
+ strayEchoes.push('persistedRules');
1146
+ if (strayEchoes.length > 0) {
1147
+ hostLog('error', `liveToolApprovalWire: DISCARDING persisted-rule echo(es) [${strayEchoes.join(', ')}] on the ack for ` +
1148
+ `${frame.approvalId} — they do not correlate with the persistence arm actually sent ` +
1149
+ `(editedArm=${String(sentEditedArm)} batchArmMembers=${String(expectedBatchCount ?? 'none')} ` +
1150
+ `echoedRules=${String(ack.persistedRules?.length ?? 'none')}); never telling the user a rule ` +
1151
+ 'was stored off a receipt that does not line up with what this client actually sent');
1152
+ const { persistedRule: _pr, persistedRules: _prs, ...rest } = ack;
1153
+ void _pr;
1154
+ void _prs;
1155
+ ack = {
1156
+ ...rest,
1157
+ ...(ack.persistedRule !== undefined && sentEditedArm ? { persistedRule: ack.persistedRule } : {}),
1158
+ ...(ack.persistedRules !== undefined && batchEchoOk ? { persistedRules: ack.persistedRules } : {}),
1159
+ };
1160
+ }
1161
+ }
1162
+ // ── 🔴 **同一次 respond 的通知顺序契约**(异源对抗复审十一轮 [high]:同一次 allow 可以同时
1163
+ // 触发多条通知,而 `HitlHostSurface.showNotice` 的语义是**直接顶替 current、不排队** ⇒
1164
+ // **最后发的那条才是用户真正看得到的那条**)。所以三条按**严重度升序**发,最重的压轴:
1165
+ // ① 规则没存(本次「不再询问」丢了)—— 下次还会问,不影响这次跑的是什么;
1166
+ // ② allow_session 没记住 —— 同族,范围更大一点;
1167
+ // ③ 编辑没转发(**工具正在用原始入参跑**)—— 批的那份 ≠ 跑的那份,唯一一条 [high]。
1168
+ // ⚠️ 顺序写反(把①放最后)= 用一条「规则没保存」把「你批的和正在跑的不是同一个东西」顶掉。
1169
+ // ⚠️ 时序契约(四轮 [medium])一并在此兑现:三条**都在 `await respond(...)` 成功之后**才发 ——
1170
+ // ①的文案里有「审批本身过了」,而 respond 抛错那条路上那句是假的(决断没落定,引擎自决)。
1171
+ if (ruleArmDroppedForCaps)
1172
+ surfaceRuleArmNotSent();
1173
+ if (ruleArmDroppedForCheck)
1174
+ surfaceRuleArmRejected();
889
1175
  // 🔴 FIX①:三选卡第 2 项在用户心里签的是「本会话这个工具不再问我」。server 说没记住时,
890
1176
  // 界面必须收敛那个承诺 —— 这里只发一条诚实通知(渲不渲「全放行」徽标由宿主按 ack 决定,
891
1177
  // ack 也随本函数返回值一起交出去)。ack 缺席(旧 server / void 注入面)= 未知,不发。