@actiondock/core 2.20.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 (67) hide show
  1. package/dist/errors.d.ts +11 -1
  2. package/dist/errors.js +23 -0
  3. package/dist/execution/service.js +36 -5
  4. package/dist/host/host.d.ts +11 -0
  5. package/dist/host/host.js +258 -39
  6. package/dist/host/types.d.ts +10 -1
  7. package/dist/index.d.ts +8 -1
  8. package/dist/index.js +5 -1
  9. package/dist/invocation/types.d.ts +31 -0
  10. package/dist/invocation/types.js +10 -1
  11. package/dist/package/index.d.ts +1 -1
  12. package/dist/package/runtime.js +18 -4
  13. package/dist/platform/node.d.ts +6 -0
  14. package/dist/platform/node.js +59 -16
  15. package/dist/platform/types.d.ts +11 -0
  16. package/dist/process/command-executor.d.ts +35 -0
  17. package/dist/process/command-executor.js +326 -0
  18. package/dist/process/index.d.ts +1 -0
  19. package/dist/process/index.js +1 -0
  20. package/dist/process/process-driver.d.ts +19 -6
  21. package/dist/process/process-driver.js +223 -54
  22. package/dist/process/run-executor.js +101 -47
  23. package/dist/project/dependencies.d.ts +92 -0
  24. package/dist/project/dependencies.js +543 -0
  25. package/dist/project/index.d.ts +3 -0
  26. package/dist/project/index.js +3 -0
  27. package/dist/project/init.js +2 -2
  28. package/dist/project/loader.js +15 -30
  29. package/dist/project/package-manager.d.ts +40 -0
  30. package/dist/project/package-manager.js +224 -0
  31. package/dist/project/preparation.d.ts +8 -0
  32. package/dist/project/preparation.js +130 -0
  33. package/dist/project/transactions.d.ts +5 -1
  34. package/dist/project/transactions.js +35 -75
  35. package/dist/runtime/context.d.ts +21 -2
  36. package/dist/runtime/context.js +42 -17
  37. package/dist/runtime/run-persistence.d.ts +1 -1
  38. package/dist/runtime/run-persistence.js +4 -3
  39. package/dist/security/config-cipher.d.ts +54 -0
  40. package/dist/security/config-cipher.js +190 -0
  41. package/dist/security/config-key.d.ts +35 -0
  42. package/dist/security/config-key.js +469 -0
  43. package/dist/server/routes/config.js +20 -1
  44. package/dist/service/factory.js +3 -0
  45. package/dist/service/types.d.ts +8 -0
  46. package/dist/storage/config-value.d.ts +33 -0
  47. package/dist/storage/config-value.js +102 -0
  48. package/dist/storage/file-lock.js +35 -21
  49. package/dist/storage/index.d.ts +24 -2
  50. package/dist/storage/index.js +65 -2
  51. package/dist/storage/migrations/index.d.ts +40 -0
  52. package/dist/storage/migrations/index.js +96 -0
  53. package/dist/storage/migrations/v2-to-v3.d.ts +16 -0
  54. package/dist/storage/migrations/v2-to-v3.js +116 -0
  55. package/dist/storage/sqlite-config.d.ts +62 -3
  56. package/dist/storage/sqlite-config.js +163 -9
  57. package/dist/storage/sqlite-driver.d.ts +5 -2
  58. package/dist/storage/sqlite-driver.js +24 -3
  59. package/dist/storage/sqlite-schema.d.ts +20 -2
  60. package/dist/storage/sqlite-schema.js +129 -132
  61. package/dist/storage/sqlite.d.ts +7 -2
  62. package/dist/storage/sqlite.js +64 -13
  63. package/dist/storage/types.d.ts +72 -4
  64. package/dist/storage/types.js +7 -2
  65. package/dist/version.d.ts +1 -1
  66. package/dist/version.js +1 -1
  67. package/package.json +2 -2
@@ -0,0 +1,469 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeSync, } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { ActionDockError, CONFIG_ENCRYPTION_ERROR } from "../errors.js";
5
+ import { getActionDockHome } from "../utils/index.js";
6
+ import { configKeyId } from "./config-cipher.js";
7
+ /** 环境变量密钥来源名称,值必须为严格的 64 位十六进制字符串 */
8
+ const ENV_KEY_NAME = "ACTIONDOCK_ENCRYPTION_KEY";
9
+ /**
10
+ * 环境密钥的严格格式:恰好 64 位十六进制字符(解码后恰为 32 字节 AES-256 密钥)。
11
+ * 空字符串、空白或前后填充均视为非法取值,绝不允许回退到本地密钥来源。
12
+ */
13
+ const ENV_KEY_PATTERN = /^[0-9a-fA-F]{64}$/;
14
+ /** AES-256 密钥字节长度 */
15
+ const KEY_BYTES = 32;
16
+ /** 密钥文件与身份标记规范权限:仅属主可读写 */
17
+ const KEY_FILE_MODE = 0o600;
18
+ /** 密钥目录规范权限:仅属主可访问 */
19
+ const KEY_DIR_MODE = 0o700;
20
+ /** 密钥指纹的规范形式:64 位小写十六进制字符 */
21
+ const KEY_ID_PATTERN = /^[0-9a-f]{64}$/;
22
+ /**
23
+ * POSIX 权限位语义在 Windows 上不存在,权限强制校验仅在 POSIX 平台执行;
24
+ * 常规文件与真实目录(拒绝符号链接)检查在所有平台始终生效。
25
+ */
26
+ const IS_POSIX = process.platform !== "win32";
27
+ /**
28
+ * 平台明确不支持目录 fsync 的错误码集合,仅这些可安全忽略;
29
+ * EIO 等真实 I/O 故障必须作为失败透传,不得静默吞没。
30
+ */
31
+ const DIRECTORY_FSYNC_UNSUPPORTED_CODES = new Set([
32
+ "EINVAL",
33
+ "ENOTSUP",
34
+ "EOPNOTSUPP",
35
+ ]);
36
+ /** Windows 可能拒绝打开目录句柄;POSIX 上同类权限错误必须透传。 */
37
+ const WINDOWS_DIRECTORY_FSYNC_UNSUPPORTED_CODES = new Set(["EPERM", "EACCES", "EISDIR"]);
38
+ /**
39
+ * 创建配置加密密钥提供器。
40
+ *
41
+ * 来源优先级:
42
+ * - 显式注入 key:纯内存材料,绝不读取环境变量或真实用户目录,也无需钉住持久身份;
43
+ * - 环境变量 ACTIONDOCK_ENCRYPTION_KEY:必须为恰好 64 位十六进制字符,
44
+ * 只要被设置(含空串或空白)就必须严格合法,非法取值立即报错,
45
+ * 绝不静默回退到本地密钥;
46
+ * - 本地密钥文件:dataDir 有值时为 <dataDir>/keys/config.key,
47
+ * 否则为 getActionDockHome(customHome)/.actiondock/keys/config.key。
48
+ *
49
+ * 构造时不发生任何环境读取、文件访问或目录创建;全部定位与加载在
50
+ * 首次调用时完成并缓存,此后同一提供器实例不再感知环境变化(宿主快照)。
51
+ * 若首次调用以 create 为 false 加载了未钉住身份的材料,后续 create 为 true
52
+ * 的调用会先原子创建或校验身份标记再返回缓存材料,保证允许写入前
53
+ * 持久域身份一定已经落盘。
54
+ *
55
+ * 身份保护:标记存在而密钥材料丢失时直接报错,绝不为同一存储域重新
56
+ * 生成新密钥;提供密钥与标记指纹不符时同样拒绝。
57
+ */
58
+ export function createConfigKeyProvider(options) {
59
+ const opts = options ?? {};
60
+ let cached;
61
+ return (create) => {
62
+ if (cached === undefined) {
63
+ cached =
64
+ opts.key !== undefined
65
+ ? { material: materialFromInjectedKey(opts.key), pinned: true, source: "injected" }
66
+ : loadMaterialFromEnvironmentOrFile(opts, create);
67
+ return cached.material;
68
+ }
69
+ if (create && !cached.pinned) {
70
+ pinPersistedIdentity(opts, cached);
71
+ cached = { ...cached, pinned: true };
72
+ }
73
+ return cached.material;
74
+ };
75
+ }
76
+ /**
77
+ * 为缓存的未钉住材料补齐持久域身份:原子创建或校验身份标记。
78
+ *
79
+ * 首次以只读方式(create 为 false)加载的材料可能尚未在磁盘钉住身份;
80
+ * 在任何允许写入的调用返回前必须补齐,防止宿主进程在从未落盘身份的
81
+ * 状态下写出密文后丢失密钥归属。注入来源纯内存,直接跳过。
82
+ */
83
+ function pinPersistedIdentity(opts, state) {
84
+ if (state.source === "injected") {
85
+ return;
86
+ }
87
+ const paths = resolveKeyPaths(opts);
88
+ const id = state.material.id;
89
+ if (state.source === "env" && existsSync(paths.keyPath)) {
90
+ assertSecureKeyDirectory(paths.keyDir);
91
+ const fileBytes = readKeyFile(paths.keyPath);
92
+ if (configKeyId(fileBytes) !== id) {
93
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Environment key identity does not match the existing local key file '${paths.keyPath}'`);
94
+ }
95
+ }
96
+ const markerId = readMarkerIdIfExists(paths.markerPath);
97
+ if (markerId !== undefined) {
98
+ assertSecureKeyDirectory(paths.keyDir);
99
+ if (markerId !== id) {
100
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Recorded key identity marker '${paths.markerPath}' does not match the active key; refusing to continue with a different key`);
101
+ }
102
+ return;
103
+ }
104
+ assertOrCreateSecureKeyDirectory(paths.keyDir);
105
+ publishMarkerFile(paths.markerPath, id);
106
+ }
107
+ /**
108
+ * 解析密钥存储路径集合。
109
+ */
110
+ function resolveKeyPaths(opts) {
111
+ const keyDir = opts.dataDir && opts.dataDir.length > 0
112
+ ? join(opts.dataDir, "keys")
113
+ : join(getActionDockHome(opts.customHome), ".actiondock", "keys");
114
+ return {
115
+ keyDir,
116
+ keyPath: join(keyDir, "config.key"),
117
+ markerPath: join(keyDir, "config.key.id"),
118
+ };
119
+ }
120
+ /**
121
+ * 构造显式注入密钥的材料。
122
+ *
123
+ * 纯内存路径:仅校验密钥长度并计算指纹,不读取环境变量,
124
+ * 不访问、校验或创建任何真实文件系统路径,也不持久化身份标记。
125
+ */
126
+ function materialFromInjectedKey(key) {
127
+ if (!(key instanceof Uint8Array) || key.byteLength !== KEY_BYTES) {
128
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Invalid injected config encryption key: expected ${KEY_BYTES} bytes, got ${key instanceof Uint8Array ? key.byteLength : typeof key}`);
129
+ }
130
+ return { key, id: configKeyId(key) };
131
+ }
132
+ /**
133
+ * 依据来源优先级加载持久化密钥材料:环境变量优先于本地密钥文件。
134
+ *
135
+ * 环境变量一旦被设置(包括空串、空白或任何非法形态)就必须严格合法,
136
+ * 不存在“视为未设置”的宽松路径,杜绝无效配置静默回退。
137
+ */
138
+ function loadMaterialFromEnvironmentOrFile(opts, create) {
139
+ const paths = resolveKeyPaths(opts);
140
+ const envSource = opts.env ?? process.env;
141
+ const raw = envSource[ENV_KEY_NAME];
142
+ if (raw !== undefined) {
143
+ return loadEnvKeyMaterial(raw, paths, create);
144
+ }
145
+ return loadLocalKeyMaterial(paths, create);
146
+ }
147
+ /**
148
+ * 解析环境变量密钥来源。
149
+ *
150
+ * 值必须为恰好 64 位十六进制字符(仅允许大小写差异),空串、空白、
151
+ * 前后填充或任何非法字符均立即抛出 CONFIG_ENCRYPTION_ERROR,绝不回退
152
+ * 到本地密钥文件。首次允许创建时同样原子记录身份标记;若本地密钥文件
153
+ * 或标记已存在且指纹不同,则拒绝使用该环境密钥,防止用第二把有效密钥
154
+ * 写入历史密钥的存储域。标记存在时密钥目录必须通过安全校验,不因
155
+ * 缺少本地密钥文件而绕过目录检查。
156
+ */
157
+ function loadEnvKeyMaterial(raw, paths, create) {
158
+ if (typeof raw !== "string" || !ENV_KEY_PATTERN.test(raw)) {
159
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Environment variable ${ENV_KEY_NAME} must be exactly 64 hex characters (32 bytes); the configured value is invalid and falling back to another key source is not permitted`);
160
+ }
161
+ const keyBytes = Buffer.from(raw, "hex");
162
+ const id = configKeyId(keyBytes);
163
+ if (existsSync(paths.keyPath)) {
164
+ assertSecureKeyDirectory(paths.keyDir);
165
+ const fileBytes = readKeyFile(paths.keyPath);
166
+ if (configKeyId(fileBytes) !== id) {
167
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Environment key identity does not match the existing local key file '${paths.keyPath}'`);
168
+ }
169
+ }
170
+ const markerId = readMarkerIdIfExists(paths.markerPath);
171
+ if (markerId !== undefined) {
172
+ assertSecureKeyDirectory(paths.keyDir);
173
+ if (markerId !== id) {
174
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Environment key identity does not match the recorded key identity marker '${paths.markerPath}'`);
175
+ }
176
+ return { material: { key: keyBytes, id }, pinned: true, source: "env" };
177
+ }
178
+ if (create) {
179
+ assertOrCreateSecureKeyDirectory(paths.keyDir);
180
+ publishMarkerFile(paths.markerPath, id);
181
+ return { material: { key: keyBytes, id }, pinned: true, source: "env" };
182
+ }
183
+ return { material: { key: keyBytes, id }, pinned: false, source: "env" };
184
+ }
185
+ /**
186
+ * 加载本地密钥文件材料,必要时按安全约束生成并发布新密钥。
187
+ */
188
+ function loadLocalKeyMaterial(paths, create) {
189
+ const markerId = readMarkerIdIfExists(paths.markerPath);
190
+ if (!existsSync(paths.keyPath)) {
191
+ if (markerId !== undefined) {
192
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Config encryption key file '${paths.keyPath}' is missing while identity marker '${paths.markerPath}' still pins key id '${markerId}'; refusing to generate a new key for this storage domain. Restore the original key file or provide ${ENV_KEY_NAME}`, { keyPath: paths.keyPath, markerPath: paths.markerPath });
193
+ }
194
+ if (!create) {
195
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Config encryption key file '${paths.keyPath}' does not exist and key creation is disabled for this call`, { keyPath: paths.keyPath });
196
+ }
197
+ publishGeneratedKeyFile(paths.keyDir, paths.keyPath);
198
+ }
199
+ assertSecureKeyDirectory(paths.keyDir);
200
+ const keyBytes = readKeyFile(paths.keyPath);
201
+ const id = configKeyId(keyBytes);
202
+ if (markerId !== undefined) {
203
+ if (markerId !== id) {
204
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Config encryption key file '${paths.keyPath}' does not match the recorded key identity marker '${paths.markerPath}'`, { keyPath: paths.keyPath, markerPath: paths.markerPath });
205
+ }
206
+ return { material: { key: keyBytes, id }, pinned: true, source: "file" };
207
+ }
208
+ if (create) {
209
+ publishMarkerFile(paths.markerPath, id);
210
+ return { material: { key: keyBytes, id }, pinned: true, source: "file" };
211
+ }
212
+ return { material: { key: keyBytes, id }, pinned: false, source: "file" };
213
+ }
214
+ /**
215
+ * 生成随机密钥并安全发布到目标路径。
216
+ *
217
+ * 发布语义:先在私有临时文件中完整写入 32 字节随机密钥并 fsync,
218
+ * 再以不覆盖的硬链接原子发布;竞争失败方直接读取胜者已发布的完整文件,
219
+ * 绝不出现先用独占标志创建目标再缓慢写入导致他人读到半文件的窗口。
220
+ */
221
+ function publishGeneratedKeyFile(keyDir, keyPath) {
222
+ assertOrCreateSecureKeyDirectory(keyDir);
223
+ const material = randomBytes(KEY_BYTES);
224
+ publishAtomically(keyPath, material, KEY_FILE_MODE);
225
+ }
226
+ /**
227
+ * 原子发布身份标记文件,竞争发布失败时复核既有标记一致性。
228
+ *
229
+ * 发布成功路径内含所属目录的尽力 fsync,确保标记目录项先于密文持久化。
230
+ */
231
+ function publishMarkerFile(markerPath, id) {
232
+ const published = publishAtomically(markerPath, Buffer.from(`${id}\n`, "utf8"), KEY_FILE_MODE);
233
+ if (!published) {
234
+ const existing = readMarkerIdIfExists(markerPath);
235
+ if (existing !== id) {
236
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Recorded key identity marker '${markerPath}' does not match the active key; refusing to overwrite`);
237
+ }
238
+ }
239
+ }
240
+ /**
241
+ * 以不覆盖方式原子发布文件内容。
242
+ *
243
+ * 发布分三段且各段失败均清理临时文件:完整写入并 fsync 私有临时文件、
244
+ * 修正权限位、以硬链接原子发布到目标路径。目标已存在(EEXIST)时返回
245
+ * false 表示竞争失败,由调用方读取胜者已发布的完整文件。
246
+ *
247
+ * 临时文件承载主密钥内容:任何失败路径都必须尽力删除临时文件;发布成功
248
+ * 后临时文件名与目标共享同一 inode,也必须移除临时名,避免密钥材料多出
249
+ * 一条可达路径。清理失败与真实 I/O 故障一律包装为 CONFIG_ENCRYPTION_ERROR
250
+ * 透传,不得静默吞没。
251
+ */
252
+ function publishAtomically(targetPath, content, mode) {
253
+ const dir = dirname(targetPath);
254
+ const tmpPath = join(dir, `.${basename(targetPath)}.tmp.${process.pid}.${randomUUID()}`);
255
+ try {
256
+ const fd = openSync(tmpPath, "wx", mode);
257
+ try {
258
+ writeAll(fd, content);
259
+ fsyncSync(fd);
260
+ }
261
+ finally {
262
+ closeSync(fd);
263
+ }
264
+ if (IS_POSIX) {
265
+ chmodSync(tmpPath, mode);
266
+ }
267
+ }
268
+ catch (err) {
269
+ if (!cleanupTempFile(tmpPath)) {
270
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Failed to write key-bearing temporary file '${tmpPath}' and its cleanup also failed; remove the file manually before retrying`, { path: tmpPath });
271
+ }
272
+ throw wrapFsError(err, tmpPath);
273
+ }
274
+ try {
275
+ linkSync(tmpPath, targetPath);
276
+ }
277
+ catch (err) {
278
+ if (!cleanupTempFile(tmpPath)) {
279
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Failed to publish '${targetPath}' and cleaning up key-bearing temporary file '${tmpPath}' also failed; remove the file manually before retrying`, { path: tmpPath });
280
+ }
281
+ if (err instanceof Error && err.code === "EEXIST") {
282
+ return false;
283
+ }
284
+ throw wrapFsError(err, targetPath);
285
+ }
286
+ if (!cleanupTempFile(tmpPath)) {
287
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Published '${targetPath}' but removing its temporary name '${tmpPath}' failed; both names currently expose the same key material`, { path: tmpPath });
288
+ }
289
+ fsyncDirectory(dir);
290
+ return true;
291
+ }
292
+ /**
293
+ * 删除承载密钥材料的临时文件;返回是否清理成功。
294
+ *
295
+ * 文件不存在视为已清理;其余失败向上交由调用方以统一错误透传,
296
+ * 因为残留的临时文件本身就是主密钥的一条可达路径。
297
+ */
298
+ function cleanupTempFile(tmpPath) {
299
+ try {
300
+ unlinkSync(tmpPath);
301
+ return true;
302
+ }
303
+ catch (err) {
304
+ if (err instanceof Error && err.code === "ENOENT") {
305
+ return true;
306
+ }
307
+ return false;
308
+ }
309
+ }
310
+ /**
311
+ * 循环写入直至全部字节落盘。
312
+ */
313
+ function writeAll(fd, content) {
314
+ let offset = 0;
315
+ while (offset < content.byteLength) {
316
+ offset += writeSync(fd, content, offset, content.byteLength - offset);
317
+ }
318
+ }
319
+ /**
320
+ * 同步目录项以确保发布链接在断电后仍然存在。
321
+ *
322
+ * 崩溃耐久性:仅 fsync 临时文件不足以保证断电后文件名存在,发布链接后
323
+ * 必须 fsync 所属目录,使目录项先于任何依赖该密钥的密文持久化;提供器
324
+ * 在目录同步完成后才返回材料,保证调用方后续写入的密文不会引用一个
325
+ * 掉电后丢失的密钥名。仅平台明确不支持目录 fsync 的错误码可忽略,
326
+ * EIO 等真实故障必须失败。
327
+ */
328
+ function fsyncDirectory(dir) {
329
+ let fd;
330
+ try {
331
+ fd = openSync(dir, "r");
332
+ fsyncSync(fd);
333
+ }
334
+ catch (err) {
335
+ const code = err instanceof Error && typeof err.code === "string" ? err.code : undefined;
336
+ if (code !== undefined && (DIRECTORY_FSYNC_UNSUPPORTED_CODES.has(code) ||
337
+ (!IS_POSIX && WINDOWS_DIRECTORY_FSYNC_UNSUPPORTED_CODES.has(code)))) {
338
+ return;
339
+ }
340
+ throw wrapFsError(err, dir);
341
+ }
342
+ finally {
343
+ if (fd !== undefined) {
344
+ try {
345
+ closeSync(fd);
346
+ }
347
+ catch {
348
+ // fsync 已完成,描述符关闭失败不影响目录项持久化语义
349
+ }
350
+ }
351
+ }
352
+ }
353
+ /**
354
+ * 读取并严格校验本地密钥文件。
355
+ *
356
+ * 所有平台拒绝符号链接与任何非常规文件、非 32 字节长度;
357
+ * POSIX 平台额外强制权限位恰为 0600,安全权限异常不得被忽略。
358
+ */
359
+ function readKeyFile(keyPath) {
360
+ let stat;
361
+ try {
362
+ stat = lstatSync(keyPath);
363
+ }
364
+ catch (err) {
365
+ throw wrapFsError(err, keyPath);
366
+ }
367
+ if (!stat.isFile()) {
368
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Config encryption key file '${keyPath}' is not a regular file (symlinks and special files are rejected)`);
369
+ }
370
+ if (IS_POSIX && (stat.mode & 0o777) !== KEY_FILE_MODE) {
371
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Config encryption key file '${keyPath}' has unsafe permissions ${(stat.mode & 0o777).toString(8)} (expected ${KEY_FILE_MODE.toString(8)})`);
372
+ }
373
+ let content;
374
+ try {
375
+ content = readFileSync(keyPath);
376
+ }
377
+ catch (err) {
378
+ throw wrapFsError(err, keyPath);
379
+ }
380
+ if (content.byteLength !== KEY_BYTES) {
381
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Config encryption key file '${keyPath}' must contain exactly ${KEY_BYTES} bytes, got ${content.byteLength}`);
382
+ }
383
+ return content;
384
+ }
385
+ /**
386
+ * 读取身份标记文件中的指纹;不存在时返回 undefined,存在但损坏即报错。
387
+ *
388
+ * 所有平台拒绝非常规文件;POSIX 平台额外强制权限位恰为 0600,
389
+ * 标记与密钥文件同级安全,不得因非敏感而放宽。
390
+ */
391
+ function readMarkerIdIfExists(markerPath) {
392
+ let stat;
393
+ try {
394
+ stat = lstatSync(markerPath);
395
+ }
396
+ catch (err) {
397
+ if (err instanceof Error && err.code === "ENOENT") {
398
+ return undefined;
399
+ }
400
+ throw wrapFsError(err, markerPath);
401
+ }
402
+ if (!stat.isFile()) {
403
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Config key identity marker '${markerPath}' is not a regular file`);
404
+ }
405
+ if (IS_POSIX && (stat.mode & 0o777) !== KEY_FILE_MODE) {
406
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Config key identity marker '${markerPath}' has unsafe permissions ${(stat.mode & 0o777).toString(8)} (expected ${KEY_FILE_MODE.toString(8)})`);
407
+ }
408
+ let content;
409
+ try {
410
+ content = readFileSync(markerPath, "utf8");
411
+ }
412
+ catch (err) {
413
+ throw wrapFsError(err, markerPath);
414
+ }
415
+ const id = content.trim();
416
+ if (!KEY_ID_PATTERN.test(id)) {
417
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Config key identity marker '${markerPath}' is corrupted (expected 64 lowercase hex characters)`);
418
+ }
419
+ return id;
420
+ }
421
+ /**
422
+ * 校验密钥目录为真实目录(拒绝符号链接);POSIX 平台额外强制权限恰为 0700。
423
+ */
424
+ function assertSecureKeyDirectory(keyDir) {
425
+ let stat;
426
+ try {
427
+ stat = lstatSync(keyDir);
428
+ }
429
+ catch (err) {
430
+ throw wrapFsError(err, keyDir);
431
+ }
432
+ if (!stat.isDirectory()) {
433
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Config encryption key directory '${keyDir}' is not a real directory (symlinks are rejected)`);
434
+ }
435
+ if (IS_POSIX && (stat.mode & 0o777) !== KEY_DIR_MODE) {
436
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Config encryption key directory '${keyDir}' has unsafe permissions ${(stat.mode & 0o777).toString(8)} (expected ${KEY_DIR_MODE.toString(8)})`);
437
+ }
438
+ }
439
+ /**
440
+ * 确保密钥目录存在且安全:缺失时以 0700 递归创建并显式修正权限。
441
+ */
442
+ function assertOrCreateSecureKeyDirectory(keyDir) {
443
+ if (existsSync(keyDir)) {
444
+ assertSecureKeyDirectory(keyDir);
445
+ return;
446
+ }
447
+ try {
448
+ mkdirSync(keyDir, { recursive: true, mode: KEY_DIR_MODE });
449
+ }
450
+ catch (err) {
451
+ throw wrapFsError(err, keyDir);
452
+ }
453
+ if (IS_POSIX) {
454
+ try {
455
+ chmodSync(keyDir, KEY_DIR_MODE);
456
+ }
457
+ catch (err) {
458
+ throw wrapFsError(err, keyDir);
459
+ }
460
+ }
461
+ assertSecureKeyDirectory(keyDir);
462
+ }
463
+ /**
464
+ * 将文件系统异常包装为统一的结构化加密配置错误,消息不携带任何秘密。
465
+ */
466
+ function wrapFsError(err, path) {
467
+ const reason = err instanceof Error && typeof err.code === "string" ? err.code : undefined;
468
+ return new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Config encryption key storage operation failed on '${path}'${reason ? ` (${reason})` : ""}`, { path });
469
+ }
@@ -1,4 +1,4 @@
1
- import { CAPABILITY_UNAVAILABLE, CONFIG_DELETE_ERROR, CONFIG_ENV_ERROR, CONFIG_LIST_ERROR, CONFIG_SET_ERROR, CONFIG_VALUE_INVALID, INVALID_ARGUMENT, INVALID_PACKAGE_ID, PACKAGE_NOT_ALLOWED, PACKAGE_NOT_FOUND, PATH_TRAVERSAL, } from "../../errors.js";
1
+ import { CAPABILITY_UNAVAILABLE, CONFIG_DELETE_ERROR, CONFIG_ENV_ERROR, CONFIG_LIST_ERROR, CONFIG_SET_ERROR, CONFIG_VALUE_INVALID, INVALID_ARGUMENT, INVALID_PACKAGE_ID, PACKAGE_NOT_ALLOWED, PACKAGE_NOT_FOUND, PATH_TRAVERSAL, isConfigEncryptionError, } from "../../errors.js";
2
2
  import { resolveEnvValue } from "../../runtime/index.js";
3
3
  import { isSecretConfigKey, sanitizeConfigDefinitions } from "../../storage/index.js";
4
4
  import { assertConcreteConfigKey, isConfigTemplate } from "../../project/config-declarations.js";
@@ -13,6 +13,10 @@ function isClientConfigError(err) {
13
13
  err?.status === 400 ||
14
14
  err?.status === 404);
15
15
  }
16
+ /** 加密/迁移错误原样透传响应(保留原始码、消息与详情) */
17
+ function encryptionErrorResponse(err, corsHeaders) {
18
+ return jsonResponse({ ok: false, error: { code: err.code, message: err.message, details: err.details } }, 500, corsHeaders);
19
+ }
16
20
  /**
17
21
  * 处理配置元数据与当前值读取、更新及删除接口。
18
22
  * 在未显式开启管理功能时返回 403 拒绝。
@@ -72,6 +76,9 @@ export async function handleConfigRoutes(ctx) {
72
76
  },
73
77
  }, 403, corsHeaders);
74
78
  }
79
+ if (isConfigEncryptionError(err)) {
80
+ return encryptionErrorResponse(err, corsHeaders);
81
+ }
75
82
  const isClient = isClientConfigError(err);
76
83
  return jsonResponse({ ok: false, error: { code: isClient ? INVALID_ARGUMENT : CONFIG_ENV_ERROR, message: err.message } }, isClient ? 400 : 500, corsHeaders);
77
84
  }
@@ -128,6 +135,9 @@ export async function handleConfigRoutes(ctx) {
128
135
  },
129
136
  }, 403, corsHeaders);
130
137
  }
138
+ if (isConfigEncryptionError(err)) {
139
+ return encryptionErrorResponse(err, corsHeaders);
140
+ }
131
141
  const isClient = isClientConfigError(err);
132
142
  return jsonResponse({ ok: false, error: { code: isClient ? INVALID_ARGUMENT : CONFIG_LIST_ERROR, message: err.message } }, isClient ? 400 : 500, corsHeaders);
133
143
  }
@@ -157,6 +167,9 @@ export async function handleConfigRoutes(ctx) {
157
167
  },
158
168
  }, 403, corsHeaders);
159
169
  }
170
+ if (isConfigEncryptionError(err)) {
171
+ return encryptionErrorResponse(err, corsHeaders);
172
+ }
160
173
  const isClient = isClientConfigError(err);
161
174
  const errCode = err?.code === CONFIG_VALUE_INVALID
162
175
  ? CONFIG_VALUE_INVALID
@@ -189,6 +202,9 @@ export async function handleConfigRoutes(ctx) {
189
202
  },
190
203
  }, 403, corsHeaders);
191
204
  }
205
+ if (isConfigEncryptionError(err)) {
206
+ return encryptionErrorResponse(err, corsHeaders);
207
+ }
192
208
  const isClient = isClientConfigError(err);
193
209
  return jsonResponse({ ok: false, error: { code: isClient ? INVALID_ARGUMENT : CONFIG_LIST_ERROR, message: err.message } }, isClient ? 400 : 500, corsHeaders);
194
210
  }
@@ -213,6 +229,9 @@ export async function handleConfigRoutes(ctx) {
213
229
  },
214
230
  }, 403, corsHeaders);
215
231
  }
232
+ if (isConfigEncryptionError(err)) {
233
+ return encryptionErrorResponse(err, corsHeaders);
234
+ }
216
235
  const isClient = isClientConfigError(err);
217
236
  return jsonResponse({ ok: false, error: { code: isClient ? INVALID_ARGUMENT : CONFIG_DELETE_ERROR, message: err.message } }, isClient ? 400 : 500, corsHeaders);
218
237
  }
@@ -22,6 +22,8 @@ export async function createActionDock(options = {}) {
22
22
  logger: options.runtimeOptions.logger,
23
23
  recoverOrphans: options.recoverOrphans,
24
24
  enableManagement: options.enableManagement,
25
+ allowInstall: options.allowInstall,
26
+ prepareLocalPackage: options.hostOptions?.prepareLocalPackage ?? options.prepareLocalPackage,
25
27
  });
26
28
  return host;
27
29
  }
@@ -30,6 +32,7 @@ export async function createActionDock(options = {}) {
30
32
  ...options,
31
33
  recoverOrphans: options.recoverOrphans ?? true,
32
34
  enableManagement: options.enableManagement,
35
+ allowInstall: options.allowInstall,
33
36
  ...(options.hostOptions || {}),
34
37
  });
35
38
  return host;
@@ -2,6 +2,7 @@ import type { ActionRef, ExecutionEvent, ExecutionResult, JsonValue, RunOrigin,
2
2
  import type { ActionSpec, ActionSummary, ListActionsOptions, PackageInfo, PackageRuntime, PackageRuntimeOptions, PlaybookSpec, PlaybookSummary } from "../package/types.js";
3
3
  import type { CancelResult, ExecutionTicket } from "../execution/types.js";
4
4
  import type { ActionDockHostOptions } from "../host/types.js";
5
+ import type { LocalPackagePreparer } from "../invocation/types.js";
5
6
  import type { RuntimePlatform } from "../platform/types.js";
6
7
  import type { StateEntry } from "../storage/types.js";
7
8
  import type { ActionBindings } from "../invocation/bindings.js";
@@ -295,6 +296,13 @@ export interface CreateActionDockOptions {
295
296
  recoverOrphans?: boolean;
296
297
  /** 是否开启管理端口(默认 true) */
297
298
  enableManagement?: boolean;
299
+ /**
300
+ * 是否允许执行依赖自动安装(主要控制启动与事务恢复中的依赖安装;
301
+ * 本地包运行期准备由 prepareLocalPackage 注入器根据自身配置决定)。
302
+ */
303
+ allowInstall?: boolean;
304
+ /** 本地包准备协调器回调 */
305
+ prepareLocalPackage?: LocalPackagePreparer;
298
306
  }
299
307
  /**
300
308
  * 连接 ActionDock 远程服务选项。
@@ -0,0 +1,33 @@
1
+ import type { ConfigItemDefinition } from "../project/types.js";
2
+ import { type ConfigEncryptionKey } from "../security/config-cipher.js";
3
+ /**
4
+ * 密文行内部负载信封:业务值与历史保护声明一同加密。
5
+ *
6
+ * 历史保护声明仅保留脱敏所需的结构/类型/secret 信息,供当前声明
7
+ * 弱化后的管理视图脱敏兜底;业务值透明解密后原样返回。
8
+ *
9
+ * 本模块是密文信封编码与保护声明清洗的单一事实源:正常写入路径
10
+ * (SqliteConfigStore)与版本迁移路径(migrations/v2-to-v3)共用,
11
+ * 禁止任何一侧复制 envelope 或保护清洗逻辑。
12
+ */
13
+ export interface ConfigValueEnvelope {
14
+ value: unknown;
15
+ protection: ConfigItemDefinition[];
16
+ }
17
+ /**
18
+ * 合并历史保护声明与当前敏感声明,绝不弱化保护强度。
19
+ *
20
+ * - 以规范化 JSON 作为去重标识,防止重复写入导致保护数组无界增长;
21
+ * - 当前声明中仅并入判定敏感的条目(非敏感声明不产生历史保护义务);
22
+ * - 合并结果为空时保守回退为整项敏感,保证密文行始终有脱敏依据。
23
+ */
24
+ export declare function mergeProtectionDeclarations(stored: readonly ConfigItemDefinition[] | undefined, current: readonly ConfigItemDefinition[], key: string): ConfigItemDefinition[];
25
+ /** 加密业务值与保护声明构成的完整信封,返回规范密文信封字符串。 */
26
+ export declare function encryptConfigEnvelope(value: unknown, protection: readonly ConfigItemDefinition[], scope: string, key: string, material: ConfigEncryptionKey): string;
27
+ /**
28
+ * 严格解密密文信封并校验负载形态。
29
+ *
30
+ * 信封负载必须为含 value 属性的对象;protection 省略或为声明对象数组。
31
+ * 任何不合规输入抛 CONFIG_DECRYPT_FAILED,绝不把密文降级当作明文返回。
32
+ */
33
+ export declare function decryptConfigEnvelope(ciphertext: string, scope: string, key: string, material: ConfigEncryptionKey): ConfigValueEnvelope;