@sema-agent/client-core 0.35.0 → 0.37.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.
- package/CHANGELOG.md +241 -0
- package/README.md +1 -1
- package/dist/adapt/arms.js +3 -0
- package/dist/adapter/activeRunSelfHeal.d.ts +35 -2
- package/dist/adapter/activeRunSelfHeal.js +157 -4
- package/dist/adapter/downstream/eventToSdkMessage.js +5 -0
- package/dist/adapter/runStream.d.ts +63 -1
- package/dist/adapter/runStream.js +38 -0
- package/dist/engineCapsCache.d.ts +48 -2
- package/dist/engineCapsCache.js +157 -11
- package/dist/fleet/fleetProjection.d.ts +9 -1
- package/dist/fleet/fleetProjection.js +13 -3
- package/dist/fleetTaskDesc.d.ts +5 -1
- package/dist/fleetTaskDesc.js +39 -2
- package/dist/hitl/armedGateRegistry.js +11 -3
- package/dist/hitl/hitlBridge.d.ts +20 -0
- package/dist/hitl/hitlBridge.js +156 -12
- package/dist/hitl/parkResolver.d.ts +1 -0
- package/dist/hitl/parkResolver.js +22 -2
- package/dist/hitl/toolApprovalWire.d.ts +88 -7
- package/dist/hitl/toolApprovalWire.js +50 -6
- package/dist/request/taskRequest.js +11 -11
- package/dist/retryStatus.d.ts +38 -3
- package/dist/retryStatus.js +15 -5
- package/dist/toolResult.d.ts +8 -0
- package/dist/toolResult.js +15 -0
- package/docs/INTEGRATION-CLIENTS.md +20 -19
- package/package.json +2 -2
package/dist/hitl/hitlBridge.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { askParkForeignGateKind } from '../adapter/activeRunSelfHeal.js';
|
|
2
2
|
import { eventSeq } from '../adapter/types.js';
|
|
3
3
|
import { hostLog } from '../host.js';
|
|
4
|
+
import { abortableSleep } from '../abortableSleep.js';
|
|
4
5
|
// ── deny/plan-review 归因的 wire 窄化(0.28.0 发版扫描 F1/F2/F3 收编;单源,三条腿共用)──────
|
|
5
6
|
//
|
|
6
7
|
// server 对 `/decide` 与 plan-review 两条腿的 `reason` 同限 `MAX_APPROVAL_REASON_CHARS`(4096 字符,
|
|
@@ -52,6 +53,111 @@ export class HitlSafetyError extends Error {
|
|
|
52
53
|
this.name = 'HitlSafetyError';
|
|
53
54
|
}
|
|
54
55
|
}
|
|
56
|
+
// ── decide 出站的瞬断分诊与有界重试(Inkglow-1085 P0a,2026-08-19;[4664] server 定谳后对表)────
|
|
57
|
+
//
|
|
58
|
+
// 病形(案卷 INKGLOW-1085 车1 全链):`approvals.decide` 是**非 submit POST** —— SDK transport 对它
|
|
59
|
+
// `maxAttempts = 1`(「a decide must not double-act」),且每发带显式 `AbortSignal.timeout(timeoutMs)`
|
|
60
|
+
// (缺省 60s;per-call opts 只有 `signal`,调用方 signal 与之**合流取先**,只能收短、不能放长 ——
|
|
61
|
+
// timeoutMs 是 client 构造级旋钮,本包对已构造的注入 client 不可配)。于是一次网络瞬断/超时 =
|
|
62
|
+
// decide 单发即死 → 上层拼 `failed` → parkResolver 合成 `hitl_unanswered` 终帧 → ask 死局。
|
|
63
|
+
//
|
|
64
|
+
// 🔴 [4664] server 定谳的真因与过渡期口径(2026-08-19):60s 超时**结构性必炸** —— legacy 任务级
|
|
65
|
+
// decide 腿(`/v1/approvals/:sessionId/decide`)在响应前**同步跑整条 resume**(模型往返,无上限);
|
|
66
|
+
// server 立件 #316 改快速 ack,过渡期口径 = **壳把这条腿当长调用处理(预期上限无界)**。SDK 的
|
|
67
|
+
// per-attempt 60s 帽对注入 client 不可配(调用面只有 signal,合流只能收短)⇒ 本层的消化形:
|
|
68
|
+
// · **超时类**(TimeoutError = 60s 帽掐断,server 多半仍在跑 resume)⇒ 带退避**继续重试**,
|
|
69
|
+
// 总窗 {@link DECIDE_TIMEOUT_RETRY_TOTAL_BUDGET_MS}(10 分钟级 —— 长调用口径);
|
|
70
|
+
// · **网络断类**(ECONNREFUSED / fetch failed 等:连语义答复都没拿到,引擎多半真死)⇒ 重试
|
|
71
|
+
// **恰一次**,让真死尽快显形;
|
|
72
|
+
// · 语义答复类(带 HTTP status 的 4xx/5xx —— 引擎收到并回答了)⇒ 零重试,原样上抛;
|
|
73
|
+
// · `HitlSafetyError`(binding mismatch 等安全信号)⇒ 恒零重试(§9.1 铁律,原有行为不动);
|
|
74
|
+
// · 调用方已中止(用户 Esc)⇒ 零重试(AbortError 是人按的,不是瞬断)。
|
|
75
|
+
//
|
|
76
|
+
// 🔴 为什么重试不违反「decide 绝不 double-act」:①[4664] 原话 —— **重复 decide 不双跑,server CAS
|
|
77
|
+
// 保证**(重试幂等安全,放心重试);②D-1 绑定回显(boundCallId+boundInputHash 逐字回显,server
|
|
78
|
+
// fail-closed on mismatch)—— 第一发其实送达时,重发只能撞 4xx(approval not found / conflict),
|
|
79
|
+
// 绝不可能批掉**另一件**事;那个 4xx 原样上抛,parkResolver 的「已解决重放救回」判据照认。
|
|
80
|
+
// 重试耗尽(超时类窗尽 / 网络断类第二发仍断)⇒ 抛 {@link DecideTransportRetryExhaustedError}
|
|
81
|
+
// (typed 判别位)—— 消费方(toolApprovalWire / parkResolver)据此走**重呈臂**而不是把 turn 判死。
|
|
82
|
+
// 引擎真死时失败也会尽快显形:重呈臂的下一步(approvals.list / runs.events)对死引擎当场失败,
|
|
83
|
+
// 走既有诚实红。
|
|
84
|
+
// 🔴 请托半场(候黑板):SDK decide 若开 per-call timeoutMs(或对 HITL 面单列长缺省),本层的
|
|
85
|
+
// 超时类重试环可整段收敛成一发长等待;#316 落地(快速 ack)后总窗也可回收。
|
|
86
|
+
/** 瞬断重试的起始退避(×2 递增,封顶 {@link DECIDE_RETRY_BACKOFF_MAX_MS};别把 decide 打成连发)。 */
|
|
87
|
+
const DECIDE_TRANSPORT_RETRY_BACKOFF_MS = 750;
|
|
88
|
+
const DECIDE_RETRY_BACKOFF_MAX_MS = 5_000;
|
|
89
|
+
/** 超时类重试的总窗([4664] 长调用口径:legacy decide 腿同步跑整条 resume,60s 帽结构性必炸;
|
|
90
|
+
* 每发本身就要跑满 SDK 的 60s 帽,10 分钟 ≈ 9 发 —— 覆盖长 resume 而不至于无限吊死)。
|
|
91
|
+
* 🔴 窗的语义是**不再起新发**,刻意不掐在飞那一发(codex 复审议题,驳回后成文):给一发可能已被
|
|
92
|
+
* server 受理的 decide 塞截止 signal 换不来任何安全 —— server 侧照跑,客户端只多制造一个「送达
|
|
93
|
+
* 未知」;上冲上界 = 一发(SDK 60s 帽)+ 一拍退避,可预算。 */
|
|
94
|
+
const DECIDE_TIMEOUT_RETRY_TOTAL_BUDGET_MS = 10 * 60_000;
|
|
95
|
+
let decideTimeoutRetryBudgetOverrideMs;
|
|
96
|
+
/** 测试钩:把超时类重试总窗调小(传 undefined 还原缺省)。 */
|
|
97
|
+
export function __setDecideTimeoutRetryBudgetForTests(ms) {
|
|
98
|
+
decideTimeoutRetryBudgetOverrideMs = ms;
|
|
99
|
+
}
|
|
100
|
+
function decideTimeoutRetryBudgetMs() {
|
|
101
|
+
return decideTimeoutRetryBudgetOverrideMs ?? DECIDE_TIMEOUT_RETRY_TOTAL_BUDGET_MS;
|
|
102
|
+
}
|
|
103
|
+
/** 超时类判别(自身或 cause 链上的 TimeoutError —— SDK per-attempt `AbortSignal.timeout` 的产物;
|
|
104
|
+
* [4664] 口径下它多半意味着 server 仍在同步跑 resume,不是引擎死了)。 */
|
|
105
|
+
function isDecideAttemptTimeout(e, depth = 0) {
|
|
106
|
+
if (depth > 3 || e === null || typeof e !== 'object')
|
|
107
|
+
return false;
|
|
108
|
+
const o = e;
|
|
109
|
+
if (o.name === 'TimeoutError')
|
|
110
|
+
return true;
|
|
111
|
+
return isDecideAttemptTimeout(o.cause, depth + 1);
|
|
112
|
+
}
|
|
113
|
+
/** 瞬断类网络错误码(自身或 cause 链上;undici 的 fetch failed 把真因挂在 cause)。 */
|
|
114
|
+
const TRANSIENT_NETWORK_CODES = new Set([
|
|
115
|
+
'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN',
|
|
116
|
+
'ENETUNREACH', 'EHOSTUNREACH', 'ECONNABORTED',
|
|
117
|
+
'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT', 'UND_ERR_SOCKET',
|
|
118
|
+
]);
|
|
119
|
+
/**
|
|
120
|
+
* decide 出站失败的瞬断判别(module 私有 —— 唯一消费点是 {@link HitlBridge} 的 decide choke point)。
|
|
121
|
+
* 🔴 方向:判不出一律**非瞬断**(fail 到「不重试」侧 —— 语义拒绝被误判成瞬断才是真事故:那会把
|
|
122
|
+
* 一次已被拒绝的决断再发一遍)。带数字 `status` 的错误 = 引擎**答了**,无论 4xx/5xx 都不是瞬断。
|
|
123
|
+
*/
|
|
124
|
+
function isTransientDecideTransportFailure(e, depth = 0) {
|
|
125
|
+
if (depth > 3 || e === null || typeof e !== 'object')
|
|
126
|
+
return false;
|
|
127
|
+
if (e instanceof HitlSafetyError)
|
|
128
|
+
return false;
|
|
129
|
+
const o = e;
|
|
130
|
+
if (typeof o.status === 'number')
|
|
131
|
+
return false;
|
|
132
|
+
if (o.name === 'TimeoutError' || o.name === 'AbortError')
|
|
133
|
+
return true;
|
|
134
|
+
if (typeof o.code === 'string' && TRANSIENT_NETWORK_CODES.has(o.code))
|
|
135
|
+
return true;
|
|
136
|
+
if (typeof o.message === 'string' && o.message.toLowerCase().includes('fetch failed'))
|
|
137
|
+
return true;
|
|
138
|
+
return isTransientDecideTransportFailure(o.cause, depth + 1);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* decide 出站在瞬断类失败上重试一次**仍未送达**(两发都没拿到引擎的语义答复)。
|
|
142
|
+
*
|
|
143
|
+
* 🔴 判别位契约:消费方(`toolApprovalWire` 的 catch 臂 / `parkResolver.surfaceGateAndDecide`)
|
|
144
|
+
* 据 `instanceof` 在 outcome 上 stamp `retryExhausted: true`,parkResolver 对该位走**重呈臂**
|
|
145
|
+
* (re-attach ⇒ durable 流重放 park ⇒ 同一张卡重新交给用户),不再合成 `hitl_unanswered` 把 turn
|
|
146
|
+
* 判死 —— run 仍 parked、pending 行仍可决,判死是三条出路里唯一不可逆的那条。
|
|
147
|
+
* 🔴 message 刻意避开 `isAlreadyResolvedGateReason` 的词表(no pending checkpoint / resolved /
|
|
148
|
+
* already / not found):被那把兜底尺误判成「已解决」会走成静默 reattach,判别位就白铸了 ——
|
|
149
|
+
* pure 门(hitl-gate-honesty F6-d)有常驻负控钉着这一条。
|
|
150
|
+
*/
|
|
151
|
+
export class DecideTransportRetryExhaustedError extends Error {
|
|
152
|
+
attempts;
|
|
153
|
+
constructor(
|
|
154
|
+
/** 实际发出的次数(网络断类 = 2:首发 + 单次重试;超时类 = 总窗内发出的全部)。 */
|
|
155
|
+
attempts, lastFailureText) {
|
|
156
|
+
super(`decide did not reach the engine after ${attempts} attempts (transient transport failure): ${lastFailureText}`);
|
|
157
|
+
this.attempts = attempts;
|
|
158
|
+
this.name = 'DecideTransportRetryExhaustedError';
|
|
159
|
+
}
|
|
160
|
+
}
|
|
55
161
|
/**
|
|
56
162
|
* The single source for "which `PendingCheckpoint` row is the human about to decide on", used by the two
|
|
57
163
|
* decision wires (`toolApprovalWire.surfaceFsApprovalAndDecide` / `askGateWire.surfaceGateAndDecide`)
|
|
@@ -323,19 +429,57 @@ export class HitlBridge {
|
|
|
323
429
|
}
|
|
324
430
|
// ── decide transport (the single choke point; maps 409 binding mismatch → safety stop) ──
|
|
325
431
|
async decideRaw(sessionId, decision, opts) {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
432
|
+
// Inkglow-1085 P0a([4664] 对表后形):瞬断类失败带退避有界重试 —— 超时类按长调用总窗,
|
|
433
|
+
// 网络断类恰一次;为什么重试不违反「decide 绝不 double-act」、哪些失败绝不重试,见文件上方
|
|
434
|
+
// DECIDE_TRANSPORT_RETRY 段总注。
|
|
435
|
+
// 🔴 经函数读 aborted(waitForClaimRelease.isAborted 同注):它在 await 两侧会变,直接读两次
|
|
436
|
+
// 会被 tsc 控制流分析把第二次窄成恒假比较(TS2367)。
|
|
437
|
+
const callerAborted = () => opts?.signal?.aborted === true;
|
|
438
|
+
const startedAt = Date.now();
|
|
439
|
+
let attempts = 0;
|
|
440
|
+
let netRetriesUsed = 0;
|
|
441
|
+
let backoff = DECIDE_TRANSPORT_RETRY_BACKOFF_MS;
|
|
442
|
+
for (;;) {
|
|
443
|
+
attempts++;
|
|
444
|
+
try {
|
|
445
|
+
const r = await this.client.approvals.decide(sessionId, decision, opts);
|
|
446
|
+
// A successful decide resumes the SAME durable stream; the gate clears on the next running arm.
|
|
447
|
+
this.active = null;
|
|
448
|
+
return r;
|
|
449
|
+
}
|
|
450
|
+
catch (e) {
|
|
451
|
+
if (isBindingMismatch(e)) {
|
|
452
|
+
// contract/04 §2.2 / §9.1: a binding mismatch means the pending action changed under the human.
|
|
453
|
+
// SAFETY signal — re-present to the human, NEVER auto-retry / auto-re-decide.
|
|
454
|
+
throw new HitlSafetyError('approval_binding_mismatch — the pending action changed under the human; refetch + re-present', 'binding_mismatch');
|
|
455
|
+
}
|
|
456
|
+
// 调用方已中止(用户 Esc)⇒ 这个 AbortError 是人按的,不是瞬断 —— 原错上抛,零重试。
|
|
457
|
+
if (!isTransientDecideTransportFailure(e) || callerAborted())
|
|
458
|
+
throw e;
|
|
459
|
+
if (isDecideAttemptTimeout(e)) {
|
|
460
|
+
// [4664] 长调用口径:60s 帽掐断 ≠ 引擎死了 —— legacy decide 腿在响应前同步跑整条 resume。
|
|
461
|
+
// 重复 decide 由 server CAS 保证不双跑:server 已受理时,下一发要么等到快速答复(conflict/
|
|
462
|
+
// not-found ⇒ 上抛,已解决判据接手),要么继续被长调用吃满 60s —— 总窗兜底。
|
|
463
|
+
if (Date.now() - startedAt >= decideTimeoutRetryBudgetMs()) {
|
|
464
|
+
throw new DecideTransportRetryExhaustedError(attempts, String(e));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
else if (netRetriesUsed >= 1) {
|
|
468
|
+
// 网络断类(连语义答复都没有)重试恰一次 —— 引擎真死时让失败尽快显形。
|
|
469
|
+
throw new DecideTransportRetryExhaustedError(attempts, String(e));
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
netRetriesUsed++;
|
|
473
|
+
}
|
|
474
|
+
hostLog('debug', `hitlBridge: decide transport failure (transient: ${String(e)}) — retrying after ${backoff}ms ` +
|
|
475
|
+
`(attempt ${attempts}; duplicate decide is CAS-safe per [4664] and the D-1 binding echo makes it ` +
|
|
476
|
+
'at-most-once-effective: a landed first shot turns the retry into a 4xx, never a second act)');
|
|
477
|
+
await abortableSleep(backoff, opts?.signal ?? new AbortController().signal);
|
|
478
|
+
backoff = Math.min(backoff * 2, DECIDE_RETRY_BACKOFF_MAX_MS);
|
|
479
|
+
// 退避期间被中止 ⇒ 不再补发(abortableSleep 对 abort 是提前 resolve,不抛)。
|
|
480
|
+
if (callerAborted())
|
|
481
|
+
throw e;
|
|
337
482
|
}
|
|
338
|
-
throw e;
|
|
339
483
|
}
|
|
340
484
|
}
|
|
341
485
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { HitlBridge, HitlSafetyError, findPendingForTask } from './hitlBridge.js';
|
|
1
|
+
import { DecideTransportRetryExhaustedError, HitlBridge, HitlSafetyError, findPendingForTask } from './hitlBridge.js';
|
|
2
2
|
import { publishQuestionFrame, registerLocalQuestionResponder, hasQuestionOverlay, } from '../liveQuestionStore.js';
|
|
3
3
|
import { hostLog } from '../host.js';
|
|
4
4
|
import { surfaceFsApprovalAndDecide } from './toolApprovalWire.js';
|
|
@@ -179,7 +179,14 @@ async function surfaceGateAndDecide(deps, taskId, askArgsByCall, signal) {
|
|
|
179
179
|
// 这里 instanceof 窄化后再过 isGateFailureCode 白名单——不认得的码(将来 hitlBridge 那边
|
|
180
180
|
// 万一多加一种)一律降级成 undefined,退回文案兜底,不让开集腐蚀这个新判别位。
|
|
181
181
|
const code = e instanceof HitlSafetyError && isGateFailureCode(e.code) ? e.code : undefined;
|
|
182
|
-
return {
|
|
182
|
+
return {
|
|
183
|
+
kind: 'failed',
|
|
184
|
+
gatedCallId,
|
|
185
|
+
reason: `decide failed: ${String(e)}`,
|
|
186
|
+
...(code !== undefined ? { code } : {}),
|
|
187
|
+
// Inkglow-1085 P0a:ask 腿与 fs 腿同形 —— 瞬断耗尽给判别位,resolvePark 走重呈臂。
|
|
188
|
+
...(e instanceof DecideTransportRetryExhaustedError ? { retryExhausted: true } : {}),
|
|
189
|
+
};
|
|
183
190
|
}
|
|
184
191
|
}
|
|
185
192
|
/** 台账里那批 gated `tool_start` 当拍 args 快照 → 决断腿要的 `toolCallId → args` 表(UNTRUSTED 原样搬运)。 */
|
|
@@ -247,6 +254,19 @@ export async function resolvePark(park, ctx) {
|
|
|
247
254
|
(led.decidedCount() > 0 ? ` [decided so far: ${led.decidedCount()}]` : ''));
|
|
248
255
|
return { kind: 'reattach' };
|
|
249
256
|
}
|
|
257
|
+
// Inkglow-1085 P0a —— **重呈臂**:decide 出站在瞬断类失败上重试一次仍未送达(两发都没拿到引擎的
|
|
258
|
+
// 语义答复)。此刻 run 仍 parked、pending 行仍可决 —— 合成 `hitl_unanswered` 把 turn 判死是三条
|
|
259
|
+
// 出路里唯一不可逆的那条,只配给「引擎答了且拒了」的语义失败。这里改走 reattach:durable 流对
|
|
260
|
+
// suspended run 必然把 park 帧再送一遍(#110 已解决重放同一条机械路径),于是**同一张卡重新交给
|
|
261
|
+
// 用户**(重呈的呈现就是卡本身,归端零新 UI);hop 预算照吃(每轮都要人再答一次,不会空转)。
|
|
262
|
+
// 引擎真死时失败也尽快显形:下一轮的 approvals.list / runs.events 对死引擎当场失败,walks 既有
|
|
263
|
+
// 诚实红(reason 是 approvals.list failed,不带 retryExhausted ⇒ 不再进本臂)。
|
|
264
|
+
if (outcome.kind === 'failed' && outcome.retryExhausted === true) {
|
|
265
|
+
const seq = led.lastSeq();
|
|
266
|
+
hostLog('debug', `liveHitlAskWire: decide transport retries exhausted (${outcome.reason}) — re-presenting the gate via ` +
|
|
267
|
+
`re-attach runs.events(${taskId})${seq ? ` from seq ${seq}` : ''} instead of failing the turn (the run is still parked and the pending row is still decidable)`);
|
|
268
|
+
return { kind: 'reattach' };
|
|
269
|
+
}
|
|
250
270
|
if (outcome.kind !== 'decided') {
|
|
251
271
|
hostLog('debug', `liveHitlAskWire: gate not decided (${outcome.kind}${'reason' in outcome ? `: ${outcome.reason}` : ''}) — fail-soft to suspended terminal`);
|
|
252
272
|
const events = [...led.flushHeld()]; // 回退:毒化帧照旧渲染(= 修复前的诚实红)
|
|
@@ -104,10 +104,16 @@ export type FsApprovalOutcome = {
|
|
|
104
104
|
} | {
|
|
105
105
|
kind: 'aborted';
|
|
106
106
|
gatedCallId?: string | undefined;
|
|
107
|
-
}
|
|
107
|
+
}
|
|
108
|
+
/** `retryExhausted`(Inkglow-1085 P0a,0.37.0):decide 出站在**瞬断类**失败上重试一次仍未送达
|
|
109
|
+
* (两发都没拿到引擎的语义答复)—— 在场即真。消费方(parkResolver)据此走**重呈臂**(re-attach
|
|
110
|
+
* 重放 park ⇒ 同一张卡重新交给用户),不合成 `hitl_unanswered` 判死 turn。缺席 = 语义类失败
|
|
111
|
+
* (引擎答了且拒了)或卡面失败,既有 fail-soft 语义逐字节不变。 */
|
|
112
|
+
| {
|
|
108
113
|
kind: 'failed';
|
|
109
114
|
gatedCallId?: string | undefined;
|
|
110
115
|
reason: string;
|
|
116
|
+
retryExhausted?: true;
|
|
111
117
|
};
|
|
112
118
|
/** 本桥消费的 wire 面(liveHitlAskWire 的 AskGateWireDeps 同形切片,mock 可注入)。 */
|
|
113
119
|
export interface FsApprovalWireDeps {
|
|
@@ -268,6 +274,28 @@ export interface ApprovalCardRequest {
|
|
|
268
274
|
* UNTRUSTED-for-display:只渲染,绝不回喂模型/工具入参。
|
|
269
275
|
*/
|
|
270
276
|
ruleEvidence?: unknown;
|
|
277
|
+
/**
|
|
278
|
+
* 安全类 ask 出身位(#283,0.36.0;server ≥7.33.0 / core 5.37)——原样来自
|
|
279
|
+
* {@link ToolApprovalFrame.requiresRealApproval}(**单源:活卡帧腿**;durable 行腿今天无对偶,
|
|
280
|
+
* 见帧上同名键的 JSDoc 末段)。在场 = 这只 ask **拒绝 blanket-allow**:一切自动放行必须让位
|
|
281
|
+
* (含记住的规则、`allow_session`、bypass 姿态)——那正是[4390]③ 预埋壳侧读器时写死的消费语义。
|
|
282
|
+
* 缺席 = 这不是一次安全类 ask / 老 server(两者同形,不猜),卡形与 0.35.0 字节不变。
|
|
283
|
+
*
|
|
284
|
+
* 🔴 **缺席绝不折成 `false`**(类型 `true` 而非 `boolean`,与 {@link governanceForced} 同族纪律):
|
|
285
|
+
* 折成 false 会把「没有证据」渲成「已确认这是普通 ask」,而这一位的整个存在理由就是不让自动放行
|
|
286
|
+
* 在安全类门上悄悄生效。
|
|
287
|
+
* 🔴 **与 {@link governanceForced} 分键不合流**:那位答「门是运维治理层下的」,本位答「引擎侧安全
|
|
288
|
+
* 策略/hook 判这门必须真人过目」。两句话对用户的下一步建议不同(改不改 permissionMode),
|
|
289
|
+
* 合并即谎报出身。
|
|
290
|
+
* 🔴 **展示 + 放行闸,不是裁决输入**:它不改变本次决断的任何字节,只约束宿主**自动**决断的资格;
|
|
291
|
+
* 人真按下的 allow 仍是 allow。
|
|
292
|
+
* 🔴 **分工写死,别把「包搬运了」读成「包挡住了」**:本包只保证这一位**到得了卡口**,
|
|
293
|
+
* 它**不**改写、不否决、也不收窄你回给 wire 的决断动词 —— `surfaceToolApprovalFrameAndRespond`
|
|
294
|
+
* 对 {@link ApprovalCardDecision} 的映射与 0.35.0 逐字节相同。⇒ 「在场时不要走自动放行」「在场时
|
|
295
|
+
* 卡上要不要还给人一个会话级选项」这两条策略**属于宿主**,包不替你执行;要它变成包级强制,是一次
|
|
296
|
+
* **行为面**改动(会改到人已按下的决断的字节),按宪法三问单独立项,不在本过境批内。
|
|
297
|
+
*/
|
|
298
|
+
requiresRealApproval?: true;
|
|
271
299
|
}
|
|
272
300
|
/**
|
|
273
301
|
* 🔴 **拆缝口** —— 弹「三选卡」并等人的决断。壳 = vendored CC `PermissionRequest`;
|
|
@@ -325,9 +353,18 @@ export interface ToolApprovalFrame {
|
|
|
325
353
|
* lane 的 ask 出此键(宿主自身 ask 的 sourceTaskId=sessionId 已在 server 侧收口不上帧)。
|
|
326
354
|
* ⚠️ 值=子代 sessionId 非 a… handle(fence 实测 2026-07-23)——不是 fleet 台账键。 */
|
|
327
355
|
sourceTaskId?: string;
|
|
328
|
-
/**
|
|
329
|
-
*
|
|
330
|
-
|
|
356
|
+
/**
|
|
357
|
+
* core 1.378 RB-39②([1550]):Runner 填充的只读显式判别键——委派子代 gate 的 ask 恒带,
|
|
358
|
+
* 受信 internals 事实不可伪造。在场即子代(优先于 sourceTaskId 权宜式)。
|
|
359
|
+
*
|
|
360
|
+
* 🔴 **字面 `true` 不是 `boolean`**(#307 双扫 S47,2026-08-19;SDK 7.1.0/7.2.0
|
|
361
|
+
* `dist/resources/tool-approvals.d.ts` 的 `fromSubagent?: true` 逐字)——与本 interface 上
|
|
362
|
+
* `governanceForced?: true` / `requiresRealApproval?: true` 同族的**在场即真**键:
|
|
363
|
+
* wire 上只有「在场」与「缺席」两态,`false` 根本不是合法取值。此前这里写 `boolean` 是本仓
|
|
364
|
+
* 自铸的宽形,把一个不存在的第三态写进了契约。
|
|
365
|
+
* 缺席 = **没有显式判别证据**(不是「明确不是子代」),读法见 {@link isFromSubagent}。
|
|
366
|
+
*/
|
|
367
|
+
fromSubagent?: true;
|
|
331
368
|
/** core 1.378 RB-39②:展示身份(UNTRUSTED-for-display,server redact 后上帧)——徽章名一手源。 */
|
|
332
369
|
sourceAgentName?: string;
|
|
333
370
|
/**
|
|
@@ -433,6 +470,30 @@ export interface ToolApprovalFrame {
|
|
|
433
470
|
* 端呈前消毒,只渲染绝不回喂模型/工具入参。
|
|
434
471
|
*/
|
|
435
472
|
ruleEvidence?: unknown;
|
|
473
|
+
/**
|
|
474
|
+
* server ≥7.33.0(#283,core 5.37 起铸;**ADDITIVE**,`"tool_approval"` only。来源锚 = engine fixture
|
|
475
|
+
* `@sema-agent/server/dist/tool-approval.d.ts` 的同名键)——这只 ask 是一次**安全类** ask:
|
|
476
|
+
* 它**拒绝 blanket-allow**。出身是 core 自己的两条安全策略(`createUnverifiableDeletePolicy` /
|
|
477
|
+
* `createTranscriptIntegrityPolicy`)与 PreToolUse hook 族在 `AskRequest.requiresRealApproval` 上铸的位。
|
|
478
|
+
*
|
|
479
|
+
* 🔴 **契约 = 真才带,缺席绝不编 `false`**({@link governanceForced} 同形,故类型是 `true` 而不是
|
|
480
|
+
* `boolean`):缺席 = 这不是一次安全类 ask —— 那是**正常的否定形**,不是坏形、也不是「老 server」的
|
|
481
|
+
* 同义词(两者今天同形,不猜)。
|
|
482
|
+
* 🔴 **与卡内那一位的在场契约刻意不同、别混**(server `ApprovalCardSchema` 顶注逐字):durable
|
|
483
|
+
* `ApprovalCard.risk.requiresRealApproval` 是**恒在布尔**(投影层已把缺席按 `=== true` 归一化),
|
|
484
|
+
* 而本键走帧顶层、只在为真时在场。⇒ 「帧上没有」与「卡上是 false」是两条不同的陈述,别互相推导。
|
|
485
|
+
* 🔴 **与 {@link governanceForced} 刻意分列不合并**:那个键答「门是谁下的」(运维治理层
|
|
486
|
+
* AUTONOMY/commandPolicy/守卫集),本键答「这门为什么掀不掉」(引擎侧安全类策略/hook 出身)。
|
|
487
|
+
* 合并会让任一方谎报出身 —— 而消费端对这两句话的正确反应不同:治理位该劝「别去改
|
|
488
|
+
* permissionMode」,本位该劝「这一次必须真人过目,记住的规则与 bypass 姿态都让位」。
|
|
489
|
+
* 🔴 本键**领先** SDK 运行期锚一代(sdk 7.1.0 的 `TOOL_APPROVAL_FRAME_KEYS` 尚无)⇒ 对账门
|
|
490
|
+
* (run-approval-frame-keys-test.mjs)AHEAD_OF_ANCHOR 带退出条件登记,#144 `persistedRuleShadowed`
|
|
491
|
+
* 与 #280 `probeCause` 同形先例:SDK 锚补上当天登记自红逼删。
|
|
492
|
+
* 耐久路今天**无对偶**(sdk 7.1.0 的 `PendingCheckpoint` / `riskDescriptor` 均未声明本键;server 侧
|
|
493
|
+
* 那份在**卡内** `risk` 子树,不是行上的键)⇒ durable 行 → 卡那条腿不 stamp,行为钉在
|
|
494
|
+
* run-durable-card-display-keys-test.mjs ⑨ 段;上游补位后按 {@link probeCause} 的双源合流形跟批。
|
|
495
|
+
*/
|
|
496
|
+
requiresRealApproval?: true;
|
|
436
497
|
}
|
|
437
498
|
/** {@link ToolApprovalFrame.delegation} 的形(命名形,不用内联匿名 —— typeshape 门 B4 棘轮口径)。 */
|
|
438
499
|
export interface ToolApprovalDelegation {
|
|
@@ -448,9 +509,29 @@ export interface ToolApprovalDelegation {
|
|
|
448
509
|
* `TOOL_APPROVAL_FRAME_KEYS` 比对——SDK additive 增键时对账当天红,不再人肉追平。
|
|
449
510
|
* 下面两个类型钉保证镜像与 interface 本身不可能漂移(少键/多键都是编译错)。
|
|
450
511
|
*/
|
|
451
|
-
export declare const TOOL_APPROVAL_FRAME_KEYS_MIRROR: readonly ["type", "approvalId", "toolCallId", "toolName", "sourceTaskId", "fromSubagent", "sourceAgentName", "message", "args", "argsOmitted", "governanceForced", "ruleSuggestions", "persistedRuleShadowed", "probeCause", "ruleEvidence", "delegation", "outcome"];
|
|
452
|
-
/**
|
|
453
|
-
*
|
|
512
|
+
export declare const TOOL_APPROVAL_FRAME_KEYS_MIRROR: readonly ["type", "approvalId", "toolCallId", "toolName", "sourceTaskId", "fromSubagent", "sourceAgentName", "message", "args", "argsOmitted", "governanceForced", "ruleSuggestions", "persistedRuleShadowed", "probeCause", "ruleEvidence", "requiresRealApproval", "delegation", "outcome"];
|
|
513
|
+
/**
|
|
514
|
+
* 子代帧判别:显式键 fromSubagent(core 1.378 RB-39②)优先;缺席退 sourceTaskId 在场性权宜式
|
|
515
|
+
* (server 1.258 [1549]①3,旧代际兼容)。
|
|
516
|
+
*
|
|
517
|
+
* 🔴 **`=== true` 是在场判别,不是布尔求值**(#307 S47 复核结论,2026-08-19)。`fromSubagent`
|
|
518
|
+
* 的 wire 形是 `?: true`(见上方声明),所以运行期只可能是 `true` 或缺席;一个**显式 `false`**
|
|
519
|
+
* 只能来自注入面或不合契约的实现,它承载的信息是「这个载体不合契约」,**不是**「引擎明确判定不是
|
|
520
|
+
* 子代」。故这里刻意**不**把 `false` 当成否定证据:它与缺席同档 —— 落回 `sourceTaskId` 在场性
|
|
521
|
+
* 那条旧代际权宜臂。
|
|
522
|
+
*
|
|
523
|
+
* 为什么这个方向是对的(而不是「显式 false ⇒ 直接 return false」):
|
|
524
|
+
* · 本判据的**唯一消费面是展示归属**(`workerBadge` 徽章 + `delegation` 出处链,见下方卡口),
|
|
525
|
+
* 不参与任何放行/收窄决策 —— 两个方向的代价不对称:多一枚徽章只是噪声,少一枚徽章是把
|
|
526
|
+
* 「这是子代发起的」这条事实对用户藏起来;
|
|
527
|
+
* · `sourceTaskId` 本身就是 server 只对后台子代 lane 才出的键(见其声明),它在场是**独立的**
|
|
528
|
+
* 子代证据。让一个不合契约的 `false` 去否决一条独立成立的证据,等于让注入面拿到一个
|
|
529
|
+
* 「隐藏子代身份」的开关;
|
|
530
|
+
* · 与本文件 `governanceForced` 的「缺席 ≠ false」同族纪律:在场即真的键上,非 `true` 一律读作
|
|
531
|
+
* 「没有这条证据」,而不是「有一条相反的证据」。
|
|
532
|
+
* 反漂移:类型侧 `?: true` 已让**编译期**的显式 false 不可能构造;本臂守的是运行期(wire/注入面)
|
|
533
|
+
* 的越界载体,pure 门有对应负控(`fromSubagent:false` + sourceTaskId 在场 ⇒ 仍判子代)。
|
|
534
|
+
*/
|
|
454
535
|
export declare function isFromSubagent(frame: ToolApprovalFrame): boolean;
|
|
455
536
|
/** 三选卡决断 → respond 端点的 wire 枚举(server parseToolApprovalDecision)。 */
|
|
456
537
|
export type ToolApprovalRespondDecision = 'allow' | 'allow_session' | 'deny';
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
* 静默截断);真落行与否看 ack 的 `noteRecorded`(缺席 ≠ false)。
|
|
78
78
|
* 🔴 方向纪律:reason/note 只做归因,绝不参与裁决;缺席 ⇒ 现状字节不变。
|
|
79
79
|
*/
|
|
80
|
-
import { DEFAULT_DENY_REASON, HitlBridge, HitlSafetyError, denyReasonForWire, findPendingForTask, } from './hitlBridge.js';
|
|
80
|
+
import { DEFAULT_DENY_REASON, DecideTransportRetryExhaustedError, HitlBridge, HitlSafetyError, denyReasonForWire, findPendingForTask, } from './hitlBridge.js';
|
|
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';
|
|
@@ -305,7 +305,10 @@ export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signa
|
|
|
305
305
|
// binding 不匹配意味着「人看见的那一行在他决断期间被换掉了」,自动重试等于替人对一件
|
|
306
306
|
// 他没看过的事按了 Yes。`no_pending` 同族(那一行已经没了,重试同样只会再失败一次)。
|
|
307
307
|
// 上抛给外层 catch ⇒ typed `failed` ⇒ 调用方走 fail-soft 诚实红,由人重新决断。
|
|
308
|
-
|
|
308
|
+
// Inkglow-1085 P0a:瞬断耗尽同样**不许**回退纯 approve —— 引擎此刻根本够不着,再补一发
|
|
309
|
+
// 纯 approve 只是再烧一轮超时,还把「传输断了」错标成「老 server 不识别 remember」。
|
|
310
|
+
// 上抛给外层 catch ⇒ retryExhausted 判别位 ⇒ parkResolver 走重呈臂。
|
|
311
|
+
if (e instanceof HitlSafetyError || e instanceof DecideTransportRetryExhaustedError)
|
|
309
312
|
throw e;
|
|
310
313
|
hostLog('debug', `liveToolApprovalWire: decide(approve+remember) failed (${String(e)}) — falling back to plain approve`);
|
|
311
314
|
}
|
|
@@ -314,7 +317,13 @@ export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signa
|
|
|
314
317
|
return { kind: 'decided', gatedCallId };
|
|
315
318
|
}
|
|
316
319
|
catch (e) {
|
|
317
|
-
return {
|
|
320
|
+
return {
|
|
321
|
+
kind: 'failed',
|
|
322
|
+
gatedCallId,
|
|
323
|
+
reason: `decide(approve) failed: ${String(e)}`,
|
|
324
|
+
// Inkglow-1085 P0a:瞬断耗尽的判别位(在场即真)—— 语义失败缺席,reason 字节不变。
|
|
325
|
+
...(e instanceof DecideTransportRetryExhaustedError ? { retryExhausted: true } : {}),
|
|
326
|
+
};
|
|
318
327
|
}
|
|
319
328
|
case 'deny':
|
|
320
329
|
try {
|
|
@@ -326,7 +335,12 @@ export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signa
|
|
|
326
335
|
return { kind: 'decided', gatedCallId, denied: true };
|
|
327
336
|
}
|
|
328
337
|
catch (e) {
|
|
329
|
-
return {
|
|
338
|
+
return {
|
|
339
|
+
kind: 'failed',
|
|
340
|
+
gatedCallId,
|
|
341
|
+
reason: `decide(deny) failed: ${String(e)}`,
|
|
342
|
+
...(e instanceof DecideTransportRetryExhaustedError ? { retryExhausted: true } : {}),
|
|
343
|
+
};
|
|
330
344
|
}
|
|
331
345
|
}
|
|
332
346
|
}
|
|
@@ -370,6 +384,11 @@ export const TOOL_APPROVAL_FRAME_KEYS_MIRROR = [
|
|
|
370
384
|
// ⚠️ 与上面两例**不同形**:sdk 7.1.0 的运行期锚**已经含**本键(node 直读实证)⇒ 这是一次**追平**,
|
|
371
385
|
// 不是领先,故**不**进 AHEAD_OF_ANCHOR(往那张表里塞一个锚已有的键,它的第二条退出条件当场红)。
|
|
372
386
|
'ruleEvidence',
|
|
387
|
+
// #283(client-core 0.36.0):server 7.33.0 起真发 `requiresRealApproval`(core 5.37 铸的安全类 ask
|
|
388
|
+
// 出身位,投到帧顶层)。⚠️ 与 `ruleEvidence` **不同形**、与 `persistedRuleShadowed`/`probeCause` 同形:
|
|
389
|
+
// sdk 7.1.0 的运行期锚**尚无**本键(node 直读实证:锚 17 项)⇒ 这是一次**领先**,进对账门的
|
|
390
|
+
// AHEAD_OF_ANCHOR 带退出条件登记(SDK 追平当天那条登记自红逼删,回到逐元素相等)。
|
|
391
|
+
'requiresRealApproval',
|
|
373
392
|
'delegation',
|
|
374
393
|
'outcome',
|
|
375
394
|
];
|
|
@@ -398,8 +417,28 @@ function isToolApprovalDelegation(v) {
|
|
|
398
417
|
return false;
|
|
399
418
|
return d.agentName === undefined || typeof d.agentName === 'string';
|
|
400
419
|
}
|
|
401
|
-
/**
|
|
402
|
-
*
|
|
420
|
+
/**
|
|
421
|
+
* 子代帧判别:显式键 fromSubagent(core 1.378 RB-39②)优先;缺席退 sourceTaskId 在场性权宜式
|
|
422
|
+
* (server 1.258 [1549]①3,旧代际兼容)。
|
|
423
|
+
*
|
|
424
|
+
* 🔴 **`=== true` 是在场判别,不是布尔求值**(#307 S47 复核结论,2026-08-19)。`fromSubagent`
|
|
425
|
+
* 的 wire 形是 `?: true`(见上方声明),所以运行期只可能是 `true` 或缺席;一个**显式 `false`**
|
|
426
|
+
* 只能来自注入面或不合契约的实现,它承载的信息是「这个载体不合契约」,**不是**「引擎明确判定不是
|
|
427
|
+
* 子代」。故这里刻意**不**把 `false` 当成否定证据:它与缺席同档 —— 落回 `sourceTaskId` 在场性
|
|
428
|
+
* 那条旧代际权宜臂。
|
|
429
|
+
*
|
|
430
|
+
* 为什么这个方向是对的(而不是「显式 false ⇒ 直接 return false」):
|
|
431
|
+
* · 本判据的**唯一消费面是展示归属**(`workerBadge` 徽章 + `delegation` 出处链,见下方卡口),
|
|
432
|
+
* 不参与任何放行/收窄决策 —— 两个方向的代价不对称:多一枚徽章只是噪声,少一枚徽章是把
|
|
433
|
+
* 「这是子代发起的」这条事实对用户藏起来;
|
|
434
|
+
* · `sourceTaskId` 本身就是 server 只对后台子代 lane 才出的键(见其声明),它在场是**独立的**
|
|
435
|
+
* 子代证据。让一个不合契约的 `false` 去否决一条独立成立的证据,等于让注入面拿到一个
|
|
436
|
+
* 「隐藏子代身份」的开关;
|
|
437
|
+
* · 与本文件 `governanceForced` 的「缺席 ≠ false」同族纪律:在场即真的键上,非 `true` 一律读作
|
|
438
|
+
* 「没有这条证据」,而不是「有一条相反的证据」。
|
|
439
|
+
* 反漂移:类型侧 `?: true` 已让**编译期**的显式 false 不可能构造;本臂守的是运行期(wire/注入面)
|
|
440
|
+
* 的越界载体,pure 门有对应负控(`fromSubagent:false` + sourceTaskId 在场 ⇒ 仍判子代)。
|
|
441
|
+
*/
|
|
403
442
|
export function isFromSubagent(frame) {
|
|
404
443
|
if (frame.fromSubagent === true)
|
|
405
444
|
return true;
|
|
@@ -617,6 +656,11 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
|
|
|
617
656
|
// 🔴 呈现谓词归端(无治理部署下每只 ask 都带全具名缺席,[4050] live 实测 / [4051] 定性);
|
|
618
657
|
// 本层若替端做「全 not_wired 就别给了」的过滤,就是替引擎把一条真事实湮灭掉。
|
|
619
658
|
...(isWireRecordCarrier(frame.ruleEvidence) ? { ruleEvidence: frame.ruleEvidence } : {}),
|
|
659
|
+
// #283(0.36.0):安全类 ask 出身位透传([4390]③ 请托的正位解 —— 壳侧读器早已预埋,缺的一直是
|
|
660
|
+
// 包边界这一格)。条件 stamp **只认严格 true**,与 governanceForced 同一条纪律:缺席的语义是
|
|
661
|
+
// 「这不是一次安全类 ask」,把它折成显式 false 会让宿主把「没有证据」读成「已确认可自动放行」,
|
|
662
|
+
// 而本键存在的全部理由正是在这种门上把自动放行(记住的规则 / allow_session / bypass 姿态)拦下。
|
|
663
|
+
...(frame.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
|
|
620
664
|
});
|
|
621
665
|
const decision = card.kind === 'allow' ? (card.allowSession ? 'allow_session' : 'allow') : 'deny';
|
|
622
666
|
if (card.kind === 'failed') {
|
|
@@ -59,7 +59,7 @@ export const REQUEST_FIELD_MATRIX = [
|
|
|
59
59
|
{ field: 'reasoningEffort', lanes: ['interactive'], live: false, why: '`/effort` 拨盘存在 AppState,print 无 AppState', gap: true },
|
|
60
60
|
{ field: 'model', lanes: ['interactive'], live: false, why: '`/model` 中途换模型读 toolUseContext.options.mainLoopModel;print 的模型走 MODEL_ID/--model 另一条路', gap: true },
|
|
61
61
|
{ field: 'images', lanes: ['interactive'], live: false, why: '贴图提交是交互动作,`-p` 的 stdin 没有图片块' },
|
|
62
|
-
// ⚠️ 四键一条(0.
|
|
62
|
+
// ⚠️ 四键一条(0.35.0 补第四键 `resumeAtMode`):它是 `rewindWireCaps` 的 `SeamRewindSpec` 真 wire 键,
|
|
63
63
|
// 且 `rewindSpecForMode` 的 `both` / `conversation` 两臂**恒返**它(排他截语义 —— 回到目标消息
|
|
64
64
|
// **之前**)。修前合写项只列三键 ⇒ 真 `/rewind` 还原 turn 上 `unregisteredRequestKeys` 会点名
|
|
65
65
|
// `resumeAtMode`,而那正是「本层不许有未登记键」这条判据的误报,会教端去白名单化真键。
|
|
@@ -129,7 +129,7 @@ const shapeTag = (v) => {
|
|
|
129
129
|
/**
|
|
130
130
|
* plain object 判别 —— 按 **prototype** 判,不按 `Object.prototype.toString` 的标签判。
|
|
131
131
|
*
|
|
132
|
-
* 🔴 标签判法是**假的**(0.
|
|
132
|
+
* 🔴 标签判法是**假的**(0.35.0 codex 对抗复审 [high] 采纳):`[object Object]` 对**普通类实例**同样
|
|
133
133
|
* 成立,而 `Symbol.toStringTag` 还能让任意载体自报这个标签。放它过去之后,下面的摊开用的是
|
|
134
134
|
* `Object.entries`(**只取自有可枚举键**)—— 于是一个把 `permissions.deny/ask` 挂在**原型 getter**
|
|
135
135
|
* 上的载体会摊出一个**空快照**,请求照发 = 权限静默变宽,正是本节要堵的那个 fail-open 形。
|
|
@@ -139,7 +139,7 @@ const shapeTag = (v) => {
|
|
|
139
139
|
* `Map` / `Date` / boxed 包装对象 / 数组一律拒 —— 它们要么内容不在自有键上,要么摊开就是垃圾键;
|
|
140
140
|
* 「上游产出坏了」比「悄悄发一个更宽的权限面」更该被人看见。
|
|
141
141
|
*
|
|
142
|
-
* 🔴 判据 **realm 无关**(0.
|
|
142
|
+
* 🔴 判据 **realm 无关**(0.35.0 codex 对抗复审第八轮 [medium] 采纳):不拿「**本** realm 的
|
|
143
143
|
* `Object.prototype`」做身份比较 —— iframe / `node:vm` / 另一个渲染进程里的对象字面量各有**自己的**
|
|
144
144
|
* `Object.prototype`,身份比较会把这些**完全合法、JSON 忠实**的载体误判成异形,在 web / desktop 宿主
|
|
145
145
|
* 上变成提交前的硬失败。
|
|
@@ -242,7 +242,7 @@ const materializeJsonFaithful = (v, path, seen = new Set()) => {
|
|
|
242
242
|
}
|
|
243
243
|
return { value: items }; // 重建:原数组(可能是子类/带访问器/带覆盖方法)不出门
|
|
244
244
|
}
|
|
245
|
-
// 🔴 重建成**无原型**记录(0.
|
|
245
|
+
// 🔴 重建成**无原型**记录(0.35.0 codex 对抗复审第七轮 [high] 的可采半场):
|
|
246
246
|
// ① 忠实 —— 源本来就允许 `Object.create(null)` 字典,重建成 `{}` 等于把载体形换掉了;
|
|
247
247
|
// ② 少一条改写面 —— 无原型记录不会继承任何**事后**装到 `Object.prototype` 上的 `toJSON`。
|
|
248
248
|
// ⚠️ 但这只关掉了 record 那一半:重建出来的**数组**必须是真数组(`Array.isArray` / 序列化成
|
|
@@ -264,7 +264,7 @@ const materializeJsonFaithful = (v, path, seen = new Set()) => {
|
|
|
264
264
|
// `Object.prototype` 的 setter 上:键整个丢掉、还顺手换了目标对象的原型。
|
|
265
265
|
Object.defineProperty(rec, k, { value: r.value, enumerable: true, writable: true, configurable: true });
|
|
266
266
|
}
|
|
267
|
-
// 🔴 判据的读法**一律排在捕获之后**(0.
|
|
267
|
+
// 🔴 判据的读法**一律排在捕获之后**(0.35.0 codex 对抗复审第十二轮 [high] 采纳):`toJSON` 走属性
|
|
268
268
|
// 查找、`isPlainRecord` 走 `getPrototypeOf`/`constructor` —— 这三种读法**都可以带副作用**
|
|
269
269
|
// (`getPrototypeOf` 陷阱在被问的那一刻把 `deny` 清空),而**原生 `JSON.stringify` 从不问原型**。
|
|
270
270
|
// 校在捕获之前 = 本层自己的读法成了丢内容的那一环(比被动撒谎更糟:那是我们引入的丢失面)。
|
|
@@ -281,7 +281,7 @@ const materializeJsonFaithful = (v, path, seen = new Set()) => {
|
|
|
281
281
|
/**
|
|
282
282
|
* 快照 → 摊开成 `settings` 子键前的两道处理:**具名通道让位** + **畸形响亮拒**。
|
|
283
283
|
*
|
|
284
|
-
* ## ① 具名通道管着的键一律让位(0.
|
|
284
|
+
* ## ① 具名通道管着的键一律让位(0.35.0)
|
|
285
285
|
*
|
|
286
286
|
* 快照是**开放集 spread**(子键原样就是 wire 键),所以它天生是一条**第二通道**。0.34.0 只剥了
|
|
287
287
|
* 「车道异名」键(表里那行的 lanes 不含本车道),于是**两条车道都登记**的具名键剥不到 ——
|
|
@@ -296,7 +296,7 @@ const materializeJsonFaithful = (v, path, seen = new Set()) => {
|
|
|
296
296
|
* 🔴 剥得**窄**:只剥表里点名的具名子键。快照**表外**的子键(permissions / env / model / …)
|
|
297
297
|
* 原样摊开 —— 那是开放集的全部意义;`outputStyle` 没有车道行(它是 live 兜底层的位)故不受影响。
|
|
298
298
|
*
|
|
299
|
-
* ## ② 畸形快照响亮拒(0.
|
|
299
|
+
* ## ② 畸形快照响亮拒(0.35.0,fail-closed)
|
|
300
300
|
*
|
|
301
301
|
* `undefined` / `null` = **合法缺席**(端没解析出快照;老写法拿 null 当空快照传),照旧降空对象。
|
|
302
302
|
* 其余非 plain-object 形(数组 / 原始值 / boxed 包装对象 / Map / **类实例**…)= **上游产出坏了**:
|
|
@@ -305,11 +305,11 @@ const materializeJsonFaithful = (v, path, seen = new Set()) => {
|
|
|
305
305
|
* `TypeError` 带形状描述(合法载体的判据见 {@link isPlainRecord} —— 「摊得全」是它的全部理由)。
|
|
306
306
|
* (本包是发出去的 npm 公开面:JS 调用方与版本偏斜的宿主都到得了这里,型面拦不住。)
|
|
307
307
|
*
|
|
308
|
-
* ## ③ 权限面**子树**同样要摊得全(0.
|
|
308
|
+
* ## ③ 权限面**子树**同样要摊得全(0.35.0 codex 对抗复审第二轮 [high] 采纳)
|
|
309
309
|
*
|
|
310
310
|
* 只校最外层是不够的:`{ permissions: new Map([['deny',['Write']]]) }` 的外层是合法字面量,可
|
|
311
311
|
* `JSON.stringify` 把那只 Map 序列化成 `"permissions":{}` —— deny 规则**静默消失**,与 ② 要堵的
|
|
312
|
-
* 是同一个权限变宽形,只是深了一层。所以 `permissions` 子树递归过 {@link
|
|
312
|
+
* 是同一个权限变宽形,只是深了一层。所以 `permissions` 子树递归过 {@link materializeJsonFaithful}:
|
|
313
313
|
* plain record / 数组 / JSON 原始值才算摊得全,`Map`/`Set`/类实例/boxed/环一律拒。
|
|
314
314
|
*
|
|
315
315
|
* 🔴 **射程刻意只到 `permissions`**,不做整份请求体的深净化:那是「包级 wire 载荷 JSON-safe
|
|
@@ -335,7 +335,7 @@ const resolvedSnapshotForWire = (resolved) => {
|
|
|
335
335
|
// 当场物化成脱钩副本),原件的形状判据留到最后;校不过就整体拒,拒绝面一字未变。
|
|
336
336
|
const named = namedSettingsSubKeys();
|
|
337
337
|
const out = {};
|
|
338
|
-
// 🔴 **键先捕获、值逐个即读即处理**(0.
|
|
338
|
+
// 🔴 **键先捕获、值逐个即读即处理**(0.35.0 codex 对抗复审第六轮 [high]):`Object.entries(resolved)`
|
|
339
339
|
// 会把**所有兄弟键**的值先读齐 —— 于是一个 `env`/`model` 位上的 getter 能在 `permissions` 被
|
|
340
340
|
// 重建**之前**把它的 deny 数组清空(拿到的是同一个引用)。这与内层那条(见
|
|
341
341
|
// {@link materializeJsonFaithful} 第五条)是同一个病、只是高一层:两层都必须按 stringify 的
|
|
@@ -410,7 +410,7 @@ export function buildTaskRequest(input, lane) {
|
|
|
410
410
|
// resolved 两车道都进表(#292 P1)——它是**开放集 spread**(子键即 wire 键),所以走
|
|
411
411
|
// `laneHas + live` 而不是 `on()`:`on()` 判的是「这个键值非空」,而这里要判的是「这份快照要不要
|
|
412
412
|
// 摊开」。
|
|
413
|
-
// 🔴 具名通道**赢过快照**这件事不靠下面的合并序(0.
|
|
413
|
+
// 🔴 具名通道**赢过快照**这件事不靠下面的合并序(0.35.0):合并序只在具名通道**有值**时管用,
|
|
414
414
|
// 而具名门否决时的表现恰恰是**没值**(端不给这个键)。让位改由 `resolvedSnapshotForWire`
|
|
415
415
|
// 做**结构剥离** —— 具名通道管着的子键快照一概不产,顺序此后只是可读性,不再是安全依据。
|
|
416
416
|
const s = input.settings ?? {};
|