@sema-agent/settings-schema 1.0.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 +728 -0
- package/LICENSE +103 -0
- package/README.md +129 -0
- package/dist/api/auth-bridge.d.ts +331 -0
- package/dist/api/auth-bridge.js +210 -0
- package/dist/api/auth.d.ts +216 -0
- package/dist/api/auth.js +138 -0
- package/dist/api/scopes.d.ts +344 -0
- package/dist/api/scopes.js +222 -0
- package/dist/api/wire.d.ts +60 -0
- package/dist/api/wire.js +89 -0
- package/dist/bundle.d.ts +13 -0
- package/dist/bundle.js +67 -0
- package/dist/config-fns.d.ts +318 -0
- package/dist/config-fns.js +472 -0
- package/dist/cross-domain.d.ts +34 -0
- package/dist/cross-domain.js +118 -0
- package/dist/file-edit.d.ts +36 -0
- package/dist/file-edit.js +125 -0
- package/dist/file-store.d.ts +89 -0
- package/dist/file-store.js +238 -0
- package/dist/fleet.d.ts +498 -0
- package/dist/fleet.js +317 -0
- package/dist/hash.d.ts +32 -0
- package/dist/hash.js +59 -0
- package/dist/hooks.d.ts +5477 -0
- package/dist/hooks.js +627 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +26 -0
- package/dist/local-load.d.ts +42 -0
- package/dist/local-load.js +172 -0
- package/dist/migrate.d.ts +255 -0
- package/dist/migrate.js +542 -0
- package/dist/node.d.ts +11 -0
- package/dist/node.js +11 -0
- package/dist/reader.d.ts +18 -0
- package/dist/reader.js +1 -0
- package/dist/remote-exec.d.ts +274 -0
- package/dist/remote-exec.js +182 -0
- package/dist/resolve-roster.d.ts +28 -0
- package/dist/resolve-roster.js +108 -0
- package/dist/safety-merge-spec.d.ts +327 -0
- package/dist/safety-merge-spec.js +70 -0
- package/dist/scheduler-store-node.d.ts +72 -0
- package/dist/scheduler-store-node.js +119 -0
- package/dist/scheduler-store.d.ts +67 -0
- package/dist/scheduler-store.js +89 -0
- package/dist/secret-refs.d.ts +33 -0
- package/dist/secret-refs.js +48 -0
- package/dist/sha256.d.ts +16 -0
- package/dist/sha256.js +114 -0
- package/dist/skills-manifest.d.ts +12 -0
- package/dist/skills-manifest.js +54 -0
- package/dist/types.d.ts +13560 -0
- package/dist/types.js +2118 -0
- package/package.json +138 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import { RemoteExecSpec } from "./remote-exec.js";
|
|
3
|
+
import { type DomainConfig, type DomainName, type EffectiveConfig } from "./types.js";
|
|
4
|
+
export type ValidateResult<T> = {
|
|
5
|
+
ok: true;
|
|
6
|
+
value: T;
|
|
7
|
+
} | {
|
|
8
|
+
ok: false;
|
|
9
|
+
issues: z.ZodIssue[];
|
|
10
|
+
};
|
|
11
|
+
/** VALIDATE a domain payload against its zod schema WITHOUT writing — the editor's "is this form valid?" check.
|
|
12
|
+
* Returns the parsed+defaulted value or the zod issues. Identical rules to the server's parseDomain. Throws
|
|
13
|
+
* (clear message) if `domain` is not a real domain name (a misuse, not invalid user data). */
|
|
14
|
+
export declare function validateDomain<K extends DomainName>(domain: K, raw: unknown): ValidateResult<DomainConfig[K]>;
|
|
15
|
+
/** Same, for the remote-exec contract (a standalone contract, not a DOMAIN_SCHEMAS member). */
|
|
16
|
+
export declare function validateRemoteExec(raw: unknown): ValidateResult<RemoteExecSpec>;
|
|
17
|
+
/** Absolute path to a domain's JSON file under a config root (mirrors FileConfigStore.domainPath). Guards the
|
|
18
|
+
* name (no `../../secret` traversal even if a caller bypasses the DomainName type). */
|
|
19
|
+
export declare function domainFilePath(root: string, domain: DomainName): string;
|
|
20
|
+
/** WRITE a domain file ATOMICALLY (write a tmp in the same dir → rename) so a crash never leaves a half-written
|
|
21
|
+
* config.d/<domain>.json. VALIDATES first (throws on invalid — never persists garbage); persists the parsed +
|
|
22
|
+
* defaulted value so the file is canonical. */
|
|
23
|
+
export declare function writeDomainFile<K extends DomainName>(root: string, domain: K, value: unknown): Promise<DomainConfig[K]>;
|
|
24
|
+
export interface RequiredEnvName {
|
|
25
|
+
envName: string;
|
|
26
|
+
refs: {
|
|
27
|
+
domain: string;
|
|
28
|
+
detail: string;
|
|
29
|
+
}[];
|
|
30
|
+
/** true if this env-NAME resolves in the supplied environment (.env merged over process.env). */
|
|
31
|
+
present: boolean;
|
|
32
|
+
}
|
|
33
|
+
/** LIST every env-NAME the current local config requires (across models/workers/mcp + the
|
|
34
|
+
* remote-exec backend), marking which are MISSING from the resolved env — the TOC "you still need to set
|
|
35
|
+
* these secrets" panel. Reuses collectEnvRefs for the domain side, then appends the remoteExec secret refs. */
|
|
36
|
+
export declare function listRequiredEnvNames(eff: EffectiveConfig, remoteExec: RemoteExecSpec | undefined, resolvedEnv: Record<string, string | undefined>): RequiredEnvName[];
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOC editor helpers — the read/write/VALIDATE surface the desktop/CLI builds its OWN config UX on top of
|
|
3
|
+
* (Clay's decision: only the LIB is shared, the TOC builds its own UI). These expose write/validate, not just
|
|
4
|
+
* read, so a single-machine consumer never needs the server's routes.
|
|
5
|
+
*
|
|
6
|
+
* Validation uses the SAME zod schemas as the server's parseDomain → identical rules on both sides.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { collectEnvRefs } from "./secret-refs.js";
|
|
11
|
+
import { RemoteExecSpec } from "./remote-exec.js";
|
|
12
|
+
import { parseDomain, sanitizeZodError } from "./config-fns.js";
|
|
13
|
+
import { DOMAIN_SCHEMAS, isDomainName } from "./types.js";
|
|
14
|
+
/** Throw a CLEAR error for a non-domain key instead of the cryptic `undefined.parse(...)` you'd get from
|
|
15
|
+
* `DOMAIN_SCHEMAS[badKey]`. The classic trip-wire: a consumer scans `config.d/*.json` and feeds every file
|
|
16
|
+
* (incl. the non-domain `remote-exec.json`) into writeBundle/writeDomainFile. `remote-exec` is the exec
|
|
17
|
+
* backend, NOT a domain — write it via writeRemoteExec. Filter arbitrary keys with `isDomainName` first. */
|
|
18
|
+
function assertDomainName(domain) {
|
|
19
|
+
if (!isDomainName(domain)) {
|
|
20
|
+
const hint = domain === "remote-exec" ? " — the exec backend is NOT a domain; use writeRemoteExec (config.d/remote-exec.json)" : "";
|
|
21
|
+
throw new Error(`"${domain}" is not a config domain (expected one of: ${DOMAINS_LIST})${hint}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const DOMAINS_LIST = Object.keys(DOMAIN_SCHEMAS).join(", ");
|
|
25
|
+
// Monotonic suffix so concurrent same-domain atomic writes in one process+ms can't collide on the tmp name.
|
|
26
|
+
let tmpSeq = 0;
|
|
27
|
+
/** VALIDATE a domain payload against its zod schema WITHOUT writing — the editor's "is this form valid?" check.
|
|
28
|
+
* Returns the parsed+defaulted value or the zod issues. Identical rules to the server's parseDomain. Throws
|
|
29
|
+
* (clear message) if `domain` is not a real domain name (a misuse, not invalid user data). */
|
|
30
|
+
export function validateDomain(domain, raw) {
|
|
31
|
+
assertDomainName(domain);
|
|
32
|
+
let r;
|
|
33
|
+
try {
|
|
34
|
+
r = DOMAIN_SCHEMAS[domain].safeParse(raw === undefined ? {} : raw); // explicit null is corrupt, not defaults (R4)
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// a hostile getter/Proxy trap threw during traversal — never surface its (secret-bearing) message. R13.
|
|
38
|
+
return { ok: false, issues: [{ code: "custom", message: `invalid "${domain}" config (a value could not be read during validation)`, path: [] }] };
|
|
39
|
+
}
|
|
40
|
+
if (r.success)
|
|
41
|
+
return { ok: true, value: r.data };
|
|
42
|
+
return { ok: false, issues: sanitizeZodError(r.error).issues }; // recursive redaction (R13/R14) — no secret echo
|
|
43
|
+
}
|
|
44
|
+
/** Same, for the remote-exec contract (a standalone contract, not a DOMAIN_SCHEMAS member). */
|
|
45
|
+
export function validateRemoteExec(raw) {
|
|
46
|
+
let r;
|
|
47
|
+
try {
|
|
48
|
+
r = RemoteExecSpec.safeParse(raw);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return { ok: false, issues: [{ code: "custom", message: "invalid remoteExec (a value could not be read during validation)", path: [] }] };
|
|
52
|
+
}
|
|
53
|
+
return r.success ? { ok: true, value: r.data } : { ok: false, issues: sanitizeZodError(r.error).issues }; // no secret echo (R14)
|
|
54
|
+
}
|
|
55
|
+
/** Absolute path to a domain's JSON file under a config root (mirrors FileConfigStore.domainPath). Guards the
|
|
56
|
+
* name (no `../../secret` traversal even if a caller bypasses the DomainName type). */
|
|
57
|
+
export function domainFilePath(root, domain) {
|
|
58
|
+
assertDomainName(domain);
|
|
59
|
+
return path.join(root, "config.d", `${domain}.json`);
|
|
60
|
+
}
|
|
61
|
+
/** WRITE a domain file ATOMICALLY (write a tmp in the same dir → rename) so a crash never leaves a half-written
|
|
62
|
+
* config.d/<domain>.json. VALIDATES first (throws on invalid — never persists garbage); persists the parsed +
|
|
63
|
+
* defaulted value so the file is canonical. */
|
|
64
|
+
export async function writeDomainFile(root, domain, value) {
|
|
65
|
+
assertDomainName(domain); // a stray non-domain key (e.g. "remote-exec" from a config.d scan) fails LOUD + clear
|
|
66
|
+
const parsed = parseDomain(domain, value); // routes through the SANITIZED parser (explicit-null reject R4 + no secret echo R14)
|
|
67
|
+
const dir = path.join(root, "config.d");
|
|
68
|
+
await mkdir(dir, { recursive: true });
|
|
69
|
+
const dest = path.join(dir, `${domain}.json`);
|
|
70
|
+
const tmp = path.join(dir, `.${domain}.json.${process.pid}.${Date.now()}.${tmpSeq++}.tmp`);
|
|
71
|
+
try {
|
|
72
|
+
await writeFile(tmp, JSON.stringify(parsed, null, 2) + "\n", "utf8");
|
|
73
|
+
await rename(tmp, dest);
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
await unlink(tmp).catch(() => { }); // never leave a half-written tmp behind on a write/rename failure
|
|
77
|
+
throw e;
|
|
78
|
+
}
|
|
79
|
+
return parsed;
|
|
80
|
+
}
|
|
81
|
+
/** LIST every env-NAME the current local config requires (across models/workers/mcp + the
|
|
82
|
+
* remote-exec backend), marking which are MISSING from the resolved env — the TOC "you still need to set
|
|
83
|
+
* these secrets" panel. Reuses collectEnvRefs for the domain side, then appends the remoteExec secret refs. */
|
|
84
|
+
export function listRequiredEnvNames(eff, remoteExec, resolvedEnv) {
|
|
85
|
+
const map = new Map();
|
|
86
|
+
const add = (envName, domain, detail) => {
|
|
87
|
+
if (!envName)
|
|
88
|
+
return;
|
|
89
|
+
const list = map.get(envName) ?? [];
|
|
90
|
+
list.push({ domain, detail });
|
|
91
|
+
map.set(envName, list);
|
|
92
|
+
};
|
|
93
|
+
for (const r of collectEnvRefs(eff))
|
|
94
|
+
for (const use of r.refs)
|
|
95
|
+
add(r.envName, use.domain, use.detail);
|
|
96
|
+
if (remoteExec) {
|
|
97
|
+
const re = remoteExec;
|
|
98
|
+
if (re.provider === "e2b") {
|
|
99
|
+
add(re.apiKeyEnv, "remoteExec", "e2b.apiKeyEnv");
|
|
100
|
+
for (const e of re.sandboxEnv ?? [])
|
|
101
|
+
add(e, "remoteExec", "e2b.sandboxEnv[]");
|
|
102
|
+
}
|
|
103
|
+
else if (re.provider === "k8s") {
|
|
104
|
+
add(re.tokenEnv, "remoteExec", "k8s.tokenEnv");
|
|
105
|
+
add(re.caCertEnv, "remoteExec", "k8s.caCertEnv");
|
|
106
|
+
if (re.s3Snapshot) {
|
|
107
|
+
add(re.s3Snapshot.accessKeyEnv, "remoteExec", "k8s.s3Snapshot.accessKeyEnv");
|
|
108
|
+
add(re.s3Snapshot.secretKeyEnv, "remoteExec", "k8s.s3Snapshot.secretKeyEnv");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else if (re.provider === "ssh") {
|
|
112
|
+
add(re.privateKeyEnv, "remoteExec", "ssh.privateKeyEnv");
|
|
113
|
+
}
|
|
114
|
+
else if (re.provider === "remote-docker") {
|
|
115
|
+
add(re.tlsCertEnv, "remoteExec", "remote-docker.tlsCertEnv");
|
|
116
|
+
add(re.tlsKeyEnv, "remoteExec", "remote-docker.tlsKeyEnv");
|
|
117
|
+
add(re.tlsCaEnv, "remoteExec", "remote-docker.tlsCaEnv");
|
|
118
|
+
add(re.sshKeyEnv, "remoteExec", "remote-docker.sshKeyEnv");
|
|
119
|
+
}
|
|
120
|
+
// adb / host / local-docker carry no secret env-NAME refs.
|
|
121
|
+
}
|
|
122
|
+
return [...map.entries()]
|
|
123
|
+
.map(([envName, refs]) => ({ envName, refs, present: resolvedEnv[envName] !== undefined }))
|
|
124
|
+
.sort((a, b) => a.envName.localeCompare(b.envName));
|
|
125
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { type EffectiveReadWarning } from "./config-fns.js";
|
|
2
|
+
import type { ConfigReader } from "./reader.js";
|
|
3
|
+
import { type DomainConfig, type DomainName, type EffectiveConfig } from "./types.js";
|
|
4
|
+
/** The domains a single-machine TOC carries: what `FileConfigStore` reads out of `config.d/`, and the
|
|
5
|
+
* **default**域集 of the bundle helpers (`exportBundle`/`readBundle`). 每个被排除的域必须在
|
|
6
|
+
* {@link NON_PORTABLE_DOMAIN_REASONS} 里有一条成文理由(禁裸排除,#282 件5 附带裁定)。
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ 口径诚实(codex 复审 round1 F4):这是**默认集与本地读取集,不是 API 级硬边界** —— `exportBundle`
|
|
9
|
+
* / `readBundle` 的 `domains` 参数可显式传任意 `DomainName`(bundle.ts 不拒非便携域)。把非便携域搬进
|
|
10
|
+
* bundle 的那条口子归调用方纪律(以及各自域的导出面),不是本常量能挡住的;要不要把 bundle API 收窄到
|
|
11
|
+
* `PortableDomain` 是一个独立的行为面裁定(#282 未决项,需三问),本批不擅自改。
|
|
12
|
+
*
|
|
13
|
+
* 🔴 **开新顶层域时必须同步登记本表**(成案 = sema-server #322 真事故):域表是**闭集**,不在这里的
|
|
14
|
+
* 域名,`config.d/<域>.json` 不会被读进 effective —— 消费方拿到的是「该域缺席」,和文件不存在**逐字
|
|
15
|
+
* 同形**。当年 `limits` 域就是这样在单机腿静默丢了一整片限额发布(远端腿同批可用,所以更难看出来)。
|
|
16
|
+
* 今日的兜底:漏登记的文件会被 {@link FileConfigStore} 的 `"unread-config-file"` 警告点名(fail-loud,
|
|
17
|
+
* 见 getEffective),但那只在消费方接了 `onWarning` 时才响 —— **登记仍是第一现场的义务**。 */
|
|
18
|
+
export declare const PORTABLE_DOMAINS: readonly ["models", "rosters", "skills", "mcp", "plugins", "scenarios", "collab", "runtime", "governance", "execution", "prompts", "limits"];
|
|
19
|
+
export type PortableDomain = (typeof PORTABLE_DOMAINS)[number];
|
|
20
|
+
/**
|
|
21
|
+
* 非便携域的**排除理由表**(#282 件5 附带裁定:禁裸排除)。
|
|
22
|
+
*
|
|
23
|
+
* `satisfies Record<Exclude<DomainName, PortableDomain>, string>` 让键集在**编译期**双向咬死:新开一个
|
|
24
|
+
* 域却不放进 PORTABLE_DOMAINS ⇒ 这里缺行 ⇒ 编译错;把某域改成便携却忘了删行 ⇒ 多余键 ⇒ 编译错。
|
|
25
|
+
* (此前是「理由写在注释里」,机器只能做子串匹配——域名出现在文件任何角落都算数,等于没门;codex
|
|
26
|
+
* 复审 round1 F3。)理由要写**为什么它不该被单机/跨部署搬运**,带出处。
|
|
27
|
+
*/
|
|
28
|
+
export declare const NON_PORTABLE_DOMAIN_REASONS: {
|
|
29
|
+
readonly workers: "FLEET-only:单机没有 reconciler,`{workers:[]}` 的 schema 默认就是正确语义(缺席不需要 stub)。";
|
|
30
|
+
readonly hosts: "FLEET-only:同 workers —— 单机无机器注册表/自动排布,空注册表即正确语义。";
|
|
31
|
+
readonly systems: "FLEET-only:系统接入/凭据下发的元数据(nonce+路由)属于 center 侧生命周期,单机 lane 无对应执法点。";
|
|
32
|
+
readonly projects: "TENANT IDENTITY DATA,不是可搬运的配置:登记簿随包复制进另一个部署 = 凭空多出的身份登记(design/142 §1.4 威胁模型①「carried-in foreign projectId」换了身 import 的衣服)。要带走走 142 §5 的导出面(service 半场)。";
|
|
33
|
+
readonly entitlement: "per-PRINCIPAL 商务/治理数据(tier·预算上限·kill-switch·allowlist),与 projects 同威胁模型(#282 件5 附带裁定,此前是唯一裸排除项):(a) 搬走 = 把一个租户的商务档复制进另一部署;(b) 反向也不成立 —— 单机 TOC 无 principal 轴(无鉴权语境、无 `/effective?principal=` 解析面),本地 entitlement.json 只会造出一份**永不被执法**的假管控面(真执法点在 service/center 边界)。缺席语义如实:本地 buildEffective 给它 schema 默认值(空表),这**不等于**把远端 kill-switch/预算上限搬到了本地 —— 单机 lane 本就没有那个执法点。";
|
|
34
|
+
};
|
|
35
|
+
/** One unreadable/corrupt domain FILE surfaced by `getEffective({tolerant:true})`(0.10.12,[897]②)。
|
|
36
|
+
* `error` 是 #readDomainRaw 的 sanitized 文案(带文件路径语境、绝不回显文件内容)。 */
|
|
37
|
+
export type DomainReadError = {
|
|
38
|
+
domain: DomainName;
|
|
39
|
+
error: string;
|
|
40
|
+
};
|
|
41
|
+
/** `getEffective({tolerant:true})` 的返回:好域照常装配的 effective + 坏文件清单(空数组=全部健康,
|
|
42
|
+
* effective 与 strict getEffective() 逐字节一致、version 同值)。 */
|
|
43
|
+
export type TolerantEffective = {
|
|
44
|
+
effective: EffectiveConfig;
|
|
45
|
+
domainErrors: DomainReadError[];
|
|
46
|
+
};
|
|
47
|
+
export declare class FileConfigStore implements ConfigReader {
|
|
48
|
+
#private;
|
|
49
|
+
private readonly root;
|
|
50
|
+
private readonly opts;
|
|
51
|
+
/** `opts.onWarning` (0.8.1): receives buildEffective's read-degradation warnings (grandfathered hosts /
|
|
52
|
+
* domain defaulted — see {@link EffectiveReadWarning}) so a TOC shell can surface them; omit = resilient
|
|
53
|
+
* but silent. Additive — existing `new FileConfigStore(root)` callers are unchanged. */
|
|
54
|
+
constructor(root: string, opts?: {
|
|
55
|
+
onWarning?: (w: EffectiveReadWarning) => void;
|
|
56
|
+
});
|
|
57
|
+
/** Absolute path to a domain's JSON file. Guards the name (defence in depth — a non-domain string like
|
|
58
|
+
* `../../secret` must never be joined into a filesystem path, even if a caller bypasses the type). */
|
|
59
|
+
domainPath(domain: DomainName): string;
|
|
60
|
+
/** Read+parse one domain (validated, defaults filled), or `undefined` if the domain is ABSENT — matching
|
|
61
|
+
* the {@link ConfigReader} contract (reader.ts: absent domain → undefined) and the server stores'
|
|
62
|
+
* `has(domain) ? parse : undefined`. A present-but-empty file (`{}` / whitespace) IS a set domain → parsed
|
|
63
|
+
* with defaults; a missing file is absence → undefined. (getEffective still fills defaults for absent
|
|
64
|
+
* domains; getDomain is the per-domain "was this ever set?" probe.) */
|
|
65
|
+
getDomain<K extends DomainName>(domain: K): Promise<DomainConfig[K] | undefined>;
|
|
66
|
+
/** All portable domains → EffectiveConfig. The PAYLOAD and its `version` come from ONE raws snapshot (no
|
|
67
|
+
* re-read between them — else a concurrent atomic rename could pair payload A with version B and a consumer
|
|
68
|
+
* would skip the change). `updatedAt` = max file mtime (informational; the content-hash version is the
|
|
69
|
+
* authoritative change-detector).
|
|
70
|
+
*
|
|
71
|
+
* TOLERANT MODE(0.10.12,server 接单 seam):strict getEffective 对任何一个坏形 domain 文件
|
|
72
|
+
* throw,消费者(server main 单点 catch)只能整包回落 env——models.json 一个坏文件把 skills/mcp/
|
|
73
|
+
* scenarios 全域连坐。`getEffective({tolerant:true})` 把 READ 层的坏文件按 parse 层既有的
|
|
74
|
+
* {@link DOMAIN_READ_FALLBACK} 同一张表处置:
|
|
75
|
+
* - `"default"` 域(models/skills/mcp/… 追加型 capability 目录):该域按 schema default 落 +
|
|
76
|
+
* 错误单列进 `domainErrors`(fail-closed:default 只会移除能力),其余好域照常生效;
|
|
77
|
+
* - `"throw"` 域(governance/rosters/entitlement/…):坏文件仍然 throw——READ 容错绝不放宽
|
|
78
|
+
* gate 域的 fail-loud(与 parse 层同一条纪律,defaulting=放权,不许)。
|
|
79
|
+
* 版本语义:坏域在 version hash 里以 `{__corruptDomainFile: <文件文本 hash>}` 哨兵参与——绝不与
|
|
80
|
+
* 「文件缺失(absent)」同 bytes(#readDomainRaw 头注的 lock-step 要求),坏→修好、好→坏、
|
|
81
|
+
* 坏内容 A→坏内容 B 均 bump version,version-gated poller 不漏拍。 */
|
|
82
|
+
getEffective(): Promise<EffectiveConfig>;
|
|
83
|
+
getEffective(opts: {
|
|
84
|
+
tolerant: true;
|
|
85
|
+
}): Promise<TolerantEffective>;
|
|
86
|
+
/** Stable version number for the current files: same files ⇒ same number ⇒ a consumer's "skip re-apply if
|
|
87
|
+
* version unchanged" works locally too. */
|
|
88
|
+
getVersion(): Promise<number>;
|
|
89
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileConfigStore — a local, single-machine config source for the TOC desktop/CLI. Reads the PORTABLE domains
|
|
3
|
+
* from `<root>/config.d/<domain>.json` (one file per domain, each = exactly that domain's zod object) and
|
|
4
|
+
* resolves them into an {@link EffectiveConfig} via the SAME pure `buildEffective`/`parseDomain` the server
|
|
5
|
+
* stores use. Implements ONLY the narrow {@link ConfigReader} (3 methods) — no publish/RBAC/worker-status.
|
|
6
|
+
*
|
|
7
|
+
* LOCAL FILE LAYOUT (the TOC owns the root, e.g. `~/.ai-agent/` or a project-local `.ai-agent/`):
|
|
8
|
+
* <root>/config.d/<domain>.json for every domain in {@link PORTABLE_DOMAINS} — that constant is the
|
|
9
|
+
* AUTHORITY; do NOT hand-copy the list here (the hand-written enumeration that used to sit
|
|
10
|
+
* on this line silently missed `prompts` for three releases — codex 复审 round2 F5).
|
|
11
|
+
* <root>/.env secret VALUES (KEY=value) — the ONLY file holding secrets; gitignored, never read here.
|
|
12
|
+
*
|
|
13
|
+
* Rules:
|
|
14
|
+
* - A MISSING file (ENOENT) → the domain is absent → schema defaults fill in (parseDomain tolerates undefined).
|
|
15
|
+
* A present-but-EMPTY/whitespace file is a half-write → THROW (fail-closed, like remote-exec); to mean
|
|
16
|
+
* "absent/defaults" the file must be missing, not empty. A valid `{}` object IS a set domain (→ defaults).
|
|
17
|
+
* - Non-portable domains are OMITTED on a single machine and resolve to their schema defaults (`{workers:[]}`
|
|
18
|
+
* etc.) — no stubbing needed. 逐域为什么被排除见 {@link NON_PORTABLE_DOMAIN_REASONS}(禁裸排除)。
|
|
19
|
+
* - The JSON files only ever carry env-NAMEs (the ENV_NAME boundary holds locally too); `.env` holds values.
|
|
20
|
+
* - `getVersion()` = a stable content hash of all portable files → a u53 number, so a consumer's
|
|
21
|
+
* "skip re-apply if version unchanged" works locally. `getEffective().updatedAt` = the max file mtime,
|
|
22
|
+
* passed EXPLICITLY into buildEffective (the determinism refactor) so identical files ⇒ identical bytes.
|
|
23
|
+
*/
|
|
24
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { buildEffective, DOMAIN_READ_FALLBACK, parseDomainLoud } from "./config-fns.js";
|
|
27
|
+
import { stableHash } from "./hash.js";
|
|
28
|
+
import { isDomainName } from "./types.js";
|
|
29
|
+
/** The domains a single-machine TOC carries: what `FileConfigStore` reads out of `config.d/`, and the
|
|
30
|
+
* **default**域集 of the bundle helpers (`exportBundle`/`readBundle`). 每个被排除的域必须在
|
|
31
|
+
* {@link NON_PORTABLE_DOMAIN_REASONS} 里有一条成文理由(禁裸排除,#282 件5 附带裁定)。
|
|
32
|
+
*
|
|
33
|
+
* ⚠️ 口径诚实(codex 复审 round1 F4):这是**默认集与本地读取集,不是 API 级硬边界** —— `exportBundle`
|
|
34
|
+
* / `readBundle` 的 `domains` 参数可显式传任意 `DomainName`(bundle.ts 不拒非便携域)。把非便携域搬进
|
|
35
|
+
* bundle 的那条口子归调用方纪律(以及各自域的导出面),不是本常量能挡住的;要不要把 bundle API 收窄到
|
|
36
|
+
* `PortableDomain` 是一个独立的行为面裁定(#282 未决项,需三问),本批不擅自改。
|
|
37
|
+
*
|
|
38
|
+
* 🔴 **开新顶层域时必须同步登记本表**(成案 = sema-server #322 真事故):域表是**闭集**,不在这里的
|
|
39
|
+
* 域名,`config.d/<域>.json` 不会被读进 effective —— 消费方拿到的是「该域缺席」,和文件不存在**逐字
|
|
40
|
+
* 同形**。当年 `limits` 域就是这样在单机腿静默丢了一整片限额发布(远端腿同批可用,所以更难看出来)。
|
|
41
|
+
* 今日的兜底:漏登记的文件会被 {@link FileConfigStore} 的 `"unread-config-file"` 警告点名(fail-loud,
|
|
42
|
+
* 见 getEffective),但那只在消费方接了 `onWarning` 时才响 —— **登记仍是第一现场的义务**。 */
|
|
43
|
+
export const PORTABLE_DOMAINS = ["models", "rosters", "skills", "mcp", "plugins", "scenarios", "collab", "runtime", "governance", "execution", "prompts", "limits"];
|
|
44
|
+
/** `config.d/` 里**合法存在但不是域文档**的文件名(不进 `"unread-config-file"` 告警)。`remote-exec`
|
|
45
|
+
* 不是 `DOMAIN_SCHEMAS` 成员(resolver 不读它),按 service 表决住在这个目录,由 loadRemoteExec 读。 */
|
|
46
|
+
const NON_DOMAIN_CONFIG_FILES = new Set(["remote-exec.json"]);
|
|
47
|
+
/**
|
|
48
|
+
* 非便携域的**排除理由表**(#282 件5 附带裁定:禁裸排除)。
|
|
49
|
+
*
|
|
50
|
+
* `satisfies Record<Exclude<DomainName, PortableDomain>, string>` 让键集在**编译期**双向咬死:新开一个
|
|
51
|
+
* 域却不放进 PORTABLE_DOMAINS ⇒ 这里缺行 ⇒ 编译错;把某域改成便携却忘了删行 ⇒ 多余键 ⇒ 编译错。
|
|
52
|
+
* (此前是「理由写在注释里」,机器只能做子串匹配——域名出现在文件任何角落都算数,等于没门;codex
|
|
53
|
+
* 复审 round1 F3。)理由要写**为什么它不该被单机/跨部署搬运**,带出处。
|
|
54
|
+
*/
|
|
55
|
+
export const NON_PORTABLE_DOMAIN_REASONS = {
|
|
56
|
+
workers: "FLEET-only:单机没有 reconciler,`{workers:[]}` 的 schema 默认就是正确语义(缺席不需要 stub)。",
|
|
57
|
+
hosts: "FLEET-only:同 workers —— 单机无机器注册表/自动排布,空注册表即正确语义。",
|
|
58
|
+
systems: "FLEET-only:系统接入/凭据下发的元数据(nonce+路由)属于 center 侧生命周期,单机 lane 无对应执法点。",
|
|
59
|
+
projects: "TENANT IDENTITY DATA,不是可搬运的配置:登记簿随包复制进另一个部署 = 凭空多出的身份登记(design/142 §1.4 威胁模型①「carried-in foreign projectId」换了身 import 的衣服)。要带走走 142 §5 的导出面(service 半场)。",
|
|
60
|
+
entitlement: "per-PRINCIPAL 商务/治理数据(tier·预算上限·kill-switch·allowlist),与 projects 同威胁模型(#282 件5 附带裁定,此前是唯一裸排除项):(a) 搬走 = 把一个租户的商务档复制进另一部署;(b) 反向也不成立 —— 单机 TOC 无 principal 轴(无鉴权语境、无 `/effective?principal=` 解析面),本地 entitlement.json 只会造出一份**永不被执法**的假管控面(真执法点在 service/center 边界)。缺席语义如实:本地 buildEffective 给它 schema 默认值(空表),这**不等于**把远端 kill-switch/预算上限搬到了本地 —— 单机 lane 本就没有那个执法点。",
|
|
61
|
+
};
|
|
62
|
+
export class FileConfigStore {
|
|
63
|
+
root;
|
|
64
|
+
opts;
|
|
65
|
+
/** `opts.onWarning` (0.8.1): receives buildEffective's read-degradation warnings (grandfathered hosts /
|
|
66
|
+
* domain defaulted — see {@link EffectiveReadWarning}) so a TOC shell can surface them; omit = resilient
|
|
67
|
+
* but silent. Additive — existing `new FileConfigStore(root)` callers are unchanged. */
|
|
68
|
+
constructor(root, opts = {}) {
|
|
69
|
+
this.root = root;
|
|
70
|
+
this.opts = opts;
|
|
71
|
+
}
|
|
72
|
+
/** Absolute path to a domain's JSON file. Guards the name (defence in depth — a non-domain string like
|
|
73
|
+
* `../../secret` must never be joined into a filesystem path, even if a caller bypasses the type). */
|
|
74
|
+
domainPath(domain) {
|
|
75
|
+
if (!isDomainName(domain))
|
|
76
|
+
throw new Error(`"${domain}" is not a config domain — refusing to build a config.d path (no traversal)`);
|
|
77
|
+
return path.join(this.root, "config.d", `${domain}.json`);
|
|
78
|
+
}
|
|
79
|
+
/** Read + JSON.parse one domain file. A MISSING file (ENOENT) → undefined = the domain is ABSENT (the ONLY
|
|
80
|
+
* way to mean "use defaults"). A present file that is EMPTY/whitespace-only, or parses to anything but a JSON
|
|
81
|
+
* OBJECT (literal `null`, an array, a number/string/bool), is CORRUPT → throw, FAIL CLOSED — identical
|
|
82
|
+
* discipline to loadRemoteExec + the `file:` ref (an empty present file is a half-write/truncation, NOT
|
|
83
|
+
* "absent": treating it as absent would silently DROP a published domain — e.g. a truncated runtime.json
|
|
84
|
+
* drops its commandPolicy/autonomy guardrails and the consumer falls back to env). This also keeps
|
|
85
|
+
* getVersion in lock-step with getEffective/getDomain: stableStringify collapses `undefined` (absent) and
|
|
86
|
+
* `null` to the SAME bytes, so an empty/null file that resolved to undefined would hash IDENTICALLY to
|
|
87
|
+
* "domain absent" and a version-gated poller would skip a change getEffective/getDomain actually FAIL on.
|
|
88
|
+
* Every domain schema is a z.object, so a non-object/empty file is never valid — reject all those shapes
|
|
89
|
+
* here, once. R17 + R(0.1.27 整体复审). */
|
|
90
|
+
async #readDomainRaw(domain) {
|
|
91
|
+
let text;
|
|
92
|
+
try {
|
|
93
|
+
text = await readFile(this.domainPath(domain), "utf8");
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
if (e.code === "ENOENT")
|
|
97
|
+
return undefined;
|
|
98
|
+
throw e;
|
|
99
|
+
}
|
|
100
|
+
const trimmed = text.trim();
|
|
101
|
+
// FAIL CLOSED on an empty/whitespace present file (half-write/truncation), same as loadRemoteExec/file: —
|
|
102
|
+
// to mean "absent → defaults" the file must be MISSING (ENOENT), never present-but-empty.
|
|
103
|
+
if (!trimmed)
|
|
104
|
+
throw new Error(`config.d/${domain}.json is empty/whitespace — delete it for the domain default, or write a valid object`);
|
|
105
|
+
let parsed;
|
|
106
|
+
try {
|
|
107
|
+
parsed = JSON.parse(trimmed);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
throw new Error(`invalid JSON in config.d/${domain}.json`); // sanitized: never echo the raw file text into the error
|
|
111
|
+
}
|
|
112
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
113
|
+
const kind = parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed;
|
|
114
|
+
throw new Error(`config.d/${domain}.json must be a JSON object (got ${kind}) — delete the file for the domain default, or write a valid object`);
|
|
115
|
+
}
|
|
116
|
+
return parsed;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* FAIL-LOUD 扫描(0.19.0):`config.d/` 里存在、但本地读取集**永远不会读**的 `*.json` 文件,逐个经
|
|
120
|
+
* `onWarning` 点名(`"unread-config-file"`)。
|
|
121
|
+
*
|
|
122
|
+
* 成案 = sema-server #322:消费方开了新顶层域 `limits` 并发布 `config.d/limits.json`,而本包的域表是
|
|
123
|
+
* 闭集 —— 文件在、进程跑、限额还是 env 老值,**零告警**(「域缺席」与「文件不存在」在下游逐字同形)。
|
|
124
|
+
* 修法方向是**响亮,不是收紧**:未登记文件照旧不读(拒收会让一个无害的散落文件打死整个部署),但必须
|
|
125
|
+
* 说出来。两类分开报,因为处置不同:`not-a-domain` = 名字打错 / 消费方开了本包还不认识的域(要来本包
|
|
126
|
+
* 登记);`non-portable-domain` = 域认识但单机 lane 刻意不读(理由见 {@link NON_PORTABLE_DOMAIN_REASONS})。
|
|
127
|
+
*
|
|
128
|
+
* 只在挂了 `onWarning` 时才 readdir —— 没接通道的调用方不该为一条没人听的警告付 IO(诚实登记:
|
|
129
|
+
* 因此本门的响亮程度取决于消费方接不接这个座)。忽略:隐藏文件(含 file-edit 原子写的 `.<域>.json.*.tmp`
|
|
130
|
+
* 中间态)、非 `.json`、{@link NON_DOMAIN_CONFIG_FILES}。
|
|
131
|
+
*/
|
|
132
|
+
async #warnUnreadFiles() {
|
|
133
|
+
const onWarning = this.opts.onWarning;
|
|
134
|
+
if (!onWarning)
|
|
135
|
+
return;
|
|
136
|
+
let names;
|
|
137
|
+
try {
|
|
138
|
+
names = await readdir(path.join(this.root, "config.d"));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// 目录读不了(不存在 / 只有 x 没有 r 的目录权限)⇒ 本次扫不了,**不抛**:这条是尽力而为的审计线,
|
|
142
|
+
// 绝不能把一次本来能成功的 getEffective 拖成失败(域文件本身仍按名逐个打开,该失败的自然会失败)。
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
for (const name of names) {
|
|
146
|
+
if (name.startsWith(".") || !name.endsWith(".json") || NON_DOMAIN_CONFIG_FILES.has(name))
|
|
147
|
+
continue;
|
|
148
|
+
const base = name.slice(0, -".json".length);
|
|
149
|
+
if (PORTABLE_DOMAINS.includes(base))
|
|
150
|
+
continue; // 真被读的域
|
|
151
|
+
onWarning(isDomainName(base)
|
|
152
|
+
? { kind: "unread-config-file", file: name, domain: base, why: "non-portable-domain" }
|
|
153
|
+
: { kind: "unread-config-file", file: name, why: "not-a-domain" });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** Raw (unparsed) values for every PORTABLE domain, keyed by domain name (absent → undefined). */
|
|
157
|
+
async #readAllRaw() {
|
|
158
|
+
const out = {};
|
|
159
|
+
await Promise.all(PORTABLE_DOMAINS.map(async (d) => { out[d] = await this.#readDomainRaw(d); }));
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
/** The max mtime (ISO) across the present portable files, or the epoch when none exist — so `updatedAt`
|
|
163
|
+
* is deterministic for a given set of files (not the wall clock). */
|
|
164
|
+
async #maxMtimeIso() {
|
|
165
|
+
let maxMs = 0;
|
|
166
|
+
await Promise.all(PORTABLE_DOMAINS.map(async (d) => {
|
|
167
|
+
try {
|
|
168
|
+
const s = await stat(this.domainPath(d));
|
|
169
|
+
if (s.mtimeMs > maxMs)
|
|
170
|
+
maxMs = s.mtimeMs;
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
if (e.code !== "ENOENT")
|
|
174
|
+
throw e;
|
|
175
|
+
}
|
|
176
|
+
}));
|
|
177
|
+
return new Date(maxMs).toISOString();
|
|
178
|
+
}
|
|
179
|
+
/** Read+parse one domain (validated, defaults filled), or `undefined` if the domain is ABSENT — matching
|
|
180
|
+
* the {@link ConfigReader} contract (reader.ts: absent domain → undefined) and the server stores'
|
|
181
|
+
* `has(domain) ? parse : undefined`. A present-but-empty file (`{}` / whitespace) IS a set domain → parsed
|
|
182
|
+
* with defaults; a missing file is absence → undefined. (getEffective still fills defaults for absent
|
|
183
|
+
* domains; getDomain is the per-domain "was this ever set?" probe.) */
|
|
184
|
+
async getDomain(domain) {
|
|
185
|
+
const raw = await this.#readDomainRaw(domain);
|
|
186
|
+
if (raw === undefined)
|
|
187
|
+
return undefined; // missing/empty file → domain absent (not schema defaults)
|
|
188
|
+
// 0.19.0(codex 复审 round2 F3):与 buildEffective **同一条** parse-and-warn 判据。此前本方法直接
|
|
189
|
+
// parseDomain,于是 `exportBundle`(它就是逐域走 getDomain 读的)会把一份携带未知键的域**静默剥**
|
|
190
|
+
// 成一份"干净"的 bundle —— 再 import 回去就是一次持久的配置丢失,全程零告警。
|
|
191
|
+
return parseDomainLoud(domain, raw, this.opts.onWarning);
|
|
192
|
+
}
|
|
193
|
+
/** Content hash of a raws snapshot → a stable u53-ish version number (≤ Number.MAX_SAFE_INTEGER). */
|
|
194
|
+
#versionOf(raws) {
|
|
195
|
+
return parseInt(stableHash(raws).slice(0, 13), 16); // 13 hex digits ≤ 2^52, safely within u53
|
|
196
|
+
}
|
|
197
|
+
async getEffective(opts) {
|
|
198
|
+
if (opts?.tolerant !== true) {
|
|
199
|
+
const [raws] = await Promise.all([this.#readAllRaw(), this.#warnUnreadFiles()]); // 两条读法同一门(0.19.0)
|
|
200
|
+
const updatedAt = await this.#maxMtimeIso();
|
|
201
|
+
return buildEffective(this.#versionOf(raws), (d) => raws[d], updatedAt, this.opts.onWarning);
|
|
202
|
+
}
|
|
203
|
+
await this.#warnUnreadFiles();
|
|
204
|
+
const raws = {};
|
|
205
|
+
const hashRaws = {};
|
|
206
|
+
const domainErrors = [];
|
|
207
|
+
await Promise.all(PORTABLE_DOMAINS.map(async (d) => {
|
|
208
|
+
try {
|
|
209
|
+
const v = await this.#readDomainRaw(d);
|
|
210
|
+
raws[d] = v;
|
|
211
|
+
hashRaws[d] = v;
|
|
212
|
+
}
|
|
213
|
+
catch (e) {
|
|
214
|
+
if (DOMAIN_READ_FALLBACK[d] === "throw")
|
|
215
|
+
throw e; // gate 域:READ 容错也不放宽 fail-loud
|
|
216
|
+
domainErrors.push({ domain: d, error: e instanceof Error ? e.message : String(e) });
|
|
217
|
+
raws[d] = undefined; // buildEffective 按 absent → schema default 落(fail-closed)
|
|
218
|
+
// 哨兵进 hash:用文件文本 hash 区分不同坏内容;读不回文本(竞态删除等)退回错误文案。
|
|
219
|
+
let textHash;
|
|
220
|
+
try {
|
|
221
|
+
textHash = stableHash((await readFile(this.domainPath(d), "utf8")).trim());
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
textHash = stableHash(e instanceof Error ? e.message : String(e));
|
|
225
|
+
}
|
|
226
|
+
hashRaws[d] = { __corruptDomainFile: textHash };
|
|
227
|
+
}
|
|
228
|
+
}));
|
|
229
|
+
const updatedAt = await this.#maxMtimeIso();
|
|
230
|
+
const effective = buildEffective(this.#versionOf(hashRaws), (d) => raws[d], updatedAt, this.opts.onWarning);
|
|
231
|
+
return { effective, domainErrors };
|
|
232
|
+
}
|
|
233
|
+
/** Stable version number for the current files: same files ⇒ same number ⇒ a consumer's "skip re-apply if
|
|
234
|
+
* version unchanged" works locally too. */
|
|
235
|
+
async getVersion() {
|
|
236
|
+
return this.#versionOf(await this.#readAllRaw());
|
|
237
|
+
}
|
|
238
|
+
}
|