@actiondock/core 2.19.0 → 2.21.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.
Files changed (108) hide show
  1. package/dist/client/config.d.ts +17 -14
  2. package/dist/client/config.js +112 -14
  3. package/dist/client/index.d.ts +1 -1
  4. package/dist/client/index.js +1 -1
  5. package/dist/errors.d.ts +13 -1
  6. package/dist/errors.js +25 -0
  7. package/dist/execution/service.js +44 -10
  8. package/dist/execution/types.d.ts +3 -3
  9. package/dist/host/host.d.ts +20 -1
  10. package/dist/host/host.js +430 -66
  11. package/dist/host/types.d.ts +13 -1
  12. package/dist/index.d.ts +11 -3
  13. package/dist/index.js +5 -1
  14. package/dist/input/config-override.d.ts +5 -0
  15. package/dist/input/config-override.js +23 -0
  16. package/dist/input/input-resolver.d.ts +24 -7
  17. package/dist/input/input-resolver.js +73 -5
  18. package/dist/invocation/bindings.d.ts +53 -0
  19. package/dist/invocation/bindings.js +248 -0
  20. package/dist/invocation/policy.d.ts +5 -1
  21. package/dist/invocation/policy.js +16 -9
  22. package/dist/invocation/remote.d.ts +40 -0
  23. package/dist/invocation/remote.js +489 -0
  24. package/dist/invocation/types.d.ts +33 -0
  25. package/dist/invocation/types.js +11 -1
  26. package/dist/package/index.d.ts +2 -2
  27. package/dist/package/runtime.js +48 -15
  28. package/dist/platform/node.d.ts +6 -0
  29. package/dist/platform/node.js +59 -16
  30. package/dist/platform/types.d.ts +59 -1
  31. package/dist/process/command-executor.d.ts +35 -0
  32. package/dist/process/command-executor.js +326 -0
  33. package/dist/process/index.d.ts +1 -0
  34. package/dist/process/index.js +1 -0
  35. package/dist/process/process-driver.d.ts +19 -6
  36. package/dist/process/process-driver.js +223 -54
  37. package/dist/process/run-executor.js +101 -47
  38. package/dist/project/config-declarations.d.ts +61 -0
  39. package/dist/project/config-declarations.js +297 -0
  40. package/dist/project/dependencies.d.ts +92 -0
  41. package/dist/project/dependencies.js +543 -0
  42. package/dist/project/index.d.ts +8 -2
  43. package/dist/project/index.js +8 -2
  44. package/dist/project/init.js +2 -2
  45. package/dist/project/loader.js +19 -30
  46. package/dist/project/manifest.js +10 -0
  47. package/dist/project/package-manager.d.ts +40 -0
  48. package/dist/project/package-manager.js +224 -0
  49. package/dist/project/preparation.d.ts +8 -0
  50. package/dist/project/preparation.js +130 -0
  51. package/dist/project/transactions.d.ts +5 -1
  52. package/dist/project/transactions.js +35 -75
  53. package/dist/project/types.d.ts +30 -0
  54. package/dist/runtime/context.d.ts +27 -5
  55. package/dist/runtime/context.js +98 -26
  56. package/dist/runtime/env.js +15 -1
  57. package/dist/runtime/run-persistence.d.ts +1 -1
  58. package/dist/runtime/run-persistence.js +4 -3
  59. package/dist/runtime/standalone-commands.d.ts +2 -2
  60. package/dist/runtime/standalone-commands.js +113 -47
  61. package/dist/runtime/standalone-help.d.ts +1 -0
  62. package/dist/runtime/standalone-help.js +15 -9
  63. package/dist/runtime/standalone.js +2 -2
  64. package/dist/security/config-cipher.d.ts +54 -0
  65. package/dist/security/config-cipher.js +190 -0
  66. package/dist/security/config-key.d.ts +35 -0
  67. package/dist/security/config-key.js +469 -0
  68. package/dist/server/policy.d.ts +24 -107
  69. package/dist/server/policy.js +134 -291
  70. package/dist/server/routes/actions.js +10 -8
  71. package/dist/server/routes/common.d.ts +5 -20
  72. package/dist/server/routes/common.js +22 -58
  73. package/dist/server/routes/config.js +84 -7
  74. package/dist/server/routes/doctor.d.ts +1 -0
  75. package/dist/server/routes/doctor.js +4 -3
  76. package/dist/server/routes/info.js +100 -13
  77. package/dist/server/routes/playbooks.js +1 -3
  78. package/dist/server/routes/runs.js +96 -18
  79. package/dist/server/server.js +25 -53
  80. package/dist/server/types.d.ts +6 -6
  81. package/dist/service/factory.js +4 -0
  82. package/dist/service/remote.js +14 -29
  83. package/dist/service/types.d.ts +28 -5
  84. package/dist/service/types.js +8 -0
  85. package/dist/storage/config-value.d.ts +33 -0
  86. package/dist/storage/config-value.js +102 -0
  87. package/dist/storage/file-lock.js +35 -21
  88. package/dist/storage/index.d.ts +24 -2
  89. package/dist/storage/index.js +65 -2
  90. package/dist/storage/mask.d.ts +33 -6
  91. package/dist/storage/mask.js +298 -16
  92. package/dist/storage/migrations/index.d.ts +40 -0
  93. package/dist/storage/migrations/index.js +96 -0
  94. package/dist/storage/migrations/v2-to-v3.d.ts +16 -0
  95. package/dist/storage/migrations/v2-to-v3.js +116 -0
  96. package/dist/storage/sqlite-config.d.ts +62 -3
  97. package/dist/storage/sqlite-config.js +163 -9
  98. package/dist/storage/sqlite-driver.d.ts +5 -2
  99. package/dist/storage/sqlite-driver.js +24 -3
  100. package/dist/storage/sqlite-schema.d.ts +20 -2
  101. package/dist/storage/sqlite-schema.js +129 -132
  102. package/dist/storage/sqlite.d.ts +7 -2
  103. package/dist/storage/sqlite.js +64 -13
  104. package/dist/storage/types.d.ts +72 -4
  105. package/dist/storage/types.js +7 -2
  106. package/dist/version.d.ts +1 -1
  107. package/dist/version.js +1 -1
  108. package/package.json +2 -2
@@ -1,15 +1,45 @@
1
1
  /**
2
- * 检查指定配置项是否为敏感数据(密码、私钥、Token、密钥等)。
3
- *
4
- * 判定逻辑:
5
- * 严格依据配置项元数据属性判定:若声明了 `secret: true`,直接判定为敏感数据;否则不视为敏感数据。
6
- * 不进行基于正则或键名的自动探测,由开发者显式配置驱动。
2
+ * 递归检查 Schema 是否包含任何标记为 secret: true 的节点。
3
+ */
4
+ function schemaHasSecret(schema) {
5
+ if (!schema || typeof schema !== "object") {
6
+ return false;
7
+ }
8
+ if (schema.secret === true) {
9
+ return true;
10
+ }
11
+ if (schema.properties) {
12
+ for (const propSchema of Object.values(schema.properties)) {
13
+ if (schemaHasSecret(propSchema)) {
14
+ return true;
15
+ }
16
+ }
17
+ }
18
+ if (schema.items && schemaHasSecret(schema.items)) {
19
+ return true;
20
+ }
21
+ if (schema.additionalProperties &&
22
+ typeof schema.additionalProperties === "object" &&
23
+ schemaHasSecret(schema.additionalProperties)) {
24
+ return true;
25
+ }
26
+ return false;
27
+ }
28
+ /**
29
+ * 检查指定配置项是否为敏感数据(顶层 secret: true 或模式中嵌套 secret: true)。
30
+ * 不进行基于正则或键名的自动探测,调用方若涉及模板应先解析具体声明项。
7
31
  *
8
32
  * @param _key 配置键名(保留入参以兼容契约)
9
33
  * @param declaredItem 项目中声明的配置元数据(可选)
10
34
  */
11
35
  export function isSecretConfigKey(_key, declaredItem) {
12
- return declaredItem?.secret === true;
36
+ if (declaredItem?.secret === true) {
37
+ return true;
38
+ }
39
+ if (declaredItem?.schema && schemaHasSecret(declaredItem.schema)) {
40
+ return true;
41
+ }
42
+ return false;
13
43
  }
14
44
  /**
15
45
  * 对敏感数据的值进行掩码脱敏处理(返回 "********")。
@@ -24,25 +54,277 @@ export function maskSecretValue(value) {
24
54
  return "null";
25
55
  return "********";
26
56
  }
57
+ function escapePointerToken(token) {
58
+ return token.replace(/~/g, "~0").replace(/\//g, "~1");
59
+ }
60
+ function unescapePointerToken(token) {
61
+ return token.replace(/~1/g, "/").replace(/~0/g, "~");
62
+ }
63
+ function isTypeMatch(val, expectedType) {
64
+ if (!expectedType)
65
+ return true;
66
+ switch (expectedType) {
67
+ case "string":
68
+ return typeof val === "string";
69
+ case "number":
70
+ return typeof val === "number" && Number.isFinite(val);
71
+ case "integer":
72
+ return typeof val === "number" && Number.isInteger(val);
73
+ case "boolean":
74
+ return typeof val === "boolean";
75
+ case "array":
76
+ return Array.isArray(val);
77
+ case "object":
78
+ return val !== null && typeof val === "object" && !Array.isArray(val);
79
+ default:
80
+ return true;
81
+ }
82
+ }
83
+ function redactNode(val, schema, path) {
84
+ // 模式显式标记敏感节点:直接掩码并终止下钻
85
+ if (schema?.secret === true) {
86
+ return {
87
+ value: "********",
88
+ redacted: true,
89
+ paths: [path],
90
+ };
91
+ }
92
+ // 即使省略 type,结构关键字也不能让标量绕过子字段的秘密保护。
93
+ const objectShape = schema?.properties !== undefined || schema?.additionalProperties !== undefined || schema?.required !== undefined;
94
+ const arrayShape = schema?.items !== undefined;
95
+ const incompatibleShape = !schema?.type && (objectShape || arrayShape) &&
96
+ !(objectShape && val !== null && typeof val === "object" && !Array.isArray(val)) &&
97
+ !(arrayShape && Array.isArray(val));
98
+ // 结构与声明类型不符:保守隐藏该节点
99
+ if (incompatibleShape || (schema?.type && !isTypeMatch(val, schema.type))) {
100
+ return {
101
+ value: "********",
102
+ redacted: true,
103
+ paths: [path],
104
+ };
105
+ }
106
+ // 数组节点处理
107
+ if (Array.isArray(val)) {
108
+ const newArr = [];
109
+ const childPaths = [];
110
+ let childRedacted = false;
111
+ if (schema?.items && typeof schema.items === "object") {
112
+ for (let i = 0; i < val.length; i++) {
113
+ const elemPath = path === "" ? `/${i}` : `${path}/${i}`;
114
+ const res = redactNode(val[i], schema.items, elemPath);
115
+ newArr.push(res.value);
116
+ if (res.redacted)
117
+ childRedacted = true;
118
+ childPaths.push(...res.paths);
119
+ }
120
+ }
121
+ else {
122
+ // 数组按 items 遍历,无对应声明则元素隐藏
123
+ for (let i = 0; i < val.length; i++) {
124
+ const elemPath = path === "" ? `/${i}` : `${path}/${i}`;
125
+ newArr.push("********");
126
+ childPaths.push(elemPath);
127
+ }
128
+ if (val.length > 0) {
129
+ childRedacted = true;
130
+ }
131
+ }
132
+ return {
133
+ value: newArr,
134
+ redacted: childRedacted,
135
+ paths: childPaths,
136
+ };
137
+ }
138
+ // 普通对象节点处理
139
+ if (val !== null && typeof val === "object") {
140
+ const entries = [];
141
+ const childPaths = [];
142
+ let childRedacted = false;
143
+ for (const k of Object.keys(val)) {
144
+ const childVal = val[k];
145
+ const token = escapePointerToken(k);
146
+ const propPath = path === "" ? `/${token}` : `${path}/${token}`;
147
+ if (schema?.properties && Object.prototype.hasOwnProperty.call(schema.properties, k)) {
148
+ const res = redactNode(childVal, schema.properties[k], propPath);
149
+ entries.push([k, res.value]);
150
+ if (res.redacted)
151
+ childRedacted = true;
152
+ childPaths.push(...res.paths);
153
+ }
154
+ else if (schema?.additionalProperties && typeof schema.additionalProperties === "object") {
155
+ // additionalProperties 声明为模式时继续递归
156
+ const res = redactNode(childVal, schema.additionalProperties, propPath);
157
+ entries.push([k, res.value]);
158
+ if (res.redacted)
159
+ childRedacted = true;
160
+ childPaths.push(...res.paths);
161
+ }
162
+ else {
163
+ // 对象未知属性值隐藏
164
+ entries.push([k, "********"]);
165
+ childRedacted = true;
166
+ childPaths.push(propPath);
167
+ }
168
+ }
169
+ return {
170
+ value: Object.fromEntries(entries),
171
+ redacted: childRedacted,
172
+ paths: childPaths,
173
+ };
174
+ }
175
+ // 标量基元节点
176
+ return {
177
+ value: val,
178
+ redacted: false,
179
+ paths: [],
180
+ };
181
+ }
182
+ /**
183
+ * 依据单项配置声明对配置值执行纯函数脱敏处理。
184
+ *
185
+ * @param value 待脱敏的配置原始数据值
186
+ * @param item 对应的单项配置定义元数据
187
+ */
188
+ export function redactConfigValue(value, item) {
189
+ // 外层 secret: true 判定
190
+ if (item?.secret === true) {
191
+ if (value === undefined) {
192
+ return { value: undefined, secret: true, redactedPaths: [] };
193
+ }
194
+ return { value: undefined, secret: true, redactedPaths: [""] };
195
+ }
196
+ // 未声明 schema 且非顶层 secret 维持旧行为
197
+ if (!item?.schema) {
198
+ return { value: value, secret: false, redactedPaths: [] };
199
+ }
200
+ // 数据值未配置时,不生成虚假脱敏路径,但反映声明敏感性
201
+ if (value === undefined) {
202
+ return {
203
+ value: undefined,
204
+ secret: schemaHasSecret(item.schema),
205
+ redactedPaths: [],
206
+ };
207
+ }
208
+ const res = redactNode(value, item.schema, "");
209
+ const declarationHasSecret = schemaHasSecret(item.schema);
210
+ const secret = declarationHasSecret || res.redacted;
211
+ return {
212
+ value: res.value,
213
+ secret,
214
+ redactedPaths: res.paths,
215
+ };
216
+ }
217
+ function isAncestorPath(parent, child) {
218
+ return parent === "" ? child !== "" : child.startsWith(parent + "/");
219
+ }
220
+ /** 根据可信隐藏路径应用幂等掩码,绝不把远端提供的路径本身当成已脱敏的证据。 */
221
+ export function redactConfigValueAtPaths(value, paths) {
222
+ const hideRoot = () => ({ value: undefined, secret: true, redactedPaths: [""] });
223
+ if (paths.some((path) => typeof path !== "string" || (path !== "" && (!path.startsWith("/") || /~(?![01])/.test(path))))) {
224
+ return hideRoot();
225
+ }
226
+ if (paths.includes(""))
227
+ return hideRoot();
228
+ const unique = [...new Set(paths)];
229
+ const minimal = unique.filter((path) => !unique.some((parent) => parent !== path && isAncestorPath(parent, path))).sort();
230
+ const copy = structuredClone(value);
231
+ for (const path of minimal) {
232
+ const tokens = path.slice(1).split("/").map(unescapePointerToken);
233
+ let node = copy;
234
+ for (const token of tokens.slice(0, -1)) {
235
+ if (node === null || typeof node !== "object")
236
+ return hideRoot();
237
+ if (!Object.hasOwn(node, token)) {
238
+ node = undefined;
239
+ break;
240
+ }
241
+ node = node[token];
242
+ }
243
+ if (node === undefined)
244
+ continue;
245
+ if (node === null || typeof node !== "object")
246
+ return hideRoot();
247
+ const leaf = tokens[tokens.length - 1];
248
+ if (Object.hasOwn(node, leaf)) {
249
+ Object.defineProperty(node, leaf, { value: "********", enumerable: true, writable: true, configurable: true });
250
+ }
251
+ }
252
+ return { value: copy, secret: minimal.length > 0, redactedPaths: minimal };
253
+ }
254
+ /**
255
+ * 依据跨包多重声明对配置值执行合并脱敏处理(取脱敏路径并集,祖先路径覆盖子路径)。
256
+ *
257
+ * @param value 待脱敏的配置原始数据值
258
+ * @param items 各相关包分别解析得到的配置声明集合
259
+ */
260
+ export function redactConfigValueForDeclarations(value, items) {
261
+ if (!items || items.length === 0) {
262
+ return { value: value, secret: false, redactedPaths: [] };
263
+ }
264
+ // 任何声明要求整项敏感,直接隐藏整个根节点
265
+ if (items.some((item) => item.secret === true)) {
266
+ if (value === undefined) {
267
+ return { value: undefined, secret: true, redactedPaths: [] };
268
+ }
269
+ return { value: undefined, secret: true, redactedPaths: [""] };
270
+ }
271
+ if (value === undefined) {
272
+ const hasSecret = items.some((item) => isSecretConfigKey("", item));
273
+ return { value: undefined, secret: hasSecret, redactedPaths: [] };
274
+ }
275
+ let anySecret = false;
276
+ const allPaths = new Set();
277
+ for (const item of items) {
278
+ const res = redactConfigValue(value, item);
279
+ if (res.secret) {
280
+ anySecret = true;
281
+ }
282
+ for (const p of res.redactedPaths) {
283
+ allPaths.add(p);
284
+ }
285
+ }
286
+ const merged = redactConfigValueAtPaths(value, [...allPaths]);
287
+ return { ...merged, secret: anySecret || merged.secret };
288
+ }
289
+ function sanitizeSchemaNode(schema, inheritedSecret = false) {
290
+ const copy = { ...schema };
291
+ const hidden = inheritedSecret || schema.secret === true;
292
+ // 祖先枚举也可能携带子字段秘密,未知结构同样不可借约束回显。
293
+ if (hidden || schemaHasSecret(schema) || schema.enum?.some((value) => redactNode(value, schema, "").redacted)) {
294
+ delete copy.enum;
295
+ }
296
+ if (copy.properties) {
297
+ copy.properties = Object.fromEntries(Object.entries(copy.properties).map(([key, child]) => [key, sanitizeSchemaNode(child, hidden)]));
298
+ }
299
+ if (copy.additionalProperties && typeof copy.additionalProperties === "object") {
300
+ copy.additionalProperties = sanitizeSchemaNode(copy.additionalProperties, hidden);
301
+ }
302
+ if (copy.items && typeof copy.items === "object") {
303
+ copy.items = sanitizeSchemaNode(copy.items, hidden);
304
+ }
305
+ return copy;
306
+ }
27
307
  /**
28
308
  * 对配置定义字典进行脱敏处理:
29
- * 针对声明为 secret: true 的项,剥离其 default 默认值,不返回敏感默认值。
309
+ * - 针对外层 secret: true 或嵌套包含敏感字段的项,剥离其 default 默认值,避免敏感默认值泄露或误用于执行;
310
+ * - 针对模式中敏感节点的 enum 约束列表进行清洗,杜绝约束文本泄露秘密;
311
+ * - 仅返回安全副本,严禁修改清单原始数据。
30
312
  *
31
313
  * @param config 声明的配置定义字典
32
314
  */
33
315
  export function sanitizeConfigDefinitions(config) {
34
316
  if (!config)
35
317
  return config;
36
- const result = {};
37
- for (const [key, item] of Object.entries(config)) {
38
- if (item?.secret === true) {
39
- const copy = { ...item };
318
+ return Object.fromEntries(Object.entries(config).map(([key, item]) => {
319
+ if (!item)
320
+ return [key, item];
321
+ const copy = { ...item };
322
+ if (isSecretConfigKey(key, item) || redactConfigValue(item.default, item).redactedPaths.length > 0) {
40
323
  delete copy.default;
41
- result[key] = copy;
42
324
  }
43
- else {
44
- result[key] = item;
325
+ if (copy.schema) {
326
+ copy.schema = sanitizeSchemaNode(copy.schema, copy.secret === true);
45
327
  }
46
- }
47
- return result;
328
+ return [key, copy];
329
+ }));
48
330
  }
@@ -0,0 +1,40 @@
1
+ import type { ConfigItemDefinition } from "../../project/types.js";
2
+ import type { ConfigKeyProvider } from "../../security/config-cipher.js";
3
+ import type { Clock } from "../clock.js";
4
+ import { type SqliteDriver } from "../types.js";
5
+ /**
6
+ * 版本迁移执行上下文:迁移所需的最小依赖集合。
7
+ *
8
+ * 由存储装配入口(SqliteRuntimeStorage)提供底层驱动、目标命名空间、
9
+ * 配置声明集合与密钥提供器;主干层只装配数据,不做任何迁移判断。
10
+ */
11
+ export interface SchemaMigrationContext {
12
+ driver: SqliteDriver;
13
+ dbPath: string;
14
+ packageId: string;
15
+ declarations: readonly Record<string, ConfigItemDefinition>[];
16
+ keyProvider: ConfigKeyProvider;
17
+ clock: Clock;
18
+ }
19
+ /** 单个版本迁移步骤:from → to 的原子转换。 */
20
+ export interface StorageMigration {
21
+ from: number;
22
+ to: number;
23
+ run(context: SchemaMigrationContext): void;
24
+ }
25
+ /**
26
+ * 存储打开前的版本就绪入口:新库建库、旧库迁移、当前库校验。
27
+ *
28
+ * 调用时机:先于任何正常子存储构造(当前版本存储实现始终假定数据库
29
+ * 已经是当前版本),也先于运行记录恢复(恢复依赖子存储)。
30
+ *
31
+ * - 版本等于当前:锁外只读校验核心表齐备,缺失即拒绝(半升级或损坏
32
+ * 状态不得静默修补);
33
+ * - 其余版本(含全新库):统一进入 BEGIN IMMEDIATE 写事务,锁内重读
34
+ * 版本后决策——全新库执行当前版本 DDL,旧版本沿注册链迁移并逐步更新
35
+ * 版本号,无支持路径(如版本 1 或未来版本)明确拒绝;
36
+ * - 结构完整性校验在事务内、提交前执行:任何失败(含迁移步骤与最终
37
+ * 校验)都会把数据、结构与版本号整体回滚,数据库保持进入前状态可重试;
38
+ * - 并发连接已抢先完成同一升级时,锁内版本已是当前版本,校验后直接通过。
39
+ */
40
+ export declare function ensureStorageSchema(context: SchemaMigrationContext): void;
@@ -0,0 +1,96 @@
1
+ import { ActionDockError, UNSUPPORTED_STORAGE_SCHEMA } from "../../errors.js";
2
+ import { createCurrentSchema, validateCurrentSchema } from "../sqlite-schema.js";
3
+ import { STORAGE_SCHEMA_VERSION } from "../types.js";
4
+ import { migrateV2ToV3 } from "./v2-to-v3.js";
5
+ /**
6
+ * 已注册的版本迁移链。
7
+ *
8
+ * 每个历史版本的全部转换逻辑封装在各自迁移文件中;下线某旧版本支持时,
9
+ * 删除对应迁移文件与注册项即可,当前版本读写代码不感知历史版本。
10
+ */
11
+ const MIGRATIONS = [
12
+ { from: 2, to: 3, run: migrateV2ToV3 },
13
+ ];
14
+ /** 读取数据库当前 schema 版本号。 */
15
+ function readUserVersion(driver) {
16
+ const row = driver.prepare("PRAGMA user_version;").get();
17
+ return Number(row?.user_version ?? 0);
18
+ }
19
+ /** 构造不支持版本的统一拒绝错误。 */
20
+ function unsupportedVersionError(version, dbPath) {
21
+ return new ActionDockError(UNSUPPORTED_STORAGE_SCHEMA, `Database schema version ${version} of '${dbPath}' has no supported migration path to the current version ${STORAGE_SCHEMA_VERSION}; upgrade the database first with a build that still supports version ${version}.`, { dbPath, version, current: STORAGE_SCHEMA_VERSION });
22
+ }
23
+ /**
24
+ * 沿注册链推导从 from 到当前版本的迁移步骤序列。
25
+ *
26
+ * 返回 null 表示无支持路径:from 非法(<= 0)、不低于当前版本(未来版本
27
+ * 一律拒绝,结构完整也不得当作当前版本打开),或链上存在缺口、注册步骤
28
+ * 无法精确抵达当前版本。
29
+ */
30
+ function buildMigrationChain(from) {
31
+ if (from <= 0 || from >= STORAGE_SCHEMA_VERSION) {
32
+ return null;
33
+ }
34
+ const chain = [];
35
+ let version = from;
36
+ while (version < STORAGE_SCHEMA_VERSION) {
37
+ const step = MIGRATIONS.find((migration) => migration.from === version);
38
+ if (!step) {
39
+ return null;
40
+ }
41
+ chain.push(step);
42
+ version = step.to;
43
+ }
44
+ if (version !== STORAGE_SCHEMA_VERSION) {
45
+ return null;
46
+ }
47
+ return chain;
48
+ }
49
+ /**
50
+ * 存储打开前的版本就绪入口:新库建库、旧库迁移、当前库校验。
51
+ *
52
+ * 调用时机:先于任何正常子存储构造(当前版本存储实现始终假定数据库
53
+ * 已经是当前版本),也先于运行记录恢复(恢复依赖子存储)。
54
+ *
55
+ * - 版本等于当前:锁外只读校验核心表齐备,缺失即拒绝(半升级或损坏
56
+ * 状态不得静默修补);
57
+ * - 其余版本(含全新库):统一进入 BEGIN IMMEDIATE 写事务,锁内重读
58
+ * 版本后决策——全新库执行当前版本 DDL,旧版本沿注册链迁移并逐步更新
59
+ * 版本号,无支持路径(如版本 1 或未来版本)明确拒绝;
60
+ * - 结构完整性校验在事务内、提交前执行:任何失败(含迁移步骤与最终
61
+ * 校验)都会把数据、结构与版本号整体回滚,数据库保持进入前状态可重试;
62
+ * - 并发连接已抢先完成同一升级时,锁内版本已是当前版本,校验后直接通过。
63
+ */
64
+ export function ensureStorageSchema(context) {
65
+ const { driver, dbPath } = context;
66
+ // 跨进程写并发基线:优先设置 busy_timeout,确保后续 PRAGMA(如
67
+ // journal_mode = WAL)与迁移事务在锁争用时能够重试等待
68
+ driver.exec("PRAGMA busy_timeout = 5000;");
69
+ driver.exec("PRAGMA journal_mode = WAL;");
70
+ driver.exec("PRAGMA synchronous = NORMAL;");
71
+ if (readUserVersion(driver) === STORAGE_SCHEMA_VERSION) {
72
+ validateCurrentSchema(driver, dbPath);
73
+ return;
74
+ }
75
+ driver.transaction(() => {
76
+ const locked = readUserVersion(driver);
77
+ if (locked === STORAGE_SCHEMA_VERSION) {
78
+ validateCurrentSchema(driver, dbPath);
79
+ return;
80
+ }
81
+ if (locked === 0) {
82
+ createCurrentSchema(driver);
83
+ }
84
+ else {
85
+ const chain = buildMigrationChain(locked);
86
+ if (chain === null) {
87
+ throw unsupportedVersionError(locked, dbPath);
88
+ }
89
+ for (const step of chain) {
90
+ step.run(context);
91
+ driver.exec(`PRAGMA user_version = ${step.to};`);
92
+ }
93
+ }
94
+ validateCurrentSchema(driver, dbPath);
95
+ }, "immediate");
96
+ }
@@ -0,0 +1,16 @@
1
+ import type { SchemaMigrationContext } from "./index.js";
2
+ /**
3
+ * V2 → V3 专用迁移:V2 数据库全部历史知识的唯一归属地。
4
+ *
5
+ * 在调用方维护事务(BEGIN IMMEDIATE)内完成:
6
+ * - 结构补齐:runs 表新增列(origin_json / host_session_id / host_pid /
7
+ * heartbeat_at)、events 审计事件表、config_encryption 密钥元数据表;
8
+ * - 敏感明文转密文:按声明集合(各字典内具体键优先于模板、跨字典取并集)
9
+ * 判定当前命名空间的配置键,整键加密并保留业务值与 updated_at;
10
+ * - 密钥管理:存在待加密行时才解析并持久化密钥(提供器在迁移事务内先落盘
11
+ * 密钥与身份标记,再由本事务提交密文),同一事务内钉住密钥指纹。
12
+ *
13
+ * 直接操作底层 SQL,不构造也不调用当前版本存储对象:将来修改正常写入
14
+ * 行为不会意外改变历史迁移语义。本文件与注册项在下线 V2 支持时整体删除。
15
+ */
16
+ export declare function migrateV2ToV3(context: SchemaMigrationContext): void;
@@ -0,0 +1,116 @@
1
+ import { ActionDockError, CONFIG_ENCRYPTION_ERROR } from "../../errors.js";
2
+ import { resolveConfigDeclaration } from "../../project/config-declarations.js";
3
+ import { isEncryptedConfigValue } from "../../security/config-cipher.js";
4
+ import { isSecretConfigKey } from "../mask.js";
5
+ import { CONFIG_ENCRYPTION_TABLE_DDL } from "../sqlite-schema.js";
6
+ import { encryptConfigEnvelope, mergeProtectionDeclarations } from "../config-value.js";
7
+ import { safeParseStoredJson } from "../utils.js";
8
+ /**
9
+ * V2 → V3 专用迁移:V2 数据库全部历史知识的唯一归属地。
10
+ *
11
+ * 在调用方维护事务(BEGIN IMMEDIATE)内完成:
12
+ * - 结构补齐:runs 表新增列(origin_json / host_session_id / host_pid /
13
+ * heartbeat_at)、events 审计事件表、config_encryption 密钥元数据表;
14
+ * - 敏感明文转密文:按声明集合(各字典内具体键优先于模板、跨字典取并集)
15
+ * 判定当前命名空间的配置键,整键加密并保留业务值与 updated_at;
16
+ * - 密钥管理:存在待加密行时才解析并持久化密钥(提供器在迁移事务内先落盘
17
+ * 密钥与身份标记,再由本事务提交密文),同一事务内钉住密钥指纹。
18
+ *
19
+ * 直接操作底层 SQL,不构造也不调用当前版本存储对象:将来修改正常写入
20
+ * 行为不会意外改变历史迁移语义。本文件与注册项在下线 V2 支持时整体删除。
21
+ */
22
+ export function migrateV2ToV3(context) {
23
+ upgradeRunsColumns(context.driver);
24
+ ensureEventsTable(context.driver);
25
+ context.driver.exec(CONFIG_ENCRYPTION_TABLE_DDL);
26
+ encryptSensitivePlaintextConfigs(context);
27
+ }
28
+ /** 补齐 runs 表历史新增列(旧库形态可能全部缺失)。 */
29
+ function upgradeRunsColumns(driver) {
30
+ const columns = driver.prepare("PRAGMA table_info(runs);").all();
31
+ const present = new Set(columns.map((column) => column.name));
32
+ const additions = [
33
+ ["origin_json", "TEXT"],
34
+ ["host_session_id", "TEXT"],
35
+ ["host_pid", "INTEGER"],
36
+ ["heartbeat_at", "TEXT"],
37
+ ];
38
+ for (const [column, type] of additions) {
39
+ if (!present.has(column)) {
40
+ driver.exec(`ALTER TABLE runs ADD COLUMN ${column} ${type};`);
41
+ }
42
+ }
43
+ }
44
+ /** 补齐 events 审计事件表(V3 必备结构)。 */
45
+ function ensureEventsTable(driver) {
46
+ driver.exec(`
47
+ CREATE TABLE IF NOT EXISTS events (
48
+ id TEXT PRIMARY KEY,
49
+ package_id TEXT NOT NULL,
50
+ event_type TEXT NOT NULL,
51
+ payload_json TEXT,
52
+ created_at TEXT NOT NULL
53
+ );
54
+ CREATE INDEX IF NOT EXISTS idx_events_type ON events(package_id, event_type);
55
+ `);
56
+ }
57
+ /** 解析目标键在各声明字典下的有效声明并集(各字典内具体键优先于模板)。 */
58
+ function resolveDeclarations(declarations, key) {
59
+ const items = [];
60
+ for (const dict of declarations) {
61
+ const resolved = resolveConfigDeclaration(dict, key)?.item;
62
+ if (resolved !== undefined) {
63
+ items.push(resolved);
64
+ }
65
+ }
66
+ return items;
67
+ }
68
+ /**
69
+ * 按声明集合加密当前命名空间的敏感明文配置行。
70
+ *
71
+ * - 只处理非空明文行:普通明文与非敏感键保持原状(普通明文与密文在
72
+ * 当前版本内合法共存);空串与空值行留待正常写入路径处理;
73
+ * - 先对全部目标行完成严格 JSON 解析,再解析密钥并钉住指纹,避免为
74
+ * 注定失败的迁移提前创建密钥文件;任何一步失败由调用方事务整体回滚;
75
+ * - 迁移只替换 value_json,业务原值与 updated_at 完整保留。
76
+ */
77
+ function encryptSensitivePlaintextConfigs(context) {
78
+ const { driver, packageId, declarations, keyProvider, clock, dbPath } = context;
79
+ const rows = driver
80
+ .prepare("SELECT key, value_json FROM config WHERE package_id = ?")
81
+ .all(packageId);
82
+ const targets = [];
83
+ for (const row of rows) {
84
+ const raw = row.value_json;
85
+ if (typeof raw !== "string" || raw === "" || isEncryptedConfigValue(raw)) {
86
+ continue;
87
+ }
88
+ const items = resolveDeclarations(declarations, row.key);
89
+ if (!items.some((item) => isSecretConfigKey(row.key, item))) {
90
+ continue;
91
+ }
92
+ const value = safeParseStoredJson(raw, `v2-migration config db=${dbPath} package=${packageId} key=${row.key}`, { strict: true });
93
+ targets.push({ key: row.key, value, items });
94
+ }
95
+ if (targets.length === 0) {
96
+ return;
97
+ }
98
+ const material = keyProvider(true);
99
+ const pinned = driver
100
+ .prepare("SELECT key_id FROM config_encryption WHERE id = 1")
101
+ .get();
102
+ if (pinned === undefined) {
103
+ driver
104
+ .prepare("INSERT INTO config_encryption (id, key_id, created_at) VALUES (1, ?, ?)")
105
+ .run(material.id, clock.now().toISOString());
106
+ }
107
+ else if (pinned.key_id !== material.id) {
108
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Config encryption metadata exists with a different key identity in database '${dbPath}'`, { dbPath, pinnedKeyId: pinned.key_id });
109
+ }
110
+ const update = driver.prepare("UPDATE config SET value_json = ? WHERE package_id = ? AND key = ?");
111
+ for (const target of targets) {
112
+ const protection = mergeProtectionDeclarations([], target.items, target.key);
113
+ const ciphertext = encryptConfigEnvelope(target.value, protection, packageId, target.key, material);
114
+ update.run(ciphertext, packageId, target.key);
115
+ }
116
+ }