@sema-agent/settings-schema 14.6.0 → 14.7.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +266 -0
  2. package/README.md +31 -3
  3. package/conformance/VERSION +1 -1
  4. package/conformance/cases/c84-sandbox-git-block-carried-verbatim/expected.json +153 -0
  5. package/conformance/cases/c84-sandbox-git-block-carried-verbatim/input.json +23 -0
  6. package/conformance/cases/c84-sandbox-git-block-carried-verbatim/meta.json +5 -0
  7. package/conformance/cases/c85-sandbox-git-refused-block-still-carried/expected.json +153 -0
  8. package/conformance/cases/c85-sandbox-git-refused-block-still-carried/input.json +23 -0
  9. package/conformance/cases/c85-sandbox-git-refused-block-still-carried/meta.json +5 -0
  10. package/dist/api/auth-bridge.d.ts +16 -2
  11. package/dist/api/auth-bridge.js +59 -24
  12. package/dist/api/credentials.d.ts +281 -0
  13. package/dist/api/credentials.js +149 -0
  14. package/dist/api/scopes.js +5 -1
  15. package/dist/config-d-file.js +33 -15
  16. package/dist/config-fns.js +142 -78
  17. package/dist/conformance/canonical.js +4 -4
  18. package/dist/conformance/contract-adapter.js +26 -1
  19. package/dist/conformance/corpus.js +4 -4
  20. package/dist/conformance/derive.js +2 -2
  21. package/dist/conformance/run.js +43 -6
  22. package/dist/cross-session.d.ts +4 -4
  23. package/dist/declared-capture.d.ts +41 -0
  24. package/dist/declared-capture.js +344 -0
  25. package/dist/file-edit.js +11 -15
  26. package/dist/fleet.d.ts +11 -0
  27. package/dist/fleet.js +59 -26
  28. package/dist/hash.d.ts +0 -9
  29. package/dist/hash.js +64 -7
  30. package/dist/hooks-config.js +18 -3
  31. package/dist/http-hook-allowlists.d.ts +22 -9
  32. package/dist/http-hook-allowlists.js +169 -91
  33. package/dist/index.d.ts +2 -0
  34. package/dist/index.js +2 -0
  35. package/dist/mcp-request-entry.d.ts +9 -7
  36. package/dist/mcp-request-entry.js +89 -82
  37. package/dist/one-time-migrations.js +207 -95
  38. package/dist/own-key-read.d.ts +247 -0
  39. package/dist/own-key-read.js +449 -0
  40. package/dist/plain-json-object.d.ts +11 -1
  41. package/dist/plain-json-object.js +37 -1
  42. package/dist/remote-exec.js +9 -1
  43. package/dist/safety-merge-spec.js +50 -37
  44. package/dist/sandbox-git.d.ts +64 -0
  45. package/dist/sandbox-git.js +89 -0
  46. package/dist/scheduler-store.js +19 -11
  47. package/dist/settings-units.js +14 -12
  48. package/dist/types.d.ts +6 -2
  49. package/dist/types.js +11 -6
  50. package/dist/url-shape.d.ts +16 -0
  51. package/dist/url-shape.js +18 -0
  52. package/dist/web-admin-credential.js +5 -2
  53. package/dist/web-admin-session-url.js +2 -2
  54. package/dist/worker-row-verdict.d.ts +11 -0
  55. package/dist/worker-row-verdict.js +51 -1
  56. package/dist/workers-rows.js +38 -10
  57. package/dist/zod-unwrap.d.ts +6 -0
  58. package/dist/zod-unwrap.js +14 -7
  59. package/docs/API-SURFACE.md +35 -4
  60. package/docs/CONSUMER-INTEGRATION.md +178 -0
  61. package/llms-full.txt +23 -4
  62. package/llms.txt +3 -2
  63. package/package.json +1 -1
  64. package/dist/definition-copy.d.ts +0 -23
  65. package/dist/definition-copy.js +0 -52
@@ -17,7 +17,8 @@
17
17
  // its entry collections. zod is this package's one runtime dependency, so this costs nothing new.
18
18
  import { z } from "zod";
19
19
  import { MODEL_ENTRY_REFUSAL_CODES, ModelRefusalsWire } from "./fleet.js";
20
- import { isPlainJsonObject } from "./plain-json-object.js";
20
+ import { UNINSPECTABLE, dataDescriptor, emptyDocument, guardedSafeParse, inspect, isAccessorDescriptor, isolatedObject, ownEntriesOf, ownRow, ownValueOf } from "./own-key-read.js";
21
+ import { isJsonArray, isPlainJsonObject, jsonShapeOf } from "./plain-json-object.js";
21
22
  import { carriesUserinfo, secretScanForms } from "./secret-scan.js"; // package-private normalization (both scanners read it) + the userinfo judgement (not in the barrel)
22
23
  import { sha256Hex } from "./sha256.js";
23
24
  import { unwrapSchema } from "./zod-unwrap.js"; // package-private: the ONE reading of zod's wrapper layers (salvage + the one-time migrations share it)
@@ -103,12 +104,12 @@ function boundIssueIdentifiers(rec) {
103
104
  changed.set(v, out);
104
105
  return out;
105
106
  };
106
- if (Array.isArray(rec.path))
107
+ if (isJsonArray(rec.path))
107
108
  rec.path = rec.path.map(bound);
108
- if (Array.isArray(rec.keys))
109
+ if (isJsonArray(rec.keys))
109
110
  rec.keys = rec.keys.map(bound);
110
111
  const params = rec.params;
111
- if (params !== undefined && Array.isArray(params.template) && Array.isArray(params.operands)) {
112
+ if (params !== undefined && isJsonArray(params.template) && isJsonArray(params.operands)) {
112
113
  const quoted = { template: params.template, operands: params.operands.map((o) => bound(o)) };
113
114
  rec.params = { ...params, ...quoted };
114
115
  rec.message = renderQuotedSentence(quoted);
@@ -124,14 +125,31 @@ function sanitizeIssueValue(v, depth) {
124
125
  return redactSecrets(v);
125
126
  if (typeof v === "symbol")
126
127
  return redactSecrets(String(v));
127
- if (v === null || typeof v !== "object")
128
- return v;
129
- if (depth > SANITIZE_MAX_DEPTH)
130
- return "[redacted-secret]";
131
- if (Array.isArray(v))
132
- return v.map((el) => sanitizeIssueValue(el, depth + 1));
133
- sanitizeIssueFields(v, depth);
134
- return v;
128
+ const shape = jsonShapeOf(v);
129
+ switch (shape) {
130
+ case "null":
131
+ case "primitive":
132
+ return v;
133
+ case "uninspectable":
134
+ return "[redacted-secret]"; // a value the walk cannot read is withheld, like one the depth bound does not reach
135
+ case "array": {
136
+ if (depth > SANITIZE_MAX_DEPTH)
137
+ return "[redacted-secret]";
138
+ const items = inspect(() => v.map((el) => sanitizeIssueValue(el, depth + 1))); // a member that cannot be read (a getter / trap that throws) ⇒ the whole value is withheld
139
+ return items.ok ? items.value : "[redacted-secret]";
140
+ }
141
+ case "plain":
142
+ case "non_plain_object": {
143
+ if (depth > SANITIZE_MAX_DEPTH)
144
+ return "[redacted-secret]";
145
+ const walked = inspect(() => sanitizeIssueFields(v, depth)); // same: a member read or write that throws ⇒ the whole value is withheld
146
+ return walked.ok ? v : "[redacted-secret]";
147
+ }
148
+ default: {
149
+ const unhandled = shape;
150
+ throw new Error(`sanitizeIssueValue: unhandled shape ${String(unhandled)}`);
151
+ }
152
+ }
135
153
  }
136
154
  export const ROLE_RANK = { viewer: 0, editor: 1, publisher: 2, admin: 3 };
137
155
  export const USER_ROLES = ["viewer", "editor", "publisher", "admin"];
@@ -181,7 +199,7 @@ export const DOMAIN_WRITE_ROLE = {
181
199
  };
182
200
  /** The minimum role required to write `domain` (the raised gate for skills/mcp, else editor). */
183
201
  export function domainWriteRole(domain) {
184
- return DOMAIN_WRITE_ROLE[domain] ?? DEFAULT_DOMAIN_WRITE_ROLE;
202
+ return ownRow(DOMAIN_WRITE_ROLE, domain) ?? DEFAULT_DOMAIN_WRITE_ROLE;
185
203
  }
186
204
  /** The empty/defaults effective config at version 0 — what publish mode serves before the first publish.
187
205
  * Seeds the epoch (`new Date(0)`) so it's deterministic (the wall-clock default was the original store.ts
@@ -230,17 +248,20 @@ export function parseDomain(domain, raw) {
230
248
  throw new Error(`"${outgoingSegment(String(domain))}" is not a config domain`);
231
249
  // ONLY true absence (undefined) defaults; an explicit `null` (e.g. a `config.d/<d>.json` containing JSON
232
250
  // `null`) is corrupt input → let zod reject it ("expected object, received null"), not silently default. R4.
233
- let r;
234
- try {
235
- r = DOMAIN_SCHEMAS[domain].safeParse(raw === undefined ? {} : raw);
236
- }
237
- catch {
238
- // a hostile getter/Proxy trap threw during traversal — its message may carry a secret; never surface it. R13.
239
- throw new Error(`invalid "${domain}" config (a value could not be read during validation)`);
240
- }
241
- if (r.success)
242
- return r.data;
243
- throw sanitizeZodError(r.error); // recursive redaction (message / every issue field / nested issues) — no secret echo. R13/R14.
251
+ return strictParse(DOMAIN_SCHEMAS[domain], domain, raw === undefined ? emptyDocument() : raw);
252
+ }
253
+ /**
254
+ * 一次**整份严判**(域文档与 wire 上的 skills 清单共用这一只 —— 清单的读法与非门控域同一臂,见 `readSkillsManifest`):过 ⇒ 数据;
255
+ * 不过 ⇒ 抛脱敏后的 ZodError(逐字段递归脱敏,不回显秘密;R13/R14);校验过程抛(含首读 `error` 时抛,见 {@link guardedSafeParse})⇒ 抛 {@link unreadableDomainError}
256
+ * (抛出值的文案可能带秘密,从不转述;R13)。
257
+ */
258
+ function strictParse(schema, label, doc) {
259
+ const judged = guardedSafeParse(() => schema.safeParse(doc), sanitizeZodError);
260
+ if (!judged.ok)
261
+ throw unreadableDomainError(label);
262
+ if (judged.value.success)
263
+ return judged.value.data;
264
+ throw judged.value.error;
244
265
  }
245
266
  /** 一个**逐字出门**的字符串段的样子 —— 一条判据,不是一串特例:**逐字写出去之后读回来只能是它自己**。
246
267
  * 所以四类字符串段必须改走引号形:空串、含 `.`(段分隔符)、含 `"`(引号形自身)或控制字符的,以及
@@ -348,7 +369,7 @@ function readEntryIdentity(entry) {
348
369
  return undefined;
349
370
  const own = (k) => {
350
371
  const d = Object.getOwnPropertyDescriptor(entry, k);
351
- return d !== undefined && Object.hasOwn(d, "value") ? d.value : undefined; // 描述符本身也只读自有键
372
+ return d !== undefined && !isAccessorDescriptor(d) ? d.value : undefined; // 访问器不调(描述符的字段只认自有的,见内核)
352
373
  };
353
374
  const name = own("name");
354
375
  const id = own("id");
@@ -385,17 +406,18 @@ function droppedEntry(path, verdict, entry) {
385
406
  }
386
407
  /** 一次「什么都没丢」的抢救的位移表(冻结的共享空表 —— {@link rebaseWarningPaths} 对它恒等)。 */
387
408
  const NO_ENTRIES_DROPPED = Object.freeze({});
409
+ /** 一个域的校验过程抛了(恶意 getter / Proxy 陷阱 / 校验器自己对怪形值做类型转换)⇒ 给调用方的那一句中性话:不带抛出值的文案(可能带 secret),类是 `TypeError`(调用方给的值读不出是调用方的错)。 */
410
+ function unreadableDomainError(domain) {
411
+ return new TypeError(`invalid "${domain}" config (a value could not be read during validation)`);
412
+ }
388
413
  /** 一条条目的 parse —— 坏就返回它的判决(脱敏错误 + 闭集词)。`rejectedAs` = 调用点在集合里的位置赋予「schema 没过」的那个词
389
414
  * (条目 / Record 值 ⇒ `entry-schema-invalid`;Record 键 ⇒ `entry-key-invalid`);判的过程抛了则恒为 `entry-unreadable`,与位置无关。
390
415
  * 恶意 getter/Proxy 在遍历中抛错时**绝不**外泄其文案(可能带 secret,与 parseDomain 的 R13 同纪律),只留一句中性话。 */
391
416
  function judgeEntry(schema, value, rejectedAs) {
392
- try {
393
- const r = schema.safeParse(value);
394
- return r.success ? undefined : { error: sanitizeZodError(r.error), reasonCode: rejectedAs };
395
- }
396
- catch {
397
- return { error: new Error("entry could not be read during validation"), reasonCode: "entry-unreadable" };
398
- }
417
+ const judged = guardedSafeParse(() => schema.safeParse(value), sanitizeZodError); // 脱敏也在守护里:issue 读不完(`params` 里会抛的 getter)⇒ 这一条读不出(v14.6.0 同)
418
+ if (!judged.ok)
419
+ return { error: new TypeError("entry could not be read during validation"), reasonCode: "entry-unreadable" }; // 同一原因(校验时值读不出)一种错类:与 `domain-defaulted` 告警的 error 同为 TypeError
420
+ return judged.value.success ? undefined : { error: judged.value.error, reasonCode: rejectedAs };
399
421
  }
400
422
  /**
401
423
  * 从**schema 自身**派生条目集合(数组字段 / Record 字段),逐条判,坏的丢掉并逐条记账。
@@ -441,12 +463,8 @@ export function salvageEntries(schema, raw) {
441
463
  /** 一层抢救,抢不动就当没抢救(反射 / 访问器抛错:绝不外泄其文案)。最外层与每一条条目体内走的是**同一只** —— 一条条目体内抢不动,
442
464
  * 只是这一条不下钻、照原样去判,不连坐它所在的那一层。 */
443
465
  function salvageLayer(schema, raw) {
444
- try {
445
- return salvageLayerUnsafe(schema, raw);
446
- }
447
- catch {
448
- return { raw, dropped: [], shifts: [] };
449
- }
466
+ const layer = inspect(() => salvageLayerUnsafe(schema, raw));
467
+ return layer.ok ? layer.value : { raw, dropped: [], shifts: [] };
450
468
  }
451
469
  /** 集合字段的值 → 它的位置表;值的形与 schema 对不上(schema 说数组、文档给对象)⇒ `undefined`(不抢救,交给整域档位)。
452
470
  * 数组走 `forEach`(不是手写 `for (i < value.length)`):它在**进入时**就钉死了长度,而重读 `value.length` 会跟着一个在校验期间
@@ -454,7 +472,7 @@ function salvageLayer(schema, raw) {
454
472
  * 位置表在任何判定之前就收齐了,之后数组再怎么长都与本次抢救无关。`forEach` 跳过空洞,与此前的 `filter` 同。 */
455
473
  function collectionSlots(value, isArray) {
456
474
  if (isArray) {
457
- if (!Array.isArray(value))
475
+ if (!isJsonArray(value))
458
476
  return undefined;
459
477
  const slots = [];
460
478
  value.forEach((entry, i) => void slots.push([i, entry]));
@@ -478,7 +496,10 @@ function salvageLayerUnsafe(schema, raw) {
478
496
  const isArray = collection instanceof z.ZodArray;
479
497
  if (!isArray && !(collection instanceof z.ZodRecord))
480
498
  continue; // 不是条目集合:连读都不读(少触发一次访问器)
481
- const slots = collectionSlots(raw[field], isArray);
499
+ const held = ownValueOf(raw, field); // the field is read from the document's OWN keys (a polluted `Object.prototype.<field>` is not a collection the document holds); unreadable ⇒ nothing to salvage here
500
+ if (!held.ok)
501
+ return untouched;
502
+ const slots = collectionSlots(held.value, isArray);
482
503
  if (slots === undefined)
483
504
  continue;
484
505
  const element = (collection instanceof z.ZodArray ? collection.element : collection.valueType);
@@ -729,13 +750,13 @@ export const EXTENSION_NAMESPACES = {
729
750
  * 本身——键名里可以含 `.`(`semaX.foo` 是一个键不是两层),段形让这件事不必再靠文法猜;前缀契约就是前缀
730
751
  * 契约(`semaGPT_5` / `semaX-v2` 都是成员,后缀不设未声明的文法)。 */
731
752
  export function isReservedExtensionKey(domain, parent, key) {
732
- const rules = EXTENSION_NAMESPACES[domain];
753
+ const rules = ownRow(EXTENSION_NAMESPACES, domain);
733
754
  return rules !== undefined && rules.some((r) => r.at.length === parent.length && r.at.every((seg, i) => (seg === ANY_INDEX ? typeof parent[i] === "number" : seg === parent[i])) && r.key.test(key));
734
755
  }
735
756
  function collectDropped(domain, raw, parsed, prefix, depth, acc) {
736
757
  if (depth > DROP_SCAN_MAX_DEPTH)
737
758
  return;
738
- if (Array.isArray(raw) && Array.isArray(parsed)) {
759
+ if (isJsonArray(raw) && isJsonArray(parsed)) {
739
760
  // 长度不同 = 整条元素被丢(`entry-dropped` 有自己的警告),不在本判据内,别造重复噪声。
740
761
  if (raw.length !== parsed.length)
741
762
  return;
@@ -818,13 +839,18 @@ const OPEN_WORLD_KNOWN_KEYS = {
818
839
  * 子树)不再下探,那本就是消费方自定义的内容,逐层猜没有意义。
819
840
  */
820
841
  function carriedUnknownKeys(domain, parsed) {
821
- const table = OPEN_WORLD_KNOWN_KEYS[domain];
842
+ const table = ownRow(OPEN_WORLD_KNOWN_KEYS, domain);
822
843
  const acc = { keys: [], total: 0 };
823
844
  if (table === undefined)
824
845
  return acc;
825
846
  for (const [at, known] of table) {
826
847
  // 段形下探:`[]` 落在文档根上,所以「顶层 vs 嵌套」不再是两条分支(规则少一条)。
827
- const node = at.reduce((cur, seg) => (isPlainJsonObject(cur) ? cur[String(seg)] : undefined), parsed);
848
+ const node = at.reduce((cur, seg) => {
849
+ if (!isPlainJsonObject(cur))
850
+ return undefined;
851
+ const read = ownValueOf(cur, String(seg));
852
+ return read.ok ? read.value : undefined;
853
+ }, parsed);
828
854
  if (!isPlainJsonObject(node))
829
855
  continue;
830
856
  for (const k of Object.keys(node)) {
@@ -892,16 +918,7 @@ function readDomainDocument(domain, raw, onWarning, whenUnreadable) {
892
918
  function parseSalvaged(domain, raw, unreadable, onWarning) {
893
919
  if (DOMAIN_ENTRY_SALVAGE[domain] !== "salvage")
894
920
  throw unreadable;
895
- const salvage = salvageEntries(DOMAIN_SCHEMAS[domain], raw);
896
- if (salvage.dropped.length === 0)
897
- throw unreadable;
898
- let parsed;
899
- try {
900
- parsed = parseDomain(domain, salvage.raw);
901
- }
902
- catch {
903
- throw unreadable; // 条目之外还有毛病(重名 / 悬空 ref / 域级判据)—— 丢条目救不了它
904
- }
921
+ const { parsed, salvage } = salvageThenParse(DOMAIN_SCHEMAS[domain], raw, (doc) => parseDomain(domain, doc), unreadable);
905
922
  if (onWarning) {
906
923
  // 坐标律(S-323b):一条告警用「铸它的那一层所见文档」的坐标表达;哪一层压缩了文档,哪一层就把从它下方上来的告警回写一次。
907
924
  // `entry-dropped` 是本层(salvage)铸的,用的就是本层入参那份文档的坐标 ⇒ 原样出门;未知键扫描是对本层**压缩后**文档的诊断 ⇒ 过本层的位移表。
@@ -911,6 +928,23 @@ function parseSalvaged(domain, raw, unreadable, onWarning) {
911
928
  }
912
929
  return parsed;
913
930
  }
931
+ /**
932
+ * 逐条抢救的**那一步**(域面 {@link parseSalvaged} 与 wire 面 skills 清单 `readSkillsManifest` 共用 —— 一条律,两个调用点):整份严判已经失败
933
+ * (校验不过,或校验过程抛 —— 同一条臂:{@link salvageEntries} 逐条判,抛的那一条记 `entry-unreadable` 丢它自己)之后,丢掉坏条目再严判一次。
934
+ * 什么都没丢 / 丢了仍读不进(条目之外还有毛病:重名 / 悬空 ref / 域级判据;或抢救后的文档在重判时读不出)⇒ 抛 `unreadable`(第一次严判的原错)——
935
+ * 一个文档一个结论,没救活就不留「丢了某条」的半路叙述。
936
+ */
937
+ function salvageThenParse(schema, raw, parse, unreadable) {
938
+ const salvage = salvageEntries(schema, raw);
939
+ if (salvage.dropped.length === 0)
940
+ throw unreadable;
941
+ try {
942
+ return { parsed: parse(salvage.raw), salvage };
943
+ }
944
+ catch {
945
+ throw unreadable;
946
+ }
947
+ }
914
948
  /** 未知键两条告警(剥掉的 / 承运的)—— `raw` 是这次真被 parse 的那份文档,`emit` 决定坐标出门前要不要回写。 */
915
949
  function announceUnknownKeys(domain, raw, parsed, emit) {
916
950
  const dropped = droppedKeys(domain, raw, parsed);
@@ -1098,9 +1132,31 @@ export function buildEffective(version, getRaw, updatedAt, onWarning) {
1098
1132
  * and so does any key no domain names(the 0.6-era `teams` key included — not a domain since 0.7, and no engine reads it). Throws ONLY on garbage no caller can partially trust: a non-object payload, or a missing/
1099
1133
  * non-finite `version` — that is not "a bad domain", it is not an effective config at all. */
1100
1134
  export function readEffectiveWire(raw, onWarning) {
1101
- if (!isPlainJsonObject(raw))
1102
- throw new Error("effective wire payload is not a JSON object");
1103
- const r = raw;
1135
+ // 判形只问座位:读不出(撤销的 Proxy / 陷阱抛)是「读不出」—— 与下面读键读不出同一类同一句,不是「不是对象」;不是对象(null / 数组 / 原始值 / 类实例)才是既有的那句
1136
+ const rawShape = jsonShapeOf(raw);
1137
+ switch (rawShape) {
1138
+ case "plain":
1139
+ break;
1140
+ case "uninspectable":
1141
+ throw new TypeError(`effective wire payload could not be read — ${UNINSPECTABLE}`);
1142
+ case "non_plain_object":
1143
+ case "array":
1144
+ case "null":
1145
+ case "primitive":
1146
+ throw new Error("effective wire payload is not a JSON object");
1147
+ default: {
1148
+ const unhandled = rawShape;
1149
+ throw new Error(`readEffectiveWire: unhandled shape ${String(unhandled)}`);
1150
+ }
1151
+ }
1152
+ // 载荷只读一次,读成它自有的键值快照(隔离原型 `COPY_PROTOTYPE` 的袋):信封键(`version` / `updatedAt`)、域键、`skills` / `prompts` 与未知键都从快照里取 ——
1153
+ // 键只靠继承可见(`Object.prototype.models` 被污染成数据值)不算载荷里写着的;读不出(`get` 陷阱 / 会抛的 getter)⇒ 抛本函数自己的那一句,不是引擎的错
1154
+ const entries = ownEntriesOf(raw); // 自有串键里有不可枚举的 ⇒ 整份读不出(不当缺席、不静默丢)
1155
+ if (!entries.ok)
1156
+ throw new TypeError(`effective wire payload could not be read — ${entries.sentence}`);
1157
+ const r = isolatedObject(); // 载荷的袋:与所有副本同一只原型
1158
+ for (const [k, v] of entries.value)
1159
+ Object.defineProperty(r, k, dataDescriptor(v));
1104
1160
  if (typeof r.version !== "number" || !Number.isFinite(r.version))
1105
1161
  throw new Error("effective wire payload has no numeric version");
1106
1162
  const updatedAt = typeof r.updatedAt === "string" ? r.updatedAt : "";
@@ -1112,26 +1168,12 @@ export function readEffectiveWire(raw, onWarning) {
1112
1168
  // downstream as "the center withdrew its layer" — the consumer's judge refuses it loudly instead. verbatim, by presence(见 return)。
1113
1169
  const built = buildEffective(r.version, (d) => (d === "skills" || d === "prompts" ? undefined : r[d]), updatedAt, onWarning);
1114
1170
  // retired 域名不是 open-world 的未知键:墓碑已在 buildEffective 里判过(带事实 ⇒ 抛),剩下的 `{}` 没有可承运的东西。
1115
- let skills;
1116
- const manifest = SkillsManifestSchema.safeParse(r.skills ?? {});
1117
- if (manifest.success) {
1118
- skills = manifest.data;
1119
- }
1120
- else {
1121
- // 逐条容错律的 wire 腿:manifest 走的是另一张 schema,但**同一个**抢救点 —— 一条坏条目只丢它自己,
1122
- // 整张 skill 清单不再因此蒸发(零幸存者仍整份退默认,见 salvageEntries 头注)。
1123
- const salvage = salvageEntries(SkillsManifestSchema, r.skills);
1124
- const retry = salvage.dropped.length > 0 ? SkillsManifestSchema.safeParse(salvage.raw) : undefined;
1125
- if (retry?.success) {
1126
- for (const d of salvage.dropped)
1127
- onWarning?.(entryDroppedWarning("skills", d));
1128
- skills = retry.data;
1129
- }
1130
- else {
1131
- onWarning?.({ domain: "skills", kind: "domain-defaulted", error: sanitizeZodError(manifest.error) });
1132
- skills = { skills: [] };
1133
- }
1134
- }
1171
+ const manifest = readSkillsManifest(r.skills);
1172
+ // 告警在判完之后、读法之外发:`onWarning` 自己抛的错不会被当成「清单读不进」
1173
+ if (onWarning)
1174
+ for (const w of manifest.warnings)
1175
+ onWarning(w);
1176
+ const skills = manifest.skills;
1135
1177
  // Open-world passthrough: keys that are neither validated domains nor the envelope fields ride verbatim.
1136
1178
  // `hasOwnProperty.call(built, k)`,**不是** `k in built`(S-332):`in` 会一路问到 `Object.prototype`,
1137
1179
  // 于是 `constructor` / `toString` / `hasOwnProperty` / `__proto__` 这些**原型链上的名字**作未知顶层键时
@@ -1143,7 +1185,29 @@ export function readEffectiveWire(raw, onWarning) {
1143
1185
  // prompts:剥掉 buildEffective 恒物化的 `{}` 底座,按 wire 在场性 verbatim 挂回(#100:
1144
1186
  // 缺席进缺席出——发布闸关是常态形,物化会让消费端「缺席」分支永不可达)。
1145
1187
  const { prompts: _promptsMaterialized, ...builtSansPrompts } = built;
1146
- return { ...passthrough, ...builtSansPrompts, skills, ...("prompts" in r ? { prompts: r.prompts } : {}) };
1188
+ return { ...passthrough, ...builtSansPrompts, skills, ...(Object.hasOwn(r, "prompts") ? { prompts: r.prompts } : {}) };
1189
+ }
1190
+ /**
1191
+ * wire 上 skills 清单的读法 —— **与非门控域同一臂**,只是判的是清单 schema({@link SkillsManifestSchema}):整份严判({@link strictParse})→ 读不进
1192
+ * (校验不过,或校验器自己抛 —— 合法 JSON 里的怪形值、嵌入方对象的 getter / 陷阱 —— 同一条臂)⇒ 逐条抢救({@link salvageThenParse}:坏条目丢它自己 + `entry-dropped`,
1193
+ * 其余合法条目保留;零幸存者不凭空造空集合)→ 仍读不进 ⇒ 整份退默认(空清单)+ `domain-defaulted`(`error` = 第一次严判的原错:脱敏的 ZodError,或包自己那一句的 TypeError)。
1194
+ * 缺席 ⇒ 按包自己造的空文档判(`COPY_PROTOTYPE`,读不到污染值)。告警交回给调用方发。
1195
+ */
1196
+ function readSkillsManifest(raw) {
1197
+ const parse = (doc) => strictParse(SkillsManifestSchema, "skills", doc);
1198
+ try {
1199
+ return { skills: parse(raw ?? emptyDocument()), warnings: [] };
1200
+ }
1201
+ catch (unreadable) {
1202
+ try {
1203
+ const { parsed, salvage } = salvageThenParse(SkillsManifestSchema, raw, parse, unreadable);
1204
+ return { skills: parsed, warnings: salvage.dropped.map((d) => entryDroppedWarning("skills", d)) };
1205
+ }
1206
+ catch {
1207
+ // 救不回 ⇒ 告警带第一次严判的原错(salvageThenParse 只重抛它;salvageEntries 是总的)—— 在结构上就是它,不靠「被捕获的恰好是它」
1208
+ return { skills: { skills: [] }, warnings: [{ domain: "skills", kind: "domain-defaulted", error: unreadable }] };
1209
+ }
1210
+ }
1147
1211
  }
1148
1212
  /** 档位组→引擎单表的唯一解析点(0.9.0)。返回 ACTIVE 档位组的绑定表(剔除未绑档),即 core
1149
1213
  * `RunnerDeps.tiers` 要吃的那张单表——组切换=web-admin 换这张表下发,零新引擎机制。无 active 组/未命中/
@@ -10,7 +10,7 @@
10
10
  * `stableKey` 是唯一一处把值转成串的地方,它只用来给**多重集计数**当哈希键 —— 用的是 JSON 编码,
11
11
  * 数组边界与段类型(`"1"` vs `1`)在编码里都还在,所以它不是那种有损的点号串。
12
12
  */
13
- import { isPlainJsonObject } from "../plain-json-object.js";
13
+ import { isJsonArray, isPlainJsonObject } from "../plain-json-object.js";
14
14
  /**
15
15
  * 「这个对象**在场**的键」—— 本文件里唯一一处判键在不在 / 取键的值的地方。
16
16
  *
@@ -37,7 +37,7 @@ function presentKeys(o) {
37
37
  * 「键不在场」与「键在场但值是 undefined」在比对上分成两件事,而 wire 上它们同形)。
38
38
  */
39
39
  export function canonicalize(value) {
40
- if (Array.isArray(value))
40
+ if (isJsonArray(value))
41
41
  return value.map(canonicalize);
42
42
  if (isPlainJsonObject(value)) {
43
43
  // `Object.fromEntries`,**不是**逐键 `out[k] = …`:后者对 `"__proto__"` 走的是原型 setter(改掉对象的
@@ -55,7 +55,7 @@ export function stableKey(value) {
55
55
  export function deepEqual(a, b) {
56
56
  if (Object.is(a, b))
57
57
  return true;
58
- if (Array.isArray(a) && Array.isArray(b))
58
+ if (isJsonArray(a) && isJsonArray(b))
59
59
  return a.length === b.length && a.every((x, i) => deepEqual(x, b[i]));
60
60
  if (isPlainJsonObject(a) && isPlainJsonObject(b)) {
61
61
  const ka = presentKeys(a);
@@ -78,7 +78,7 @@ export function diffJson(expected, actual, at = []) {
78
78
  const path = at.length === 0 ? "" : "/" + at.map(pointerSegment).join("/");
79
79
  if (deepEqual(expected, actual))
80
80
  return [];
81
- if (Array.isArray(expected) && Array.isArray(actual)) {
81
+ if (isJsonArray(expected) && isJsonArray(actual)) {
82
82
  const out = [];
83
83
  for (let i = 0; i < Math.max(expected.length, actual.length); i++) {
84
84
  if (i >= expected.length)
@@ -24,8 +24,33 @@
24
24
  import { materializeLocalRoot } from "./run.js";
25
25
  import { contractOwnedEntry, contractOwnedEntryWhy } from "./entry-origin.js";
26
26
  import { LEG_NAMES, REF_INTEGRITY_REJECT_CODE } from "./types.js";
27
+ import { inspect } from "../own-key-read.js";
28
+ import { isJsonArray, jsonShapeOf } from "../plain-json-object.js";
27
29
  /** ZodError 形(鸭子判,不用 `instanceof` —— historical 模式下旧版 dist 可能挂在另一份 zod 上)。 */
28
- const isZodLike = (e) => e !== null && typeof e === "object" && Array.isArray(e.issues);
30
+ /** A thrown value that looks like a zod error (an object — not a function — with an `issues` array on it; v14.6.0's `typeof e === "object"` test, nothing more). Shape from the seat;
31
+ * the one member read goes through the inspection kernel. The seat reads the prototype once to classify, but the answer never depends on it: an object whose shape cannot
32
+ * be read (a `getPrototypeOf` trap that throws) is still asked for `issues` — a thrown proxy whose `issues` cannot be read is not zod-like. */
33
+ const isZodLike = (e) => {
34
+ const shape = jsonShapeOf(e);
35
+ switch (shape) {
36
+ case "null":
37
+ case "primitive":
38
+ return false;
39
+ case "array":
40
+ case "plain":
41
+ case "non_plain_object":
42
+ case "uninspectable": {
43
+ if (typeof e === "function")
44
+ return false; // 被抛的函数不是 zod 错(v14.6.0 的 `typeof e === "object"` 前置判把函数排除在外)
45
+ const issues = inspect(() => e.issues);
46
+ return issues.ok && isJsonArray(issues.value);
47
+ }
48
+ default: {
49
+ const unhandled = shape;
50
+ throw new Error(`isZodLike: unhandled shape ${String(unhandled)}`);
51
+ }
52
+ }
53
+ };
29
54
  /** 内容哈希那一层拒绝的自述 —— `stableStringify` 的深度闸是**本包自己**抛的、也只有它会抛这一句;
30
55
  * 适配器给自家流水线分层,认得自家那句话是合理的(消费方的适配器认它自己那几层的错)。 */
31
56
  const HASH_STAGE_MARK = "stableStringify:";
@@ -20,7 +20,7 @@ import { dirname, join } from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { LEG_NAMES, REJECT_STAGES } from "./types.js";
22
22
  import { LEG_EXPECTATION } from "./derive.js";
23
- import { isPlainJsonObject } from "../plain-json-object.js";
23
+ import { isJsonArray, isPlainJsonObject } from "../plain-json-object.js";
24
24
  /** 本包的根目录 —— `src/conformance/*.ts` 与 `dist/conformance/*.js` 距根同深,两处求值同一个地方。 */
25
25
  export const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
26
26
  /** 语料目录(默认 = 本包的 `conformance/`)。 */
@@ -45,7 +45,7 @@ function checkLegExpectation(v, where) {
45
45
  }
46
46
  if (r.outcome === "effective") {
47
47
  requireRecord(r.effective, `${where}.effective`);
48
- if (!Array.isArray(r.warnings))
48
+ if (!isJsonArray(r.warnings))
49
49
  throw new Error(`conformance corpus: ${where}.warnings must be an array`);
50
50
  const meta = requireRecord(r.metadata, `${where}.metadata`);
51
51
  if (typeof meta.updatedAt !== "string")
@@ -56,7 +56,7 @@ function checkLegExpectation(v, where) {
56
56
  if (!REJECT_STAGES.includes(r.stage))
57
57
  throw new Error(`conformance corpus: ${where}.stage must be one of ${REJECT_STAGES.join("|")}`);
58
58
  if (r.refIssues !== undefined) {
59
- const ok = Array.isArray(r.refIssues) && r.refIssues.every((x) => isPlainJsonObject(x) && Array.isArray(x.path) && x.path.every((seg) => typeof seg === "string" || typeof seg === "number"));
59
+ const ok = isJsonArray(r.refIssues) && r.refIssues.every((x) => isPlainJsonObject(x) && isJsonArray(x.path) && x.path.every((seg) => typeof seg === "string" || typeof seg === "number"));
60
60
  if (!ok)
61
61
  throw new Error(`conformance corpus: ${where}.refIssues must be an array of { path: (string|number)[] }`);
62
62
  }
@@ -76,7 +76,7 @@ export async function loadCase(dir, id) {
76
76
  requireRecord(input.domains, `${id}/input.json#domains`);
77
77
  if (typeof meta.claim !== "string" || meta.claim.trim().length === 0)
78
78
  throw new Error(`conformance corpus: ${id}/meta.json#claim is empty — 每条语料必须写它想打谁`);
79
- if (!Array.isArray(meta.firstRedOn))
79
+ if (!isJsonArray(meta.firstRedOn))
80
80
  throw new Error(`conformance corpus: ${id}/meta.json#firstRedOn must be an array`);
81
81
  const legs = {};
82
82
  for (const leg of LEG_NAMES) {
@@ -36,7 +36,7 @@
36
36
  */
37
37
  import { NON_DOMAIN_CONFIG_FILES } from "../file-store.js";
38
38
  import { PORTABLE_DOMAINS } from "../portable-domains.js";
39
- import { isPlainJsonObject } from "../plain-json-object.js";
39
+ import { isJsonArray, isPlainJsonObject } from "../plain-json-object.js";
40
40
  import { skillContentHash } from "../skills-manifest.js";
41
41
  import { DOMAINS, isDomainName } from "../types.js";
42
42
  /** 变换表(本文件头注的机器形;`docs/CONFORMANCE.md` 与 `test/conformance-coverage.test.ts` 都读它)。 */
@@ -72,7 +72,7 @@ export const foreignKeysOf = (docs) => Object.keys(docs).filter((k) => !isDomain
72
72
  /** T2 的**入参**半场:域形的 skills 文档 → wire 形的 manifest 文档。结构变换,不解析 —— 坏形原样传下去,
73
73
  * 让 wire 腿自己的判据去裁(否则语料就没法喂一份坏 manifest 了)。 */
74
74
  export function toManifestDoc(doc) {
75
- if (!isPlainJsonObject(doc) || !Array.isArray(doc.skills))
75
+ if (!isPlainJsonObject(doc) || !isJsonArray(doc.skills))
76
76
  return doc;
77
77
  return {
78
78
  ...doc,
@@ -14,7 +14,7 @@
14
14
  import { mkdtemp, mkdir, readFile, realpath, rm, stat, writeFile, utimes } from "node:fs/promises";
15
15
  import { tmpdir } from "node:os";
16
16
  import { basename, extname, isAbsolute, join, resolve, sep } from "node:path";
17
- import { isPlainJsonObject } from "../plain-json-object.js";
17
+ import { isJsonArray, isPlainJsonObject, jsonShapeOf } from "../plain-json-object.js";
18
18
  import { canonicalize, deepEqual, diffJson, stableKey } from "./canonical.js";
19
19
  import { diffWarningMultiset, findSecretLeaks, projectWarning } from "./warnings.js";
20
20
  import { deriveLocalExpectation, deriveLocalInput, deriveWireExpectation, deriveWireInput, toManifestDoc } from "./derive.js";
@@ -178,7 +178,7 @@ function ledgerIndexForOutcome(ledger, caseId, leg, shape) {
178
178
  if (o === undefined)
179
179
  continue;
180
180
  // 形不合法的登记(`cases` 缺席/空 —— 违例已单记)绝不因此**扩大**覆盖:它谁都不命中,fail-closed。
181
- if (o.leg !== leg || !Array.isArray(o.cases) || !o.cases.includes(caseId))
181
+ if (o.leg !== leg || !isJsonArray(o.cases) || !o.cases.includes(caseId))
182
182
  continue;
183
183
  if (o.expected !== shape.expected || o.actual !== shape.actual)
184
184
  continue;
@@ -289,12 +289,49 @@ function isCalendarDate(v) {
289
289
  return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v;
290
290
  }
291
291
  function stanceOf(adapter, leg) {
292
+ // an adapter is CODE (a stance may be a class instance with `run` on its prototype), not data: its members are read as an object's members — what the seat decides is only "is it an object at all"
292
293
  const legs = adapter.legs;
293
- const s = legs !== null && typeof legs === "object" ? legs[leg] : undefined;
294
+ const legsShape = jsonShapeOf(legs);
295
+ let legsIsObject;
296
+ switch (legsShape) {
297
+ case "null":
298
+ case "primitive":
299
+ case "uninspectable":
300
+ legsIsObject = false;
301
+ break;
302
+ case "plain":
303
+ case "array":
304
+ legsIsObject = true;
305
+ break;
306
+ case "non_plain_object":
307
+ legsIsObject = typeof legs !== "function"; // 类实例是对象;函数不是(v14.6.0 的 `typeof === "object"` 判法:函数 ⇒ 没表态)
308
+ break;
309
+ default: {
310
+ const unhandled = legsShape;
311
+ throw new Error(`stanceOf: unhandled shape ${String(unhandled)}`);
312
+ }
313
+ }
314
+ const s = legsIsObject ? legs[leg] : undefined;
294
315
  if (s === undefined)
295
316
  return { kind: "missing" };
296
- if (s === null || typeof s !== "object")
297
- return { kind: "malformed", why: "the stance is not an object" };
317
+ const stanceShape = jsonShapeOf(s);
318
+ switch (stanceShape) {
319
+ case "null":
320
+ case "primitive":
321
+ case "uninspectable":
322
+ return { kind: "malformed", why: "the stance is not an object" };
323
+ case "plain":
324
+ case "array":
325
+ break;
326
+ case "non_plain_object":
327
+ if (typeof s === "function")
328
+ return { kind: "malformed", why: "the stance is not an object" }; // 函数不是对象表态(类实例是)
329
+ break;
330
+ default: {
331
+ const unhandled = stanceShape;
332
+ throw new Error(`stanceOf: unhandled shape ${String(unhandled)}`);
333
+ }
334
+ }
298
335
  const runs = typeof s.run === "function";
299
336
  const declines = Object.prototype.hasOwnProperty.call(s, "declined");
300
337
  // 与类型同律(两臂互斥):声明臂上出现实现臂的任一字段(`entry` / `run`)都算「既实现又声明」。
@@ -376,7 +413,7 @@ async function releaseViolations(adapter, opts, corpus, kit) {
376
413
  continue;
377
414
  }
378
415
  const cases = e.outcome.cases;
379
- if (!Array.isArray(cases) || cases.length === 0)
416
+ if (!isJsonArray(cases) || cases.length === 0)
380
417
  v.push(`known divergence \`${name}\` is an outcome divergence that must name at least one case — 结局形不许按前缀大包一整条腿的拒绝面`);
381
418
  }
382
419
  // **每条腿都要表态**:实现,或显式声明不承担(闭集理由词 + why)。缺表态 / 形不对 ⇒ 违例 —— 不许静默跳过,也不许默认当通过。
@@ -50,11 +50,11 @@ export type CrossSessionInboundValue = (typeof CROSS_SESSION_INBOUND_VALUES)[num
50
50
  export declare const CROSS_SESSION_INBOUND_UNSET: "default";
51
51
  /** 文件层受理面:四个显式值 + `"default"`(未设)。 */
52
52
  export declare const CrossSessionInboundSetting: z.ZodEnum<{
53
+ refuse: "refuse";
53
54
  default: "default";
54
55
  wake: "wake";
55
56
  "next-turn": "next-turn";
56
57
  hold: "hold";
57
- refuse: "refuse";
58
58
  }>;
59
59
  export type CrossSessionInboundSettingValue = z.infer<typeof CrossSessionInboundSetting>;
60
60
  /** 四值闭集(CC 逐字)。`"never"` = 不设期限。 */
@@ -64,8 +64,8 @@ export type DialogExpiryValue = (typeof DIALOG_EXPIRY_VALUES)[number];
64
64
  export declare const DIALOG_EXPIRY_DEFAULT: DialogExpiryValue;
65
65
  /** 文件层受理面:四个值 + `"default"`(未设 ⇒ 落 {@link DIALOG_EXPIRY_DEFAULT})。 */
66
66
  export declare const DialogExpirySetting: z.ZodEnum<{
67
- never: "never";
68
67
  default: "default";
68
+ never: "never";
69
69
  "60s": "60s";
70
70
  "5m": "5m";
71
71
  "10m": "10m";
@@ -78,15 +78,15 @@ export type DialogExpirySettingValue = z.infer<typeof DialogExpirySetting>;
78
78
  */
79
79
  export declare const CrossSessionSettings: z.ZodObject<{
80
80
  crossSessionInbound: z.ZodOptional<z.ZodEnum<{
81
+ refuse: "refuse";
81
82
  default: "default";
82
83
  wake: "wake";
83
84
  "next-turn": "next-turn";
84
85
  hold: "hold";
85
- refuse: "refuse";
86
86
  }>>;
87
87
  dialogExpiry: z.ZodOptional<z.ZodEnum<{
88
- never: "never";
89
88
  default: "default";
89
+ never: "never";
90
90
  "60s": "60s";
91
91
  "5m": "5m";
92
92
  "10m": "10m";