@sema-agent/settings-schema 14.5.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.
- package/CHANGELOG.md +531 -0
- package/README.md +46 -3
- package/conformance/VERSION +1 -1
- package/conformance/cases/c84-sandbox-git-block-carried-verbatim/expected.json +153 -0
- package/conformance/cases/c84-sandbox-git-block-carried-verbatim/input.json +23 -0
- package/conformance/cases/c84-sandbox-git-block-carried-verbatim/meta.json +5 -0
- package/conformance/cases/c85-sandbox-git-refused-block-still-carried/expected.json +153 -0
- package/conformance/cases/c85-sandbox-git-refused-block-still-carried/input.json +23 -0
- package/conformance/cases/c85-sandbox-git-refused-block-still-carried/meta.json +5 -0
- package/dist/api/auth-bridge.d.ts +16 -2
- package/dist/api/auth-bridge.js +59 -24
- package/dist/api/credentials.d.ts +281 -0
- package/dist/api/credentials.js +149 -0
- package/dist/api/scopes.js +5 -1
- package/dist/atomic-write.js +10 -7
- package/dist/bundle.d.ts +1 -1
- package/dist/bundle.js +1 -1
- package/dist/config-d-file.js +33 -15
- package/dist/config-dir-migration.js +2 -1
- package/dist/config-fns.js +142 -78
- package/dist/conformance/canonical.js +7 -10
- package/dist/conformance/contract-adapter.js +26 -1
- package/dist/conformance/corpus.js +5 -5
- package/dist/conformance/derive.js +6 -5
- package/dist/conformance/run.js +44 -6
- package/dist/cross-session.d.ts +4 -4
- package/dist/declared-capture.d.ts +41 -0
- package/dist/declared-capture.js +344 -0
- package/dist/file-edit.js +11 -15
- package/dist/file-store.d.ts +1 -31
- package/dist/file-store.js +3 -30
- package/dist/fleet.d.ts +11 -0
- package/dist/fleet.js +60 -26
- package/dist/fs-failure.d.ts +12 -7
- package/dist/fs-failure.js +53 -29
- package/dist/hash.d.ts +0 -9
- package/dist/hash.js +64 -7
- package/dist/hooks-config.d.ts +2 -2
- package/dist/hooks-config.js +129 -33
- package/dist/http-hook-allowlists.d.ts +27 -10
- package/dist/http-hook-allowlists.js +189 -25
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -1
- package/dist/mcp-request-entry.d.ts +13 -8
- package/dist/mcp-request-entry.js +246 -107
- package/dist/one-time-migrations.js +207 -95
- package/dist/own-key-read.d.ts +247 -0
- package/dist/own-key-read.js +449 -0
- package/dist/plain-json-object.d.ts +38 -3
- package/dist/plain-json-object.js +84 -7
- package/dist/portable-domains.d.ts +31 -0
- package/dist/portable-domains.js +30 -0
- package/dist/remote-exec.js +9 -1
- package/dist/safety-merge-spec.d.ts +88 -6
- package/dist/safety-merge-spec.js +246 -17
- package/dist/sandbox-git.d.ts +64 -0
- package/dist/sandbox-git.js +89 -0
- package/dist/scheduler-store.js +21 -12
- package/dist/settings-units.d.ts +85 -0
- package/dist/settings-units.js +177 -0
- package/dist/types.d.ts +6 -2
- package/dist/types.js +65 -56
- package/dist/url-shape.d.ts +27 -5
- package/dist/url-shape.js +29 -5
- package/dist/web-admin-credential.js +5 -2
- package/dist/web-admin-session-url.js +2 -2
- package/dist/worker-row-verdict.d.ts +11 -0
- package/dist/worker-row-verdict.js +51 -1
- package/dist/workers-rows.d.ts +2 -1
- package/dist/workers-rows.js +41 -12
- package/dist/zod-unwrap.d.ts +6 -0
- package/dist/zod-unwrap.js +14 -7
- package/docs/API-SURFACE.md +50 -5
- package/docs/CONSUMER-INTEGRATION.md +178 -0
- package/llms-full.txt +34 -5
- package/llms.txt +6 -3
- package/package.json +1 -1
package/dist/fleet.js
CHANGED
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
import { z } from "zod";
|
|
21
21
|
import { INTEGER } from "./integer.js";
|
|
22
22
|
import { fnv1a64 } from "./hash.js";
|
|
23
|
+
import { parseDeclared } from "./declared-capture.js";
|
|
24
|
+
import { readDeclaredKey, ownValueOf } from "./own-key-read.js";
|
|
25
|
+
import { isPlainJsonObject } from "./plain-json-object.js";
|
|
23
26
|
import { buildQuotedIssue } from "./quoted-issue.js";
|
|
24
27
|
// ── Web-admin's fleet endpoints (paths, one spelling) ─────────────────────────────────────────────────────
|
|
25
28
|
/**
|
|
@@ -163,24 +166,41 @@ export const RestartSignal = z.object({
|
|
|
163
166
|
since: EpochMs,
|
|
164
167
|
withheld: z.boolean().optional(),
|
|
165
168
|
});
|
|
166
|
-
|
|
167
|
-
* The ONE guarded body judgement behind every wire-shape read in this file: `pick(body)` — one key of the body, or the body itself —
|
|
168
|
-
* is judged against that shape's one schema, and the verdict says WHY when it is not a read: `not-an-object` (the body is null, an
|
|
169
|
-
* array or a scalar) or `invalid` (the shape rejected it, or an accessor threw). It never throws (a probe must not die on one odd
|
|
170
|
-
* body). Almost every reader wants only "a read or nothing" and goes through {@link readSignalBody}; the one reader whose
|
|
171
|
-
* failure direction matters ({@link readCapabilityReading}) takes the reason. A second hand-written guard anywhere else in this
|
|
172
|
-
* package is the defect, not a convenience.
|
|
173
|
-
*/
|
|
169
|
+
const WHOLE_BODY = Symbol("whole body");
|
|
174
170
|
function judgeSignalBody(body, pick, schema) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
171
|
+
if (!isPlainJsonObject(body))
|
|
172
|
+
return { kind: "not-an-object" };
|
|
173
|
+
// the carrier key is read from the body's OWN keys (a polluted `Object.prototype.engineSignal` is not a signal the body carries) with the SAME descriptor check as a declared key:
|
|
174
|
+
// an own key that cannot be read as plain data (hidden — non-enumerable —, or an accessor that throws) is no readable signal, never "absent"
|
|
175
|
+
let target = body;
|
|
176
|
+
if (pick !== WHOLE_BODY) {
|
|
177
|
+
const carried = readDeclaredKey(body, pick);
|
|
178
|
+
switch (carried.presence) {
|
|
179
|
+
case "own":
|
|
180
|
+
target = carried.value;
|
|
181
|
+
break;
|
|
182
|
+
case "inherited":
|
|
183
|
+
case "absent":
|
|
184
|
+
target = undefined;
|
|
185
|
+
break;
|
|
186
|
+
case "hidden":
|
|
187
|
+
case "unreadable":
|
|
188
|
+
return { kind: "invalid" };
|
|
189
|
+
default: {
|
|
190
|
+
const unhandled = carried;
|
|
191
|
+
throw new Error(`judgeSignalBody: unhandled presence ${String(unhandled.presence)}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
180
194
|
}
|
|
181
|
-
|
|
195
|
+
// the validator reads members with `[[Get]]`, required keys included: a key the body does not carry would be filled from a polluted `Object.prototype` (a granted lease from an empty reply, a
|
|
196
|
+
// ready signal from `{}`, a "declared" capability from nothing) — so it never sees the caller's object: it reads an own-key snapshot of exactly the keys it declares (for a union: of
|
|
197
|
+
// the arm it will try), on the kernel's isolated prototype `COPY_PROTOTYPE` (no inherited members; it still answers `toString` / `valueOf`), and a value that cannot be captured
|
|
198
|
+
// (an accessor — throwing or not —, a hidden own key, a trap that throws, a cycle, not plain data) is `invalid`. `parseDeclared` admits only a closed allow-list of schema shapes, each
|
|
199
|
+
// reasoned and pinned against zod's own verdict and data; any other shape is a loud throw — the package chose the wrong reader, not a caller value that cannot be read
|
|
200
|
+
const parsed = parseDeclared(schema, target);
|
|
201
|
+
if (!parsed.ok)
|
|
182
202
|
return { kind: "invalid" };
|
|
183
|
-
}
|
|
203
|
+
return { kind: "read", value: parsed.data };
|
|
184
204
|
}
|
|
185
205
|
/**
|
|
186
206
|
* The reader behind every wire-shape read in this file that has no use for a reason: the engine-owned signals on /health
|
|
@@ -192,8 +212,6 @@ function readSignalBody(body, pick, schema) {
|
|
|
192
212
|
const judged = judgeSignalBody(body, pick, schema);
|
|
193
213
|
return judged.kind === "read" ? judged.value : undefined;
|
|
194
214
|
}
|
|
195
|
-
/** `pick` for a shape whose keys sit flat on the body itself (as opposed to under one key of it). */
|
|
196
|
-
const wholeBody = (body) => body;
|
|
197
215
|
/**
|
|
198
216
|
* The model-refusal cause codes (S-768) — CLOSED sets, owned HERE, never a hand-copied second list. Adding a code is
|
|
199
217
|
* one edit in this file; who goes red at compile time: a reader that switches on the code exhaustively (a web-admin), and
|
|
@@ -284,11 +302,14 @@ const isModelPlaneRefusalCode = (c) => MODEL_PLANE_REFUSAL_CODES.includes(c);
|
|
|
284
302
|
* at all — it answers the key only and never judges the engine's version (see {@link InstanceAnnouncement}`.modelRefusals`).
|
|
285
303
|
*/
|
|
286
304
|
export function readModelRefusals(body) {
|
|
287
|
-
const wire = readSignalBody(body,
|
|
305
|
+
const wire = readSignalBody(body, "modelRefusals", ModelRefusalsWire);
|
|
288
306
|
if (wire === undefined)
|
|
289
307
|
return undefined;
|
|
290
308
|
const entries = Object.fromEntries(Object.entries(wire.entries).filter((e) => isModelEntryRefusalCode(e[1])));
|
|
291
|
-
|
|
309
|
+
// the validated output is an ordinary object: an absent optional `plane` stays absent (own key only), never read off a polluted `Object.prototype`
|
|
310
|
+
const planeRead = ownValueOf(wire, "plane");
|
|
311
|
+
const planeWord = planeRead.ok ? planeRead.value : undefined;
|
|
312
|
+
const plane = typeof planeWord === "string" && isModelPlaneRefusalCode(planeWord) ? planeWord : undefined;
|
|
292
313
|
if (Object.keys(entries).length === 0 && plane === undefined)
|
|
293
314
|
return undefined;
|
|
294
315
|
return plane === undefined ? { entries } : { entries, plane };
|
|
@@ -362,7 +383,7 @@ export const ENGINE_SIGNAL_KEY = "engineSignal";
|
|
|
362
383
|
* dialect; the carrier is the one key).
|
|
363
384
|
*/
|
|
364
385
|
export function readEngineSignal(body) {
|
|
365
|
-
return readSignalBody(body,
|
|
386
|
+
return readSignalBody(body, ENGINE_SIGNAL_KEY, EngineSignal);
|
|
366
387
|
}
|
|
367
388
|
/** The `/health` self-description every orchestrator reads. Liveness and readiness are deliberately SPLIT: `status` stays ok while
|
|
368
389
|
* draining (the process is alive); readiness is {@link judgeReadiness} over the engine signal the body carries under
|
|
@@ -555,6 +576,17 @@ export const FLEET_GENERATION_ENV = "FLEET_GENERATION";
|
|
|
555
576
|
* {@link FLEET_GENERATION_ENV} it is a reserved worker env name (`WorkerSpec.env` / `secretEnv` cannot set it).
|
|
556
577
|
*/
|
|
557
578
|
export const FLEET_SCOPE_ENV = "FLEET_SCOPE";
|
|
579
|
+
/**
|
|
580
|
+
* The env name a fleet member's advertise address travels under — the base URL this instance tells the control plane it can be
|
|
581
|
+
* reached at; the control plane appends request paths to it. The operator or the config renderer sets it (on Kubernetes the renderer
|
|
582
|
+
* writes a `$(POD_IP)` placeholder in the value and the kubelet expands it), and the engine reads it back. Both sides import THIS
|
|
583
|
+
* constant; neither spells the name. The address is a base that request paths are appended to, so a reader judges a present, non-empty
|
|
584
|
+
* value with `pathBaseUrlFlaws` (main barrel) — the one judge of every such seat, which forbids a credential, a
|
|
585
|
+
* query and a fragment there and allows a path prefix; judge the value AFTER the deployment has expanded its placeholders. Absent or
|
|
586
|
+
* the empty string means "no address declared" (the `${NAME:-}` idiom of a compose file), and that reading is the caller's, not the
|
|
587
|
+
* judge's.
|
|
588
|
+
*/
|
|
589
|
+
export const FLEET_ADVERTISE_ADDRESS_ENV = "FLEET_ADVERTISE_ADDRESS";
|
|
558
590
|
/**
|
|
559
591
|
* The serving lease — the ONE fence between a superseded generation and the traffic it must stop taking. A fleet-member
|
|
560
592
|
* engine renews it with web-admin; web-admin answers per the lifecycle object it holds for that instance. Three constants
|
|
@@ -646,14 +678,14 @@ export const SERVING_LEASE_VERDICTS = ["granted", "denied", "unknown"];
|
|
|
646
678
|
/** Web-admin side: the request body of a serving-lease renewal. Shape mismatch / non-object / throwing accessor ⇒ `undefined`,
|
|
647
679
|
* never throws (same guarded reader as every other read in this file). */
|
|
648
680
|
export function readServingLeaseRequest(body) {
|
|
649
|
-
return readSignalBody(body,
|
|
681
|
+
return readSignalBody(body, WHOLE_BODY, ServingLeaseRequest);
|
|
650
682
|
}
|
|
651
683
|
/** Engine side: the reply to a renewal, judged by its `verdict` key: `granted` needs a valid `ttlMs`, `denied` is `denied`
|
|
652
684
|
* whatever its `reason` (see {@link ServingLeaseReading}), `unknown` is `unknown`. A non-object, a throwing accessor, a
|
|
653
685
|
* `verdict` outside the three words or a `granted` without a valid `ttlMs` ⇒ `undefined`, never throws — and `undefined`
|
|
654
686
|
* is read like an unreachable web-admin, never like `denied` and never like `granted`. */
|
|
655
687
|
export function readServingLeaseResponse(body) {
|
|
656
|
-
return readSignalBody(body,
|
|
688
|
+
return readSignalBody(body, WHOLE_BODY, ServingLeaseReadFace);
|
|
657
689
|
}
|
|
658
690
|
/**
|
|
659
691
|
* What web-admin says it can do: capability bits an engine reads before it starts using a web-admin-side endpoint.
|
|
@@ -703,14 +735,16 @@ const CapabilitiesCarrier = z.object({ [HEARTBEAT_REPLY_CAPABILITIES_KEY]: WebAd
|
|
|
703
735
|
* other reader here.
|
|
704
736
|
*/
|
|
705
737
|
export function readCapabilityReading(reply) {
|
|
706
|
-
const judged = judgeSignalBody(reply,
|
|
738
|
+
const judged = judgeSignalBody(reply, WHOLE_BODY, CapabilitiesCarrier);
|
|
707
739
|
switch (judged.kind) {
|
|
708
740
|
case "not-an-object":
|
|
709
741
|
return { kind: "unreadable", why: "not-an-object" };
|
|
710
742
|
case "invalid":
|
|
711
743
|
return { kind: "unreadable", why: "invalid-capabilities" };
|
|
712
744
|
case "read": {
|
|
713
|
-
|
|
745
|
+
// the validated output is an ordinary object: an absent optional key must stay absent, not be read off a polluted `Object.prototype` — own key only (it was parsed from a snapshot, so it cannot be unreadable)
|
|
746
|
+
const carried = ownValueOf(judged.value, HEARTBEAT_REPLY_CAPABILITIES_KEY);
|
|
747
|
+
const capabilities = carried.ok ? carried.value : undefined;
|
|
714
748
|
return capabilities === undefined ? { kind: "undeclared" } : { kind: "declared", capabilities };
|
|
715
749
|
}
|
|
716
750
|
default: {
|
|
@@ -754,11 +788,11 @@ export const DrainReceiptState = z.enum(DRAIN_RECEIPT_STATES);
|
|
|
754
788
|
export const DrainReceipt = EngineDraining.extend({ state: DrainReceiptState });
|
|
755
789
|
/** Engine side: the request body of the drain endpoint. Shape mismatch / non-object / throwing accessor ⇒ `undefined`, never throws. */
|
|
756
790
|
export function readDrainRequest(body) {
|
|
757
|
-
return readSignalBody(body,
|
|
791
|
+
return readSignalBody(body, WHOLE_BODY, DrainRequest);
|
|
758
792
|
}
|
|
759
793
|
/** Caller side: the drain endpoint's answer. Shape mismatch / non-object / throwing accessor ⇒ `undefined`, never throws. */
|
|
760
794
|
export function readDrainReceipt(body) {
|
|
761
|
-
return readSignalBody(body,
|
|
795
|
+
return readSignalBody(body, WHOLE_BODY, DrainReceipt);
|
|
762
796
|
}
|
|
763
797
|
// ── Domain 3: dispatch policy (traffic + failover + LB — three domains, one gateway) ──────────────────────
|
|
764
798
|
export const DISPATCH_STRATEGIES = ["round-robin", "least-inflight", "consistent-hash"];
|
package/dist/fs-failure.d.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* 落点({@link FsFailureSite}.`landing`,闭集)按失败发生在哪一步分句 —— 同一个 errno 在不同落点指向不同的东西:
|
|
7
7
|
* · `dir-create-temp` —— 原子写器在目标所在目录里建临时文件。目录不让建 ⇒ 点名**目录**(文件本身没错);
|
|
8
|
+
* · `chmod` —— 原子写器给临时文件设模式(已有的文件沿用它原来的模式,临时文件建成后、`rename` 之前设)。这一步失败 ⇒ 点名**设模式**这件事(文件系统不让改文件模式;目录没有问题),
|
|
9
|
+
* 不提目录、不提不可变标志;`path` 是被保留模式的**真文件**(chmod 作用在替换它的临时副本上,用户认得的是真文件名);
|
|
8
10
|
* · `rename` —— 原子写器 `rename` 盖过目标。目录收了新文件、却换不掉这一份 ⇒ 点名**真文件**;
|
|
9
11
|
* · `remove` —— 删一个文件。删的是目录项:目录不让删 ⇒ 点名文件所在的**目录**(与建临时文件同一个因);
|
|
10
12
|
* · `read` —— 读一个目录或文件;
|
|
@@ -12,18 +14,18 @@
|
|
|
12
14
|
* 认得的 errno 给那一步对应的出路,别的 errno(磁盘满、I/O 错 …)给 Node 自己的句子 —— 照样响亮,只是不替它猜出路。两条与落点无关的律:
|
|
13
15
|
* · errno 只打一遍 —— 句首括号里一次,Node 句子自带的 `<errno>: ` 前缀去掉;
|
|
14
16
|
* · `ENAMETOOLONG`(路径某一节或整条超限)既不是目录的错也不是文件的错 ⇒ 一句 `path too long: <被操作系统拒的路径> (ENAMETOOLONG)`,不点名目录、不给权限类出路。
|
|
15
|
-
* 三个落点(`dir-create-temp` / `remove` / `rename`)
|
|
16
|
-
* (macOS `chflags uchg` / Linux `chattr +i`)
|
|
17
|
-
* `
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* 三个落点(`dir-create-temp` / `remove` / `rename`)的权限类出路走**同一张表**、一只函数({@link permissionWayOut}):落点表 {@link WAY_OUT_LANDING}(这个落点认哪些 errno、清标志点名谁、动已有目录项的落点才有粘滞目录一句)×
|
|
18
|
+
* errno 表 {@link WAY_OUT_BY_ERRNO}(穷举;每个 errno 两格 —— 目录权限位对本进程「可写」/「不可写或没读过」)。同一个 `EPERM` 既可能是目录模式不让写,也可能是不可变标志(macOS `chflags uchg` / Linux `chattr +i`),
|
|
19
|
+
* 同一个 `EACCES` 在模式位已经放行时只能是 ACL 条目或安全模块策略 —— 铸点是纯函数,分不清,但调用方在 Node 一侧能读目录模式,把「模式对本进程可写」这个事实经落点的 `directoryWritable` 递进来:
|
|
20
|
+
* 「可写」⇒ chmod 已经没用,`EPERM` 的首选出路 = 清标志(`chflags nouchg` / `chattr -i`),动已有目录项的落点(remove / rename)再接粘滞目录一句(那条属主规则给的就是 `EPERM`),`EACCES` 的出路 = 查 ACL / 安全模块(不接粘滞句);
|
|
21
|
+
* 否则(模式不可写,或调用方没读过模式 —— 注入的替身 I/O 抛的错,包看不到它背后)⇒ 先让目录可写,`EPERM` / `EROFS` 句尾提示「只读 / 不可变」的可能。`EROFS` 的出路不变(只读文件系统清不掉)。
|
|
22
|
+
* `rename` 落点的「可写」不是读出来的而是已证明的({@link RENAME_DIRECTORY_WRITABLE}:走到 rename 时临时文件刚在同一目录建成)—— 一个来源,`EPERM` 与 `EACCES` 同走「可写」那一列,所以 rename 句从不说「make the directory writable」。
|
|
21
23
|
*
|
|
22
24
|
* 原子写器抛 {@link FsFailure}(句子在 `message`,`code` 照抄底层 errno,原错误在 `cause`);它在写器内部已经按更细的落点铸过句子,
|
|
23
25
|
* 命令行 / 迁移用 {@link fsFailureMessage} 取句 —— 已经是 {@link FsFailure} 的原样用,否则按它所在的口铸。
|
|
24
26
|
*/
|
|
25
27
|
/** 一次失败发生的地方(闭集):落点加那一步交给操作系统的真实路径(`dir-create-temp` 是要建的临时文件,点名的目录是它的父目录;`rename` 是被盖过的真文件;
|
|
26
|
-
* `remove` / `write` 是那份文件;`read`
|
|
28
|
+
* `remove` / `write` 是那份文件;`read` 是读的目录或文件;`chmod` 是被保留模式的真文件〔这一格不是「交给操作系统的路径」口径:chmod 的对象是替换它的临时副本〕)。
|
|
27
29
|
* `directoryWritable`(只有 `dir-create-temp` / `remove` 两个落点有)—— 失败那一刻,目录的权限位对本进程是否可写(Node 一侧读的事实,见 `./atomic-write` 的 `directoryAdmitsWrite`);
|
|
28
30
|
* 缺席 = 没读过(注入的替身 I/O),出路顺序按「不可写」走。`rename` 落点没有这一格:走到 rename 时临时文件刚在同一目录建成,目录可写是已证明的事实,由铸点按 {@link RENAME_DIRECTORY_WRITABLE} 给出。
|
|
29
31
|
*/
|
|
@@ -31,6 +33,9 @@ export type FsFailureSite = {
|
|
|
31
33
|
readonly landing: "dir-create-temp";
|
|
32
34
|
readonly path: string;
|
|
33
35
|
readonly directoryWritable?: boolean;
|
|
36
|
+
} | {
|
|
37
|
+
readonly landing: "chmod";
|
|
38
|
+
readonly path: string;
|
|
34
39
|
} | {
|
|
35
40
|
readonly landing: "rename";
|
|
36
41
|
readonly path: string;
|
package/dist/fs-failure.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* 落点({@link FsFailureSite}.`landing`,闭集)按失败发生在哪一步分句 —— 同一个 errno 在不同落点指向不同的东西:
|
|
7
7
|
* · `dir-create-temp` —— 原子写器在目标所在目录里建临时文件。目录不让建 ⇒ 点名**目录**(文件本身没错);
|
|
8
|
+
* · `chmod` —— 原子写器给临时文件设模式(已有的文件沿用它原来的模式,临时文件建成后、`rename` 之前设)。这一步失败 ⇒ 点名**设模式**这件事(文件系统不让改文件模式;目录没有问题),
|
|
9
|
+
* 不提目录、不提不可变标志;`path` 是被保留模式的**真文件**(chmod 作用在替换它的临时副本上,用户认得的是真文件名);
|
|
8
10
|
* · `rename` —— 原子写器 `rename` 盖过目标。目录收了新文件、却换不掉这一份 ⇒ 点名**真文件**;
|
|
9
11
|
* · `remove` —— 删一个文件。删的是目录项:目录不让删 ⇒ 点名文件所在的**目录**(与建临时文件同一个因);
|
|
10
12
|
* · `read` —— 读一个目录或文件;
|
|
@@ -12,36 +14,58 @@
|
|
|
12
14
|
* 认得的 errno 给那一步对应的出路,别的 errno(磁盘满、I/O 错 …)给 Node 自己的句子 —— 照样响亮,只是不替它猜出路。两条与落点无关的律:
|
|
13
15
|
* · errno 只打一遍 —— 句首括号里一次,Node 句子自带的 `<errno>: ` 前缀去掉;
|
|
14
16
|
* · `ENAMETOOLONG`(路径某一节或整条超限)既不是目录的错也不是文件的错 ⇒ 一句 `path too long: <被操作系统拒的路径> (ENAMETOOLONG)`,不点名目录、不给权限类出路。
|
|
15
|
-
* 三个落点(`dir-create-temp` / `remove` / `rename`)
|
|
16
|
-
* (macOS `chflags uchg` / Linux `chattr +i`)
|
|
17
|
-
* `
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* 三个落点(`dir-create-temp` / `remove` / `rename`)的权限类出路走**同一张表**、一只函数({@link permissionWayOut}):落点表 {@link WAY_OUT_LANDING}(这个落点认哪些 errno、清标志点名谁、动已有目录项的落点才有粘滞目录一句)×
|
|
18
|
+
* errno 表 {@link WAY_OUT_BY_ERRNO}(穷举;每个 errno 两格 —— 目录权限位对本进程「可写」/「不可写或没读过」)。同一个 `EPERM` 既可能是目录模式不让写,也可能是不可变标志(macOS `chflags uchg` / Linux `chattr +i`),
|
|
19
|
+
* 同一个 `EACCES` 在模式位已经放行时只能是 ACL 条目或安全模块策略 —— 铸点是纯函数,分不清,但调用方在 Node 一侧能读目录模式,把「模式对本进程可写」这个事实经落点的 `directoryWritable` 递进来:
|
|
20
|
+
* 「可写」⇒ chmod 已经没用,`EPERM` 的首选出路 = 清标志(`chflags nouchg` / `chattr -i`),动已有目录项的落点(remove / rename)再接粘滞目录一句(那条属主规则给的就是 `EPERM`),`EACCES` 的出路 = 查 ACL / 安全模块(不接粘滞句);
|
|
21
|
+
* 否则(模式不可写,或调用方没读过模式 —— 注入的替身 I/O 抛的错,包看不到它背后)⇒ 先让目录可写,`EPERM` / `EROFS` 句尾提示「只读 / 不可变」的可能。`EROFS` 的出路不变(只读文件系统清不掉)。
|
|
22
|
+
* `rename` 落点的「可写」不是读出来的而是已证明的({@link RENAME_DIRECTORY_WRITABLE}:走到 rename 时临时文件刚在同一目录建成)—— 一个来源,`EPERM` 与 `EACCES` 同走「可写」那一列,所以 rename 句从不说「make the directory writable」。
|
|
21
23
|
*
|
|
22
24
|
* 原子写器抛 {@link FsFailure}(句子在 `message`,`code` 照抄底层 errno,原错误在 `cause`);它在写器内部已经按更细的落点铸过句子,
|
|
23
25
|
* 命令行 / 迁移用 {@link fsFailureMessage} 取句 —— 已经是 {@link FsFailure} 的原样用,否则按它所在的口铸。
|
|
24
26
|
*/
|
|
25
|
-
/** `dir-create-temp` 里「目录本身不让建文件」的 errno:权限不够、只读文件系统、目录不在。出路 = 让目录可写。 */
|
|
26
|
-
const DIRECTORY_NOT_WRITABLE = new Set(["EACCES", "EPERM", "EROFS", "ENOENT"]);
|
|
27
|
-
/** `remove` 里「目录不让删目录项」的 errno(同一个因:目录不可写)。目录不在 / 文件已不在(ENOENT)不是这个因,给 Node 自己的句子。 */
|
|
28
|
-
const DIRECTORY_ENTRY_NOT_REMOVABLE = new Set(["EACCES", "EPERM", "EROFS"]);
|
|
29
|
-
/** `rename` 里「目录收新文件、却换不掉这一份」的 errno:不可变标志、粘滞目录里别人的文件。出路 = 文件向(见 {@link renameWayOut}),句尾加粘滞目录一句。 */
|
|
30
|
-
const FILE_NOT_REPLACEABLE = new Set(["EACCES", "EPERM"]);
|
|
31
27
|
/** `rename` 落点上「目录对本进程可写」的事实:走到 rename 时临时文件刚在**同一目录**建成,这是已证明的,不是读出来的 —— 写器不对 rename 落点读模式位(一个来源)。 */
|
|
32
28
|
const RENAME_DIRECTORY_WRITABLE = true;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
29
|
+
const MODE_BITS_ADMIT = "the directory's permission bits already admit this process, so chmod will not help";
|
|
30
|
+
const MAKE_DIRECTORY_WRITABLE = "make the directory writable";
|
|
31
|
+
const READ_ONLY_OR_IMMUTABLE = `${MAKE_DIRECTORY_WRITABLE}; directory may be read-only or immutable (chflags / chattr)`;
|
|
32
|
+
/** errno 表:每个权限类 errno 在目录权限位「可写」(`admits`:chmod 已无用,说还剩什么真因)与「不可写或没读过」(`otherwise`)两格的出路,以及「可写」时是否接粘滞目录一句(`stickyWhenAdmitted`)。
|
|
33
|
+
* `EROFS` / `ENOENT` 的两格同一句 —— 模式位可不可写不改变它们的出路(只读文件系统清不掉;目录不在,先让它在)。 */
|
|
34
|
+
const WAY_OUT_BY_ERRNO = {
|
|
35
|
+
EPERM: {
|
|
36
|
+
admits: (flagSubject) => `clear the immutable flag on ${flagSubject} (macOS: chflags nouchg; Linux: chattr -i) — ${MODE_BITS_ADMIT}`,
|
|
37
|
+
otherwise: READ_ONLY_OR_IMMUTABLE,
|
|
38
|
+
stickyWhenAdmitted: true,
|
|
39
|
+
},
|
|
40
|
+
EACCES: {
|
|
41
|
+
admits: () => `${MODE_BITS_ADMIT}; an ACL entry may still deny this process (macOS: ls -le; Linux: getfacl); so may a security-module policy (SELinux / AppArmor: the audit log names the rule)`,
|
|
42
|
+
otherwise: MAKE_DIRECTORY_WRITABLE,
|
|
43
|
+
stickyWhenAdmitted: false, // 粘滞目录的属主规则返回的是 EPERM(macOS `man 2 unlink` / `rename`;Linux 同,推断),EACCES 的句尾不指向一个产生不了它的因
|
|
44
|
+
},
|
|
45
|
+
EROFS: { admits: () => READ_ONLY_OR_IMMUTABLE, otherwise: READ_ONLY_OR_IMMUTABLE, stickyWhenAdmitted: false },
|
|
46
|
+
ENOENT: { admits: () => MAKE_DIRECTORY_WRITABLE, otherwise: MAKE_DIRECTORY_WRITABLE, stickyWhenAdmitted: false },
|
|
47
|
+
};
|
|
48
|
+
const WAY_OUT_LANDING = {
|
|
49
|
+
"dir-create-temp": { errnos: ["EACCES", "EPERM", "EROFS", "ENOENT"], flagSubject: "the directory" },
|
|
50
|
+
remove: { errnos: ["EACCES", "EPERM", "EROFS"], flagSubject: "the file or its directory", stickyVerb: "remove" },
|
|
51
|
+
rename: { errnos: ["EACCES", "EPERM"], flagSubject: "the file or its directory", stickyVerb: "replace" },
|
|
52
|
+
};
|
|
53
|
+
/** 权限类出路的**唯一一只函数**(三个落点共用,表驱动):`code` 不是这个落点认的权限类 errno ⇒ `undefined`(调用方给 Node 自己的句子)。
|
|
54
|
+
* 组合律一条:errno 表的格(按 `directoryWritable === true` 选「可写」列或「不可写或没读过」列);「可写」∧ 该 errno 带粘滞 ∧ 这个落点有 `stickyVerb` ⇒ 句尾接 `in a sticky directory only the file's owner may <verb> it`。 */
|
|
55
|
+
const permissionWayOut = (landing, code, directoryWritable) => {
|
|
56
|
+
const spec = WAY_OUT_LANDING[landing];
|
|
57
|
+
const errno = spec.errnos.find((e) => e === code);
|
|
58
|
+
if (errno === undefined)
|
|
59
|
+
return undefined;
|
|
60
|
+
const row = WAY_OUT_BY_ERRNO[errno];
|
|
61
|
+
if (directoryWritable !== true)
|
|
62
|
+
return row.otherwise;
|
|
63
|
+
const way = row.admits(spec.flagSubject);
|
|
64
|
+
return row.stickyWhenAdmitted && spec.stickyVerb !== undefined ? `${way}; in a sticky directory only the file's owner may ${spec.stickyVerb} it` : way;
|
|
65
|
+
};
|
|
66
|
+
/** `chmod` 落点认的 errno:`EPERM` —— 创建者对自己刚建的临时文件 chmod 被拒,只能是文件系统 / 挂载不支持改模式。别的 errno 给 Node 自己的句子。 */
|
|
67
|
+
const CHMOD_REFUSED = new Set(["EPERM"]);
|
|
68
|
+
const CHMOD_WAY_OUT = "the filesystem does not allow changing file modes — keep this file on a filesystem that supports POSIX permissions";
|
|
45
69
|
const errnoOf = (e) => {
|
|
46
70
|
const code = e?.code;
|
|
47
71
|
return typeof code === "string" ? code : undefined;
|
|
@@ -65,13 +89,13 @@ export function fsFailureSentence(site, cause) {
|
|
|
65
89
|
const tag = code === undefined ? "" : ` (${code})`;
|
|
66
90
|
switch (site.landing) {
|
|
67
91
|
case "dir-create-temp":
|
|
68
|
-
return `cannot write into directory ${parentDirectory(site.path)}${tag}: ${
|
|
92
|
+
return `cannot write into directory ${parentDirectory(site.path)}${tag}: ${permissionWayOut("dir-create-temp", code, site.directoryWritable) ?? nodeSentenceOf(cause, code)}`;
|
|
93
|
+
case "chmod":
|
|
94
|
+
return `cannot set the mode of ${site.path}${tag}: ${code !== undefined && CHMOD_REFUSED.has(code) ? CHMOD_WAY_OUT : nodeSentenceOf(cause, code)}`;
|
|
69
95
|
case "rename":
|
|
70
|
-
return code
|
|
71
|
-
? `cannot replace ${site.path}${tag}: ${renameWayOut(code)}; ${STICKY_DIRECTORY_NOTE}`
|
|
72
|
-
: `cannot replace ${site.path}${tag}: ${nodeSentenceOf(cause, code)}`;
|
|
96
|
+
return `cannot replace ${site.path}${tag}: ${permissionWayOut("rename", code, RENAME_DIRECTORY_WRITABLE) ?? nodeSentenceOf(cause, code)}`;
|
|
73
97
|
case "remove":
|
|
74
|
-
return `cannot remove ${site.path} from directory ${parentDirectory(site.path)}${tag}: ${
|
|
98
|
+
return `cannot remove ${site.path} from directory ${parentDirectory(site.path)}${tag}: ${permissionWayOut("remove", code, site.directoryWritable) ?? nodeSentenceOf(cause, code)}`;
|
|
75
99
|
case "read":
|
|
76
100
|
return `cannot read ${site.of === "directory" ? "directory " : ""}${site.path}${tag}: ${nodeSentenceOf(cause, code)}`;
|
|
77
101
|
case "write":
|
package/dist/hash.d.ts
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tiny stable content hash — shared by `workerSpecHash` (reconciler drift detection) and the per-worker
|
|
3
|
-
* `/effective` ETag (so an unrelated roster change doesn't churn a worker's cache). Zero-dep, deterministic.
|
|
4
|
-
*
|
|
5
|
-
* FNV-1a **64-bit** (BigInt) → 16 hex. NOT cryptographic; it's a change-detector. 64-bit pushes the
|
|
6
|
-
* birthday-collision boundary to ~5B distinct inputs — a collision would silently SUPPRESS a needed
|
|
7
|
-
* container restart (drift) or serve a stale 304 (ETag), so we don't want the 32-bit ~77k boundary here
|
|
8
|
-
* (council finding). Output length isn't a contract (values are only compared for equality before/after).
|
|
9
|
-
*/
|
|
10
1
|
/** Max nesting stableStringify will hash. A real EffectiveConfig nests ~10-15 deep; this generous cap turns a
|
|
11
2
|
* pathologically deep OR cyclic value (e.g. a poisoned `model.extraBody` = z.unknown()) into a DETERMINISTIC
|
|
12
3
|
* throw instead of a `RangeError: Maximum call stack` that would crash every scoped-worker `/effective` poll
|
package/dist/hash.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* container restart (drift) or serve a stale 304 (ETag), so we don't want the 32-bit ~77k boundary here
|
|
8
8
|
* (council finding). Output length isn't a contract (values are only compared for equality before/after).
|
|
9
9
|
*/
|
|
10
|
+
import { UNINSPECTABLE, containerView, inspect, valueOfDescriptor } from "./own-key-read.js";
|
|
11
|
+
import { jsonShapeOf } from "./plain-json-object.js";
|
|
10
12
|
/** Max nesting stableStringify will hash. A real EffectiveConfig nests ~10-15 deep; this generous cap turns a
|
|
11
13
|
* pathologically deep OR cyclic value (e.g. a poisoned `model.extraBody` = z.unknown()) into a DETERMINISTIC
|
|
12
14
|
* throw instead of a `RangeError: Maximum call stack` that would crash every scoped-worker `/effective` poll
|
|
@@ -37,13 +39,68 @@ export function stableStringify(v, depth = 0) {
|
|
|
37
39
|
// crash every scoped-worker poll. Treat undefined like null for hashing.
|
|
38
40
|
if (v === undefined)
|
|
39
41
|
return "null";
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
// the shape is asked of the seat (total); a node whose members cannot be read is REFUSED loudly with the package's one sentence — a hash of "what could be read" would be a hash of something else.
|
|
43
|
+
// Every container is read through the package's ONE whole-container read (`containerView`, wide): its members and ONE descriptor each are fixed when they are listed (objects: own
|
|
44
|
+
// enumerable string keys; arrays: own indexes + `length`); a value only ever comes from such a descriptor — not from `[[Get]]` (a proxy's `get` / `has` traps are not asked), not
|
|
45
|
+
// through the prototype chain (a hole stays a hole), not from a method of the array (an own `map` is not called).
|
|
46
|
+
// VALUE ORDER = the hash's own order: object keys sorted, array indexes ascending; each member's value is taken from its listed descriptor right before its subtree is hashed,
|
|
47
|
+
// so getters run in the order the hash emits them (a value-first read would run them in insertion order and change the hash of a successful read).
|
|
48
|
+
// Every raw read happens inside `inspect`, the recursion outside it: a refusal raised by a child (depth, unreadable) is never re-labelled by its parent.
|
|
49
|
+
const shape = jsonShapeOf(v);
|
|
50
|
+
switch (shape) {
|
|
51
|
+
case "null":
|
|
52
|
+
case "primitive":
|
|
53
|
+
return JSON.stringify(v);
|
|
54
|
+
case "uninspectable":
|
|
55
|
+
throw new TypeError(`stableStringify: ${UNINSPECTABLE} — refusing to hash`);
|
|
56
|
+
case "array": {
|
|
57
|
+
// the length is the listed `length` value through `ToLength(ToNumber(·))`; an index that is not an own member is a hole (it hashes as nothing between two commas)
|
|
58
|
+
const view = readOrRefuse(() => containerView(v, true, "wide"));
|
|
59
|
+
const length = readOrRefuse(() => lengthOf(view.length));
|
|
60
|
+
const byIndex = new Map(view.members);
|
|
61
|
+
let out = "";
|
|
62
|
+
for (let i = 0; i < length; i++) {
|
|
63
|
+
const d = byIndex.get(String(i));
|
|
64
|
+
out += `${i === 0 ? "" : ","}${d === undefined ? "" : stableStringify(readOrRefuse(() => valueOfDescriptor(v, d)), depth + 1)}`;
|
|
65
|
+
}
|
|
66
|
+
return `[${out}]`;
|
|
67
|
+
}
|
|
68
|
+
case "plain":
|
|
69
|
+
case "non_plain_object": {
|
|
70
|
+
if (typeof v === "function")
|
|
71
|
+
return JSON.stringify(v); // not data (and never was hashed as an object)
|
|
72
|
+
// the package's one whole-container read (wide): the members — own ENUMERABLE string keys, `Object.keys`' set — and ONE descriptor each are fixed when the keys are listed
|
|
73
|
+
// (a later change to the object by a getter does not change what is hashed); keys sorted; each member's value comes from ITS listed descriptor right before its subtree is hashed
|
|
74
|
+
const members = readOrRefuse(() => containerView(v, false, "wide")).members.slice().sort(([a], [b]) => (a < b ? -1 : 1));
|
|
75
|
+
let out = "";
|
|
76
|
+
for (const [key, d] of members) {
|
|
77
|
+
const value = readOrRefuse(() => valueOfDescriptor(v, d));
|
|
78
|
+
out += `${out === "" ? "" : ","}${JSON.stringify(key)}:${stableStringify(value, depth + 1)}`;
|
|
79
|
+
}
|
|
80
|
+
return `{${out}}`;
|
|
81
|
+
}
|
|
82
|
+
default: {
|
|
83
|
+
const unhandled = shape;
|
|
84
|
+
throw new Error(`stableStringify: unhandled shape ${String(unhandled)}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** One raw read of a node's member (inside `inspect`); unreadable ⇒ the package's one refusal sentence. */
|
|
89
|
+
function readOrRefuse(read) {
|
|
90
|
+
const r = inspect(read);
|
|
91
|
+
if (!r.ok)
|
|
92
|
+
throw new TypeError(`stableStringify: ${r.sentence} — refusing to hash`);
|
|
93
|
+
return r.value;
|
|
94
|
+
}
|
|
95
|
+
/** An array's length from its listed `length` value: `ToLength(ToNumber(value))` — the unary plus is `ToNumber` (a BigInt / Symbol throws, an object is converted through
|
|
96
|
+
* `valueOf` / `toString`), not a number ⇒ 0, truncated — with the allocation limit of an array: a length beyond the largest array length (2^32 − 1 — only a proxy's descriptor
|
|
97
|
+
* trap can report one) throws before a single index is hashed (the caller refuses with the one sentence). */
|
|
98
|
+
function lengthOf(value) {
|
|
99
|
+
const n = +value;
|
|
100
|
+
const length = Number.isNaN(n) || n <= 0 ? 0 : Math.floor(n);
|
|
101
|
+
if (length > 2 ** 32 - 1)
|
|
102
|
+
throw new RangeError("Invalid array length");
|
|
103
|
+
return length;
|
|
47
104
|
}
|
|
48
105
|
const FNV64_OFFSET = 0xcbf29ce484222325n;
|
|
49
106
|
const FNV64_PRIME = 0x100000001b3n;
|
package/dist/hooks-config.d.ts
CHANGED
|
@@ -167,8 +167,8 @@ export declare const HOOKS_DROPPED_PIECE_CODES: readonly ["hooks-not-object", "u
|
|
|
167
167
|
export type HooksDroppedPieceCode = (typeof HOOKS_DROPPED_PIECE_CODES)[number];
|
|
168
168
|
/**
|
|
169
169
|
* Why the document was REJECTED (closed; each is "a guard may be in this piece"):
|
|
170
|
-
* · `guard-outside-hooks` — a guard sign outside `hooks` (top level, under another key, or the document itself matcher-shaped);
|
|
171
|
-
* · `hooks-is-matcher` — `hooks` is an array holding objects / arrays, a single matcher, or a single entry;
|
|
170
|
+
* · `guard-outside-hooks` — a guard sign outside `hooks` (top level, under another key, or the document itself matcher-shaped), or the document could not be read at all (path `[]`);
|
|
171
|
+
* · `hooks-is-matcher` — `hooks` is an array holding objects / arrays, a single matcher, or a single entry, or an object that is not a readable block (a class instance, a value that cannot be inspected) holding — or possibly holding — a guard sign;
|
|
172
172
|
* · `guard-under-unknown-event` — an unknown event name holding a guard sign;
|
|
173
173
|
* · `guard-event-not-array` — a guard event whose value is not an array (and not null), or a known event whose non-array value holds a sign;
|
|
174
174
|
* · `guard-matcher-invalid` — a bad matcher in a guard event, or a matcher holding a sign (in any event);
|