@sema-agent/settings-schema 6.0.0 → 6.1.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 +162 -0
- package/README.md +37 -2
- package/conformance/VERSION +1 -1
- package/conformance/cases/c01-entry-salvage-then-typo/expected.json +3 -0
- package/conformance/cases/c02-hosts-clamp-stacked/expected.json +3 -0
- package/conformance/cases/c23-mixed-validity-models/expected.json +3 -0
- package/conformance/cases/c40-s712-identity-only-what-reads-out/expected.json +166 -0
- package/conformance/cases/c40-s712-identity-only-what-reads-out/input.json +64 -0
- package/conformance/cases/c40-s712-identity-only-what-reads-out/meta.json +13 -0
- package/conformance/cases/cli01-model-name-nfc-vs-nfd/expected.json +8 -0
- package/conformance/cases/cli02-control-and-zero-width-chars/expected.json +8 -0
- package/conformance/cases/t04-secret-literal-never-in-warning-value/expected.json +4 -0
- package/dist/api/auth-bridge.d.ts +2 -2
- package/dist/center-credential.d.ts +116 -0
- package/dist/center-credential.js +149 -0
- package/dist/config-fns.d.ts +35 -3
- package/dist/config-fns.js +57 -8
- package/dist/conformance/types.d.ts +6 -1
- package/dist/conformance/warnings.js +9 -2
- package/dist/fleet.d.ts +98 -8
- package/dist/fleet.js +47 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.js +7 -1
- package/package.json +1 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 中心(config-center 产品)凭证的**文件形契约** —— S-698(design/325 §1)。
|
|
3
|
+
*
|
|
4
|
+
* 引擎向中心出站时用的凭证由部署声明(见 server `src/center-credential.ts`,单属主);声明成文件时
|
|
5
|
+
* (`SEMA_REGISTRY_TOKEN_FILE`),文件内容有两种**种类**,不是一种语义的两种拼法:
|
|
6
|
+
*
|
|
7
|
+
* · **裸行**(一个静态令牌,种类与今天相同):首行 = 静态 bearer,只收可打印 ASCII({@link TokenString} 的律)。它是用户 JWT 还是 worker
|
|
8
|
+
* 拉取令牌,由读方用 `looksLikeJwt`(`./api/auth-bridge`)自判 —— 本文件不重判种类。
|
|
9
|
+
* · **信封**(新):一个 JSON 对象,形 = {@link CenterCredentialEnvelope}。今只有一员 `kind:"user-session"`
|
|
10
|
+
* (人的登录会话:可轮换的访问令牌 + 单次有效的刷新令牌 + 到期时刻 + 签发它的中心根 URL)。worker 拉取
|
|
11
|
+
* 令牌**不进信封**:它不轮换、不属于人,保持裸行。
|
|
12
|
+
*
|
|
13
|
+
* 判别律(一条,{@link parseCenterCredentialFile} 是它的**唯一**实现):内容 `trim()` 后以 `{` 开头 ⇒ 只按
|
|
14
|
+
* 信封读,读不成 = `unreadable`(**不回落**成裸行 —— 一个 `{` 开头的裸 token 不存在,回落只会把一段坏 JSON
|
|
15
|
+
* 当 bearer 发出去);否则裸行;空 = `unreadable`。同一份内容只有一种读法(零双读)。
|
|
16
|
+
*
|
|
17
|
+
* 声明面(S-742,本文件尾段):两个声明键名 {@link CENTER_CREDENTIAL_ENV} 与二选一判官
|
|
18
|
+
* {@link judgeCenterCredentialDeclaration} —— 规则此前只活在 server,管理面 / 壳各自手抄键名。
|
|
19
|
+
*
|
|
20
|
+
* 纯契约:零 IO(读文件、写文件、续签都是消费方的事),browser-safe,与本包其余部分同一条纯度律。
|
|
21
|
+
*/
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
/** 信封 `kind` 的闭集今只有一员(`SEALED_BOX_ALG` 同式的字面常量:schema 的字面与读方的比较都引它)。 */
|
|
24
|
+
export declare const CENTER_CREDENTIAL_ENVELOPE_KIND: "user-session";
|
|
25
|
+
/**
|
|
26
|
+
* 中心凭证**信封**(`SEMA_REGISTRY_TOKEN_FILE` 指向的文件是一个 JSON 对象时的唯一形)。`.strict()`:
|
|
27
|
+
* 多一个键就不是这份契约(拼错的 `refresh_token` 被静默剥掉,续签就会拿着缺席的刷新令牌去撞中心)。
|
|
28
|
+
*
|
|
29
|
+
* · `accessToken` —— 出站 bearer(中心签的用户 JWT,形判 `looksLikeJwt`);读方拿它做身份分箱的方式与裸行
|
|
30
|
+
* `user-jwt` 逐字同。令牌形与裸行同一条律({@link TokenString})。
|
|
31
|
+
* · `refreshToken` —— 单次有效、严格轮换(`POST /api/v1/auth/token/refresh`,见 `./api/auth`);续签前**重读
|
|
32
|
+
* 文件**取当前值,不用内存旧值(另一个写者可能刚轮换过)。
|
|
33
|
+
* · `expiresAt` —— 访问令牌到期时刻(RFC 3339)。
|
|
34
|
+
* · `issuer` —— 签发它的中心的**基础 URL**(与读方 `configCenter.url` 同形,可带路径前缀):续签只向同源的中心发,
|
|
35
|
+
* 同不同源由读方比。URL 形判据与 `remoteExec` 的端点同一只 {@link NON_SECRET_URL}(http(s)、有 host、userinfo 与
|
|
36
|
+
* query / fragment 里不许夹带凭据)—— 本包不另写一份更窄的 URL 律。
|
|
37
|
+
*/
|
|
38
|
+
export declare const CenterCredentialEnvelope: z.ZodObject<{
|
|
39
|
+
kind: z.ZodLiteral<"user-session">;
|
|
40
|
+
accessToken: z.ZodEffects<z.ZodString, string, string>;
|
|
41
|
+
refreshToken: z.ZodString;
|
|
42
|
+
expiresAt: z.ZodString;
|
|
43
|
+
issuer: z.ZodEffects<z.ZodString, string, string>;
|
|
44
|
+
}, "strict", z.ZodTypeAny, {
|
|
45
|
+
kind: "user-session";
|
|
46
|
+
issuer: string;
|
|
47
|
+
accessToken: string;
|
|
48
|
+
refreshToken: string;
|
|
49
|
+
expiresAt: string;
|
|
50
|
+
}, {
|
|
51
|
+
kind: "user-session";
|
|
52
|
+
issuer: string;
|
|
53
|
+
accessToken: string;
|
|
54
|
+
refreshToken: string;
|
|
55
|
+
expiresAt: string;
|
|
56
|
+
}>;
|
|
57
|
+
export type CenterCredentialEnvelope = z.infer<typeof CenterCredentialEnvelope>;
|
|
58
|
+
/** {@link parseCenterCredentialFile} 的三态结果(判别键 `kind`)。`unreadable.reason` 只说**哪里**不对
|
|
59
|
+
* (信封字段名 / zod 问题码 / 未知键**个数**),从不回显文件内容 —— 文件里装的就是凭据。 */
|
|
60
|
+
export type CenterCredentialFile = {
|
|
61
|
+
kind: "bare";
|
|
62
|
+
bearer: string;
|
|
63
|
+
} | {
|
|
64
|
+
kind: "envelope";
|
|
65
|
+
envelope: CenterCredentialEnvelope;
|
|
66
|
+
} | {
|
|
67
|
+
kind: "unreadable";
|
|
68
|
+
reason: string;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* 凭证文件内容 → 三态({@link CenterCredentialFile})。判别律见模块头注,本函数是它的唯一实现:
|
|
72
|
+
*
|
|
73
|
+
* 1. `trim()`(含 BOM);空 ⇒ `unreadable("empty")`。
|
|
74
|
+
* 2. 以 `{` 开头 ⇒ 只按 {@link CenterCredentialEnvelope} 读:JSON 不成立或 schema 不过 ⇒ `unreadable`
|
|
75
|
+
* (**不回落**裸行)。
|
|
76
|
+
* 3. 否则 ⇒ 首行(第一个 CR 或 LF 之前,再剥一次空白)。它是令牌({@link TokenString} 的律)⇒ `bare`;
|
|
77
|
+
* 不是 ⇒ `unreadable`(放不进 Authorization 头的行不是 bearer)。多行文件只认首行。
|
|
78
|
+
*
|
|
79
|
+
* 纯函数、零 IO;读方负责读文件(以及文件不可读时自己的那条 `unreadable` 臂)。
|
|
80
|
+
*/
|
|
81
|
+
export declare function parseCenterCredentialFile(text: string): CenterCredentialFile;
|
|
82
|
+
/**
|
|
83
|
+
* 中心凭证的两个**声明键名**(部署 env)。一处定义、三方 import(server 读、管理面给 worker 渲染、壳给本机
|
|
84
|
+
* 引擎写)—— 键名是 wire 面,手抄的键名会静默漂。
|
|
85
|
+
*
|
|
86
|
+
* · `token`(`SEMA_REGISTRY_TOKEN`)—— 值即凭证(部署注入;读方在进程生命期内读一次)。
|
|
87
|
+
* · `tokenFile`(`SEMA_REGISTRY_TOKEN_FILE`)—— 凭证文件的路径,读方**每次取值都读文件**(轮换 / 换账号只需
|
|
88
|
+
* 改写文件,不重启);文件内容的读法见 {@link parseCenterCredentialFile}。
|
|
89
|
+
*/
|
|
90
|
+
export declare const CENTER_CREDENTIAL_ENV: {
|
|
91
|
+
readonly token: "SEMA_REGISTRY_TOKEN";
|
|
92
|
+
readonly tokenFile: "SEMA_REGISTRY_TOKEN_FILE";
|
|
93
|
+
};
|
|
94
|
+
/** {@link judgeCenterCredentialDeclaration} 的结果:凭证在哪里声明。`env` 形带**值** —— 它就是凭据,只在读方
|
|
95
|
+
* 自己的凭证模块里流转,不进日志。 */
|
|
96
|
+
export type CenterCredentialDeclaration = {
|
|
97
|
+
from: "env";
|
|
98
|
+
token: string;
|
|
99
|
+
} | {
|
|
100
|
+
from: "file";
|
|
101
|
+
path: string;
|
|
102
|
+
} | {
|
|
103
|
+
from: "absent";
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* 读两个声明键,判凭证在哪里声明 —— 二选一规则的**唯一**实现:
|
|
107
|
+
*
|
|
108
|
+
* · 空串 = 未设(`undefined` 同);其余一律算「设了」(不 trim —— 与规则上提前 server 的读法逐字同)。
|
|
109
|
+
* · 两个都设 ⇒ **throw**(读方拒启):两个写者写同一个语义面,哪个赢都是猜。拒句点名两个键、不回显值。
|
|
110
|
+
* · 只设 `tokenFile` ⇒ `{from:"file", path}`,`path` **原样**:相对路径按 cwd 解析是读方的事(纯契约不碰
|
|
111
|
+
* `node:path`,也不知道读方的 cwd)。
|
|
112
|
+
* · 只设 `token` ⇒ `{from:"env", token}`;都不设 ⇒ `{from:"absent"}`。
|
|
113
|
+
*
|
|
114
|
+
* 只读这两个键,别的 env 一概不看。
|
|
115
|
+
*/
|
|
116
|
+
export declare function judgeCenterCredentialDeclaration(env: Readonly<Record<string, string | undefined>>): CenterCredentialDeclaration;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 中心(config-center 产品)凭证的**文件形契约** —— S-698(design/325 §1)。
|
|
3
|
+
*
|
|
4
|
+
* 引擎向中心出站时用的凭证由部署声明(见 server `src/center-credential.ts`,单属主);声明成文件时
|
|
5
|
+
* (`SEMA_REGISTRY_TOKEN_FILE`),文件内容有两种**种类**,不是一种语义的两种拼法:
|
|
6
|
+
*
|
|
7
|
+
* · **裸行**(一个静态令牌,种类与今天相同):首行 = 静态 bearer,只收可打印 ASCII({@link TokenString} 的律)。它是用户 JWT 还是 worker
|
|
8
|
+
* 拉取令牌,由读方用 `looksLikeJwt`(`./api/auth-bridge`)自判 —— 本文件不重判种类。
|
|
9
|
+
* · **信封**(新):一个 JSON 对象,形 = {@link CenterCredentialEnvelope}。今只有一员 `kind:"user-session"`
|
|
10
|
+
* (人的登录会话:可轮换的访问令牌 + 单次有效的刷新令牌 + 到期时刻 + 签发它的中心根 URL)。worker 拉取
|
|
11
|
+
* 令牌**不进信封**:它不轮换、不属于人,保持裸行。
|
|
12
|
+
*
|
|
13
|
+
* 判别律(一条,{@link parseCenterCredentialFile} 是它的**唯一**实现):内容 `trim()` 后以 `{` 开头 ⇒ 只按
|
|
14
|
+
* 信封读,读不成 = `unreadable`(**不回落**成裸行 —— 一个 `{` 开头的裸 token 不存在,回落只会把一段坏 JSON
|
|
15
|
+
* 当 bearer 发出去);否则裸行;空 = `unreadable`。同一份内容只有一种读法(零双读)。
|
|
16
|
+
*
|
|
17
|
+
* 声明面(S-742,本文件尾段):两个声明键名 {@link CENTER_CREDENTIAL_ENV} 与二选一判官
|
|
18
|
+
* {@link judgeCenterCredentialDeclaration} —— 规则此前只活在 server,管理面 / 壳各自手抄键名。
|
|
19
|
+
*
|
|
20
|
+
* 纯契约:零 IO(读文件、写文件、续签都是消费方的事),browser-safe,与本包其余部分同一条纯度律。
|
|
21
|
+
*/
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
import { looksLikeJwt } from "./api/auth-bridge.js";
|
|
24
|
+
import { NON_SECRET_URL } from "./types.js";
|
|
25
|
+
/** 信封 `kind` 的闭集今只有一员(`SEALED_BOX_ALG` 同式的字面常量:schema 的字面与读方的比较都引它)。 */
|
|
26
|
+
export const CENTER_CREDENTIAL_ENVELOPE_KIND = "user-session";
|
|
27
|
+
/** RFC 3339 `date-time` 的**剖面**:zod 的 `datetime({offset:true})` 管日历有效性(月日范围、闰年、时分秒范围),
|
|
28
|
+
* 这里再补它放过的三处 —— 秒必备、数字偏移必须是 `±hh:mm`、偏移的时 00–23 / 分 00–59(zod 也收 `10:00Z`、
|
|
29
|
+
* `+0800` 与 `+99:99`,都不是 RFC 3339,后者 `Date.parse` 读成 NaN)。大写 `T` / `Z`、不收闰秒 `:60`:读方普遍用
|
|
30
|
+
* `Date.parse` 取时刻,这是那组形里它处处读得准的子集。 */
|
|
31
|
+
const RFC3339_DATE_TIME = z
|
|
32
|
+
.string()
|
|
33
|
+
.datetime({ offset: true })
|
|
34
|
+
.regex(/T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/, "must be an RFC 3339 date-time: seconds and a Z / ±hh:mm offset (hh 00-23, mm 00-59) are required");
|
|
35
|
+
/** 一枚**令牌串**的形 —— 裸行 bearer、信封里的 `accessToken` / `refreshToken` 共用这**一条**律:可打印 ASCII
|
|
36
|
+
* (0x20–0x7E),首尾是可见字符(0x21–0x7E)、中间可有空格(共享令牌可以是一句口令 —— 验修轮 N2:今天走得通的
|
|
37
|
+
* 这种令牌不许被本律拒掉)。控制字符、制表、非 ASCII、不可见字符一律不是令牌:判别器把它判 `unreadable`,而不是
|
|
38
|
+
* 发出去 —— 更不会把一段前面粘了不可见字符的信封 JSON 当 bearer 送出门(对抗轮 #7)。刻意比 RFC 6750 `b64token`
|
|
39
|
+
* 宽:部署手写的静态令牌可以带 `!` / `%` / 空格之类。**如实登记**:Latin-1 令牌(HTTP 头的 ByteString 收、中心也
|
|
40
|
+
* 逐字比得上)在这里判 `unreadable` —— 非 ASCII 与不可见字符(`\u0085` / `\u00AD`)分不开,取 fail-closed。 */
|
|
41
|
+
const TOKEN_CHARS = /^[\x21-\x7E](?:[\x20-\x7E]*[\x21-\x7E])?$/;
|
|
42
|
+
const TokenString = z.string().regex(TOKEN_CHARS, "must be a token: printable ASCII, no leading / trailing space, no control or non-ASCII characters");
|
|
43
|
+
/**
|
|
44
|
+
* 中心凭证**信封**(`SEMA_REGISTRY_TOKEN_FILE` 指向的文件是一个 JSON 对象时的唯一形)。`.strict()`:
|
|
45
|
+
* 多一个键就不是这份契约(拼错的 `refresh_token` 被静默剥掉,续签就会拿着缺席的刷新令牌去撞中心)。
|
|
46
|
+
*
|
|
47
|
+
* · `accessToken` —— 出站 bearer(中心签的用户 JWT,形判 `looksLikeJwt`);读方拿它做身份分箱的方式与裸行
|
|
48
|
+
* `user-jwt` 逐字同。令牌形与裸行同一条律({@link TokenString})。
|
|
49
|
+
* · `refreshToken` —— 单次有效、严格轮换(`POST /api/v1/auth/token/refresh`,见 `./api/auth`);续签前**重读
|
|
50
|
+
* 文件**取当前值,不用内存旧值(另一个写者可能刚轮换过)。
|
|
51
|
+
* · `expiresAt` —— 访问令牌到期时刻(RFC 3339)。
|
|
52
|
+
* · `issuer` —— 签发它的中心的**基础 URL**(与读方 `configCenter.url` 同形,可带路径前缀):续签只向同源的中心发,
|
|
53
|
+
* 同不同源由读方比。URL 形判据与 `remoteExec` 的端点同一只 {@link NON_SECRET_URL}(http(s)、有 host、userinfo 与
|
|
54
|
+
* query / fragment 里不许夹带凭据)—— 本包不另写一份更窄的 URL 律。
|
|
55
|
+
*/
|
|
56
|
+
export const CenterCredentialEnvelope = z
|
|
57
|
+
.object({
|
|
58
|
+
kind: z.literal(CENTER_CREDENTIAL_ENVELOPE_KIND),
|
|
59
|
+
accessToken: TokenString.refine(looksLikeJwt, "must be a JWT (three dot-separated segments) — a worker pull token never goes into an envelope"),
|
|
60
|
+
refreshToken: TokenString,
|
|
61
|
+
expiresAt: RFC3339_DATE_TIME,
|
|
62
|
+
issuer: NON_SECRET_URL,
|
|
63
|
+
})
|
|
64
|
+
.strict();
|
|
65
|
+
/** 信封自己的字段名(schema 的键 —— reason 里只许出现这几个名字,不许出现用户写的键)。 */
|
|
66
|
+
const ENVELOPE_FIELDS = new Set(Object.keys(CenterCredentialEnvelope.shape));
|
|
67
|
+
/** 一次 schema 失败 → 不含文件内容的一句话。 */
|
|
68
|
+
function envelopeRejection(error) {
|
|
69
|
+
const fields = new Set(); // 一个字段挂两道判据(datetime + 剖面正则)时只说一次
|
|
70
|
+
let unknownKeys = 0;
|
|
71
|
+
for (const issue of error.issues) {
|
|
72
|
+
if (issue.code === z.ZodIssueCode.unrecognized_keys) {
|
|
73
|
+
unknownKeys += issue.keys.length;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const head = issue.path[0];
|
|
77
|
+
const field = typeof head === "string" && ENVELOPE_FIELDS.has(head) ? head : "(envelope)";
|
|
78
|
+
fields.add(`${field} (${issue.code})`);
|
|
79
|
+
}
|
|
80
|
+
const parts = [...(fields.size > 0 ? [`fields rejected: ${[...fields].join(", ")}`] : []), ...(unknownKeys > 0 ? [`unknown keys: ${unknownKeys}`] : [])];
|
|
81
|
+
return `envelope does not match CenterCredentialEnvelope — ${parts.join("; ")}`;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* 凭证文件内容 → 三态({@link CenterCredentialFile})。判别律见模块头注,本函数是它的唯一实现:
|
|
85
|
+
*
|
|
86
|
+
* 1. `trim()`(含 BOM);空 ⇒ `unreadable("empty")`。
|
|
87
|
+
* 2. 以 `{` 开头 ⇒ 只按 {@link CenterCredentialEnvelope} 读:JSON 不成立或 schema 不过 ⇒ `unreadable`
|
|
88
|
+
* (**不回落**裸行)。
|
|
89
|
+
* 3. 否则 ⇒ 首行(第一个 CR 或 LF 之前,再剥一次空白)。它是令牌({@link TokenString} 的律)⇒ `bare`;
|
|
90
|
+
* 不是 ⇒ `unreadable`(放不进 Authorization 头的行不是 bearer)。多行文件只认首行。
|
|
91
|
+
*
|
|
92
|
+
* 纯函数、零 IO;读方负责读文件(以及文件不可读时自己的那条 `unreadable` 臂)。
|
|
93
|
+
*/
|
|
94
|
+
export function parseCenterCredentialFile(text) {
|
|
95
|
+
const trimmed = text.trim();
|
|
96
|
+
if (trimmed === "")
|
|
97
|
+
return { kind: "unreadable", reason: "empty" };
|
|
98
|
+
if (trimmed.startsWith("{")) {
|
|
99
|
+
let json;
|
|
100
|
+
try {
|
|
101
|
+
json = JSON.parse(trimmed);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// JSON.parse 的报错会引用出错位置附近的原文 —— 不转述它。
|
|
105
|
+
return { kind: "unreadable", reason: "envelope is not valid JSON" };
|
|
106
|
+
}
|
|
107
|
+
const parsed = CenterCredentialEnvelope.safeParse(json);
|
|
108
|
+
return parsed.success ? { kind: "envelope", envelope: parsed.data } : { kind: "unreadable", reason: envelopeRejection(parsed.error) };
|
|
109
|
+
}
|
|
110
|
+
const firstLine = trimmed.split(/[\r\n]/, 1)[0].trim(); // 行尾 = 第一个 CR 或 LF(CRLF / LF / 孤 CR 同判)
|
|
111
|
+
if (!TOKEN_CHARS.test(firstLine))
|
|
112
|
+
return { kind: "unreadable", reason: "bare line is not a token — printable ASCII only (no control or non-ASCII characters)" };
|
|
113
|
+
return { kind: "bare", bearer: firstLine };
|
|
114
|
+
}
|
|
115
|
+
// ── S-742:凭证的两个声明键 + 二选一判官 ─────────────────────────────────────────────────────────────
|
|
116
|
+
/**
|
|
117
|
+
* 中心凭证的两个**声明键名**(部署 env)。一处定义、三方 import(server 读、管理面给 worker 渲染、壳给本机
|
|
118
|
+
* 引擎写)—— 键名是 wire 面,手抄的键名会静默漂。
|
|
119
|
+
*
|
|
120
|
+
* · `token`(`SEMA_REGISTRY_TOKEN`)—— 值即凭证(部署注入;读方在进程生命期内读一次)。
|
|
121
|
+
* · `tokenFile`(`SEMA_REGISTRY_TOKEN_FILE`)—— 凭证文件的路径,读方**每次取值都读文件**(轮换 / 换账号只需
|
|
122
|
+
* 改写文件,不重启);文件内容的读法见 {@link parseCenterCredentialFile}。
|
|
123
|
+
*/
|
|
124
|
+
export const CENTER_CREDENTIAL_ENV = { token: "SEMA_REGISTRY_TOKEN", tokenFile: "SEMA_REGISTRY_TOKEN_FILE" };
|
|
125
|
+
/**
|
|
126
|
+
* 读两个声明键,判凭证在哪里声明 —— 二选一规则的**唯一**实现:
|
|
127
|
+
*
|
|
128
|
+
* · 空串 = 未设(`undefined` 同);其余一律算「设了」(不 trim —— 与规则上提前 server 的读法逐字同)。
|
|
129
|
+
* · 两个都设 ⇒ **throw**(读方拒启):两个写者写同一个语义面,哪个赢都是猜。拒句点名两个键、不回显值。
|
|
130
|
+
* · 只设 `tokenFile` ⇒ `{from:"file", path}`,`path` **原样**:相对路径按 cwd 解析是读方的事(纯契约不碰
|
|
131
|
+
* `node:path`,也不知道读方的 cwd)。
|
|
132
|
+
* · 只设 `token` ⇒ `{from:"env", token}`;都不设 ⇒ `{from:"absent"}`。
|
|
133
|
+
*
|
|
134
|
+
* 只读这两个键,别的 env 一概不看。
|
|
135
|
+
*/
|
|
136
|
+
export function judgeCenterCredentialDeclaration(env) {
|
|
137
|
+
const { token: tokenKey, tokenFile: fileKey } = CENTER_CREDENTIAL_ENV;
|
|
138
|
+
const token = env[tokenKey] ?? "";
|
|
139
|
+
const file = env[fileKey] ?? "";
|
|
140
|
+
if (token !== "" && file !== "") {
|
|
141
|
+
throw new Error(`${tokenKey} and ${fileKey} are both set — pick exactly one: ${tokenKey} carries the credential value, ` +
|
|
142
|
+
`${fileKey} names a file re-read on every request (rotation without restart). Two writers on one credential is a guess, not a config.`);
|
|
143
|
+
}
|
|
144
|
+
if (file !== "")
|
|
145
|
+
return { from: "file", path: file };
|
|
146
|
+
if (token !== "")
|
|
147
|
+
return { from: "env", token };
|
|
148
|
+
return { from: "absent" };
|
|
149
|
+
}
|
package/dist/config-fns.d.ts
CHANGED
|
@@ -19,7 +19,17 @@ import { type DomainConfig, type DomainName, type EffectiveConfig, type Effectiv
|
|
|
19
19
|
* `received 'sk-live-…'`), so a secret pasted into the WRONG field would leak via the error string. Redacts
|
|
20
20
|
* space-spanning auth credentials (`Bearer <token>`) FIRST (the token pass can't see across the space), then
|
|
21
21
|
* any secret-shaped run (`%` kept in the run so a percent-encoded secret stays whole for the percent-aware
|
|
22
|
-
* containsSecretToken). invariant ①: a secret VALUE never rides an error. R13/R14.
|
|
22
|
+
* containsSecretToken). invariant ①: a secret VALUE never rides an error. R13/R14.
|
|
23
|
+
*
|
|
24
|
+
* **Output invariant** (S-712 verification pass N1 + codex review [high] ×2): a string holding a private-key PEM
|
|
25
|
+
* header ANYWHERE ({@link PRIVATE_KEY_PEM_HEADER}) is withheld WHOLE, and no result trips `containsSecretToken`.
|
|
26
|
+
* The PEM header is judged on the ORIGINAL text, before any replacement: the token pass can rewrite the header
|
|
27
|
+
* itself away (`sk-note:-----BEGIN` is one token-charset run that starts with `sk-`, so it becomes
|
|
28
|
+
* `[redacted-secret]` and the key body after it would ride out untouched — codex verification round). A PEM header
|
|
29
|
+
* carries spaces, so the targeted passes can never isolate it, and the detector only sees it at the very start of
|
|
30
|
+
* a string. Every warning face that goes through here (coordinates, error text, entry identity) inherits the same
|
|
31
|
+
* guarantee. The validation-side detector `containsSecretToken` is deliberately NOT widened here: making it reject
|
|
32
|
+
* a mid-string PEM would tighten every field it validates (a major). */
|
|
23
33
|
export declare function redactSecrets(text: string): string;
|
|
24
34
|
/** Sanitize a ZodError IN PLACE so no surfaced error (message / `received` / nested `unionErrors`) echoes a
|
|
25
35
|
* pasted secret. RECURSES into `unionErrors` (a `z.union` failure nests a ZodError per member — e.g. RoleTarget
|
|
@@ -151,11 +161,30 @@ export type ConfigPath = readonly (string | number)[];
|
|
|
151
161
|
* **刻意不提供 parse 反函数**:2.1.0 起没有任何一处把坐标读回来(回写走 `keptIndex` 那本账),给它
|
|
152
162
|
* 一个反函数就是给下一个人一条「在串上做事」的路。 */
|
|
153
163
|
export declare function formatConfigPath(p: ConfigPath): string;
|
|
164
|
+
/** 一条被丢掉的条目的**身份**(S-712):只从被丢条目**原文**里取,三键各自可缺。
|
|
165
|
+
*
|
|
166
|
+
* 律(一条,不分域):`name` / `id` 取**字符串**形、`enabled` 取**布尔**形,类型不对 ⇒ 不带该键(不猜、
|
|
167
|
+
* 不 `String()` 转换);只认**数据属性**(不触发访问器);{@link redactSecrets} 会改动的串 ⇒ 不带该键 ——
|
|
168
|
+
* 与坐标段同一只脱敏判据:坐标段必须在场,所以打码出门;身份是可缺的,打过码的串不再是任何条目的身份,
|
|
169
|
+
* 所以是**不带**而不是打码。三键都读不出 ⇒ 整个身份缺席(不铸空对象)。
|
|
170
|
+
*
|
|
171
|
+
* **缺席 = 读不出**(不是字符串,或是字符串但不能原样出门),两种原因刻意不分:对消费方的正确处置相同
|
|
172
|
+
* (身份未知 ⇒ 按未知处置,例如 fail-closed),而一个「被扣下」的标记本身就在说「这里有一段像凭据的东西」。
|
|
173
|
+
*
|
|
174
|
+
* 为什么要带:条目被丢之后它的名字从目录里消失,只给坐标的话,消费方要知道「丢的是谁」只能拿坐标回读
|
|
175
|
+
* 原文档 —— 而它手里那份原文档未必是这一次读到的那份。铸告警的这一层判的就是那条原文,在这里取一次、
|
|
176
|
+
* 随告警走,是唯一不会读错版本的地方。停用条目(`enabled:false`)照样带身份:判不判墓碑是消费方的事。 */
|
|
177
|
+
export interface DroppedEntryIdentity {
|
|
178
|
+
name?: string;
|
|
179
|
+
id?: string;
|
|
180
|
+
enabled?: boolean;
|
|
181
|
+
}
|
|
154
182
|
/** 一条被丢掉的条目:`path` = 段坐标({@link ConfigPath};`["models", 3]` / `["roles", "summarize"]`),
|
|
155
|
-
* `error`
|
|
183
|
+
* `error` 已脱敏,`identity`(S-712)见 {@link DroppedEntryIdentity}(读不出 ⇒ 键缺席)。 */
|
|
156
184
|
export interface DroppedEntry {
|
|
157
185
|
path: ConfigPath;
|
|
158
186
|
error: unknown;
|
|
187
|
+
identity?: DroppedEntryIdentity;
|
|
159
188
|
}
|
|
160
189
|
/** 抢救造成的**下标位移表**:数组字段名 → 该字段压缩后每个位置的**原**下标(`kept[j]` = 压缩后第 j 条
|
|
161
190
|
* 在原文档里的下标)。纯数据,无行为。
|
|
@@ -282,12 +311,15 @@ export type EffectiveReadWarning = {
|
|
|
282
311
|
/** 一条**条目**没通过它自己的 entry schema,被单独丢掉了(域内其余条目照常生效)。`path` = 段坐标
|
|
283
312
|
* (`["models",3]` / `["roles","summarize"]`),与 `unknown-keys-dropped` 的坐标形同一套。逐条一发 —— 载荷是
|
|
284
313
|
* O(域里的条目数),与被读文档同阶,不放大,故不设条数上限(有上限的是**单条**告警里的键列表)。
|
|
285
|
-
* 2.0.0 起;只可能来自 {@link DOMAIN_READ_FALLBACK} 判 `"default"` 的域,gate 域坏形仍然 throw。
|
|
314
|
+
* 2.0.0 起;只可能来自 {@link DOMAIN_READ_FALLBACK} 判 `"default"` 的域,gate 域坏形仍然 throw。
|
|
315
|
+
* **S-712**(additive):`identity` = 被丢条目原文里读得出的身份({@link DroppedEntryIdentity};
|
|
316
|
+
* 读不出 ⇒ 键缺席),消费方据此知道「丢的是谁」而不必拿坐标回读原文档。 */
|
|
286
317
|
| {
|
|
287
318
|
domain: DomainName;
|
|
288
319
|
kind: "entry-dropped";
|
|
289
320
|
path: ConfigPath;
|
|
290
321
|
error: unknown;
|
|
322
|
+
identity?: DroppedEntryIdentity;
|
|
291
323
|
}
|
|
292
324
|
/** `config.d/` 里存在、但本地读取集**永远不会读**的文件(名字不是域,或是域但不便携)。 */
|
|
293
325
|
| {
|
package/dist/config-fns.js
CHANGED
|
@@ -16,16 +16,27 @@
|
|
|
16
16
|
// zod is a VALUE import here (not just a type): {@link salvageEntries} introspects a domain schema to find
|
|
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
|
-
import { DOMAIN_SCHEMAS, GOVERNANCE_MIRROR_KEYS, HOSTS_ARRAY_MAX, HOST_NAME_MAX, InfraCostRates, LimitsConfig, SkillsManifestSchema, containsSecretToken } from "./types.js";
|
|
19
|
+
import { DOMAIN_SCHEMAS, GOVERNANCE_MIRROR_KEYS, HOSTS_ARRAY_MAX, HOST_NAME_MAX, InfraCostRates, LimitsConfig, PRIVATE_KEY_PEM_HEADER, SkillsManifestSchema, containsSecretToken } from "./types.js";
|
|
20
20
|
/** Redact secret-shaped tokens from a validation MESSAGE — a zod error echoes the offending value (e.g.
|
|
21
21
|
* `received 'sk-live-…'`), so a secret pasted into the WRONG field would leak via the error string. Redacts
|
|
22
22
|
* space-spanning auth credentials (`Bearer <token>`) FIRST (the token pass can't see across the space), then
|
|
23
23
|
* any secret-shaped run (`%` kept in the run so a percent-encoded secret stays whole for the percent-aware
|
|
24
|
-
* containsSecretToken). invariant ①: a secret VALUE never rides an error. R13/R14.
|
|
24
|
+
* containsSecretToken). invariant ①: a secret VALUE never rides an error. R13/R14.
|
|
25
|
+
*
|
|
26
|
+
* **Output invariant** (S-712 verification pass N1 + codex review [high] ×2): a string holding a private-key PEM
|
|
27
|
+
* header ANYWHERE ({@link PRIVATE_KEY_PEM_HEADER}) is withheld WHOLE, and no result trips `containsSecretToken`.
|
|
28
|
+
* The PEM header is judged on the ORIGINAL text, before any replacement: the token pass can rewrite the header
|
|
29
|
+
* itself away (`sk-note:-----BEGIN` is one token-charset run that starts with `sk-`, so it becomes
|
|
30
|
+
* `[redacted-secret]` and the key body after it would ride out untouched — codex verification round). A PEM header
|
|
31
|
+
* carries spaces, so the targeted passes can never isolate it, and the detector only sees it at the very start of
|
|
32
|
+
* a string. Every warning face that goes through here (coordinates, error text, entry identity) inherits the same
|
|
33
|
+
* guarantee. The validation-side detector `containsSecretToken` is deliberately NOT widened here: making it reject
|
|
34
|
+
* a mid-string PEM would tighten every field it validates (a major). */
|
|
25
35
|
export function redactSecrets(text) {
|
|
26
|
-
|
|
36
|
+
const targeted = text
|
|
27
37
|
.replace(/\b(bearer|basic)(\s+)\S+/gi, "$1$2[redacted-secret]")
|
|
28
38
|
.replace(/[A-Za-z0-9_./:+=%-]{6,}/g, (tok) => (containsSecretToken(tok) ? "[redacted-secret]" : tok));
|
|
39
|
+
return PRIVATE_KEY_PEM_HEADER.test(text) || containsSecretToken(targeted) ? "[redacted-secret]" : targeted;
|
|
29
40
|
}
|
|
30
41
|
/** Sanitize a ZodError IN PLACE so no surfaced error (message / `received` / nested `unionErrors`) echoes a
|
|
31
42
|
* pasted secret. RECURSES into `unionErrors` (a `z.union` failure nests a ZodError per member — e.g. RoleTarget
|
|
@@ -173,6 +184,44 @@ const PLAIN_PATH_SEGMENT = /^(?!(?:0|[1-9]\d*)$)[^."\u0000-\u001f]+$/;
|
|
|
173
184
|
export function formatConfigPath(p) {
|
|
174
185
|
return p.map((seg) => (typeof seg === "number" ? String(seg) : PLAIN_PATH_SEGMENT.test(seg) ? seg : JSON.stringify(seg))).join(".");
|
|
175
186
|
}
|
|
187
|
+
/** 身份串:字符串,且 {@link redactSecrets} 原样放行 —— 与坐标段脱敏、`error` 文案脱敏({@link sanitizeZodError})
|
|
188
|
+
* **同一只**判据:告警面上任何一处用户写的串,要么过这一只脱敏器出门,要么(可缺的身份)不出门。两只判据
|
|
189
|
+
* 会在同一条告警里给出两种答案(对抗轮 #2 真复现:`Bearer` 后接 NBSP 或短令牌时,旧判据放行、坐标却打码)。 */
|
|
190
|
+
const isIdentityString = (v) => typeof v === "string" && redactSecrets(v) === v;
|
|
191
|
+
/** 一条被丢条目原文的身份({@link DroppedEntryIdentity} 的**唯一**读法)。只看纯 JSON 对象的**自有数据
|
|
192
|
+
* 属性**;任何反射/陷阱抛错 ⇒ 当作读不出(身份是诊断面的附件,绝不许把抢救拖下水)。 */
|
|
193
|
+
function readEntryIdentity(entry) {
|
|
194
|
+
try {
|
|
195
|
+
if (!isPlainJsonObject(entry))
|
|
196
|
+
return undefined;
|
|
197
|
+
const own = (k) => {
|
|
198
|
+
const d = Object.getOwnPropertyDescriptor(entry, k);
|
|
199
|
+
return d !== undefined && "value" in d ? d.value : undefined;
|
|
200
|
+
};
|
|
201
|
+
const name = own("name");
|
|
202
|
+
const id = own("id");
|
|
203
|
+
const enabled = own("enabled");
|
|
204
|
+
const identity = {
|
|
205
|
+
...(isIdentityString(name) ? { name } : {}),
|
|
206
|
+
...(isIdentityString(id) ? { id } : {}),
|
|
207
|
+
...(typeof enabled === "boolean" ? { enabled } : {}),
|
|
208
|
+
};
|
|
209
|
+
return Object.keys(identity).length > 0 ? identity : undefined;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** 一条记账行 → 它的 `entry-dropped` 告警。域面({@link buildEffective})与 wire 面(skills manifest)共用
|
|
216
|
+
* 这**一只** —— 告警形只在这里拼,新字段不会在一条腿上漏铸。 */
|
|
217
|
+
function entryDroppedWarning(domain, d) {
|
|
218
|
+
return d.identity === undefined ? { domain, kind: "entry-dropped", path: d.path, error: d.error } : { domain, kind: "entry-dropped", path: d.path, error: d.error, identity: d.identity };
|
|
219
|
+
}
|
|
220
|
+
/** 一条被丢条目的记账行 —— `salvageEntries` 两种集合形(数组 / Record)共用的**唯一**铸点。 */
|
|
221
|
+
function droppedEntry(path, error, entry) {
|
|
222
|
+
const identity = readEntryIdentity(entry);
|
|
223
|
+
return identity === undefined ? { path, error } : { path, error, identity };
|
|
224
|
+
}
|
|
176
225
|
/** 一次「什么都没丢」的抢救的位移表(冻结的共享空表 —— {@link rebaseWarningPaths} 对它恒等)。 */
|
|
177
226
|
const NO_ENTRIES_DROPPED = Object.freeze({});
|
|
178
227
|
/** 一条条目的 parse —— 坏就返回它的(脱敏)错误。恶意 getter/Proxy 在遍历中抛错时**绝不**外泄其文案
|
|
@@ -250,7 +299,7 @@ function salvageEntriesUnsafe(schema, raw) {
|
|
|
250
299
|
keptFrom.push(i); // 压缩后第 keptFrom.length-1 条 = 原文档第 i 条
|
|
251
300
|
return true;
|
|
252
301
|
}
|
|
253
|
-
bad.push(
|
|
302
|
+
bad.push(droppedEntry([field, i], error, entry));
|
|
254
303
|
return false;
|
|
255
304
|
});
|
|
256
305
|
// 零幸存者 ⇒ 没有要抢救的东西(见头注:绝不凭空造出一个空集合)。
|
|
@@ -270,7 +319,7 @@ function salvageEntriesUnsafe(schema, raw) {
|
|
|
270
319
|
if (error === undefined)
|
|
271
320
|
kept[key] = entry;
|
|
272
321
|
else
|
|
273
|
-
bad.push(
|
|
322
|
+
bad.push(droppedEntry([field, redactSecrets(key)], error, entry)); // 键是用户写的,可能就是一条粘错位置的凭据
|
|
274
323
|
}
|
|
275
324
|
if (bad.length > 0 && Object.keys(kept).length > 0) {
|
|
276
325
|
dropped.push(...bad);
|
|
@@ -362,7 +411,7 @@ export function projectWarning(w) {
|
|
|
362
411
|
case "legacy-governance-lifted":
|
|
363
412
|
return { kind: w.kind, domain: w.domain, keys: w.keys };
|
|
364
413
|
case "entry-dropped":
|
|
365
|
-
return { kind: w.kind, domain: w.domain, path: w.path };
|
|
414
|
+
return { kind: w.kind, domain: w.domain, path: w.path, ...(w.identity === undefined ? {} : { identity: w.identity }) };
|
|
366
415
|
case "unread-config-file":
|
|
367
416
|
return { kind: w.kind, file: w.file, ...(w.domain === undefined ? {} : { domain: w.domain }), why: w.why };
|
|
368
417
|
default: {
|
|
@@ -764,7 +813,7 @@ export function buildEffective(version, getRaw, updatedAt, onWarning) {
|
|
|
764
813
|
// 不过本层自己的表,直接交给出口(出口会补上更外层的换算);`buffered` 里的是对**本层压缩后**
|
|
765
814
|
// 文档的诊断,是从下方上来的 ⇒ 先过本层的表,再交给出口。scan 函数一字不动。
|
|
766
815
|
for (const d of salvage.dropped)
|
|
767
|
-
emit?.(
|
|
816
|
+
emit?.(entryDroppedWarning(domain, d));
|
|
768
817
|
for (const w of buffered)
|
|
769
818
|
emit?.(rebaseWarningPaths(w, salvage.keptIndex));
|
|
770
819
|
return parsed;
|
|
@@ -875,7 +924,7 @@ export function readEffectiveWire(raw, onWarning) {
|
|
|
875
924
|
const retry = salvage.dropped.length > 0 ? SkillsManifestSchema.safeParse(salvage.raw) : undefined;
|
|
876
925
|
if (retry?.success) {
|
|
877
926
|
for (const d of salvage.dropped)
|
|
878
|
-
onWarning?.(
|
|
927
|
+
onWarning?.(entryDroppedWarning("skills", d));
|
|
879
928
|
skills = retry.data;
|
|
880
929
|
}
|
|
881
930
|
else {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* 规范化(canonical JSON、告警投影、坐标深相等)全部在 kit 内**单铸** —— 消费方零复述,因为一份被各自
|
|
10
10
|
* 复述的规范化就是第二份真源,而两份真源的分歧正是本门要抓的那类病。
|
|
11
11
|
*/
|
|
12
|
-
import type { EffectiveReadWarning } from "../config-fns.js";
|
|
12
|
+
import type { EffectiveReadWarning, WarningProjection } from "../config-fns.js";
|
|
13
13
|
/** 三条读腿的名字。**闭集** —— 新开一条腿要同时在语料派生表(`derive.ts` 的 `DERIVATION_TRANSFORMS`)里表态。
|
|
14
14
|
*
|
|
15
15
|
* · `domains`:各域**原始文档**(center 侧存储形)→ effective。契约仓的 `buildEffective`。
|
|
@@ -192,6 +192,11 @@ export interface ConformanceNormalizer {
|
|
|
192
192
|
why: string;
|
|
193
193
|
/** 把一条坐标归一到共同空间。有损归一器在这里把段拼成串。 */
|
|
194
194
|
path(p: readonly (string | number)[]): unknown;
|
|
195
|
+
/** 把一条**告警投影**归一到共同空间(可选;先于 {@link ConformanceNormalizer.path} 作用,期望与实际两侧同过)。
|
|
196
|
+
* 用途:剥掉旧版**铸不出**的 additive 字段(S-712 的 `entry-dropped.identity`)—— 旧版没有这个字段不是读法
|
|
197
|
+
* 分歧,不许被记成「旧版行为红」。剥掉的字段同样是有损的一部分,`why` 里写明。release 模式禁归一器,故
|
|
198
|
+
* 这只钩子只作用于 historical 读数。 */
|
|
199
|
+
warning?(p: WarningProjection): WarningProjection;
|
|
195
200
|
}
|
|
196
201
|
/**
|
|
197
202
|
* 一格结局。`pass` / `expected-rejected` / `diff-ledgered` 不阻塞,`diff` / `harness-error` 红,
|
|
@@ -51,13 +51,20 @@ export function normalizeProjection(p, f) {
|
|
|
51
51
|
}
|
|
52
52
|
/** 恒等坐标映射(无归一器时的那一只)。 */
|
|
53
53
|
const identityPath = (path) => path;
|
|
54
|
+
/** 归一器对一条投影的**整体**作用:先过投影级钩子({@link ConformanceNormalizer.warning},可缺),再做坐标映射 +
|
|
55
|
+
* 集合字段按袋。比对键与报告值共用这一只,两处不会各归一各的。 */
|
|
56
|
+
function normalizedProjection(p, normalizer) {
|
|
57
|
+
if (normalizer === undefined)
|
|
58
|
+
return normalizeProjection(p, identityPath);
|
|
59
|
+
return normalizeProjection(normalizer.warning === undefined ? p : normalizer.warning(p), normalizer.path);
|
|
60
|
+
}
|
|
54
61
|
/** 一条投影的比对键。 */
|
|
55
62
|
export function projectionKey(p, normalizer) {
|
|
56
|
-
return stableKey(
|
|
63
|
+
return stableKey(normalizedProjection(p, normalizer));
|
|
57
64
|
}
|
|
58
65
|
/** 一条投影的规范形(进报告的 `missing`/`extra` 用,人读)。 */
|
|
59
66
|
export function projectionValue(p, normalizer) {
|
|
60
|
-
return canonicalize(
|
|
67
|
+
return canonicalize(normalizedProjection(p, normalizer));
|
|
61
68
|
}
|
|
62
69
|
/**
|
|
63
70
|
* 两个投影**多重集**的差。重数保留:期望两条、实际一条 ⇒ `missing` 里留一条。
|