@sema-agent/settings-schema 6.4.0 → 7.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 +159 -0
- package/README.md +34 -2
- package/bin/settings-schema.mjs +22 -0
- package/conformance/VERSION +1 -1
- package/conformance/cases/c50-s784-lane-host-legal/expected.json +102 -0
- package/conformance/cases/c50-s784-lane-host-legal/input.json +29 -0
- package/conformance/cases/c50-s784-lane-host-legal/meta.json +5 -0
- package/conformance/cases/c51-s784-lane-e2b-legal/expected.json +104 -0
- package/conformance/cases/c51-s784-lane-e2b-legal/input.json +32 -0
- package/conformance/cases/c51-s784-lane-e2b-legal/meta.json +5 -0
- package/conformance/cases/c52-s784-lane-k8s-legal/expected.json +106 -0
- package/conformance/cases/c52-s784-lane-k8s-legal/input.json +34 -0
- package/conformance/cases/c52-s784-lane-k8s-legal/meta.json +5 -0
- package/conformance/cases/c53-s784-lane-ssh-legal/expected.json +106 -0
- package/conformance/cases/c53-s784-lane-ssh-legal/input.json +34 -0
- package/conformance/cases/c53-s784-lane-ssh-legal/meta.json +5 -0
- package/conformance/cases/c54-s784-lane-adb-legal/expected.json +102 -0
- package/conformance/cases/c54-s784-lane-adb-legal/input.json +29 -0
- package/conformance/cases/c54-s784-lane-adb-legal/meta.json +5 -0
- package/conformance/cases/c55-s784-lane-local-docker-legal/expected.json +103 -0
- package/conformance/cases/c55-s784-lane-local-docker-legal/input.json +30 -0
- package/conformance/cases/c55-s784-lane-local-docker-legal/meta.json +5 -0
- package/conformance/cases/c56-s784-lane-device-legal/expected.json +109 -0
- package/conformance/cases/c56-s784-lane-device-legal/input.json +36 -0
- package/conformance/cases/c56-s784-lane-device-legal/meta.json +5 -0
- package/conformance/cases/c57-s784-g1-enabled-no-lane/expected.json +83 -0
- package/conformance/cases/c57-s784-g1-enabled-no-lane/input.json +23 -0
- package/conformance/cases/c57-s784-g1-enabled-no-lane/meta.json +5 -0
- package/conformance/cases/c58-s784-g2-multitenant-host/expected.json +83 -0
- package/conformance/cases/c58-s784-g2-multitenant-host/input.json +34 -0
- package/conformance/cases/c58-s784-g2-multitenant-host/meta.json +5 -0
- package/conformance/cases/c59-s784-g3-k8s-missing-image/expected.json +83 -0
- package/conformance/cases/c59-s784-g3-k8s-missing-image/input.json +26 -0
- package/conformance/cases/c59-s784-g3-k8s-missing-image/meta.json +5 -0
- package/conformance/cases/c60-s784-g3-e2b-missing-key/expected.json +83 -0
- package/conformance/cases/c60-s784-g3-e2b-missing-key/input.json +26 -0
- package/conformance/cases/c60-s784-g3-e2b-missing-key/meta.json +5 -0
- package/conformance/cases/c61-s784-g3-other-lane-key/expected.json +83 -0
- package/conformance/cases/c61-s784-g3-other-lane-key/input.json +30 -0
- package/conformance/cases/c61-s784-g3-other-lane-key/meta.json +5 -0
- package/conformance/cases/c62-s784-g7a-session-tidb/expected.json +83 -0
- package/conformance/cases/c62-s784-g7a-session-tidb/input.json +27 -0
- package/conformance/cases/c62-s784-g7a-session-tidb/meta.json +5 -0
- package/conformance/cases/c63-s784-host-two-replicas/expected.json +83 -0
- package/conformance/cases/c63-s784-host-two-replicas/input.json +27 -0
- package/conformance/cases/c63-s784-host-two-replicas/meta.json +5 -0
- package/conformance/cases/c64-s784-multitenant-local-store/expected.json +83 -0
- package/conformance/cases/c64-s784-multitenant-local-store/input.json +30 -0
- package/conformance/cases/c64-s784-multitenant-local-store/meta.json +5 -0
- package/conformance/cases/c65-s784-pg-without-db/expected.json +83 -0
- package/conformance/cases/c65-s784-pg-without-db/input.json +27 -0
- package/conformance/cases/c65-s784-pg-without-db/meta.json +5 -0
- package/conformance/cases/c66-s784-memory-backend-mismatch/expected.json +83 -0
- package/conformance/cases/c66-s784-memory-backend-mismatch/input.json +36 -0
- package/conformance/cases/c66-s784-memory-backend-mismatch/meta.json +5 -0
- package/conformance/cases/c67-s784-retired-field-loud/expected.json +83 -0
- package/conformance/cases/c67-s784-retired-field-loud/input.json +22 -0
- package/conformance/cases/c67-s784-retired-field-loud/meta.json +5 -0
- package/conformance/cases/c68-s784-device-local-store/expected.json +83 -0
- package/conformance/cases/c68-s784-device-local-store/input.json +26 -0
- package/conformance/cases/c68-s784-device-local-store/meta.json +5 -0
- package/conformance/cases/cli04-env-value-with-newline/expected.json +13 -2
- package/conformance/cases/cli04-env-value-with-newline/input.json +8 -1
- package/conformance/cases/cli04-env-value-with-newline/meta.json +1 -1
- package/conformance/migrate-workers/m01-g5-k8s-env-lifted/expected.json +49 -0
- package/conformance/migrate-workers/m01-g5-k8s-env-lifted/input.json +26 -0
- package/conformance/migrate-workers/m01-g5-k8s-env-lifted/meta.json +4 -0
- package/conformance/migrate-workers/m02-g5-idempotent/expected.json +38 -0
- package/conformance/migrate-workers/m02-g5-idempotent/input.json +33 -0
- package/conformance/migrate-workers/m02-g5-idempotent/meta.json +5 -0
- package/conformance/migrate-workers/m03-g5-refused-no-lane/expected.json +71 -0
- package/conformance/migrate-workers/m03-g5-refused-no-lane/input.json +42 -0
- package/conformance/migrate-workers/m03-g5-refused-no-lane/meta.json +4 -0
- package/conformance/migrate-workers/m04-g7b-session-tidb-lifted/expected.json +52 -0
- package/conformance/migrate-workers/m04-g7b-session-tidb-lifted/input.json +30 -0
- package/conformance/migrate-workers/m04-g7b-session-tidb-lifted/meta.json +4 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +70 -0
- package/dist/execution-lane-index.d.ts +19 -0
- package/dist/execution-lane-index.js +23 -0
- package/dist/execution-lanes.d.ts +64 -0
- package/dist/execution-lanes.js +152 -0
- package/dist/file-edit.js +26 -21
- package/dist/hooks.d.ts +4 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/migrate-workers.d.ts +63 -0
- package/dist/migrate-workers.js +331 -0
- package/dist/remote-exec.d.ts +23 -64
- package/dist/remote-exec.js +20 -71
- package/dist/secret-refs.d.ts +7 -1
- package/dist/secret-refs.js +9 -3
- package/dist/types.d.ts +1356 -455
- package/dist/types.js +258 -63
- package/package.json +9 -1
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workers 域的**一次性迁移**(7.0.0,M-R2 WorkerSpec 结构块)—— 持久面唯一允许的过渡,窗口关了就整文件删除
|
|
3
|
+
* (连同 `types.ts` 里「删除项读到即拒」那一只预处理)。纯函数、无 I/O、不改入参;文件形的入口是
|
|
4
|
+
* `settings-schema migrate-workers <file>`(`bin/settings-schema.mjs`,同一只函数)。
|
|
5
|
+
*
|
|
6
|
+
* ## 谁跑、在哪
|
|
7
|
+
* 读前抬升:在 `parseDomain("workers", raw)` / `DOMAIN_SCHEMAS.workers.safeParse` **之前**对**原始 JSON** 跑 —— workers 是
|
|
8
|
+
* 「读即抛」的 gate 域,6.x 存下来的行在 7.0.0 schema 上整域读不出来(删除项响亮拒、enabled 的 worker 缺两块)。中心在它唯一的
|
|
9
|
+
* workers 读口跑一次,结果写回;CLI 给文件后端 / 离线用。
|
|
10
|
+
*
|
|
11
|
+
* ## 规则(每个旧键恰好一种去处)
|
|
12
|
+
* · **车道**:`env.REMOTE_EXEC` 是车道词 ⇒ `runtime.execution.lane`;同车道的 env 键 ⇒ `coords`,同车道的 `secretEnv` 密钥 ⇒
|
|
13
|
+
* `secretRefs`(都按导出表 `EXECUTION_LANES` 的栏判)。别车道的键、`REQUIRE_EXECUTION_LANE` ⇒ **dropped**(引擎只读被点名车道
|
|
14
|
+
* 那一臂;声明了车道就恒渲 true)—— 今天就是惰性的。**enabled 且无 `REMOTE_EXEC` ⇒ refused**:那是「无手照回 completed」的
|
|
15
|
+
* 病例本身,迁移不替运维猜车道。停用且无 `REMOTE_EXEC` ⇒ 不写车道,残留车道键 dropped。
|
|
16
|
+
* · **店**:`sessionBackend` ⇒ `store.session`(`tidb` ⇒ `mysql`,且 `store.backend` 由此为 `mysql`);`memoryBackend` ⇒ **dropped**
|
|
17
|
+
* (它从未到达引擎);`coords.tidbHost` 在 ⇒ `store.backend = mysql`,`coords.tidb*` + `runtime.db` ⇒ `store.db`(缺端口 ⇒ 引擎
|
|
18
|
+
* `MYSQL_PORT` 的缺省 3306),`secretRefs.tidbPasswordEnv` ⇒ `secretRefs.dbPasswordEnv`;没有 host ⇒ `store.backend = local`
|
|
19
|
+
* (设计稿 §3.4:推一次、写死),零散坐标惰性 ⇒ dropped。`env` 逃生口里的 `DB_BACKEND` / `MEMORY_ENGINE` /
|
|
20
|
+
* `MEMORY_ENGINE_BACKEND`(渲染器以前不渲,运维只能写在那里)⇒ 搬进 `store`,显式的 `DB_BACKEND` 优先。停用且旧形没点名任何店
|
|
21
|
+
* (无 host、无 `DB_BACKEND`、无 `tidb` 词)⇒ 不写店,旧字段 dropped。
|
|
22
|
+
* · **表达不了 ⇒ refused**:值写错了栏(密钥以字面值写在 env、坐标来自宿主密钥)、不认识的词、新旧两形并存、`DB_BACKEND=pg`
|
|
23
|
+
* 配 MySQL 族坐标 —— 迁移不搬值进密钥位、不读宿主密钥、不替运维挑赢家、不换库。
|
|
24
|
+
* · **终判 = 7.0.0 schema**:迁出来的每只 worker 都过 `WorkerSpec.safeParse`;不过 ⇒ refused,拒因是 schema 自己的 issue
|
|
25
|
+
* (例:多租户 + host 车道、点了车道却缺必需坐标)。迁移不复述任何一条写闸规则。
|
|
26
|
+
*
|
|
27
|
+
* refused 的 worker **不进**输出 `doc`(中心不加载它、在列表标红点名 `field`;CLI 有 refused 就一字不写),但随 `refused[].original`
|
|
28
|
+
* 逐字带回:`doc` ∪ `refused[].original` 覆盖输入的全部 worker,中心写回前按 `index` 放回(或另存),运维的 spec 不会随迁移消失。
|
|
29
|
+
* 拒因 / dropped 只点名字段,不回显值。
|
|
30
|
+
*
|
|
31
|
+
* ## 幂等
|
|
32
|
+
* 已迁形(没有任何旧键、该有的块都在)⇒ 原样(同一引用)返回、`changed = []`。所以再跑一次 ⇒ 零变化、`doc` 字节相等。
|
|
33
|
+
*/
|
|
34
|
+
import { formatConfigPath, redactSecrets } from "./config-fns.js";
|
|
35
|
+
import { LANE_ENV_OWNER } from "./execution-lane-index.js";
|
|
36
|
+
import { EXECUTION_LANE_WORDS, MEMORY_ENGINE_BACKENDS, STORE_BACKENDS } from "./execution-lanes.js";
|
|
37
|
+
import { WorkerSpec } from "./types.js";
|
|
38
|
+
function isRec(v) {
|
|
39
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
40
|
+
}
|
|
41
|
+
/** 车道块的两个特殊键(不在车道表里,但归车道块)。 */
|
|
42
|
+
const LANE_WORD_KEYS = ["REMOTE_EXEC", "REQUIRE_EXECUTION_LANE"];
|
|
43
|
+
/** 渲染器以前不渲、运维只能写在 env 逃生口里的店键(7.0.0 起归 `runtime.store`)。 */
|
|
44
|
+
const STORE_ENV_KEYS = ["DB_BACKEND", "MEMORY_ENGINE", "MEMORY_ENGINE_BACKEND"];
|
|
45
|
+
/** 6.x 删除项:`[容器, 键]`。 */
|
|
46
|
+
const SIX_X_STORE_FIELDS = [
|
|
47
|
+
["runtime", "sessionBackend"],
|
|
48
|
+
["runtime", "memoryBackend"],
|
|
49
|
+
["runtime", "db"],
|
|
50
|
+
["coords", "tidbHost"],
|
|
51
|
+
["coords", "tidbPort"],
|
|
52
|
+
["coords", "tidbUser"],
|
|
53
|
+
["secretRefs", "tidbPasswordEnv"],
|
|
54
|
+
];
|
|
55
|
+
/** 引擎 `MEMORY_ENGINE` 的两族词(server `src/config.ts` :3011–3012,比较前 trim + 小写)。 */
|
|
56
|
+
const MEMORY_ENGINE_ON_WORDS = ["on", "true", "1", "yes", "enabled"];
|
|
57
|
+
const MEMORY_ENGINE_OFF_WORDS = ["off", "false", "0", "no", "disabled"];
|
|
58
|
+
/** 引擎 `MYSQL_PORT` 的缺省(server `src/config.ts` :1818)—— 旧形没写端口时引擎连的就是它。 */
|
|
59
|
+
const MYSQL_DEFAULT_PORT = 3306;
|
|
60
|
+
function isLaneWord(v) {
|
|
61
|
+
return typeof v === "string" && EXECUTION_LANE_WORDS.some((w) => w === v);
|
|
62
|
+
}
|
|
63
|
+
function isStoreWord(v) {
|
|
64
|
+
return typeof v === "string" && STORE_BACKENDS.some((w) => w === v);
|
|
65
|
+
}
|
|
66
|
+
function isMemoryBackendWord(v) {
|
|
67
|
+
return typeof v === "string" && MEMORY_ENGINE_BACKENDS.some((w) => w === v);
|
|
68
|
+
}
|
|
69
|
+
/** 拒因里只点名形如词的值(短、小写);其余一律不回显(值可能是粘错位置的凭据)。 */
|
|
70
|
+
function describeWord(v) {
|
|
71
|
+
return typeof v === "string" && /^[a-z0-9][a-z0-9-]{0,31}$/.test(v) ? `"${v}"` : "(value not shown)";
|
|
72
|
+
}
|
|
73
|
+
const UNCHANGED = { kind: "unchanged" };
|
|
74
|
+
function migrateEntry(raw, index) {
|
|
75
|
+
if (!isRec(raw) || typeof raw.name !== "string")
|
|
76
|
+
return UNCHANGED; // 不是一只可辨认的 worker:判形是 parseDomain 的事
|
|
77
|
+
const name = raw.name;
|
|
78
|
+
const refuse = (field, reason) => ({ kind: "refused", refusal: { name, index, field, reason: redactSecrets(reason), original: raw } });
|
|
79
|
+
const enabled = raw.enabled !== false;
|
|
80
|
+
const has = (container, key) => isRec(container) && Object.hasOwn(container, key);
|
|
81
|
+
const laneKeysOf = (rec) => isRec(rec) ? Object.keys(rec).filter((k) => LANE_WORD_KEYS.some((w) => w === k) || LANE_ENV_OWNER.has(k)) : [];
|
|
82
|
+
const storeKeysOf = (rec) => (isRec(rec) ? STORE_ENV_KEYS.filter((k) => Object.hasOwn(rec, k)) : []);
|
|
83
|
+
const envLaneKeys = laneKeysOf(raw.env);
|
|
84
|
+
const secretLaneKeys = laneKeysOf(raw.secretEnv);
|
|
85
|
+
const envStoreKeys = storeKeysOf(raw.env);
|
|
86
|
+
const secretStoreKeys = storeKeysOf(raw.secretEnv);
|
|
87
|
+
const oldStoreFields = SIX_X_STORE_FIELDS.filter(([c, k]) => has(raw[c], k));
|
|
88
|
+
const hasExecution = has(raw.runtime, "execution");
|
|
89
|
+
const hasStore = has(raw.runtime, "store");
|
|
90
|
+
const laneTouched = envLaneKeys.length + secretLaneKeys.length > 0;
|
|
91
|
+
const storeTouched = oldStoreFields.length + envStoreKeys.length + secretStoreKeys.length > 0;
|
|
92
|
+
if (hasExecution && laneTouched) {
|
|
93
|
+
return refuse(["runtime", "execution"], `declares runtime.execution AND execution-lane keys in env / secretEnv (${[...envLaneKeys, ...secretLaneKeys].join(", ")}) — keep one form; the migration does not pick a winner`);
|
|
94
|
+
}
|
|
95
|
+
// 新形的店字段有两处:`runtime.store` 与 `secretRefs.dbPasswordEnv`(gpt-6 腿 F1:只看前者时,手填过新口令引用、又留着旧
|
|
96
|
+
// `tidbPasswordEnv` 的行会被旧引用静默覆盖)。任一在场 ∧ 还有旧店字段 ⇒ 两形并存,不挑赢家。
|
|
97
|
+
const newStoreFields = [...(hasStore ? ["runtime.store"] : []), ...(has(raw.secretRefs, "dbPasswordEnv") ? ["secretRefs.dbPasswordEnv"] : [])];
|
|
98
|
+
if (newStoreFields.length > 0 && storeTouched) {
|
|
99
|
+
const old = [...oldStoreFields.map(([c, k]) => `${c}.${k}`), ...envStoreKeys.map((k) => `env.${k}`), ...secretStoreKeys.map((k) => `secretEnv.${k}`)];
|
|
100
|
+
return refuse(["runtime", "store"], `declares the 7.0.0 store fields (${newStoreFields.join(", ")}) AND 6.x store fields (${old.join(", ")}) — keep one form; the migration does not pick a winner`);
|
|
101
|
+
}
|
|
102
|
+
const secretStoreKey = secretStoreKeys[0];
|
|
103
|
+
if (secretStoreKey !== undefined)
|
|
104
|
+
return refuse(["secretEnv", secretStoreKey], `${secretStoreKey} is set from a host secret — the migration cannot read it; declare runtime.store by hand`);
|
|
105
|
+
if (has(raw.secretEnv, "REMOTE_EXEC"))
|
|
106
|
+
return refuse(["secretEnv", "REMOTE_EXEC"], "the execution lane is set from a host secret — the migration cannot read it; declare runtime.execution.lane by hand");
|
|
107
|
+
const needLane = !hasExecution && (enabled || laneTouched);
|
|
108
|
+
const needStore = !hasStore && (enabled || storeTouched);
|
|
109
|
+
if (!needLane && !needStore)
|
|
110
|
+
return UNCHANGED;
|
|
111
|
+
const w = structuredClone(raw);
|
|
112
|
+
const runtime = isRec(w.runtime) ? w.runtime : {};
|
|
113
|
+
w.runtime = runtime;
|
|
114
|
+
const env = isRec(w.env) ? w.env : {};
|
|
115
|
+
const secretEnv = isRec(w.secretEnv) ? w.secretEnv : {};
|
|
116
|
+
const coords = isRec(w.coords) ? w.coords : {};
|
|
117
|
+
const secretRefs = isRec(w.secretRefs) ? w.secretRefs : {};
|
|
118
|
+
const dropped = [];
|
|
119
|
+
const drop = (container, slot, key) => {
|
|
120
|
+
delete container[key];
|
|
121
|
+
dropped.push({ name, index, field: [slot, key] });
|
|
122
|
+
};
|
|
123
|
+
// ── 车道 ─────────────────────────────────────────────────────────────────────────────────────────
|
|
124
|
+
if (needLane) {
|
|
125
|
+
const rx = env.REMOTE_EXEC;
|
|
126
|
+
if (rx === undefined || rx === "") {
|
|
127
|
+
if (enabled) {
|
|
128
|
+
return refuse(["env", "REMOTE_EXEC"], `an enabled worker names no execution lane (env.REMOTE_EXEC is unset) — it boots with no hands and still reports tasks completed. ` +
|
|
129
|
+
`The migration does not guess a lane: set runtime.execution.lane (${EXECUTION_LANE_WORDS.join(" | ")}) by hand, or disable the worker`);
|
|
130
|
+
}
|
|
131
|
+
for (const k of envLaneKeys)
|
|
132
|
+
drop(env, "env", k); // 停用且没点名车道 ⇒ 每个车道键都是惰性的
|
|
133
|
+
for (const k of secretLaneKeys)
|
|
134
|
+
drop(secretEnv, "secretEnv", k);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
if (!isLaneWord(rx)) {
|
|
138
|
+
return refuse(["env", "REMOTE_EXEC"], `env.REMOTE_EXEC ${describeWord(rx)} is not an execution lane (${EXECUTION_LANE_WORDS.join(" | ")}) — the engine refuses to start with it; set runtime.execution.lane by hand`);
|
|
139
|
+
}
|
|
140
|
+
const lane = rx;
|
|
141
|
+
const laneCoords = {};
|
|
142
|
+
const laneSecretRefs = {};
|
|
143
|
+
for (const k of envLaneKeys) {
|
|
144
|
+
if (k === "REMOTE_EXEC") {
|
|
145
|
+
delete env[k];
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const owner = LANE_ENV_OWNER.get(k);
|
|
149
|
+
if (owner === undefined || owner.lane !== lane) {
|
|
150
|
+
drop(env, "env", k); // REQUIRE_EXECUTION_LANE(声明了车道就恒渲 true)与别车道的键(引擎只读被点名那一臂)
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const v = env[k];
|
|
154
|
+
if (owner.secret) {
|
|
155
|
+
return refuse(["env", k], `${k} is a secret of lane "${lane}" but env carries it as a literal value — the migration does not move a value into a secret slot: put it in the host secret source and reference it from runtime.execution.secretRefs.${k}`);
|
|
156
|
+
}
|
|
157
|
+
if (typeof v !== "string")
|
|
158
|
+
return refuse(["env", k], `env.${k} is not a string`);
|
|
159
|
+
laneCoords[k] = v;
|
|
160
|
+
delete env[k];
|
|
161
|
+
}
|
|
162
|
+
for (const k of secretLaneKeys) {
|
|
163
|
+
const owner = LANE_ENV_OWNER.get(k);
|
|
164
|
+
if (owner === undefined || owner.lane !== lane) {
|
|
165
|
+
drop(secretEnv, "secretEnv", k);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const v = secretEnv[k];
|
|
169
|
+
if (!owner.secret) {
|
|
170
|
+
return refuse(["secretEnv", k], `${k} is a non-secret coordinate of lane "${lane}" but is set from a host secret — the migration cannot read the value; write it in runtime.execution.coords.${k} by hand`);
|
|
171
|
+
}
|
|
172
|
+
if (typeof v !== "string")
|
|
173
|
+
return refuse(["secretEnv", k], `secretEnv.${k} is not a string`);
|
|
174
|
+
laneSecretRefs[k] = v;
|
|
175
|
+
delete secretEnv[k];
|
|
176
|
+
}
|
|
177
|
+
runtime.execution = { lane, coords: laneCoords, secretRefs: laneSecretRefs };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// ── 店 ───────────────────────────────────────────────────────────────────────────────────────────
|
|
181
|
+
if (needStore) {
|
|
182
|
+
if (Object.hasOwn(runtime, "memoryBackend"))
|
|
183
|
+
drop(runtime, "runtime", "memoryBackend"); // 从未到达引擎
|
|
184
|
+
const sessionRaw = runtime.sessionBackend;
|
|
185
|
+
const host = coords.tidbHost;
|
|
186
|
+
const hostSet = typeof host === "string" && host !== "";
|
|
187
|
+
const dbBackendRaw = env.DB_BACKEND;
|
|
188
|
+
const dbBackendSet = dbBackendRaw !== undefined && dbBackendRaw !== "";
|
|
189
|
+
const named = hostSet || dbBackendSet || sessionRaw === "tidb";
|
|
190
|
+
const sqlCoordFields = [
|
|
191
|
+
[runtime, "runtime", "db"],
|
|
192
|
+
[coords, "coords", "tidbHost"],
|
|
193
|
+
[coords, "coords", "tidbPort"],
|
|
194
|
+
[coords, "coords", "tidbUser"],
|
|
195
|
+
[secretRefs, "secretRefs", "tidbPasswordEnv"],
|
|
196
|
+
];
|
|
197
|
+
if (!enabled && !named) {
|
|
198
|
+
// 停用、旧形没点名任何店 ⇒ 不替它发明一个;旧字段全是惰性的
|
|
199
|
+
if (Object.hasOwn(runtime, "sessionBackend"))
|
|
200
|
+
drop(runtime, "runtime", "sessionBackend");
|
|
201
|
+
for (const [c, slot, k] of sqlCoordFields)
|
|
202
|
+
if (Object.hasOwn(c, k))
|
|
203
|
+
drop(c, slot, k);
|
|
204
|
+
for (const k of STORE_ENV_KEYS)
|
|
205
|
+
if (Object.hasOwn(env, k))
|
|
206
|
+
drop(env, "env", k);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
let session;
|
|
210
|
+
if (sessionRaw === undefined)
|
|
211
|
+
session = "auto"; // 6.x 的缺省
|
|
212
|
+
else if (sessionRaw === "tidb")
|
|
213
|
+
session = "mysql";
|
|
214
|
+
else if (sessionRaw === "memory" || sessionRaw === "auto")
|
|
215
|
+
session = sessionRaw;
|
|
216
|
+
else
|
|
217
|
+
return refuse(["runtime", "sessionBackend"], `runtime.sessionBackend ${describeWord(sessionRaw)} is not a 6.x session backend (tidb | memory | auto)`);
|
|
218
|
+
delete runtime.sessionBackend;
|
|
219
|
+
let backend;
|
|
220
|
+
if (dbBackendSet) {
|
|
221
|
+
if (!isStoreWord(dbBackendRaw))
|
|
222
|
+
return refuse(["env", "DB_BACKEND"], `env.DB_BACKEND ${describeWord(dbBackendRaw)} is not a store backend (${STORE_BACKENDS.join(" | ")}) — the engine refuses to start with it`);
|
|
223
|
+
backend = dbBackendRaw;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
backend = hostSet || sessionRaw === "tidb" ? "mysql" : "local";
|
|
227
|
+
}
|
|
228
|
+
delete env.DB_BACKEND;
|
|
229
|
+
const sqlCoordsPresent = sqlCoordFields.filter(([c, , k]) => Object.hasOwn(c, k));
|
|
230
|
+
const store = { backend, session };
|
|
231
|
+
if (backend === "mysql") {
|
|
232
|
+
if (hostSet) {
|
|
233
|
+
const port = coords.tidbPort;
|
|
234
|
+
store.db = {
|
|
235
|
+
host,
|
|
236
|
+
port: port === undefined ? MYSQL_DEFAULT_PORT : port,
|
|
237
|
+
...(Object.hasOwn(coords, "tidbUser") ? { user: coords.tidbUser } : {}),
|
|
238
|
+
...(Object.hasOwn(runtime, "db") ? { database: runtime.db } : {}),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
if (Object.hasOwn(secretRefs, "tidbPasswordEnv"))
|
|
242
|
+
secretRefs.dbPasswordEnv = secretRefs.tidbPasswordEnv;
|
|
243
|
+
for (const [c, , k] of sqlCoordsPresent)
|
|
244
|
+
delete c[k];
|
|
245
|
+
}
|
|
246
|
+
else if (backend === "pg") {
|
|
247
|
+
if (sqlCoordsPresent.length > 0) {
|
|
248
|
+
return refuse(["env", "DB_BACKEND"], `env.DB_BACKEND=pg but the SQL coordinates are the MySQL family (coords.tidb* / runtime.db / secretRefs.tidbPasswordEnv, rendered as MYSQL_*) — the migration does not move a MySQL server's coordinates onto pg; declare runtime.store by hand`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
for (const [c, slot, k] of sqlCoordsPresent)
|
|
253
|
+
drop(c, slot, k); // 非 SQL 店:SQL 坐标在引擎里是「声明不用」的惰性件
|
|
254
|
+
}
|
|
255
|
+
const memory = {};
|
|
256
|
+
const engineRaw = env.MEMORY_ENGINE;
|
|
257
|
+
if (engineRaw !== undefined) {
|
|
258
|
+
const word = typeof engineRaw === "string" ? engineRaw.trim().toLowerCase() : undefined;
|
|
259
|
+
if (word !== undefined && MEMORY_ENGINE_ON_WORDS.includes(word))
|
|
260
|
+
memory.engine = "on";
|
|
261
|
+
else if (word !== undefined && MEMORY_ENGINE_OFF_WORDS.includes(word))
|
|
262
|
+
memory.engine = "off";
|
|
263
|
+
else if (word !== "")
|
|
264
|
+
return refuse(["env", "MEMORY_ENGINE"], "env.MEMORY_ENGINE is not an on/off word — the engine refuses to start with it");
|
|
265
|
+
delete env.MEMORY_ENGINE;
|
|
266
|
+
}
|
|
267
|
+
const memoryBackendRaw = env.MEMORY_ENGINE_BACKEND;
|
|
268
|
+
if (memoryBackendRaw !== undefined) {
|
|
269
|
+
if (!isMemoryBackendWord(memoryBackendRaw)) {
|
|
270
|
+
return refuse(["env", "MEMORY_ENGINE_BACKEND"], `env.MEMORY_ENGINE_BACKEND ${describeWord(memoryBackendRaw)} is not a memory backend (${MEMORY_ENGINE_BACKENDS.join(" | ")}) — the engine refuses to start with it`);
|
|
271
|
+
}
|
|
272
|
+
memory.backend = memoryBackendRaw;
|
|
273
|
+
delete env.MEMORY_ENGINE_BACKEND;
|
|
274
|
+
}
|
|
275
|
+
if (Object.keys(memory).length > 0)
|
|
276
|
+
store.memory = memory;
|
|
277
|
+
runtime.store = store;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// 被搬空的 env / secretEnv 整键去掉(它们是可选字段;留一个 `{}` 只是噪声)
|
|
281
|
+
if (isRec(w.env) && Object.keys(env).length === 0 && isRec(raw.env) && Object.keys(raw.env).length > 0)
|
|
282
|
+
delete w.env;
|
|
283
|
+
if (isRec(w.secretEnv) && Object.keys(secretEnv).length === 0 && isRec(raw.secretEnv) && Object.keys(raw.secretEnv).length > 0)
|
|
284
|
+
delete w.secretEnv;
|
|
285
|
+
// 终判 = 7.0.0 schema 本身(迁移不复述写闸规则)
|
|
286
|
+
const verdict = WorkerSpec.safeParse(w);
|
|
287
|
+
if (!verdict.success) {
|
|
288
|
+
const issues = verdict.error.issues;
|
|
289
|
+
const shown = issues.slice(0, 3).map((i) => `${formatConfigPath(i.path)}: ${i.message}`);
|
|
290
|
+
const more = issues.length > shown.length ? ` (+${issues.length - shown.length} more)` : "";
|
|
291
|
+
const first = issues[0];
|
|
292
|
+
return refuse(first === undefined ? [] : first.path, `after migration the worker is not a valid 7.0.0 WorkerSpec — ${shown.join("; ")}${more}`);
|
|
293
|
+
}
|
|
294
|
+
return { kind: "migrated", entry: w, dropped };
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* 迁移一份 workers 域**原始**文档(`{ workers: [...] }`)。不是这个形 ⇒ 原样返回(判形是 `parseDomain` 的事,迁移不抛)。
|
|
298
|
+
* 规则、幂等与 refused 的处置见文件头。
|
|
299
|
+
*/
|
|
300
|
+
export function migrateWorkersDomain(doc) {
|
|
301
|
+
if (!isRec(doc) || !Array.isArray(doc.workers))
|
|
302
|
+
return { doc, changed: [], dropped: [], refused: [] };
|
|
303
|
+
const workers = doc.workers;
|
|
304
|
+
const kept = [];
|
|
305
|
+
const changed = [];
|
|
306
|
+
const dropped = [];
|
|
307
|
+
const refused = [];
|
|
308
|
+
workers.forEach((entry, index) => {
|
|
309
|
+
const out = migrateEntry(entry, index);
|
|
310
|
+
switch (out.kind) {
|
|
311
|
+
case "unchanged":
|
|
312
|
+
kept.push(entry);
|
|
313
|
+
break;
|
|
314
|
+
case "migrated":
|
|
315
|
+
kept.push(out.entry);
|
|
316
|
+
changed.push(String(out.entry.name));
|
|
317
|
+
dropped.push(...out.dropped);
|
|
318
|
+
break;
|
|
319
|
+
case "refused":
|
|
320
|
+
refused.push(out.refusal);
|
|
321
|
+
break;
|
|
322
|
+
default: {
|
|
323
|
+
const unhandled = out;
|
|
324
|
+
throw new Error(`migrateWorkersDomain: unhandled outcome ${JSON.stringify(unhandled)}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
if (changed.length === 0 && refused.length === 0)
|
|
329
|
+
return { doc, changed: [], dropped: [], refused: [] };
|
|
330
|
+
return { doc: { ...doc, workers: kept }, changed, dropped, refused };
|
|
331
|
+
}
|
package/dist/remote-exec.d.ts
CHANGED
|
@@ -1,26 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `remoteExec` — the execution-substrate contract (NET-NEW; not part of DOMAIN_SCHEMAS today).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* local/remote-docker fast-follow). The schema LEADS; runtime support is phased. The discriminator is `provider`:
|
|
8
|
-
* FLEET (implemented today, the worker service's config module):
|
|
4
|
+
* The exec layer is user-configurable & pluggable. This SCHEMA carries the per-machine `config.d/remote-exec.json`
|
|
5
|
+
* shape for the lanes a consumer can drive from that file. The discriminator is `provider`:
|
|
6
|
+
* FLEET:
|
|
9
7
|
* - `e2b` per-task E2B Firecracker VM (isolated, suspendable). The default code-agent worker.
|
|
10
8
|
* - `k8s` per-task Kata pod on our k3s (isolated, NOT suspendable). Self-hosted sandbox lane.
|
|
11
9
|
* - `ssh` a real host over SSH (NOT isolated/suspendable). AI-orchestrated batch deploy.
|
|
12
10
|
* - `adb` a real device over ADB (NOT isolated/suspendable). On-device APP testing.
|
|
13
|
-
* TOC single-machine (
|
|
11
|
+
* TOC single-machine (macOS + Linux):
|
|
14
12
|
* - `host` run tools DIRECTLY on this machine, no container (the TOC default, like a local coding
|
|
15
|
-
* agent). No isolation.
|
|
13
|
+
* agent). No isolation.
|
|
16
14
|
* - `local-docker` containers on THIS machine's docker (isolation for parallel/untrusted fan-out).
|
|
17
|
-
*
|
|
15
|
+
*
|
|
16
|
+
* 7.0.0: every `provider` word here is an execution lane word (`EXECUTION_LANE_WORDS`, `execution-lanes.ts` — the ONE
|
|
17
|
+
* vocabulary; `test/execution-lanes-7.0.0.test.ts` pins `provider ⊆ EXECUTION_LANE_WORDS`, at runtime and at compile time).
|
|
18
|
+
* The `remote-docker` variant was DELETED (zero aliases): no engine read point ever turned that word into a running lane
|
|
19
|
+
* (the engine refuses `REMOTE_EXEC=remote-docker` at boot and only warned "unwired" when this file named it). A remote
|
|
20
|
+
* docker daemon is the `local-docker` lane pointed at it (`DOCKER_HOST` — in a WorkerSpec, `runtime.execution.coords`).
|
|
18
21
|
*
|
|
19
22
|
* SECRET BOUNDARY (identical to the rest of the contract): every secret VALUE the consumer reads from its own
|
|
20
|
-
* env (E2B_API_KEY, K8S_TOKEN, SSH_PRIVATE_KEY, MINIO_
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
23
|
+
* env (E2B_API_KEY, K8S_TOKEN, SSH_PRIVATE_KEY, MINIO_*) is carried here as an env-NAME ref (the `ENV_NAME` regex
|
|
24
|
+
* from types.ts) — NEVER a literal value. The consumer resolves NAME → value from its own env (service env / k8s
|
|
25
|
+
* secret / the TOC local `.env`), so this package never carries a credential. This is SCHEMA + secret-as-env-NAME
|
|
26
|
+
* ONLY; the service owns the adapter implementations.
|
|
24
27
|
*/
|
|
25
28
|
import { z } from "zod";
|
|
26
29
|
export declare const RemoteExecSpec: z.ZodDiscriminatedUnion<"provider", [z.ZodObject<{
|
|
@@ -61,7 +64,7 @@ export declare const RemoteExecSpec: z.ZodDiscriminatedUnion<"provider", [z.ZodO
|
|
|
61
64
|
memory?: string | undefined;
|
|
62
65
|
workdir?: string | undefined;
|
|
63
66
|
cpus?: number | undefined;
|
|
64
|
-
network?: "
|
|
67
|
+
network?: "host" | "none" | "bridge" | undefined;
|
|
65
68
|
mountPath?: string | undefined;
|
|
66
69
|
}, {
|
|
67
70
|
provider: "local-docker";
|
|
@@ -69,52 +72,8 @@ export declare const RemoteExecSpec: z.ZodDiscriminatedUnion<"provider", [z.ZodO
|
|
|
69
72
|
memory?: string | undefined;
|
|
70
73
|
workdir?: string | undefined;
|
|
71
74
|
cpus?: number | undefined;
|
|
72
|
-
network?: "
|
|
73
|
-
mountPath?: string | undefined;
|
|
74
|
-
}>, z.ZodObject<{
|
|
75
|
-
/** The remote docker endpoint — a DOCKER_HOST url: `ssh://user@host` or `tcp://host:2376`. A `user@` is OK
|
|
76
|
-
* (it's the ssh target, not a secret); an embedded PASSWORD is rejected — credentials go in the env-NAME
|
|
77
|
-
* fields below, NEVER in the URL (the secret-as-env-NAME boundary). */
|
|
78
|
-
dockerHost: z.ZodEffects<z.ZodString, string, string>;
|
|
79
|
-
/** env-NAMEs for a tcp+TLS endpoint (NEVER the cert/key bytes). */
|
|
80
|
-
tlsCertEnv: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
81
|
-
tlsKeyEnv: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
82
|
-
tlsCaEnv: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
83
|
-
/** env-NAME for the SSH private key when dockerHost is `ssh://…` (NEVER the key value). */
|
|
84
|
-
sshKeyEnv: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
85
|
-
image: z.ZodEffects<z.ZodString, string, string>;
|
|
86
|
-
memory: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
87
|
-
cpus: z.ZodOptional<z.ZodNumber>;
|
|
88
|
-
network: z.ZodOptional<z.ZodEnum<["none", "bridge", "host"]>>;
|
|
89
|
-
workdir: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
90
|
-
mountPath: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
91
|
-
provider: z.ZodLiteral<"remote-docker">;
|
|
92
|
-
}, "strip", z.ZodTypeAny, {
|
|
93
|
-
provider: "remote-docker";
|
|
94
|
-
image: string;
|
|
95
|
-
dockerHost: string;
|
|
96
|
-
memory?: string | undefined;
|
|
97
|
-
workdir?: string | undefined;
|
|
98
|
-
cpus?: number | undefined;
|
|
99
|
-
network?: "none" | "host" | "bridge" | undefined;
|
|
100
|
-
mountPath?: string | undefined;
|
|
101
|
-
tlsCertEnv?: string | undefined;
|
|
102
|
-
tlsKeyEnv?: string | undefined;
|
|
103
|
-
tlsCaEnv?: string | undefined;
|
|
104
|
-
sshKeyEnv?: string | undefined;
|
|
105
|
-
}, {
|
|
106
|
-
provider: "remote-docker";
|
|
107
|
-
image: string;
|
|
108
|
-
dockerHost: string;
|
|
109
|
-
memory?: string | undefined;
|
|
110
|
-
workdir?: string | undefined;
|
|
111
|
-
cpus?: number | undefined;
|
|
112
|
-
network?: "none" | "host" | "bridge" | undefined;
|
|
75
|
+
network?: "host" | "none" | "bridge" | undefined;
|
|
113
76
|
mountPath?: string | undefined;
|
|
114
|
-
tlsCertEnv?: string | undefined;
|
|
115
|
-
tlsKeyEnv?: string | undefined;
|
|
116
|
-
tlsCaEnv?: string | undefined;
|
|
117
|
-
sshKeyEnv?: string | undefined;
|
|
118
77
|
}>, z.ZodObject<{
|
|
119
78
|
provider: z.ZodLiteral<"e2b">;
|
|
120
79
|
/** env-NAME holding the E2B API key (service: E2B_API_KEY). NEVER the key. */
|
|
@@ -192,8 +151,8 @@ export declare const RemoteExecSpec: z.ZodDiscriminatedUnion<"provider", [z.ZodO
|
|
|
192
151
|
}, "strip", z.ZodTypeAny, {
|
|
193
152
|
provider: "k8s";
|
|
194
153
|
image: string;
|
|
195
|
-
cpu?: string | undefined;
|
|
196
154
|
memory?: string | undefined;
|
|
155
|
+
cpu?: string | undefined;
|
|
197
156
|
mountPath?: string | undefined;
|
|
198
157
|
timeoutMs?: number | undefined;
|
|
199
158
|
apiUrl?: string | undefined;
|
|
@@ -214,8 +173,8 @@ export declare const RemoteExecSpec: z.ZodDiscriminatedUnion<"provider", [z.ZodO
|
|
|
214
173
|
}, {
|
|
215
174
|
provider: "k8s";
|
|
216
175
|
image: string;
|
|
217
|
-
cpu?: string | undefined;
|
|
218
176
|
memory?: string | undefined;
|
|
177
|
+
cpu?: string | undefined;
|
|
219
178
|
mountPath?: string | undefined;
|
|
220
179
|
timeoutMs?: number | undefined;
|
|
221
180
|
apiUrl?: string | undefined;
|
|
@@ -242,19 +201,19 @@ export declare const RemoteExecSpec: z.ZodDiscriminatedUnion<"provider", [z.ZodO
|
|
|
242
201
|
privateKeyEnv: z.ZodEffects<z.ZodString, string, string>;
|
|
243
202
|
mountPath: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
244
203
|
}, "strip", z.ZodTypeAny, {
|
|
245
|
-
provider: "ssh";
|
|
246
204
|
host: string;
|
|
205
|
+
provider: "ssh";
|
|
247
206
|
username: string;
|
|
248
207
|
privateKeyEnv: string;
|
|
249
|
-
mountPath?: string | undefined;
|
|
250
208
|
port?: number | undefined;
|
|
209
|
+
mountPath?: string | undefined;
|
|
251
210
|
}, {
|
|
252
|
-
provider: "ssh";
|
|
253
211
|
host: string;
|
|
212
|
+
provider: "ssh";
|
|
254
213
|
username: string;
|
|
255
214
|
privateKeyEnv: string;
|
|
256
|
-
mountPath?: string | undefined;
|
|
257
215
|
port?: number | undefined;
|
|
216
|
+
mountPath?: string | undefined;
|
|
258
217
|
}>, z.ZodObject<{
|
|
259
218
|
provider: z.ZodLiteral<"adb">;
|
|
260
219
|
serial: z.ZodEffects<z.ZodString, string, string>;
|
package/dist/remote-exec.js
CHANGED
|
@@ -1,34 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `remoteExec` — the execution-substrate contract (NET-NEW; not part of DOMAIN_SCHEMAS today).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* local/remote-docker fast-follow). The schema LEADS; runtime support is phased. The discriminator is `provider`:
|
|
8
|
-
* FLEET (implemented today, the worker service's config module):
|
|
4
|
+
* The exec layer is user-configurable & pluggable. This SCHEMA carries the per-machine `config.d/remote-exec.json`
|
|
5
|
+
* shape for the lanes a consumer can drive from that file. The discriminator is `provider`:
|
|
6
|
+
* FLEET:
|
|
9
7
|
* - `e2b` per-task E2B Firecracker VM (isolated, suspendable). The default code-agent worker.
|
|
10
8
|
* - `k8s` per-task Kata pod on our k3s (isolated, NOT suspendable). Self-hosted sandbox lane.
|
|
11
9
|
* - `ssh` a real host over SSH (NOT isolated/suspendable). AI-orchestrated batch deploy.
|
|
12
10
|
* - `adb` a real device over ADB (NOT isolated/suspendable). On-device APP testing.
|
|
13
|
-
* TOC single-machine (
|
|
11
|
+
* TOC single-machine (macOS + Linux):
|
|
14
12
|
* - `host` run tools DIRECTLY on this machine, no container (the TOC default, like a local coding
|
|
15
|
-
* agent). No isolation.
|
|
13
|
+
* agent). No isolation.
|
|
16
14
|
* - `local-docker` containers on THIS machine's docker (isolation for parallel/untrusted fan-out).
|
|
17
|
-
*
|
|
15
|
+
*
|
|
16
|
+
* 7.0.0: every `provider` word here is an execution lane word (`EXECUTION_LANE_WORDS`, `execution-lanes.ts` — the ONE
|
|
17
|
+
* vocabulary; `test/execution-lanes-7.0.0.test.ts` pins `provider ⊆ EXECUTION_LANE_WORDS`, at runtime and at compile time).
|
|
18
|
+
* The `remote-docker` variant was DELETED (zero aliases): no engine read point ever turned that word into a running lane
|
|
19
|
+
* (the engine refuses `REMOTE_EXEC=remote-docker` at boot and only warned "unwired" when this file named it). A remote
|
|
20
|
+
* docker daemon is the `local-docker` lane pointed at it (`DOCKER_HOST` — in a WorkerSpec, `runtime.execution.coords`).
|
|
18
21
|
*
|
|
19
22
|
* SECRET BOUNDARY (identical to the rest of the contract): every secret VALUE the consumer reads from its own
|
|
20
|
-
* env (E2B_API_KEY, K8S_TOKEN, SSH_PRIVATE_KEY, MINIO_
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
23
|
+
* env (E2B_API_KEY, K8S_TOKEN, SSH_PRIVATE_KEY, MINIO_*) is carried here as an env-NAME ref (the `ENV_NAME` regex
|
|
24
|
+
* from types.ts) — NEVER a literal value. The consumer resolves NAME → value from its own env (service env / k8s
|
|
25
|
+
* secret / the TOC local `.env`), so this package never carries a credential. This is SCHEMA + secret-as-env-NAME
|
|
26
|
+
* ONLY; the service owns the adapter implementations.
|
|
24
27
|
*/
|
|
25
28
|
import { z } from "zod";
|
|
26
|
-
import { ENV_NAME, K8S_QUANTITY, NON_SECRET_URL, TCP_PORT, containsSecretToken, hasNul
|
|
29
|
+
import { ENV_NAME, K8S_QUANTITY, NON_SECRET_URL, TCP_PORT, containsSecretToken, hasNul } from "./types.js";
|
|
27
30
|
/** A non-empty exec-bound string (an image / path / shell / arg the adapter feeds to spawn()/fs). Rejects a NUL
|
|
28
31
|
* byte uniformly — a NUL validates here but makes Node throw at spawn/boot (fail-OPEN → runtime crash). R20. */
|
|
29
32
|
const execStr = z.string().min(1).refine((s) => !hasNul(s), "must not contain a NUL byte");
|
|
30
|
-
/** Container knobs
|
|
31
|
-
*
|
|
33
|
+
/** Container knobs of the `local-docker` lane. `network` defaults to `none` at the adapter (the safe posture for
|
|
34
|
+
* untrusted code — see the dual-mode design note). */
|
|
32
35
|
const DOCKER_FIELDS = {
|
|
33
36
|
/** Sandbox image (required — a run must be reproducible). */
|
|
34
37
|
image: execStr,
|
|
@@ -58,60 +61,6 @@ export const RemoteExecSpec = z.discriminatedUnion("provider", [
|
|
|
58
61
|
// ── local-docker: containers on this machine's docker (isolated; enables parallel fan-out). No secrets
|
|
59
62
|
// (local docker.sock). Service implements this fast-follow after host.
|
|
60
63
|
z.object({ provider: z.literal("local-docker"), ...DOCKER_FIELDS }),
|
|
61
|
-
// ── remote-docker: a user-specified remote host's docker. Creds (TLS for tcp://, key for ssh://) are
|
|
62
|
-
// env-NAME refs, never bytes. Service implements this fast-follow.
|
|
63
|
-
z.object({
|
|
64
|
-
provider: z.literal("remote-docker"),
|
|
65
|
-
...DOCKER_FIELDS,
|
|
66
|
-
/** The remote docker endpoint — a DOCKER_HOST url: `ssh://user@host` or `tcp://host:2376`. A `user@` is OK
|
|
67
|
-
* (it's the ssh target, not a secret); an embedded PASSWORD is rejected — credentials go in the env-NAME
|
|
68
|
-
* fields below, NEVER in the URL (the secret-as-env-NAME boundary). */
|
|
69
|
-
dockerHost: z
|
|
70
|
-
.string()
|
|
71
|
-
.min(1)
|
|
72
|
-
.refine((s) => {
|
|
73
|
-
if (hasNul(s))
|
|
74
|
-
return false; // a NUL in DOCKER_HOST crashes the adapter at spawn — reject (R20)
|
|
75
|
-
try {
|
|
76
|
-
const u = new URL(s);
|
|
77
|
-
if (u.password)
|
|
78
|
-
return false; // a `user@` ssh target is fine; an embedded PASSWORD is not
|
|
79
|
-
// a secret can also ride in the username (`ssh://sk-live-…@host`) or the query/fragment — scan them
|
|
80
|
-
// (Round-4). The userinfo password is already rejected above.
|
|
81
|
-
let user = u.username;
|
|
82
|
-
try {
|
|
83
|
-
user = decodeURIComponent(u.username);
|
|
84
|
-
}
|
|
85
|
-
catch {
|
|
86
|
-
/* keep raw */
|
|
87
|
-
}
|
|
88
|
-
if (user && containsSecretToken(user))
|
|
89
|
-
return false;
|
|
90
|
-
return !urlQueryFragmentHasSecret(u);
|
|
91
|
-
}
|
|
92
|
-
catch {
|
|
93
|
-
// unparseable (e.g. a bare socket path) → reject a `:pw@` userinfo-password pattern AND any
|
|
94
|
-
// secret-shaped TOKEN anywhere (containsSecretToken tokenizes on URL/query delimiters incl =&, so a
|
|
95
|
-
// mid-string `/var/run/sk-…` or `socket?token=sk-…` is caught despite the ^-anchored regex). R4/5.
|
|
96
|
-
if (/:[^/@]+@/.test(s))
|
|
97
|
-
return false;
|
|
98
|
-
if (containsSecretToken(s))
|
|
99
|
-
return false;
|
|
100
|
-
try {
|
|
101
|
-
return !containsSecretToken(decodeURIComponent(s)); // also catch percent-encoded secrets
|
|
102
|
-
}
|
|
103
|
-
catch {
|
|
104
|
-
return true; // malformed %-escape → the raw scan above already passed
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}, "no credentials in dockerHost (password / secret-shaped user/query/fragment) — put them in the env-NAME fields (sshKeyEnv / tls*Env)"),
|
|
108
|
-
/** env-NAMEs for a tcp+TLS endpoint (NEVER the cert/key bytes). */
|
|
109
|
-
tlsCertEnv: ENV_NAME.optional(),
|
|
110
|
-
tlsKeyEnv: ENV_NAME.optional(),
|
|
111
|
-
tlsCaEnv: ENV_NAME.optional(),
|
|
112
|
-
/** env-NAME for the SSH private key when dockerHost is `ssh://…` (NEVER the key value). */
|
|
113
|
-
sshKeyEnv: ENV_NAME.optional(),
|
|
114
|
-
}),
|
|
115
64
|
// ── e2b: the user's E2B account (service provider "e2b"; REMOTE_EXEC=e2b + E2B_API_KEY).
|
|
116
65
|
z.object({
|
|
117
66
|
provider: z.literal("e2b"),
|
|
@@ -163,8 +112,8 @@ export const RemoteExecSpec = z.discriminatedUnion("provider", [
|
|
|
163
112
|
// ── ssh: a real host over SSH (service provider "ssh"; REMOTE_EXEC=ssh + SSH_HOST/SSH_USER/SSH_PRIVATE_KEY).
|
|
164
113
|
z.object({
|
|
165
114
|
provider: z.literal("ssh"),
|
|
166
|
-
// host + username are persisted to config.d/remote-exec.json — scan for a secret-shaped literal
|
|
167
|
-
//
|
|
115
|
+
// host + username are persisted to config.d/remote-exec.json — scan for a secret-shaped literal (a secret must go
|
|
116
|
+
// in privateKeyEnv, not the login). R14.
|
|
168
117
|
host: execStr.refine((s) => !containsSecretToken(s), "no secret literal in ssh host — credentials go in privateKeyEnv"),
|
|
169
118
|
port: TCP_PORT.optional(),
|
|
170
119
|
username: execStr.refine((s) => !containsSecretToken(s), "no secret literal in ssh username — credentials go in privateKeyEnv"),
|
package/dist/secret-refs.d.ts
CHANGED
|
@@ -22,7 +22,8 @@ export interface EnvRef {
|
|
|
22
22
|
* (integrations domain DELETED in 0.6.0 — OA/Gitea coordinates ride worker.env/secretEnv, EXPERT-REDESIGN §9.) */
|
|
23
23
|
export declare function collectEnvRefs(eff: EffectiveConfig): EnvRef[];
|
|
24
24
|
/**
|
|
25
|
-
* Names of workers that reference a given env-NAME — via any of the
|
|
25
|
+
* Names of workers that reference a given env-NAME — via any of the structured `secretRefs`, the execution lane's
|
|
26
|
+
* `runtime.execution.secretRefs` (7.0.0: where E2B_API_KEY / SSH_PRIVATE_KEY / K8S_TOKEN … live now), OR the general
|
|
26
27
|
* `secretEnv` passthrough. Drives batch-rotate (POST /secrets/<name>/rotate): after a credential write, recreate
|
|
27
28
|
* exactly the workers that project it. Reads the raw workers domain (the same shape collectEnvRefs would see).
|
|
28
29
|
*/
|
|
@@ -30,4 +31,9 @@ export declare function workersReferencingSecret(workers: {
|
|
|
30
31
|
name: string;
|
|
31
32
|
secretRefs?: Record<string, string | undefined>;
|
|
32
33
|
secretEnv?: Record<string, string>;
|
|
34
|
+
runtime?: {
|
|
35
|
+
execution?: {
|
|
36
|
+
secretRefs?: Record<string, string>;
|
|
37
|
+
};
|
|
38
|
+
};
|
|
33
39
|
}[], envName: string): string[];
|