@sema-agent/client-core 0.76.2 → 0.77.1
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 +39 -0
- package/README.md +4 -1
- package/dist/approvalsStreamLiveCapability.js +5 -1
- package/dist/deviceExecutorManagementCapability.js +2 -2
- package/dist/executionLaneCapability.js +5 -1
- package/dist/hitl/persistedRulesWire.d.ts +221 -1
- package/dist/hitl/persistedRulesWire.js +324 -18
- package/dist/hitl/sessionPolicyWire.d.ts +275 -0
- package/dist/hitl/sessionPolicyWire.js +464 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +12 -0
- package/dist/mcpReconnect.js +2 -1
- package/dist/memoryEntriesWire.js +7 -0
- package/dist/peerLaneCapability.js +5 -1
- package/dist/permissionRulesWriteCapability.js +5 -1
- package/dist/request/taskRequest.d.ts +101 -1
- package/dist/request/taskRequest.js +463 -80
- package/dist/sessionPolicyCapability.d.ts +68 -0
- package/dist/sessionPolicyCapability.js +103 -0
- package/dist/sqlEngineCapability.js +5 -1
- package/dist/subagent/engineTaskHandleWire.d.ts +8 -1
- package/dist/subagent/engineTaskHandleWire.js +27 -12
- package/dist/webSearchBackendCapability.js +5 -1
- package/dist/writeProtectionCapability.js +5 -1
- package/docs/INTEGRATION-CLIENTS.md +100 -8
- package/package.json +1 -1
|
@@ -86,7 +86,9 @@ function retryAfterSecOf(e) {
|
|
|
86
86
|
}
|
|
87
87
|
export function classifyRulesFailure(e) {
|
|
88
88
|
const { status, errorCode, message } = errShape(e);
|
|
89
|
-
|
|
89
|
+
// 🔴 店缺席只认**机器码**,不认 501 状态码本身(0.77.0 改口):引擎的错误信封恒带机器码,而一个没码的 501
|
|
90
|
+
// 谁都能发(反代对陌生动词就回它)—— 凭它藏面、或在写腿说「确知没写」,都是把出处让给了状态码。
|
|
91
|
+
if (errorCode === 'capability.rule_store_required') {
|
|
90
92
|
return { kind: 'lane-unavailable', message };
|
|
91
93
|
}
|
|
92
94
|
if (status === 404) {
|
|
@@ -104,12 +106,27 @@ export function classifyRulesFailure(e) {
|
|
|
104
106
|
}
|
|
105
107
|
if (errorCode === 'state.rule_remove_failed')
|
|
106
108
|
return { kind: 'retryable', message };
|
|
109
|
+
if (errorCode === 'state.rule_write_failed')
|
|
110
|
+
return { kind: 'write-indeterminate', message };
|
|
107
111
|
if (status === 400 && errorCode === 'request.query_invalid')
|
|
108
112
|
return { kind: 'cursor-stale', message };
|
|
113
|
+
if (status === 400 && errorCode === 'request.field_invalid')
|
|
114
|
+
return { kind: 'field-refused', message };
|
|
115
|
+
if (status === 400 && errorCode === 'request.body_shape')
|
|
116
|
+
return { kind: 'body-shape', message };
|
|
109
117
|
if (status === 413)
|
|
110
118
|
return { kind: 'too-many-candidates', message };
|
|
111
119
|
if (status === 403 && errorCode === 'auth.operator_only')
|
|
112
120
|
return { kind: 'forbidden', message };
|
|
121
|
+
// 🔴 **405 分两格,判据是「码给不给得出出处」**(异源复审 R2 [medium],真病):引擎那一支发的是没有
|
|
122
|
+
// 前缀族的裸 `method_not_allowed` —— 见到它才能说「这台 worker 比这个动词老」。而**无码**的 405
|
|
123
|
+
// (中间层/网关拦 POST 时常回 HTML 或空体)谁都能发,凭它断言对端版本会让调用方去升级一台其实没问题
|
|
124
|
+
// 的 worker、或藏掉一个其实活着的入口。空串码与缺席同判(都给不出出处),不许两者各落一格。
|
|
125
|
+
if (status === 405) {
|
|
126
|
+
return errorCode === 'method_not_allowed'
|
|
127
|
+
? { kind: 'method-unsupported', message }
|
|
128
|
+
: { kind: 'method-refused', message };
|
|
129
|
+
}
|
|
113
130
|
return { kind: 'error', message };
|
|
114
131
|
}
|
|
115
132
|
// ── 三态规则身份(behavior)────────────────────────────────────────────────────────────────────
|
|
@@ -132,6 +149,24 @@ function rowStr(row, key) {
|
|
|
132
149
|
const v = row[key];
|
|
133
150
|
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
134
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* 一条**持久规则行**的身份两键(`rule` / `scope`)读不读得出 —— 列举面与单步写口**共用的那一只**
|
|
154
|
+
* 行型窄化器(🆕 0.77.0 提名:此前它内联在列举面的翻页循环里,写口回包要判同一件事,再写一遍就是
|
|
155
|
+
* 同一份判据的第二个法官)。
|
|
156
|
+
*
|
|
157
|
+
* 🔴 **只判身份两键,不判展示键**:`rule` / `scope` 是撤销按内容寻址那一对,读不出 ⇒ 这一行既渲不出
|
|
158
|
+
* 也撤不掉;而 `tool` / `match` / `command` / `adds` 是展示键,引擎 additive 演进不该把整面打红
|
|
159
|
+
* (消费端对展示键自有坏形容忍)。
|
|
160
|
+
* 🔴 **`behavior` 刻意不在这道门里**:读不出态的行**照样交出去**(丢行 = 把一条活规则从治理清单里
|
|
161
|
+
* 藏起来),呈现走 {@link persistedRuleBehaviorLabel}、撤销侧由
|
|
162
|
+
* {@link revokeTargetFromPersistedRule} 自己拒铸 —— 两条纪律分属两个位置,合并会让「看得见但撤不掉」
|
|
163
|
+
* 这个**正确**的中间态变得不可表达。
|
|
164
|
+
* 🔴 **两处必须同判**:写口 200 体里的那一行与列举面的行在上游是**逐格相同**的一只形;两处各铸一份
|
|
165
|
+
* 判据的那天,同一条规则会在治理清单里在场、在写完回填时被判坏(或反过来)。
|
|
166
|
+
*/
|
|
167
|
+
function persistedRuleIdentityRowReadable(row) {
|
|
168
|
+
return rowStr(row, 'rule') !== undefined && rowStr(row, 'scope') !== undefined;
|
|
169
|
+
}
|
|
135
170
|
/**
|
|
136
171
|
* 这一行是哪一态。**闭三词**之外(含整键缺席的老 worker 行)一律 `undefined`。
|
|
137
172
|
*
|
|
@@ -182,22 +217,35 @@ export function revokeTargetFromPersistedRule(rule, opts) {
|
|
|
182
217
|
const scope = rowStr(rule, 'scope');
|
|
183
218
|
if (text === undefined || scope === undefined)
|
|
184
219
|
return undefined;
|
|
185
|
-
|
|
186
|
-
if (
|
|
187
|
-
|
|
188
|
-
// 键在场 ⇒ 它必须是一个**能指人**的名字;空白串指不了任何人,而「指不了人」在这条面上
|
|
189
|
-
// 会被引擎读成「就是调用者自己」——那正是本闸要挡的静默改写。
|
|
190
|
-
if (typeof raw !== 'string' || raw.trim().length === 0)
|
|
191
|
-
return undefined;
|
|
192
|
-
principal = raw;
|
|
193
|
-
}
|
|
220
|
+
const override = readPrincipalOverride(opts);
|
|
221
|
+
if (!override.ok)
|
|
222
|
+
return undefined;
|
|
194
223
|
return {
|
|
195
224
|
behavior,
|
|
196
225
|
rule: text,
|
|
197
226
|
scope,
|
|
198
|
-
...(principal !== undefined ? { principal } : {}),
|
|
227
|
+
...(override.principal !== undefined ? { principal: override.principal } : {}),
|
|
199
228
|
};
|
|
200
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* 越权域那一格的**唯一判据**(撤销体与单步写体两条腿共用):`principal` 键**缺席** = 「这一条是
|
|
232
|
+
* 我自己的」;键**在场但指不了人**(非串 / 空白串)⇒ `ok:false` = **整只拒铸**。
|
|
233
|
+
*
|
|
234
|
+
* 🔴 **显式给了一个无效名 ≠ 没给**:把它静默降级成缺席,会让一次瞄准**别人**的 operator 操作转向
|
|
235
|
+
* **调用者自己**的规则桶并拿到一个 200 —— 在收紧方向上那是「本该给租户加的拒绝,加到了自己头上」,
|
|
236
|
+
* 在撤销方向上是「本该收回租户的放行,删了自己的规则」。而目标值算空在治理面上是常见形
|
|
237
|
+
* (表单空字段、一次没查到的用户名)。
|
|
238
|
+
* ⚠️ 合法值**原样送出不 trim**:principal 的规范形归引擎,壳替它 trim 会让两端对「同一个人」的拼法
|
|
239
|
+
* 各有一份判据。
|
|
240
|
+
*/
|
|
241
|
+
function readPrincipalOverride(carrier) {
|
|
242
|
+
if (carrier === null || typeof carrier !== 'object' || !('principal' in carrier))
|
|
243
|
+
return { ok: true };
|
|
244
|
+
const raw = carrier.principal;
|
|
245
|
+
if (typeof raw !== 'string' || raw.trim().length === 0)
|
|
246
|
+
return { ok: false };
|
|
247
|
+
return { ok: true, principal: raw };
|
|
248
|
+
}
|
|
201
249
|
/** 一页要多少条。**必须显式给**(对抗复审 [medium] 实撞):server 缺省是 **50**,而页帽
|
|
202
250
|
* 按「200/页」算 ⇒ 真实上界只有 1250 条,一位规则多于 1250 的 principal 会恒拿到「翻不完」的
|
|
203
251
|
* 失败、整个治理面打不开,而注释还写着 5000。夹取语义在 server(非数/越界夹进 1..200),所以给
|
|
@@ -282,13 +330,9 @@ export async function listAllPersistedRules(facade, params = {}, opts) {
|
|
|
282
330
|
// 拒绝的病);其余展示键(tool/match/command/adds)不在此过度收紧 —— server additive 演进
|
|
283
331
|
// 不该把整面打红,消费端对展示键自有坏形容忍。
|
|
284
332
|
for (const r of res.rules) {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
typeof rr.rule !== 'string' ||
|
|
289
|
-
rr.rule === '' ||
|
|
290
|
-
typeof rr.scope !== 'string' ||
|
|
291
|
-
rr.scope === '') {
|
|
333
|
+
// 🔴 与单步写口回包**同一只**窄化器(`persistedRuleIdentityRowReadable`)—— 两处各判一遍的
|
|
334
|
+
// 那天,同一条规则会在一面在场、在另一面被判坏。
|
|
335
|
+
if (!persistedRuleIdentityRowReadable(r)) {
|
|
292
336
|
return {
|
|
293
337
|
ok: false,
|
|
294
338
|
failure: {
|
|
@@ -326,6 +370,268 @@ export async function listAllPersistedRules(facade, params = {}, opts) {
|
|
|
326
370
|
}
|
|
327
371
|
return { ok: false, failure: { kind: 'cursor-stale', message: 'rule list kept changing under the cursor — try again' } };
|
|
328
372
|
}
|
|
373
|
+
// ── 单步写口(收紧方向;engine ≥7.91.0)────────────────────────────────────────────────────────
|
|
374
|
+
/**
|
|
375
|
+
* 单步写口**不收**的那一态 —— 放宽方向的单一铸点。
|
|
376
|
+
*
|
|
377
|
+
* 🔴 `satisfies RuleBehavior` 不是装饰:它把这个字面量钉在**引擎的态词表**上。哪天词表里没有这个词了,
|
|
378
|
+
* 本行当场编译不过 —— 而一个手写的排除词在那天会安静地排除一个不存在的东西。
|
|
379
|
+
*/
|
|
380
|
+
const DIRECT_WRITE_EXCLUDED_BEHAVIOR = 'allow';
|
|
381
|
+
/**
|
|
382
|
+
* 单步写口收得下的**态**,运行期表。**由 {@link PERSISTED_RULE_BEHAVIORS} 减去放宽那一词派生**,
|
|
383
|
+
* 源码里**没有第二个 behavior 字面量数组**(门对这一条有边界钉)。
|
|
384
|
+
*
|
|
385
|
+
* 🔴 **为什么必须是派生而不是一张两词表**:态的词表属主只有一个(引擎)。一张手抄的两词表会在词表
|
|
386
|
+
* 加员的那天变成一句没人复核过的旧话 —— 新词既进不来,也没有任何一道门会响。派生式让这句话跟着
|
|
387
|
+
* 词表走:这里表达的不是「有哪两个词」,而是**「三态里哪些进得了这条口」**。
|
|
388
|
+
* 🔴 **放宽方向在类型上就拼不出来**:常驻放行只有两条路(审批卡上的人点头 / 一次显式的 settings 导入),
|
|
389
|
+
* 这条口**不是**第三条。一个能拼出来的放宽入参会让调用方以为它是。
|
|
390
|
+
* 🔴 形制同兄弟表:`Object.freeze` 的数组,不是只在类型面只读的 `readonly T[]` —— 后者一行 `.splice()`
|
|
391
|
+
* 就能改,而公面消费者拿到的正是这个实例。
|
|
392
|
+
*/
|
|
393
|
+
export const PERSISTED_RULE_WRITE_BEHAVIORS = Object.freeze(PERSISTED_RULE_BEHAVIORS.filter((b) => b !== DIRECT_WRITE_EXCLUDED_BEHAVIOR));
|
|
394
|
+
const WRITE_BEHAVIOR_SET = new Set(PERSISTED_RULE_WRITE_BEHAVIORS);
|
|
395
|
+
/** 这个值是不是一个写得进去的态。**判据只读上面那张运行期表**(不是第二份词表,也不是一串 `||`)。 */
|
|
396
|
+
function isPersistedRuleWriteBehavior(v) {
|
|
397
|
+
return typeof v === 'string' && WRITE_BEHAVIOR_SET.has(v);
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* 写**没落地**的成因闭集。每一词都对着一条真码 / 一条本地判据,**核不到的不铸**。
|
|
401
|
+
*
|
|
402
|
+
* 前四词是**发出去之前**就定局的(本地拒铸,连一次往返都没有);后六词是引擎的答话。
|
|
403
|
+
* 🔴 十词共同的、也是唯一的承诺:**这次调用一个字都没写进店**。所以一个拿不出这句话的证据的成因
|
|
404
|
+
* **不许**进这张表 —— 哪怕它看上去像一次拒绝(例:一个来源不明的 405,见
|
|
405
|
+
* {@link PERSISTED_RULE_WRITE_UNKNOWN_REASONS} 的 `unattributed_method_refusal`)。「说不清」不在这张表里,它是
|
|
406
|
+
* {@link PersistedRuleWriteOutcome} 的另一臂({@link PERSISTED_RULE_WRITE_UNKNOWN_REASONS})。
|
|
407
|
+
*/
|
|
408
|
+
export const PERSISTED_RULE_WRITE_REFUSAL_CAUSES = Object.freeze([
|
|
409
|
+
/** 本地:态不在 {@link PERSISTED_RULE_WRITE_BEHAVIORS} 里(放宽那一词,或干脆不是一个态)。 */
|
|
410
|
+
'unwritable_behavior',
|
|
411
|
+
/** 本地:`rule` / `scope` 任一不是非空串 —— 按缺席寻址什么都对不上。 */
|
|
412
|
+
'unreadable_identity',
|
|
413
|
+
/** 本地:`principal` 键在场但指不了人(静默改写那一条,见 `readPrincipalOverride`)。 */
|
|
414
|
+
'unusable_principal',
|
|
415
|
+
/** 本地:注入的 facade 上**没有这个动词**(宿主的客户端比这条口老)⇒ 连发都没发出去。 */
|
|
416
|
+
'client_too_old',
|
|
417
|
+
/** 400 `request.field_invalid` —— 字段值被拒(方向 / 规则文本 / scope 的字节),机读细格停在 wire 上。 */
|
|
418
|
+
'field_refused',
|
|
419
|
+
/** 400 `request.body_shape` —— 体读不出形;同一份体重发永远同一个结果。 */
|
|
420
|
+
'body_shape',
|
|
421
|
+
/** 403 `auth.operator_only` —— 替别人写被拒(显式名单;空名单 = 谁都不是)。 */
|
|
422
|
+
'operator_only',
|
|
423
|
+
/** 501 `capability.rule_store_required` —— 这台部署没接规则店 / 旋钮关着。 */
|
|
424
|
+
'store_absent',
|
|
425
|
+
/** 405 **带引擎自己的裸 `method_not_allowed`** —— 路径在、这个动词不在:worker 比这条动词老。 */
|
|
426
|
+
'lane_too_old',
|
|
427
|
+
/** 404 `not_found.route` —— 这条路径整条不在(worker 比规则面本身还老)。 */
|
|
428
|
+
'route_absent',
|
|
429
|
+
]);
|
|
430
|
+
/**
|
|
431
|
+
* 「**不知道落没落**」的成因闭集。
|
|
432
|
+
*
|
|
433
|
+
* 🔴 这五词与上面那十词**不可互折**:那些说的是「确知没写」,这五词说的是「**读不出结局**」。
|
|
434
|
+
* 把后者渲成前者会让人以为可以干净地重来一遍;渲成 `persisted` 则是谎报一次可能根本没发生的
|
|
435
|
+
* 收紧。四词的处置一律是:重试,然后用 {@link listAllPersistedRules} 对账。
|
|
436
|
+
*
|
|
437
|
+
* 🔴 **「重试」在批准台账上是安全的,而这条安全性在引擎里、不在这里**(异源复审 R2 [high] 提出的
|
|
438
|
+
* 「重放会让台账无界增长」,亲读引擎的写腿验真后**改成文、不改代码**):引擎在落任何东西之前**先问
|
|
439
|
+
* 「这条身份已经站着吗」**,站着就**一个字都不写**并答 `no-op`。所以一次「说不清」之后**顺序**重发
|
|
440
|
+
* 同一份身份三元组,最坏情况是收到 `no_op` —— 批准台账不增长、桶的修订号不推进。
|
|
441
|
+
* ⚠️ 真正会让同一条逻辑规则带上两枚因果点的是**并发**:两个写者同时看见「不在」而各落一枚,
|
|
442
|
+
* 那是 add-wins 的正解(两枚点、一条规则),两边都会如实收到 `persisted`,没有人被告知「没写」。
|
|
443
|
+
* ⚠️ 也正因为这道前问在**引擎**那一侧,本包**不**在重试前自己先列举一遍 —— 那会把引擎已经做了的
|
|
444
|
+
* 读再做一次,并且给同一个问题立第二个说话人。「先对账再决定要不要重发」是调用方的策略自由,
|
|
445
|
+
* 不是本口的前置条件。
|
|
446
|
+
*/
|
|
447
|
+
export const PERSISTED_RULE_WRITE_UNKNOWN_REASONS = Object.freeze([
|
|
448
|
+
/** 503 `state.rule_write_failed` —— 引擎**自己说**它判不出终局(店抖动 / 兑付面没交回可判的终局 /
|
|
449
|
+
* 写落了而回读它时一条并发撤销已先到,三者在这条口上不可分辨)。 */
|
|
450
|
+
'indeterminate',
|
|
451
|
+
/** 2xx 但体读不出(`status` 不在闭二词里 / `rev` 不是有限数 / 回包那一行的身份两键读不出)。
|
|
452
|
+
* 🔴 **绝不**折成 `persisted` —— 一个读不懂的回包证明不了任何事。 */
|
|
453
|
+
'malformed_result',
|
|
454
|
+
/**
|
|
455
|
+
* 2xx 形是好的,但回包那一行的**身份三元组与这次发出去的那份对不上**(态 / 文本 / 作用域任一不逐字相同,
|
|
456
|
+
* 含整格缺席)。
|
|
457
|
+
* 🔴 **这一格不是「同一份判据的第二个法官」,是另一个问题**(异源复审 R1 [high] 采纳):行型窄化器答的是
|
|
458
|
+
* 「这一行读得出来吗」(列举面与本口同一只);这里答的是「读出来的这一行**是不是我刚才要写的那条**」——
|
|
459
|
+
* 后者的材料(刚发出去的身份)只有写腿手上有,列举面根本问不出这个问题。
|
|
460
|
+
* 🔴 为什么非查不可:规则的身份是**三元组**,同文本不同态是两条不同的行。一个缺了态、或回了兄弟态、
|
|
461
|
+
* 或回了另一条文本的 200 若被认证成 `persisted`,调用方会对人宣布一条**根本没落地**的收紧,
|
|
462
|
+
* 而治理面上那条规则并不在。诚实的答案是「读不出结局」:重试,然后用列举面对账。
|
|
463
|
+
* ⚠️ 对一台如实作答的引擎这一格**恒不发生**:写口的 200 体里那一行是它**按同一份身份三元组**从店里
|
|
464
|
+
* 找回来的,非规范拼写在更早一道门上就已经被拒(所以 200 ⇒ 发出去的文本就是店里的拼写),
|
|
465
|
+
* 作用域判别式串的解析与回写是不动点。所以它响起来只意味着版本偏斜、中间层改写或坏回包。
|
|
466
|
+
*/
|
|
467
|
+
'identity_mismatch',
|
|
468
|
+
/**
|
|
469
|
+
* 405 **而码给不出出处**(缺席 / 空串 / 别的码)—— 看见的是「这个动词被拒了」,而**谁拒的、拒在哪一段
|
|
470
|
+
* 不知道**:引擎那一侧的 405 发在路由分派期(请求没进处理腿),但一个中间层 / 网关也能发同一个状态码,
|
|
471
|
+
* 而它**可能已经把请求转给引擎**、事后才按自己的策略回 405。
|
|
472
|
+
* 🔴 **所以它进「不知道」而不是「确知没写」**(异源复审 R3 [medium] 采纳):后者那一组的承诺是「一个字
|
|
473
|
+
* 都没写进店」,而这一形拿不出那句话的证据 —— 一个据此**跳过对账**的调用方会留下一条用户以为没生效
|
|
474
|
+
* 的常驻拒绝。处置与本组其余各词相同:重试(台账安全),然后用 {@link listAllPersistedRules} 对账。
|
|
475
|
+
* ⚠️ 与 `lane_too_old` 的分别仍然保留、而且是这一格存在的理由:**别据此要求升级 worker、也别据此藏掉
|
|
476
|
+
* 入口**(那两个动作要的是能力位或版本证据,不是一个谁都能发的状态码)。要说「这台 worker 比这个
|
|
477
|
+
* 动词老」,只有引擎自己的裸码才有资格。
|
|
478
|
+
*/
|
|
479
|
+
'unattributed_method_refusal',
|
|
480
|
+
/** 抛了,而分类器没能把它归进任何一条「确知没写」的腿(网络 / 超时 / 中间层 / 未来的码)。
|
|
481
|
+
* 非幂等动词上「没分出来」的默认必须是「不知道」,不是「没写」。 */
|
|
482
|
+
'unclassified',
|
|
483
|
+
]);
|
|
484
|
+
/** 失败分类(单一判官 {@link classifyRulesFailure})→ 写口结局的**全量**映射。 */
|
|
485
|
+
function writeOutcomeFromFailure(failure) {
|
|
486
|
+
const message = failure.message;
|
|
487
|
+
switch (failure.kind) {
|
|
488
|
+
case 'field-refused':
|
|
489
|
+
return { status: 'refused', cause: 'field_refused', message };
|
|
490
|
+
case 'body-shape':
|
|
491
|
+
return { status: 'refused', cause: 'body_shape', message };
|
|
492
|
+
case 'forbidden':
|
|
493
|
+
return { status: 'refused', cause: 'operator_only', message };
|
|
494
|
+
case 'lane-unavailable':
|
|
495
|
+
return { status: 'refused', cause: 'store_absent', message };
|
|
496
|
+
case 'method-unsupported':
|
|
497
|
+
return { status: 'refused', cause: 'lane_too_old', message };
|
|
498
|
+
// 🔴 405 但码给不出出处 ⇒ **不知道**,不是「确知没写」(异源复审 R3 [medium]):一个中间层可能已经把
|
|
499
|
+
// 请求转给引擎、事后才按自己的策略回 405。逐条理由见该词的注。
|
|
500
|
+
case 'method-refused':
|
|
501
|
+
return { status: 'unknown', why: 'unattributed_method_refusal', message };
|
|
502
|
+
case 'route-missing':
|
|
503
|
+
return { status: 'refused', cause: 'route_absent', message };
|
|
504
|
+
case 'write-indeterminate':
|
|
505
|
+
return { status: 'unknown', why: 'indeterminate', message };
|
|
506
|
+
// 🔴 其余各臂(通用 error,以及票面 / 游标 / 页帽那几条本口结构上到不了的腿)一律「不知道」:
|
|
507
|
+
// 这是一个**非幂等**动词,「没分出来」的默认是「读不出结局」,不是「什么都没写」——
|
|
508
|
+
// 一个 500 完全可能发生在店写完之后。
|
|
509
|
+
default:
|
|
510
|
+
return { status: 'unknown', why: 'unclassified', message };
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* 单步写一条**收紧**规则(deny/ask)进持久店 —— 撤销面的对偶,无同意仪式往返。
|
|
515
|
+
*
|
|
516
|
+
* 判据次序照引擎那一侧逐条对位,**只多一道在最前面**:
|
|
517
|
+
* ⓪ **口在不在**(注入的 facade 有没有这个动词)—— 与「按能力位 gate、别 trial-by-501」同一条纪律:
|
|
518
|
+
* 一次连动词都拼不出来的调用不该先去撞引擎的限流桶;
|
|
519
|
+
* ① 体形(身份三键读得出);② 方向(只收收紧);③ scope 读得出形;④ 越权域那一格。
|
|
520
|
+
* ②③④ 的**本地**判据刻意比引擎宽:本地只判「这串是不是一个 X」,「这个 X 写得进去吗」永远由引擎答
|
|
521
|
+
* (规范拼写、scope 的字节、行自洽都是引擎的判据)—— 壳替它判一遍就是同一件事的第二份真源。
|
|
522
|
+
*
|
|
523
|
+
* 🔴 **本函数不重试**:一次「说不清」被自动重放,重试预算与节奏就成了本包替调用方做的决定;而对账
|
|
524
|
+
* 那一步(用 {@link listAllPersistedRules} 读回来看)只有调用方做得了。
|
|
525
|
+
* ⚠️ 手工重试**在批准台账上是安全的**(引擎落地前先问「已经站着吗」,站着就一个字都不写)——
|
|
526
|
+
* 逐条理由见 {@link PERSISTED_RULE_WRITE_UNKNOWN_REASONS} 的头注。
|
|
527
|
+
* 🔴 **回包那一行走列举面同一只窄化器**,不给同一份判据立第二个法官;读不出 ⇒ `unknown` /
|
|
528
|
+
* `malformed_result`,**绝不**当成「写上了」。
|
|
529
|
+
* 🔴 **窄化之后还有一道身份对账**(`identity_mismatch`):回包那一行的身份三元组必须与发出去的那份逐字
|
|
530
|
+
* 相同。这不是同一份判据的第二个法官 —— 窄化器答「读得出来吗」,对账答「是不是我要的那条」,而后者的
|
|
531
|
+
* 材料只有写腿手上有。列举面「态读不出的行照样交出去」的纪律在这里不适用:看得见 ≠ 证明得了。
|
|
532
|
+
* ⚠️ 回包行的 `adds[].origin` **不是**「谁写的这条规则」的判据(引擎今天从审批记录的种类派生出处词,
|
|
533
|
+
* 而本口落店走的是同一条兑付腿)—— 原样透传,不进本包任何一条判据。
|
|
534
|
+
*/
|
|
535
|
+
export async function writePersistedRule(facade, input, opts) {
|
|
536
|
+
const port = facade?.write;
|
|
537
|
+
if (typeof port !== 'function') {
|
|
538
|
+
return {
|
|
539
|
+
status: 'refused',
|
|
540
|
+
cause: 'client_too_old',
|
|
541
|
+
message: 'this client has no single-step rule write verb — nothing was sent, so nothing was written; upgrade the client that builds the rules port',
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
if (input === null || typeof input !== 'object') {
|
|
545
|
+
return {
|
|
546
|
+
status: 'refused',
|
|
547
|
+
cause: 'unreadable_identity',
|
|
548
|
+
message: 'rule write input was not an object — refusing to mint a body out of nothing',
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
const behavior = input.behavior;
|
|
552
|
+
if (!isPersistedRuleWriteBehavior(behavior)) {
|
|
553
|
+
return {
|
|
554
|
+
status: 'refused',
|
|
555
|
+
cause: 'unwritable_behavior',
|
|
556
|
+
message: 'this entry writes standing refusals only — a standing approval is minted by answering a permission question or by importing settings, never here',
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
const rule = rowStr(input, 'rule');
|
|
560
|
+
const scope = rowStr(input, 'scope');
|
|
561
|
+
if (rule === undefined || scope === undefined) {
|
|
562
|
+
return {
|
|
563
|
+
status: 'refused',
|
|
564
|
+
cause: 'unreadable_identity',
|
|
565
|
+
message: 'rule write needs both a rule text and a scope discriminant — addressing by an absent half matches nothing',
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
const override = readPrincipalOverride(input);
|
|
569
|
+
if (!override.ok) {
|
|
570
|
+
return {
|
|
571
|
+
status: 'refused',
|
|
572
|
+
cause: 'unusable_principal',
|
|
573
|
+
message: 'the rule write named a principal that cannot name anyone — refusing to let a write aimed at someone else land in the caller own bucket',
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
let res;
|
|
577
|
+
try {
|
|
578
|
+
// 🔴 **`port.call(facade, …)` 而不是 `port(…)`**(异源复审 R1 [high],真病):把方法从对象上取下来
|
|
579
|
+
// 再裸调,`this` 就没了。本包其余三口走的都是 `facade.list(…)` 这种**方法调用**形,而宿主最自然的
|
|
580
|
+
// 装配恰恰是把真 client 的资源对象原样交进来(那是一个类实例,它的每个动词都靠 `this` 拿传输层)——
|
|
581
|
+
// 裸调在那种 facade 上当场抛,而写口会把它读成一次「不知道落没落」,重试多少次都一样。
|
|
582
|
+
// 绑回接收者让三口同律;注入的是普通对象/箭头函数时,多传一个 thisArg 也无害。
|
|
583
|
+
res = await port.call(facade, {
|
|
584
|
+
// 🔴 只送身份三键 + 可选越权域:派生键(源 / 站位)回送会被拒,而**原样送引擎给的那串字节**
|
|
585
|
+
// 是撤销面同一条纪律(拼法等价但不逐字相同的 scope 什么都匹配不上,还会得到一个笑呵呵的
|
|
586
|
+
// 「没什么可做」)。
|
|
587
|
+
behavior,
|
|
588
|
+
rule,
|
|
589
|
+
scope,
|
|
590
|
+
...(override.principal !== undefined ? { principal: override.principal } : {}),
|
|
591
|
+
}, opts);
|
|
592
|
+
}
|
|
593
|
+
catch (e) {
|
|
594
|
+
return writeOutcomeFromFailure(classifyRulesFailure(e));
|
|
595
|
+
}
|
|
596
|
+
// 🔴 2xx 体 fail-closed 窄化:传输层只 JSON.parse,不做运行期 schema 校验。一个读不懂的 200
|
|
597
|
+
// 证明不了「写上了」,而把它折成 `persisted` 会让治理面宣布一条**可能根本不存在**的收紧。
|
|
598
|
+
const status = res?.status;
|
|
599
|
+
const rev = res?.rev;
|
|
600
|
+
const row = res?.rule;
|
|
601
|
+
if (res === null ||
|
|
602
|
+
typeof res !== 'object' ||
|
|
603
|
+
(status !== 'persisted' && status !== 'no-op') ||
|
|
604
|
+
typeof rev !== 'number' ||
|
|
605
|
+
!Number.isFinite(rev) ||
|
|
606
|
+
!persistedRuleIdentityRowReadable(row)) {
|
|
607
|
+
return {
|
|
608
|
+
status: 'unknown',
|
|
609
|
+
why: 'malformed_result',
|
|
610
|
+
message: 'the rule write answered 2xx with a body this client cannot read (status, rev or the written row) — refusing to certify it as a standing rule; re-list the rules to reconcile',
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
// 🔴 **身份对账**(异源复审 R1 [high],真病):上面那道门只问「这一行读得出来吗」(与列举面同一只
|
|
614
|
+
// 窄化器、同一个问题);这一道问的是**另一个**问题 ——「读出来的这一行是不是我刚才要写的那条」。
|
|
615
|
+
// 规则的身份是**三元组**,同文本不同态是两条不同的行:一个缺了态、回了兄弟态、或回了另一条文本的
|
|
616
|
+
// 200 若被认成 `persisted`,调用方会对人宣布一条根本没落地的收紧。
|
|
617
|
+
// ⚠️ 列举面**刻意**把态读不出的行照样交出去(丢行 = 藏起一条活规则),那条纪律在这里不适用:
|
|
618
|
+
// 「看得见」与「证明得了」是两件事,两个问题各有各的门,不是一份判据的两个法官。
|
|
619
|
+
const landed = row;
|
|
620
|
+
if (landed.behavior !== behavior || landed.rule !== rule || landed.scope !== scope) {
|
|
621
|
+
return {
|
|
622
|
+
status: 'unknown',
|
|
623
|
+
why: 'identity_mismatch',
|
|
624
|
+
message: 'the rule write answered 2xx with a row whose identity triple is not the one that was sent — refusing to report the requested tightening as standing; re-list the rules to reconcile',
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
return {
|
|
628
|
+
// wire 的 `no-op` 与本包判别联合的 `no_op` 是同一件事的两种拼法;判别位在这里归一**一次**,
|
|
629
|
+
// 三端各拼一次的那天会长出两种写法。
|
|
630
|
+
status: status === 'persisted' ? 'persisted' : 'no_op',
|
|
631
|
+
rev,
|
|
632
|
+
rule: row,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
329
635
|
// ── skipped.reason 分类 ─────────────────────────────────────────────────────────────────────
|
|
330
636
|
/**
|
|
331
637
|
* `skipped[].reason` 的分类。**只按第一个 `:` 前缀**,且必须容得下**没有前缀**的两种真值形
|