@sema-agent/settings-schema 11.0.0 → 11.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 CHANGED
@@ -6,6 +6,60 @@
6
6
  > `@sema-agent/registry-core`(更早还曾是 `@sema-ai/registry-core`)。本档 `0.19.0` 以下各节里的
7
7
  > 包名是**历史记录**,如实保留不追改。
8
8
 
9
+ ## 11.1.0 — 2026-10-10 — 11.0.0 持久面三件收口:URL fragment 拒句补迁移指路 + 全称律源码钉 · 迁移命令对零域文档目录响亮拒 · 写盘处收成一只原子写器与模式表(minor:一个新导出,零删零改名)
10
+
11
+ > 本节三件,都是对 11.0.0 持久面的收口:拒句补指路、迁移命令填错目录响亮拒、写盘处收成一只。一个新导出(`NON_SECRET_URL_NO_MIGRATION`,additive),零删零改名。
12
+
13
+ ### URL 带 `#` 的拒句点名迁移 SS-M7;全称律进源码钉;登记表加 `oldForm` 格
14
+
15
+ 11.0.0 的律是「读写两面遇旧形一律响亮拒,拒句点名迁移 id」,但 `NON_SECRET_URL` 座位上的 fragment 拒句(`must not carry a fragment (…)`)没带指路 ——
16
+ 存量带 `#` 的 URL 被拒时,读到的句子不说有一条迁移能改写它。现在 fragment 拒句尾接迁移指路(`<规则句>; data stored before 11.0.0: run the one-time migration SS-M7 (…)`),
17
+ 与钥匙双设的 SS-M5 拒句同形,对新写入同样生效;userinfo / 非 http(s) 的拒句没有迁移可指,保持判官的规则句原样。
18
+
19
+ - **谁需要**:持有带 `#` 的 `baseUrl` / MCP `url` / `engineUrl` / `gitApiBaseUrl` 存量的运维 —— 拒句直接告诉他跑哪一条迁移。
20
+ - **谁受伤**:按整句字面匹配 fragment 拒句的消费方(句子变长了:尾部多了 `; <指路>`);`issue.params.flaw`(`"fragment"`)不变,按它分流的不受影响。
21
+ - **补偿**:按 `params.flaw` 取规则词,别拿整句比对;要规则句本身用 `httpUrlRule("fragment")`。
22
+ - **指路只挂在 SS-M7 够得着的座位上**:`NON_SECRET_URL` 是域文档里 URL 座位的判官(SS-M7 按这只 schema 的身份找座位,所以「带指路」与「被 SS-M7 改写」是同一件事);不在域文档里的座位 ——
23
+ `remoteExec` 的端点(`apiUrl` / `s3Snapshot.endpoint`)、web-admin 凭据信封的 `issuer` —— 改用新导出 `NON_SECRET_URL_NO_MIGRATION`:同一判官、同样的规则句,只是 fragment 拒句不带迁移指路
24
+ (迁移命令改不到那些值,指过去只会让运维白跑一趟)。自己在宿主里拿 `NON_SECRET_URL` 校验非域文档 URL 的代码,按这个口径选一只。
25
+ - **全称律进源码钉**:登记表每行新增 `oldForm`(`"refused"` 旧形被拒、拒句点名本行 id / `"rewritten-on-read"` 读时改写不拒 / `"not-stored"` 没有存储形),
26
+ 律的两个例外(调度店 SS-M3、wire 删码位 SS-W1)从散文变成表里的字段;一道源码钉(读 AST)要求每个 `refused` 行的 id 在 `src/` 至少有一处铸点
27
+ (`migrationSignpost("<id>"` 调用或 retired 形字面),非 `refused` 行零铸点 —— 加一行迁移而不加指路 ⇒ 门红。`OneTimeMigrationWindow` 因此多一个必填只读字段
28
+ (读登记表的代码不受影响;自己手造该型字面的代码要补这一格)。SS-M7 行的 `removal` 补「the fragment rule loses its SS-M7 clause」。
29
+ - **迁移指路句的目录占位统一为 `<config.d directory>`**(此前拒句里是 `<dir>`,命令行 USAGE 与文档是 `<config.d>` / `<config.d directory>` 三种拼法)。
30
+
31
+ ### `migrate <目录>` 填错目录不再静默 noop
32
+
33
+ 命令行扫完目录没找到任何域文档(`<域>.json`)时,此前对零个域文档照常跑迁移:零收据 ⇒ `outcome=noop` 退出 0,stdout 两行、stderr 空 —— 填成 `config.d` 的上一层、
34
+ 只有 `remote-exec.json` 之类非域文件的目录,都报「无事可做」,运维读成「这个 scope 已迁完」。现在零域文档分两形:
35
+
36
+ - **目录里有条目却没有一个是域文档**(任何不以 `.` 开头的条目:子目录、说明文件、`remote-exec.json`、不认识的名;填成 `config.d` 的上一层是最常见的)⇒ **退出 2、stdout 空**、
37
+ stderr 一句点名这个目录(要的是 `config.d` 目录本身;`<目录>/config.d` 在场就直接点它,给出该跑的命令)。`--check` 同判。
38
+ - **目录真空**(零条目,或只有以 `.` 开头的条目)⇒ **noop 退出 0**,stdout 两行(`outcome=noop written=false` + JSON 报告)、stderr 零字节:新部署的空 `config.d` 是合法态,
39
+ 「没有东西可迁」不是「指错了目录」。迁移把目录搬空(例如目录里只有一份空的或只剩被丢弃键的 `runtime.json`)之后再跑同一目录,也因此是 noop(幂等)。
40
+
41
+ 有域文档、只是没有要改的,仍是 noop 退出 0;只有退役域(`runtime.json`)文件仍照旧迁。
42
+
43
+ - **谁需要**:本地 `config.d` 的运维、对一批 scope 目录循环跑迁移的脚本。
44
+ - **谁受伤**:循环里夹着「里面有东西却没有域文档」的目录的脚本,此前这些目录静默过关,现在退出 2(真空目录照旧过关)。
45
+ - **补偿**:脚本先判目录里有 `<域>.json` 再调;或接住退出 2 当「这个目录不是 config.d」。
46
+
47
+ ### 写盘处收成一只原子写器,文件模式由一张按文档类的表定
48
+
49
+ 此前包里三处各写各的:`writeDomainFile` 不带模式(umask 缺省,通常 0o644)、迁移命令的写器新建一律 0o600 且已有沿用、调度店 0o600 —— 同一个目录里「写出的域文档」是 644 还是 600
50
+ 取决于哪条路写的;`writeRemoteExec` 又是第四份同样的写法;命令行入口的头注还说自己是包里唯一写文件的地方。现在 `writeDomainFile` / `writeRemoteExec` / 迁移命令的写器 /
51
+ `saveSchedulerStore` 全部调同一只(同目录隐藏临时文件 → rename;失败清残片),模式只在那一张表里:`config.d` 契约文档(域文档、`remote-exec.json`)新建 = 0o644(契约文件只带 env-NAME、
52
+ 不带秘密),**已有的文件沿用它原来的模式**;调度店耐久记录 = 0o600,每次保存都回到 0o600。入口脚本只注入真 I/O,不再自己写盘、不再自己判模式。
53
+
54
+ - **谁需要**:想让同一目录里的契约文件模式一致的运维;读 `scheduled_tasks.json` 权限的审计。
55
+ - **谁受伤**:(1) 迁移命令**新建**的域文档(例如从 `runtime.json` 搬出来的 `governance.json` / `limits.json`)此前是 0o600,现在是 0o644(经 umask),与 `writeDomainFile` 一致;
56
+ (2) `writeDomainFile` / `writeRemoteExec` 改写**已有**文件时此前按 umask 缺省重置模式,现在沿用原模式(运维 chmod 过的文件保留)。调度店的行为不变(新建 0o600、每次保存收回 0o600)。
57
+ - **补偿**:要更严的权限,对 `config.d` 里的文件 `chmod`(现在写回不再冲掉它),或收紧进程 umask(新建文件仍经 umask)。
58
+
59
+ #### 未闭环
60
+
61
+ - `remote-exec.json` 里存量带 `#` 的端点:一次性迁移 SS-M7 只走**域文档**里的座位,迁移命令也只读 `<域>.json`,所以这类存量没有迁移可跑,只能手改(拒句是规则句,不指迁移)。
62
+
9
63
  ## 11.0.0 — 2026-10-10 — 依赖换 zod 4、三处死出口收回、持久面一次性迁移族、同一个问题一只判官、名与契约(major)
10
64
 
11
65
  > 本节四件:以下先是平台一件(zod 4 + 三处出口收窄 + 上游锚抬钉),再是持久面一件(「持久面」一节起),再是判官一件(「判官」一节起),再是名与契约一件(「名与契约」一节起)。每条硬破坏写三句:谁受影响 / 怎么改 / 不改会怎样。
@@ -1525,7 +1579,7 @@ sema-server 7.108.0(S-931)给模型目录条目加了 `headers`(部署 ↔ 供
1525
1579
 
1526
1580
  ## 6.1.0 — 2026-09-25 — 四件 additive:`entry-dropped` 身份(S-712)· 中心凭证信封 + 三态判别器(S-698)· `RestartSignal` 一处定义(S-741)· 中心凭证两键名 + 二选一判官(S-742);`redactSecrets` 私钥 PEM 串内任意位置且判在替换前(6.0.0 起三面既有盲点顺带关)
1527
1581
 
1528
- > 本节四件全 additive(新可选键 / 新导出 / 新文件),零删零改名;版号与节标题由发版方写。判档口径见下方「发版纪律」:
1582
+ > 本节四件全 additive(新可选键 / 新导出 / 新文件),零删零改名;判档口径见下方「发版纪律」:
1529
1583
  > 新键 / 新导出 = minor。
1530
1584
 
1531
1585
  ### S-712 · `entry-dropped` 告警带被丢条目的身份(`identity?`,additive)
package/README.md CHANGED
@@ -70,8 +70,10 @@ therefore shows only `zod`. Node built-ins only (`node:crypto`, `node:fs`).
70
70
  `docs` 里没改的文档是**同一引用**、被移除的(退役的 `runtime` 域)不在、迁出的新文档(`governance` / `limits`)在 —— 据此写回 / 删除。
71
71
  `receipts` 逐条说「对哪个坐标做了什么」(`moved` / `rewritten` / `dropped-shadowed` / `dropped-inert` / `refused`;不带值)。
72
72
  `refused` 的坐标原样留着,读侧照旧拒:要人改。再跑一次 = 零改动。
73
- - **目录形**:`npx @sema-agent/settings-schema migrate <config.d> [--check]`(同一只函数;目录当一个 scope;有 refused 一字不写、退出 1;
74
- `--check` 有改动退出 3)。
73
+ - **目录形**:`npx @sema-agent/settings-schema migrate <config.d directory> [--check]`(同一只函数;目录当一个 scope;有 refused 一字不写、退出 1;
74
+ `--check` 有改动退出 3)。参数是 `config.d` 目录**本身**(里面直接放 `<域>.json`):目录里有条目却没有一份域文档 —— 填成了它的上一层、只有
75
+ `remote-exec.json` 之类非域文件 —— 退出 2、stdout 空,stderr 点名这个目录(`<目录>/config.d` 在场就直接点它);目录真空(零条目,或只有以 `.` 开头的条目)是合法态
76
+ (新部署的空 `config.d`),和「有域文档、只是没有要改的」一样是 noop、退出 0。
75
77
  - **迁什么**(七条,`ONE_TIME_MIGRATIONS` 每行写着改写规则、删法、删后由谁接住):退役 `runtime` 域(治理三闸 → `governance`、五个限额键 → `limits`)·
76
78
  hosts 超界 · 调度店 durable 键(读时改写,下一个写者落盘)· workers 6.x 行 · 模型钥匙双设 · `atModelAllowlist` → `atMention` · URL fragment。
77
79
  - **什么时候删**:每条的窗 = 11.0.0 发布日 + 30 天,窗关之后的那个 major 整族删(删法与删后的响亮臂见登记表那一行);本包的到期门到点即红。
@@ -293,7 +295,7 @@ therefore shows only `zod`. Node built-ins only (`node:crypto`, `node:fs`).
293
295
  `WebAdminCredentialEnvelope` (strict JSON: `kind: "user-session"` = `WEB_ADMIN_CREDENTIAL_ENVELOPE_KIND`, a JWT
294
296
  `accessToken` and a `refreshToken` under the same token law as the bare line, RFC 3339
295
297
  `expiresAt` with seconds and a `Z` / `±hh:mm` offset in range, `issuer` = the issuing web-admin's base URL judged
296
- by the same `NON_SECRET_URL` the `remoteExec` endpoints use — same-origin is the reader's comparison).
298
+ by the same judge the `remoteExec` endpoints use (`NON_SECRET_URL_NO_MIGRATION`) — same-origin is the reader's comparison).
297
299
  `parseWebAdminCredentialFile(text)` is the ONE discrimination law — trimmed text starting with `{` is read ONLY
298
300
  as an envelope (a failure is `unreadable`, never a fallback to a bare line); anything else is a bare line,
299
301
  and a first line that is not a token is `unreadable` too (never sent as a bearer); empty is `unreadable`. The `reason` names envelope fields / zod issue codes / an unknown-key COUNT, never file
@@ -1,26 +1,17 @@
1
1
  #!/usr/bin/env node
2
- // `settings-schema` — this package's command line (11.0.0: `migrate <config.d directory> [--check]`, the one-time migrations).
3
- // The ONLY place the package writes files with node:fs; the logic is `runSettingsSchemaCli` (dist/cli.js), which never
4
- // touches the filesystem itself — this entry injects real file I/O into it.
5
- import { readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
2
+ // `settings-schema` — this package's command line (`migrate <config.d directory> [--check]`, the one-time migrations).
3
+ // The real-I/O injection point: the logic is `runSettingsSchemaCli` (dist/cli.js), which never touches the filesystem itself — this
4
+ // entry hands it directory / file reads and removal, and the one atomic writer (dist/atomic-write.js), which alone decides how a file
5
+ // is written and with what mode.
6
+ import { readdir, readFile, unlink } from "node:fs/promises";
6
7
  import { runSettingsSchemaCli } from "../dist/cli.js";
8
+ import { writeFileAtomic } from "../dist/atomic-write.js";
7
9
 
8
10
  const io = {
9
11
  readDir: (path) => readdir(path),
10
12
  readFile: (path) => readFile(path, "utf8"),
11
- // atomic: write a sibling temp file (same mode as the original when there is one), then rename over it — a half-written
12
- // domain document is worse than the old one.
13
- writeFileAtomic: async (path, text) => {
14
- let mode = 0o600;
15
- try {
16
- mode = (await stat(path)).mode & 0o777;
17
- } catch (e) {
18
- if (e?.code !== "ENOENT") throw e;
19
- }
20
- const tmp = `${path}.migrate-${process.pid}.tmp`;
21
- await writeFile(tmp, text, { encoding: "utf8", mode });
22
- await rename(tmp, path);
23
- },
13
+ // the files this command writes are config.d domain documents
14
+ writeFileAtomic: async (path, text) => writeFileAtomic(path, text, "config-document"),
24
15
  removeFile: (path) => unlink(path),
25
16
  stdout: (text) => process.stdout.write(text),
26
17
  stderr: (text) => process.stderr.write(text),
@@ -1 +1 @@
1
- 11.0.0
1
+ 11.1.0
@@ -16,6 +16,6 @@
16
16
  "localRoot": {
17
17
  "outcome": "rejected",
18
18
  "stage": "parse",
19
- "why": "拒:config.d/runtime.json 带事实 ⇒ FileConfigStore 由同一只墓碑拒(本地目录用 `npx @sema-agent/settings-schema migrate <dir>` 迁)。"
19
+ "why": "拒:config.d/runtime.json 带事实 ⇒ FileConfigStore 由同一只墓碑拒(本地目录用 `npx @sema-agent/settings-schema migrate <config.d directory>` 迁)。"
20
20
  }
21
21
  }
@@ -16,6 +16,6 @@
16
16
  "localRoot": {
17
17
  "outcome": "rejected",
18
18
  "stage": "parse",
19
- "why": "拒:config.d/runtime.json 带事实 ⇒ FileConfigStore 由同一只墓碑拒(本地目录用 `npx @sema-agent/settings-schema migrate <dir>` 迁)。"
19
+ "why": "拒:config.d/runtime.json 带事实 ⇒ FileConfigStore 由同一只墓碑拒(本地目录用 `npx @sema-agent/settings-schema migrate <config.d directory>` 迁)。"
20
20
  }
21
21
  }
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "contributor": "contract",
3
- "claim": "7.0.0 删除项读到即**响亮拒**(runtime.sessionBackend,哪怕是停用的 worker)—— 11.0.0 起由 WorkerSpec 里原位的墓碑字段拒,拒句点名一次性迁移 SS-M4(与 `npx @sema-agent/settings-schema migrate <dir>`);不被 zod 静默剥成「什么都没声明」。",
3
+ "claim": "7.0.0 删除项读到即**响亮拒**(runtime.sessionBackend,哪怕是停用的 worker)—— 11.0.0 起由 WorkerSpec 里原位的墓碑字段拒,拒句点名一次性迁移 SS-M4(与 `npx @sema-agent/settings-schema migrate <config.d directory>`);不被 zod 静默剥成「什么都没声明」。",
4
4
  "firstRedOn": []
5
5
  }
@@ -0,0 +1,14 @@
1
+ /** 按文档类的模式表(闭集)。 */
2
+ export declare const ATOMIC_FILE_CLASSES: {
3
+ readonly "config-document": {
4
+ readonly newMode: 420;
5
+ readonly existing: "keep";
6
+ };
7
+ readonly "scheduler-store": {
8
+ readonly newMode: 384;
9
+ readonly existing: "reset";
10
+ };
11
+ };
12
+ export type AtomicFileClass = keyof typeof ATOMIC_FILE_CLASSES;
13
+ /** 原子写 `text`(UTF-8)到 `target`;模式按 `kind` 在 {@link ATOMIC_FILE_CLASSES} 里的行。父目录须已存在。 */
14
+ export declare function writeFileAtomic(target: string, text: string, kind: AtomicFileClass): void;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * **本包写盘的唯一一只原子写器**(Node 专用,不进主桶):`writeDomainFile` / `writeRemoteExec`(`config.d` 下的契约文件)、调度店
3
+ * `saveSchedulerStore`、命令行迁移经 `bin/settings-schema.mjs` 注入的写器 —— 全部调它。写法只有一种:在目标同目录写一个唯一命名的隐藏
4
+ * 临时文件,再 `rename` 盖过目标(POSIX 上原子,并发读者永远看不到半截文件);失败时临时文件被清掉,不留残片。
5
+ *
6
+ * 同步实现(几 KB 的配置文件,一次写不到一毫秒):调度店的写口本来就是同步 API(`saveSchedulerStore(path, records): void`,
7
+ * `mutateSchedulerStore` 的提交点),异步调用方(`writeDomainFile` 等)直接调它 —— 一只写器,没有第二份同样的写法。
8
+ *
9
+ * ## 文件模式:一张按文档类的表({@link ATOMIC_FILE_CLASSES}),不在任何调用点另写
10
+ * · `config-document` —— `config.d` 下的契约 JSON(域文档、`remote-exec.json`):只带 env-NAME、不带秘密值(README 契约),新建 = 0o644;
11
+ * **已有的文件沿用它原来的模式**(运维 chmod 过的文件不被一次写回覆盖)。
12
+ * · `scheduler-store` —— 调度店耐久记录(可能带 principal / session 标识):新建 = 0o600,且**每次写都回到 0o600**(已有的更宽的文件被收回)。
13
+ * 新建的模式照常经进程 umask 遮一道(`writeFile` 的 `mode` 语义):比表更严的 umask 只会更严,不会被这里放宽;沿用的模式原样恢复(不再过 umask)。
14
+ */
15
+ import { randomBytes } from "node:crypto";
16
+ import { chmodSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
17
+ import path from "node:path";
18
+ /** 按文档类的模式表(闭集)。 */
19
+ export const ATOMIC_FILE_CLASSES = {
20
+ "config-document": { newMode: 0o644, existing: "keep" },
21
+ "scheduler-store": { newMode: 0o600, existing: "reset" },
22
+ };
23
+ /** 原子写 `text`(UTF-8)到 `target`;模式按 `kind` 在 {@link ATOMIC_FILE_CLASSES} 里的行。父目录须已存在。 */
24
+ export function writeFileAtomic(target, text, kind) {
25
+ const spec = ATOMIC_FILE_CLASSES[kind];
26
+ let kept; // the existing file's mode, when this class keeps it
27
+ switch (spec.existing) {
28
+ case "keep":
29
+ try {
30
+ kept = statSync(target).mode & 0o777;
31
+ }
32
+ catch (e) {
33
+ if (e.code !== "ENOENT")
34
+ throw e;
35
+ }
36
+ break;
37
+ case "reset":
38
+ break;
39
+ default: {
40
+ const unhandled = spec.existing;
41
+ throw new Error(`writeFileAtomic: unhandled existing-file rule ${String(unhandled)}`);
42
+ }
43
+ }
44
+ const tmp = path.join(path.dirname(target), `.${path.basename(target)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
45
+ try {
46
+ writeFileSync(tmp, text, { encoding: "utf8", mode: kept ?? spec.newMode });
47
+ if (kept !== undefined)
48
+ chmodSync(tmp, kept); // a kept mode is restored exactly (the creation mode above went through the umask)
49
+ renameSync(tmp, target);
50
+ }
51
+ catch (e) {
52
+ try {
53
+ unlinkSync(tmp);
54
+ }
55
+ catch {
56
+ /* 临时文件没建成:没什么可清 */
57
+ }
58
+ throw e;
59
+ }
60
+ }
package/dist/cli.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- /** 入口注入的 I/O(核心不碰 node:fs)。`writeFileAtomic` 必须原子(写临时文件再改名):半写的域文档比旧文档更糟。 */
1
+ /** 入口注入的 I/O(核心不碰 node:fs)。`writeFileAtomic` 必须原子(写临时文件再改名):半写的域文档比旧文档更糟;入口注入的就是 `./atomic-write` 的那一只。 */
2
2
  export interface SettingsSchemaCliIo {
3
3
  readDir(path: string): Promise<string[]>;
4
4
  readFile(path: string): Promise<string>;
package/dist/cli.js CHANGED
@@ -3,14 +3,18 @@
3
3
  * `migrateStoredDocuments`(`./one-time-migrations`)的目录形(本地 config.d / 离线文件用;web-admin 在它起服时直接调同一只函数)。
4
4
  * 目录被当作**一个 scope 的全集**(`coverage: "scope"`):目录里没有的域文件就是从未设过。
5
5
  *
6
- * 本模块不碰 `node:fs`:读写文件由入口 `bin/settings-schema.mjs` 注入(那是本包唯一用 fs 写文件的地方),所以测试可以直接驱动它。
6
+ * 本模块不碰 `node:fs`:读写文件由入口 `bin/settings-schema.mjs` 注入(写盘用的是 `./atomic-write` 那一只原子写器 —— 写法与文件模式在那里一处定),所以测试可以直接驱动它。
7
7
  * 不进主桶 —— 它是 bin 的实现,不是契约面。
8
8
  *
9
9
  * 读哪些文件:目录里 `<名>.json`,名是配置域或retired 域(`runtime`)—— 便携与否不论(离线的 `workers.json` 一样迁);别的文件
10
10
  * (`remote-exec.json`、隐藏文件、不认识的名)不读不动。
11
11
  *
12
- * 退出码:0 = 已写回 / 无事可做;1 = 有迁不了的坐标(收据 `refused`)**或**要写回的域文档整域读不进(整域终判,见 {@link verdictOf}),**一字不写**;
13
- * 2 = 用法 / 读目录 / 读文件错,或某个域文件是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象 —— 与 `FileConfigStore` 同一只判、同一句)(此时 stdout 为空);3 = `--check` 且会有改动、整域判得过(不写)。`--check` 与真跑过同一只判定,只在
12
+ * 零域文档分两形:**有条目却没有域文档**(任何非 `.` 开头的条目 —— 子目录、说明文件、不认识的名)⇒ 退出 2;**目录真空**(零条目,或只有以 `.` 开头的条目)⇒ 照常走判定,
13
+ * 零收据 = noop 退出 0 —— 新部署的空 `config.d` 是合法态,「没有东西可迁」不是「指错目录」(搬空之后再跑同一目录也因此幂等)。
14
+ *
15
+ * 退出码:0 = 已写回 / 无事可做(目录里有域文档只是没有要改的,或目录真空);1 = 有迁不了的坐标(收据 `refused`)**或**要写回的域文档整域读不进(整域终判,见 {@link verdictOf}),**一字不写**;
16
+ * 2 = 用法 / 读目录 / 读文件错,或某个域文件是坏文件(空 / 纯空白 / 坏 JSON / 不是 JSON 对象 —— 与 `FileConfigStore` 同一只判、同一句),**或目录里有条目却没有一个是域文档**
17
+ * (多半是填错了目录:要的是 `config.d` 目录本身;`<目录>/config.d` 在场时句子直接点它)(此时 stdout 为空);3 = `--check` 且会有改动、整域判得过(不写)。`--check` 与真跑过同一只判定,只在
14
18
  * 「有改动、且整域判得过」那一支上分开(pending vs applied)—— 整域会被拒的目录 `--check` 也报 refused / rc 1。
15
19
  *
16
20
  * ## 报告形(让「没写」在机读上不可能被读成「写了」)
@@ -31,7 +35,8 @@ const USAGE = [
31
35
  " Runs the one-time migrations (@sema-agent/settings-schema/one-time-migrations) over every <domain>.json in the directory, read as one",
32
36
  " scope (a missing domain file = never set): writes the rewritten files in place and removes retired-domain files (runtime.json) only when",
33
37
  " nothing is refused and every document it would write reads as a whole domain; --check runs the same verdict and only reports (exit 3",
34
- " if something would change).",
38
+ " if something would change). The argument is the config.d directory itself; a directory that has entries but no <domain>.json among them is",
39
+ " refused (exit 2); an empty directory has nothing to migrate (exit 0).",
35
40
  " stdout (exit 0 / 1 / 3): line 1 = outcome=<refused|applied|noop|pending> written=<true|false>, line 2 = the JSON report",
36
41
  " (applied only when written=true; otherwise wouldApply).",
37
42
  ].join("\n");
@@ -119,6 +124,13 @@ function reportOf(outcome, dir, applied, refused) {
119
124
  return `outcome=${outcome} written=${written}\n${json}\n`;
120
125
  }
121
126
  const join = (dir, name) => `${dir.replace(/[\\/]+$/, "")}/${name}`;
127
+ /** 「有条目却没有域文档」的拒句:点名这个目录、说要的是 `config.d` 目录本身;`<dir>/config.d` 在场(探一次)就直接点它 —— 最常见的错是填了它的上一层。 */
128
+ async function noDomainDocumentsSentence(dir, io) {
129
+ const nested = join(dir, "config.d");
130
+ const nestedExists = await io.readDir(nested).then(() => true, () => false);
131
+ const base = `settings-schema: no domain documents in ${dir} (a domain document is <domain>.json, e.g. models.json) — the argument is the config.d directory itself`;
132
+ return nestedExists ? `${base}; ${nested} exists — run: ${MIGRATE_COMMAND} ${nested}` : base;
133
+ }
122
134
  export async function runSettingsSchemaCli(argv, io) {
123
135
  const [command, ...rest] = argv;
124
136
  const check = rest.includes("--check");
@@ -159,6 +171,12 @@ export async function runSettingsSchemaCli(argv, io) {
159
171
  return 2;
160
172
  }
161
173
  }
174
+ if (Object.keys(docs).length === 0 && names.some((name) => !name.startsWith("."))) {
175
+ // The directory has entries and none of them is a domain document: a no-op report (rc 0) would read as "this scope is migrated" for a
176
+ // directory the command never looked into. A directory with nothing visible in it (empty, or only dot entries) is a legitimate scope.
177
+ io.stderr(`${await noDomainDocumentsSentence(dir, io)}\n`);
178
+ return 2;
179
+ }
162
180
  const m = migrateStoredDocuments(docs, { coverage: "scope" });
163
181
  const applied = m.receipts.filter((r) => r.appliedAs !== "refused");
164
182
  const verdict = verdictOf(docs, m.docs, m.receipts);
@@ -17,9 +17,9 @@ export declare function validateRemoteExec(raw: unknown): ValidateResult<RemoteE
17
17
  /** Absolute path to a domain's JSON file under a config root (mirrors FileConfigStore.domainPath). Guards the
18
18
  * name (no `../../secret` traversal even if a caller bypasses the DomainName type). */
19
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. */
20
+ /** WRITE a domain file ATOMICALLY (the package's one atomic writer, `./atomic-write`: tmp in the same dir → rename) so a crash never
21
+ * leaves a half-written config.d/<domain>.json; a new file takes the contract-document mode (0o644), an existing one keeps its own.
22
+ * VALIDATES first (throws on invalid — never persists garbage); persists the parsed + defaulted value so the file is canonical. */
23
23
  export declare function writeDomainFile<K extends DomainName>(root: string, domain: K, value: unknown): Promise<DomainConfig[K]>;
24
24
  export interface RequiredEnvName {
25
25
  envName: string;
package/dist/file-edit.js CHANGED
@@ -5,8 +5,9 @@
5
5
  *
6
6
  * Validation uses the SAME zod schemas as the server's parseDomain → identical rules on both sides.
7
7
  */
8
- import { mkdir, rename, unlink, writeFile } from "node:fs/promises";
8
+ import { mkdir } from "node:fs/promises";
9
9
  import path from "node:path";
10
+ import { writeFileAtomic } from "./atomic-write.js";
10
11
  import { collectEnvRefs } from "./secret-refs.js";
11
12
  import { RemoteExecSpec } from "./remote-exec.js";
12
13
  import { RetiredDomainError, parseDomain, sanitizeZodError } from "./config-fns.js";
@@ -25,8 +26,6 @@ function assertDomainName(domain) {
25
26
  }
26
27
  }
27
28
  const DOMAINS_LIST = Object.keys(DOMAIN_SCHEMAS).join(", ");
28
- // Monotonic suffix so concurrent same-domain atomic writes in one process+ms can't collide on the tmp name.
29
- let tmpSeq = 0;
30
29
  /** VALIDATE a domain payload against its zod schema WITHOUT writing — the editor's "is this form valid?" check.
31
30
  * Returns the parsed+defaulted value or the zod issues. Identical rules to the server's parseDomain. Throws
32
31
  * (clear message) if `domain` is not a real domain name (a misuse, not invalid user data). */
@@ -61,24 +60,15 @@ export function domainFilePath(root, domain) {
61
60
  assertDomainName(domain);
62
61
  return path.join(root, "config.d", `${domain}.json`);
63
62
  }
64
- /** WRITE a domain file ATOMICALLY (write a tmp in the same dir → rename) so a crash never leaves a half-written
65
- * config.d/<domain>.json. VALIDATES first (throws on invalid — never persists garbage); persists the parsed +
66
- * defaulted value so the file is canonical. */
63
+ /** WRITE a domain file ATOMICALLY (the package's one atomic writer, `./atomic-write`: tmp in the same dir → rename) so a crash never
64
+ * leaves a half-written config.d/<domain>.json; a new file takes the contract-document mode (0o644), an existing one keeps its own.
65
+ * VALIDATES first (throws on invalid — never persists garbage); persists the parsed + defaulted value so the file is canonical. */
67
66
  export async function writeDomainFile(root, domain, value) {
68
67
  assertDomainName(domain); // a stray non-domain key (e.g. "remote-exec" from a config.d scan) fails LOUD + clear
69
68
  const parsed = parseDomain(domain, value); // routes through the SANITIZED parser (explicit-null reject R4 + no secret echo R14)
70
69
  const dir = path.join(root, "config.d");
71
70
  await mkdir(dir, { recursive: true });
72
- const dest = path.join(dir, `${domain}.json`);
73
- const tmp = path.join(dir, `.${domain}.json.${process.pid}.${Date.now()}.${tmpSeq++}.tmp`);
74
- try {
75
- await writeFile(tmp, JSON.stringify(parsed, null, 2) + "\n", "utf8");
76
- await rename(tmp, dest);
77
- }
78
- catch (e) {
79
- await unlink(tmp).catch(() => { }); // never leave a half-written tmp behind on a write/rename failure
80
- throw e;
81
- }
71
+ writeFileAtomic(path.join(dir, `${domain}.json`), JSON.stringify(parsed, null, 2) + "\n", "config-document");
82
72
  return parsed;
83
73
  }
84
74
  /** LIST every env-NAME the current local config requires (across models/workers/mcp + the
@@ -18,7 +18,7 @@
18
18
  * etc.) — no stubbing needed. 逐域为什么被排除见 {@link NON_PORTABLE_DOMAIN_REASONS}(禁裸排除)。
19
19
  * - A RETIRED domain's file (`config.d/runtime.json`, see `RETIRED_DOMAINS`) is read only for its tombstone: carrying facts ⇒
20
20
  * every read entry (`getDomain`, `getEffective` — and so `exportBundle`) throws `RetiredDomainError` (`refuseRetiredDomainFiles`, shared with `readBundle`) (the sentence names the one-time migration — `npx @sema-agent/settings-schema migrate
21
- * <dir>` rewrites the directory); `{}` carries nothing and is left alone. It is never an "unread file".
21
+ * <config.d directory>` rewrites the directory); `{}` carries nothing and is left alone. It is never an "unread file".
22
22
  * - The JSON files only ever carry env-NAMEs (the ENV_NAME boundary holds locally too); `.env` holds values.
23
23
  * - `getVersion()` = a stable content hash of all portable files → a u53 number, so a consumer's
24
24
  * "skip re-apply if version unchanged" works locally. `getEffective().updatedAt` = the max file mtime,
@@ -7,7 +7,7 @@ export declare function remoteExecPath(root: string): string;
7
7
  /** Read `<root>/config.d/remote-exec.json` → a VALIDATED RemoteExecSpec, or undefined if the file is absent
8
8
  * (→ the engine defaults to `host` under CONFIG_PROVIDER=local). Throws (zod) on a present-but-invalid file. */
9
9
  export declare function loadRemoteExec(root: string): Promise<RemoteExecSpec | undefined>;
10
- /** Validate + ATOMICALLY write `<root>/config.d/remote-exec.json` (tmp → rename; never a half file). The
10
+ /** Validate + ATOMICALLY write `<root>/config.d/remote-exec.json` (the package's one atomic writer, `./atomic-write`; never a half file). The
11
11
  * client's `config` editor writes the exec backend through this; the engine reads it via loadRemoteExec. */
12
12
  export declare function writeRemoteExec(root: string, spec: unknown): Promise<RemoteExecSpec>;
13
13
  /** Parse `<root>/.env` (KEY=VALUE, `#` comments, optional surrounding quotes) MERGED OVER process.env (the
@@ -7,9 +7,10 @@
7
7
  * `<root>/config.d/remote-exec.json` (service vote), loaded/validated by loadRemoteExec — distinct from
8
8
  * the per-domain `config.d/<domain>.json` the resolver reads. getEffective never reads it (not a DomainName).
9
9
  */
10
- import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
10
+ import { mkdir, readFile } from "node:fs/promises";
11
11
  import { homedir } from "node:os";
12
12
  import path from "node:path";
13
+ import { writeFileAtomic } from "./atomic-write.js";
13
14
  import { FileConfigStore } from "./file-store.js";
14
15
  import { listRequiredEnvNames } from "./file-edit.js";
15
16
  import { sanitizeZodError } from "./config-fns.js";
@@ -28,9 +29,6 @@ function parseRemoteExec(raw) {
28
29
  return r.data;
29
30
  throw sanitizeZodError(r.error);
30
31
  }
31
- // Monotonic suffix so two atomic writes in the SAME process+millisecond can't collide on the tmp name (which
32
- // would make one rename hit ENOENT / lose a write). pid+Date.now() alone isn't unique under concurrency.
33
- let tmpSeq = 0;
34
32
  /** The remoteExec file path under a config root (under config.d/, beside the per-domain files). */
35
33
  export function remoteExecPath(root) {
36
34
  return path.join(root, "config.d", "remote-exec.json");
@@ -62,21 +60,13 @@ export async function loadRemoteExec(root) {
62
60
  }
63
61
  return parseRemoteExec(json);
64
62
  }
65
- /** Validate + ATOMICALLY write `<root>/config.d/remote-exec.json` (tmp → rename; never a half file). The
63
+ /** Validate + ATOMICALLY write `<root>/config.d/remote-exec.json` (the package's one atomic writer, `./atomic-write`; never a half file). The
66
64
  * client's `config` editor writes the exec backend through this; the engine reads it via loadRemoteExec. */
67
65
  export async function writeRemoteExec(root, spec) {
68
66
  const parsed = parseRemoteExec(spec);
69
67
  const dir = path.join(root, "config.d");
70
68
  await mkdir(dir, { recursive: true });
71
- const tmp = path.join(dir, `.remote-exec.json.${process.pid}.${Date.now()}.${tmpSeq++}.tmp`);
72
- try {
73
- await writeFile(tmp, JSON.stringify(parsed, null, 2) + "\n", "utf8");
74
- await rename(tmp, remoteExecPath(root));
75
- }
76
- catch (e) {
77
- await unlink(tmp).catch(() => { }); // never leave a half-written tmp behind
78
- throw e;
79
- }
69
+ writeFileAtomic(remoteExecPath(root), JSON.stringify(parsed, null, 2) + "\n", "config-document");
80
70
  return parsed;
81
71
  }
82
72
  /** Resolve a `file:<path>` .env VALUE → the file's trimmed content (the file-NAME secret ref, config-research
@@ -9,6 +9,12 @@ export type WireRetirementId = (typeof WIRE_RETIREMENT_IDS)[number];
9
9
  export interface OneTimeMigrationWindow {
10
10
  /** 数据迁移 id(闭集 `ONE_TIME_MIGRATION_IDS`)或 wire 删码位 id(闭集 {@link WIRE_RETIREMENT_IDS});两集合起来每个 id 恰一行。 */
11
11
  readonly id: OneTimeMigrationId | WireRetirementId;
12
+ /** 旧形在读侧的待遇(闭集,每词一义;「读写两面遇旧形一律响亮拒,拒句点名迁移 id」这条律的例外只在这一格里写):
13
+ * · `refused` — 读写两面拒,拒句点名本行 id(经 `migrationSignpost` 铸;钉:每个 `refused` 行的 id 在 `src/` 至少一处铸点,
14
+ * 且非 `refused` 行零铸点);
15
+ * · `rewritten-on-read` — 读时改写、不拒(SS-M3:调度店的下一个写者落盘),没有拒句可点名;
16
+ * · `not-stored` — 没有存储形(wire 删码位),谈不上拒。 */
17
+ readonly oldForm: "refused" | "rewritten-on-read" | "not-stored";
12
18
  /** 引入这条迁移的版本。 */
13
19
  readonly introducedIn: string;
14
20
  /** 该版本在 npm 上的发布日(UTC 日历日 YYYY-MM-DD)。`null` = 未发布形,由抬 `package.json` 到 `introducedIn` 的发布准备提交回填。 */
@@ -45,6 +45,7 @@ export const WIRE_RETIREMENT_IDS = ["SS-W1"];
45
45
  export const ONE_TIME_MIGRATIONS = [
46
46
  {
47
47
  id: "SS-M1",
48
+ oldForm: "refused",
48
49
  introducedIn: "11.0.0",
49
50
  releasedOn: "2026-10-10",
50
51
  removableAfter: "2026-11-09",
@@ -60,6 +61,7 @@ export const ONE_TIME_MIGRATIONS = [
60
61
  },
61
62
  {
62
63
  id: "SS-M2",
64
+ oldForm: "refused",
63
65
  introducedIn: "11.0.0",
64
66
  releasedOn: "2026-10-10",
65
67
  removableAfter: "2026-11-09",
@@ -71,6 +73,7 @@ export const ONE_TIME_MIGRATIONS = [
71
73
  },
72
74
  {
73
75
  id: "SS-M3",
76
+ oldForm: "rewritten-on-read",
74
77
  introducedIn: "11.0.0",
75
78
  releasedOn: "2026-10-10",
76
79
  removableAfter: "2026-11-09",
@@ -83,6 +86,7 @@ export const ONE_TIME_MIGRATIONS = [
83
86
  },
84
87
  {
85
88
  id: "SS-M4",
89
+ oldForm: "refused",
86
90
  introducedIn: "11.0.0",
87
91
  releasedOn: "2026-10-10",
88
92
  removableAfter: "2026-11-09",
@@ -95,6 +99,7 @@ export const ONE_TIME_MIGRATIONS = [
95
99
  },
96
100
  {
97
101
  id: "SS-M5",
102
+ oldForm: "refused",
98
103
  introducedIn: "11.0.0",
99
104
  releasedOn: "2026-10-10",
100
105
  removableAfter: "2026-11-09",
@@ -104,6 +109,7 @@ export const ONE_TIME_MIGRATIONS = [
104
109
  },
105
110
  {
106
111
  id: "SS-M6",
112
+ oldForm: "refused",
107
113
  introducedIn: "11.0.0",
108
114
  releasedOn: "2026-10-10",
109
115
  removableAfter: "2026-11-09",
@@ -115,17 +121,19 @@ export const ONE_TIME_MIGRATIONS = [
115
121
  },
116
122
  {
117
123
  id: "SS-M7",
124
+ oldForm: "refused",
118
125
  introducedIn: "11.0.0",
119
126
  releasedOn: "2026-10-10",
120
127
  removableAfter: "2026-11-09",
121
128
  rewrites: "every NON_SECRET_URL seat in a domain document (derived from the domain schemas, not listed by hand — today models.models[].baseUrl, " +
122
129
  "mcp.servers[].transport.url, execution.sessionMirror.engineUrl, workers.workers[].coords.gitApiBaseUrl): `#` and everything after it are removed " +
123
130
  "(a fragment never goes on the wire)",
124
- removal: "the next major after the window: delete the SS-M7 step and its schema walk",
131
+ removal: "the next major after the window: delete the SS-M7 step and its schema walk; the NON_SECRET_URL fragment rule loses its SS-M7 clause",
125
132
  afterRemoval: "the NON_SECRET_URL shape judge, which refuses a fragment (`#` included) on read and write",
126
133
  },
127
134
  {
128
135
  id: "SS-M9",
136
+ oldForm: "refused",
129
137
  introducedIn: "11.0.0",
130
138
  releasedOn: "2026-10-10",
131
139
  removableAfter: "2026-11-09",
@@ -139,6 +147,7 @@ export const ONE_TIME_MIGRATIONS = [
139
147
  {
140
148
  // 一条 wire 位的删码登记,不是数据迁移(id 在 WIRE_RETIREMENT_IDS,不在收据闭集)。
141
149
  id: "SS-W1",
150
+ oldForm: "not-stored",
142
151
  introducedIn: "11.0.0",
143
152
  releasedOn: "2026-10-10",
144
153
  removableAfter: "2026-11-09",
@@ -28,7 +28,7 @@
28
28
  */
29
29
  import { z } from "zod";
30
30
  import { INTEGER } from "./integer.js";
31
- import { ENV_NAME, K8S_QUANTITY, NON_SECRET_URL, TCP_PORT, containsSecretToken, hasNul } from "./types.js";
31
+ import { ENV_NAME, K8S_QUANTITY, NON_SECRET_URL_NO_MIGRATION, TCP_PORT, containsSecretToken, hasNul } from "./types.js";
32
32
  import { retiredLaneWordRefusal } from "./execution-lanes.js";
33
33
  /** A non-empty exec-bound string (an image / path / shell / arg the adapter feeds to spawn()/fs). Rejects a NUL
34
34
  * byte uniformly — a NUL validates here but makes Node throw at spawn/boot (fail-OPEN → runtime crash). R20. */
@@ -84,7 +84,7 @@ export const RemoteExecSpec = z.discriminatedUnion("provider", [
84
84
  /** Sandbox container image (service: K8S_SANDBOX_IMAGE). Required so a run is reproducible. */
85
85
  image: execStr,
86
86
  /** Kube API URL (service: K8S_API_URL); absent → in-cluster service account. NO embedded credentials. */
87
- apiUrl: NON_SECRET_URL.optional(),
87
+ apiUrl: NON_SECRET_URL_NO_MIGRATION.optional(),
88
88
  /** env-NAME holding the kube bearer token (service: K8S_TOKEN). NEVER the token. */
89
89
  tokenEnv: ENV_NAME.optional(),
90
90
  /** env-NAME holding the base64 CA cert (service: K8S_CA_CERT_B64). NEVER the cert bytes. */
@@ -102,7 +102,7 @@ export const RemoteExecSpec = z.discriminatedUnion("provider", [
102
102
  * "all three of endpoint/access/secret required" gate). */
103
103
  s3Snapshot: z
104
104
  .object({
105
- endpoint: NON_SECRET_URL, // S3/MinIO endpoint — creds go in accessKeyEnv/secretKeyEnv, never the URL
105
+ endpoint: NON_SECRET_URL_NO_MIGRATION, // S3/MinIO endpoint — creds go in accessKeyEnv/secretKeyEnv, never the URL
106
106
  bucket: execStr.optional(),
107
107
  accessKeyEnv: ENV_NAME,
108
108
  secretKeyEnv: ENV_NAME,
@@ -11,7 +11,7 @@
11
11
  import { z } from "zod";
12
12
  /** 迁移命令行的唯一拼写:`npx` 全名形 —— 没装本包的运维粘贴即可跑(裸 bin 名在没装本包的机器上是 `command not found`)。参数是一个 `config.d` 目录。 */
13
13
  export declare const MIGRATE_COMMAND: "npx @sema-agent/settings-schema migrate";
14
- /** 拒句尾的迁移指路:存量由哪一条一次性迁移改写、两种跑法(子路径的函数 / 目录命令)。`before` = 旧形不再合法的那一版。
14
+ /** 拒句尾的迁移指路:存量由哪一条一次性迁移改写、两种跑法(子路径的函数 / 目录命令;目录是 `config.d` 目录本身,不是它的上一层)。`before` = 旧形不再合法的那一版。
15
15
  * 刻意短:它挂在 zod issue 句尾,读侧逐条容错时每一条被丢的条目都要过一遍出门脱敏。 */
16
16
  export declare function migrationSignpost(migration: string, before: string): string;
17
17
  /** 一个retired 形的整句:`<form> was retired in <版本> — now <今天住哪>; <指路>`。 */
@@ -11,10 +11,10 @@
11
11
  import { z } from "zod";
12
12
  /** 迁移命令行的唯一拼写:`npx` 全名形 —— 没装本包的运维粘贴即可跑(裸 bin 名在没装本包的机器上是 `command not found`)。参数是一个 `config.d` 目录。 */
13
13
  export const MIGRATE_COMMAND = "npx @sema-agent/settings-schema migrate";
14
- /** 拒句尾的迁移指路:存量由哪一条一次性迁移改写、两种跑法(子路径的函数 / 目录命令)。`before` = 旧形不再合法的那一版。
14
+ /** 拒句尾的迁移指路:存量由哪一条一次性迁移改写、两种跑法(子路径的函数 / 目录命令;目录是 `config.d` 目录本身,不是它的上一层)。`before` = 旧形不再合法的那一版。
15
15
  * 刻意短:它挂在 zod issue 句尾,读侧逐条容错时每一条被丢的条目都要过一遍出门脱敏。 */
16
16
  export function migrationSignpost(migration, before) {
17
- return `data stored before ${before}: run the one-time migration ${migration} (@sema-agent/settings-schema/one-time-migrations, or ${MIGRATE_COMMAND} <dir>)`;
17
+ return `data stored before ${before}: run the one-time migration ${migration} (@sema-agent/settings-schema/one-time-migrations, or ${MIGRATE_COMMAND} <config.d directory>)`;
18
18
  }
19
19
  export function retiredFormSentence(form, t) {
20
20
  return `${form} was retired in ${t.retiredIn} — now ${t.now}; ${migrationSignpost(t.migration, t.retiredIn)}`;
@@ -9,9 +9,10 @@ import { type SchedulerParseWarning, type SchedulerReadOptions, type SchedulerRe
9
9
  */
10
10
  export declare function loadSchedulerStore(path: string, opts: SchedulerReadOptions): SchedulerStoreRead;
11
11
  /**
12
- * Persist ATOMICALLY: `mkdir -p` the parent, write a uniquely-named `.tmp` sibling (mode 0o600 — owner-only, the
13
- * records may carry principal/session identifiers), then `rename` over the target (atomic on POSIX, so a
14
- * concurrent reader never sees a partial file). Throws on IO failure (caller maps to a domain error).
12
+ * Persist ATOMICALLY: `mkdir -p` the parent, then the package's one atomic writer (`./atomic-write`: a uniquely-named `.tmp`
13
+ * sibling, then `rename` over the target — atomic on POSIX, so a concurrent reader never sees a partial file). The file mode
14
+ * is the `scheduler-store` class's (0o600 — owner-only, the records may carry principal/session identifiers; every save
15
+ * restores it). Throws on IO failure (caller maps to a domain error).
15
16
  */
16
17
  export declare function saveSchedulerStore(path: string, records: SchedulerRecord[]): void;
17
18
  /** A filesystem-backed {@link SchedulerStore} bound to a path (convenience for the daemon/backend constructors).
@@ -6,9 +6,9 @@
6
6
  *
7
7
  * Store path = caller-supplied; both sides agree on `SEMA_CONFIG_DIR` → `~/.sema/scheduled_tasks.json`.
8
8
  */
9
- import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
9
+ import { mkdirSync, readFileSync } from 'node:fs';
10
10
  import { dirname } from 'node:path';
11
- import { randomBytes } from 'node:crypto';
11
+ import { writeFileAtomic } from './atomic-write.js';
12
12
  import { parseSchedulerStore, serializeSchedulerStore } from './scheduler-store.js';
13
13
  /**
14
14
  * Load the durable store. A MISSING file (ENOENT) ⇒ no records, no warnings (first-run/never-written case); any OTHER
@@ -31,15 +31,14 @@ export function loadSchedulerStore(path, opts) {
31
31
  return parseSchedulerStore(text, opts);
32
32
  }
33
33
  /**
34
- * Persist ATOMICALLY: `mkdir -p` the parent, write a uniquely-named `.tmp` sibling (mode 0o600 — owner-only, the
35
- * records may carry principal/session identifiers), then `rename` over the target (atomic on POSIX, so a
36
- * concurrent reader never sees a partial file). Throws on IO failure (caller maps to a domain error).
34
+ * Persist ATOMICALLY: `mkdir -p` the parent, then the package's one atomic writer (`./atomic-write`: a uniquely-named `.tmp`
35
+ * sibling, then `rename` over the target — atomic on POSIX, so a concurrent reader never sees a partial file). The file mode
36
+ * is the `scheduler-store` class's (0o600 — owner-only, the records may carry principal/session identifiers; every save
37
+ * restores it). Throws on IO failure (caller maps to a domain error).
37
38
  */
38
39
  export function saveSchedulerStore(path, records) {
39
40
  mkdirSync(dirname(path), { recursive: true });
40
- const tmp = `${path}.${randomBytes(6).toString('hex')}.tmp`;
41
- writeFileSync(tmp, serializeSchedulerStore(records), { mode: 0o600 });
42
- renameSync(tmp, path); // atomic over the target
41
+ writeFileAtomic(path, serializeSchedulerStore(records), 'scheduler-store');
43
42
  }
44
43
  /** A filesystem-backed {@link SchedulerStore} bound to a path (convenience for the daemon/backend constructors).
45
44
  *
package/dist/types.d.ts CHANGED
@@ -62,10 +62,18 @@ export declare function urlQueryFragmentHasSecret(u: URL): boolean;
62
62
  /** A URL handed to `fetch` that carries no credential: judged by THE URL shape judge ({@link httpUrlFlaws}, `./url-shape`) — a
63
63
  * hierarchical http(s) URL with neither userinfo nor a fragment (an empty `#` included, 11.0.0) — and with no secret-shaped value
64
64
  * in its path or query ({@link urlQueryFragmentHasSecret}). One issue per flaw, its message the judge's rule sentence (no value
65
- * echoed); `params.flaw` carries the rule word.
65
+ * echoed; a fragment's also names its migration, {@link nonSecretUrlSentence}); `params.flaw` carries the rule word.
66
+ *
67
+ * This is the seat of every URL in a stored DOMAIN document (models `baseUrl`, mcp `url`, `sessionMirror.engineUrl`, workers
68
+ * `gitApiBaseUrl`). A URL outside the domain documents — `remote-exec.json`'s endpoints, the web-admin credential envelope's
69
+ * `issuer` — uses {@link NON_SECRET_URL_NO_MIGRATION}.
66
70
  *
67
71
  * The value is kept EXACTLY as written: nothing here trims or normalizes it (zod's own `.url()` would hand back a rewritten string). */
68
72
  export declare const NON_SECRET_URL: z.ZodString;
73
+ /** {@link NON_SECRET_URL}'s judge for a seat no one-time migration reaches (not a domain document): identical rules and rule
74
+ * sentences, but a fragment's refusal carries no migration signpost — pointing at a migration that cannot rewrite the value would
75
+ * send the operator to a command that does nothing for it. */
76
+ export declare const NON_SECRET_URL_NO_MIGRATION: z.ZodString;
69
77
  /** Validate a free-form `extraBody`: returns true = UNSAFE (reject). A GETTER-FREE, descriptor-based, bounded
70
78
  * walk that rejects — WITHOUT ever invoking a getter (a malicious getter could throw a secret-bearing message,
71
79
  * or return different values across the validate vs persist JSON.stringify calls) — anything that isn't plain
package/dist/types.js CHANGED
@@ -168,20 +168,52 @@ export function urlQueryFragmentHasSecret(u) {
168
168
  * after it is dropped with it — the engine and server refuse a model route root that carries one, so a stored one only fails
169
169
  * later, on the worker). A query is a legitimate request parameter (`?api-version=…`) and stays. */
170
170
  const NON_SECRET_URL_FORBIDS = ["userinfo", "fragment"];
171
+ /** The refusal sentence of one flaw at a {@link NON_SECRET_URL} seat: the judge's rule sentence, and — for the one flaw that stored data
172
+ * can carry from before 11.0.0 (a fragment) at a seat the one-time migration reaches — the signpost to the migration that strips
173
+ * it, joined the way the other signposted refusals join it (`<rule>; <signpost>`). Closed over the judge's flaw words: a new word is
174
+ * a compile error here. */
175
+ function nonSecretUrlSentence(flaw, reachedByMigration) {
176
+ switch (flaw) {
177
+ case "fragment":
178
+ return reachedByMigration ? `${httpUrlRule(flaw)}; ${migrationSignpost("SS-M7", "11.0.0")}` : httpUrlRule(flaw);
179
+ case "not-http-url":
180
+ case "userinfo":
181
+ case "query":
182
+ return httpUrlRule(flaw);
183
+ default: {
184
+ const unhandled = flaw;
185
+ throw new Error("nonSecretUrlSentence: unhandled flaw word", { cause: unhandled });
186
+ }
187
+ }
188
+ }
189
+ /** One judge, two seat kinds — the only difference is the fragment refusal's migration signpost. `reachedByMigration` = the seat sits in a
190
+ * stored domain document, which the one-time migration SS-M7 rewrites (it finds its seats by this schema's identity, so "carries the
191
+ * signpost" and "is rewritten by SS-M7" cannot drift apart). */
192
+ function createNonSecretUrl(reachedByMigration) {
193
+ return z.string().superRefine((s, ctx) => {
194
+ const flaws = httpUrlFlaws(s, NON_SECRET_URL_FORBIDS);
195
+ for (const flaw of flaws)
196
+ ctx.addIssue({ code: "custom", message: nonSecretUrlSentence(flaw, reachedByMigration), params: { flaw } });
197
+ if (flaws.length === 0 && urlQueryFragmentHasSecret(new URL(s))) {
198
+ ctx.addIssue({ code: "custom", message: "must not carry a secret-shaped value in its path or query — secrets go in the *Env (env-NAME) fields" });
199
+ }
200
+ });
201
+ }
171
202
  /** A URL handed to `fetch` that carries no credential: judged by THE URL shape judge ({@link httpUrlFlaws}, `./url-shape`) — a
172
203
  * hierarchical http(s) URL with neither userinfo nor a fragment (an empty `#` included, 11.0.0) — and with no secret-shaped value
173
204
  * in its path or query ({@link urlQueryFragmentHasSecret}). One issue per flaw, its message the judge's rule sentence (no value
174
- * echoed); `params.flaw` carries the rule word.
205
+ * echoed; a fragment's also names its migration, {@link nonSecretUrlSentence}); `params.flaw` carries the rule word.
206
+ *
207
+ * This is the seat of every URL in a stored DOMAIN document (models `baseUrl`, mcp `url`, `sessionMirror.engineUrl`, workers
208
+ * `gitApiBaseUrl`). A URL outside the domain documents — `remote-exec.json`'s endpoints, the web-admin credential envelope's
209
+ * `issuer` — uses {@link NON_SECRET_URL_NO_MIGRATION}.
175
210
  *
176
211
  * The value is kept EXACTLY as written: nothing here trims or normalizes it (zod's own `.url()` would hand back a rewritten string). */
177
- export const NON_SECRET_URL = z.string().superRefine((s, ctx) => {
178
- const flaws = httpUrlFlaws(s, NON_SECRET_URL_FORBIDS);
179
- for (const flaw of flaws)
180
- ctx.addIssue({ code: "custom", message: httpUrlRule(flaw), params: { flaw } });
181
- if (flaws.length === 0 && urlQueryFragmentHasSecret(new URL(s))) {
182
- ctx.addIssue({ code: "custom", message: "must not carry a secret-shaped value in its path or query — secrets go in the *Env (env-NAME) fields" });
183
- }
184
- });
212
+ export const NON_SECRET_URL = createNonSecretUrl(true);
213
+ /** {@link NON_SECRET_URL}'s judge for a seat no one-time migration reaches (not a domain document): identical rules and rule
214
+ * sentences, but a fragment's refusal carries no migration signpost — pointing at a migration that cannot rewrite the value would
215
+ * send the operator to a command that does nothing for it. */
216
+ export const NON_SECRET_URL_NO_MIGRATION = createNonSecretUrl(false);
185
217
  /** Validate a free-form `extraBody`: returns true = UNSAFE (reject). A GETTER-FREE, descriptor-based, bounded
186
218
  * walk that rejects — WITHOUT ever invoking a getter (a malicious getter could throw a secret-bearing message,
187
219
  * or return different values across the validate vs persist JSON.stringify calls) — anything that isn't plain
@@ -32,7 +32,7 @@ export declare const WEB_ADMIN_CREDENTIAL_ENVELOPE_KIND: "user-session";
32
32
  * 文件**取当前值,不用内存旧值(另一个写者可能刚轮换过)。
33
33
  * · `expiresAt` —— 访问令牌到期时刻(RFC 3339)。
34
34
  * · `issuer` —— 签发它的 web-admin 的**基础 URL**(与读方连 web-admin 的根 URL 同形,可带路径前缀):续签只向同源的 web-admin 发,
35
- * 同不同源由读方比。URL 形判据与 `remoteExec` 的端点同一只 {@link NON_SECRET_URL}(http(s)、有 host、userinfo 与
35
+ * 同不同源由读方比。URL 形判据与 `remoteExec` 的端点同一只 {@link NON_SECRET_URL_NO_MIGRATION}(http(s)、有 host、userinfo 与
36
36
  * query / fragment 里不许夹带凭据)—— 本包不另写一份更窄的 URL 律。
37
37
  */
38
38
  export declare const WebAdminCredentialEnvelope: z.ZodObject<{
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { z } from "zod";
23
23
  import { looksLikeJwt } from "./api/auth-bridge.js";
24
- import { NON_SECRET_URL } from "./types.js";
24
+ import { NON_SECRET_URL_NO_MIGRATION } from "./types.js";
25
25
  import { WEB_ADMIN_ENV } from "./web-admin-env.js";
26
26
  /** 信封 `kind` 的闭集今只有一员(`SEALED_BOX_ALG` 同式的字面常量:schema 的字面与读方的比较都引它)。 */
27
27
  export const WEB_ADMIN_CREDENTIAL_ENVELOPE_KIND = "user-session";
@@ -50,7 +50,7 @@ const TokenString = z.string().regex(TOKEN_CHARS, "must be a token: printable AS
50
50
  * 文件**取当前值,不用内存旧值(另一个写者可能刚轮换过)。
51
51
  * · `expiresAt` —— 访问令牌到期时刻(RFC 3339)。
52
52
  * · `issuer` —— 签发它的 web-admin 的**基础 URL**(与读方连 web-admin 的根 URL 同形,可带路径前缀):续签只向同源的 web-admin 发,
53
- * 同不同源由读方比。URL 形判据与 `remoteExec` 的端点同一只 {@link NON_SECRET_URL}(http(s)、有 host、userinfo 与
53
+ * 同不同源由读方比。URL 形判据与 `remoteExec` 的端点同一只 {@link NON_SECRET_URL_NO_MIGRATION}(http(s)、有 host、userinfo 与
54
54
  * query / fragment 里不许夹带凭据)—— 本包不另写一份更窄的 URL 律。
55
55
  */
56
56
  export const WebAdminCredentialEnvelope = z
@@ -59,7 +59,7 @@ export const WebAdminCredentialEnvelope = z
59
59
  accessToken: TokenString.refine(looksLikeJwt, "must be a JWT (three dot-separated segments) — a worker pull token never goes into an envelope"),
60
60
  refreshToken: TokenString,
61
61
  expiresAt: RFC3339_DATE_TIME,
62
- issuer: NON_SECRET_URL,
62
+ issuer: NON_SECRET_URL_NO_MIGRATION,
63
63
  })
64
64
  .strict();
65
65
  /** 信封自己的字段名(schema 的键 —— reason 里只许出现这几个名字,不许出现用户写的键)。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/settings-schema",
3
- "version": "11.0.0",
3
+ "version": "11.1.0",
4
4
  "description": "Sema settings schema — pure config contract (zod domains + effective-config + roster resolution + ref-integrity + remoteExec) shared by sema-web-admin, sema-server, and the TOC desktop/CLI. One source of truth. (Renamed at 1.0.0 from @sema-agent/registry-core — name and docs only, no behavior change; note the range moves 0.19.0 -> 1.0.0, so a dependency must become ^1.0.0. The old names @sema-agent/registry-core <=0.19.0 and @sema-ai/registry-core remain published, frozen, for migration.)",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",