@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
@@ -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
+ }
@@ -3,144 +3,61 @@ import { ActionDockError } from "../errors.js";
3
3
  import type { McpEndpointHandler } from "./mcp-endpoint.js";
4
4
  import type { EffectiveServerPolicy, ServerOptions, ServerViewOptions } from "./types.js";
5
5
  export { extractBearerToken } from "./security.js";
6
- /**
7
- * 动作未列入白名单拒绝访问异常。
8
- */
6
+ type PolicyAction = {
7
+ actionId?: string;
8
+ id?: string;
9
+ packageId?: string;
10
+ } | string;
11
+ /** 校验两类白名单授权;省略或空数组不增加授权,包列表中的独立 * 显式授予全部权限。 */
12
+ export declare function assertServerPolicyValid(item: {
13
+ packageAllowlist?: unknown;
14
+ actionAllowlist?: unknown;
15
+ }, scope?: string): void;
16
+ /** 是否显式授予全部包与动作;不替代独立的管理能力门禁。 */
17
+ export declare function isFullAccessGranted(policy?: EffectiveServerPolicy | null): boolean;
9
18
  export declare class ActionForbiddenError extends ActionDockError {
10
19
  status: number;
11
20
  statusCode: number;
12
21
  constructor(actionRef: string);
13
22
  }
14
- /**
15
- * 包未列入白名单拒绝访问异常。
16
- */
17
23
  export declare class PackageNotAllowedError extends ActionDockError {
18
24
  status: number;
19
25
  statusCode: number;
20
26
  constructor(packageId: string);
21
27
  }
22
- /**
23
- * 归一化后的单个服务视图结构。
24
- */
25
28
  export interface NormalizedServerView {
26
- /** 视图唯一标识名称 */
27
29
  name: string;
28
- /** 该视图生效的统一安全策略 */
29
30
  policy: EffectiveServerPolicy;
30
- /** 该视图是否启用 MCP 端点 */
31
31
  enableMcp: boolean;
32
- /** 原始配置选项 */
33
32
  rawOptions: ServerViewOptions;
34
- /** 为该视图独立装配的 MCP 端点处理器 */
35
33
  mcpEndpointHandler?: McpEndpointHandler;
36
34
  }
37
- /**
38
- * 根据客户端携带的 Token 智能匹配已注册的视图。
39
- * 使用 safeEqual 进行恒定时间比较以防范时序攻击。
40
- *
41
- * @param token 客户端提供的 Bearer Token
42
- * @param views 可遍历的视图集合
43
- * @returns 匹配成功的视图对象,未命中返回 undefined
44
- */
35
+ /** 遍历全部视图并以常数时间比较令牌,避免提前退出泄露匹配位置。 */
45
36
  export declare function matchViewByToken(token: string, views: Iterable<NormalizedServerView>): NormalizedServerView | undefined;
46
- /**
47
- * 归一化解析服务端配置的所有视图集合。
48
- * 保证默认视图与显式自定义视图具有统一的数据结构。
49
- *
50
- * @param options 服务端配置选项
51
- * @returns 默认视图与全部视图 Map 映射
52
- */
37
+ /** 全局选项仅配置默认视图;命名视图独立授权,缺少授权的视图默认拒绝全部。 */
53
38
  export declare function normalizeServerViews(options: ServerOptions): {
54
39
  defaultView: NormalizedServerView;
55
40
  views: Map<string, NormalizedServerView>;
56
41
  };
57
- /**
58
- * 校验目标动作是否在 actionAllowlist 允许白名单中。
59
- * 支持短名 actionId 与全限定名 packageId/actionId 匹配:
60
- * - 白名单项若含 '/'(如 pkg/act),则必须 packageId 与 actionId 同时精确匹配。
61
- * - 白名单项若不含 '/'(如 act),则只需 actionId 匹配(不限包)。
62
- *
63
- * @param action 目标动作对象或引用字符串
64
- * @param allowlist 允许动作白名单列表
65
- * @returns 是否允许访问
66
- */
67
- export declare function isActionAllowed(action: {
68
- actionId?: string;
69
- id?: string;
70
- packageId?: string;
71
- } | string | null | undefined, allowlist?: string[]): boolean;
72
- /**
73
- * 根据生效策略统一判定目标动作是否允许访问。
74
- * 同时综合校验策略中的 packageAllowlist 与 actionAllowlist 约束。
75
- *
76
- * @param action 目标动作对象或引用字符串
77
- * @param policy 当前生效的服务端策略
78
- * @returns 是否允许访问
79
- */
80
- export declare function isActionAllowedByPolicy(action: {
81
- actionId?: string;
82
- id?: string;
83
- packageId?: string;
84
- } | string | null | undefined, policy?: EffectiveServerPolicy): boolean;
85
- /**
86
- * 根据生效策略统一判定目标包标识是否允许访问。
87
- *
88
- * @param packageId 目标包 ID
89
- * @param policy 当前生效的服务端策略
90
- * @returns 是否允许访问
91
- */
42
+ /** 动作列表仅按全限定引用匹配;省略或空列表不会授予任何动作。 */
43
+ export declare function isActionAllowed(action: PolicyAction | null | undefined, allowlist?: string[]): boolean;
44
+ /** 整包授权与全限定单动作授权取并集;仅缺少策略对象的内部调用不执行过滤。 */
45
+ export declare function isActionAllowedByPolicy(action: PolicyAction | null | undefined, policy?: EffectiveServerPolicy): boolean;
46
+ /** 整包能力授权,不从某个动作的授权推导配置、状态、规程或诊断权限。 */
92
47
  export declare function isPackageAllowedByPolicy(packageId?: string | null, policy?: EffectiveServerPolicy): boolean;
93
- /**
94
- * 根据生效策略统一判定配置与状态管理路由是否开启。
95
- *
96
- * @param policy 当前生效的服务端策略
97
- * @returns 是否允许管理操作
98
- */
48
+ /** 单动作授权的包可发现必要身份信息,但不因此获得整包能力。 */
49
+ export declare function isPackageVisibleByPolicy(packageId?: string | null, policy?: EffectiveServerPolicy): boolean;
50
+ export declare function requiresMinimalPackageProjection(packageId?: string | null, policy?: EffectiveServerPolicy): boolean;
99
51
  export declare function isManagementAllowedByPolicy(policy?: EffectiveServerPolicy): boolean;
100
- /**
101
- * 根据生效策略对动作列表进行统一过滤。
102
- *
103
- * @param actions 待过滤的动作列表
104
- * @param policy 当前生效的服务端策略
105
- * @returns 过滤后允许访问的动作列表
106
- */
107
52
  export declare function filterActionsByPolicy<T extends {
108
53
  id: string;
109
54
  packageId?: string;
110
55
  actionId?: string;
111
56
  }>(actions: T[], policy?: EffectiveServerPolicy): T[];
112
- /**
113
- * 根据生效策略对包列表进行统一过滤。
114
- *
115
- * @param packages 待过滤的包列表
116
- * @param policy 当前生效的服务端策略
117
- * @returns 过滤后允许访问的包列表
118
- */
119
57
  export declare function filterPackagesByPolicy<T extends {
120
58
  id: string;
121
59
  }>(packages: T[], policy?: EffectiveServerPolicy): T[];
122
- /**
123
- * 校验目标动作是否属于允许白名单,不符合则抛出 403 异常。
124
- *
125
- * @param action 目标动作对象或引用
126
- * @param policy 当前生效的服务端策略
127
- */
128
- export declare function assertActionAllowedByPolicy(action: {
129
- actionId?: string;
130
- id?: string;
131
- packageId?: string;
132
- } | string, policy?: EffectiveServerPolicy): void;
133
- /**
134
- * 校验目标包是否属于允许白名单,不符合则抛出 403 异常。
135
- *
136
- * @param packageId 目标包 ID
137
- * @param policy 当前生效的服务端策略
138
- */
60
+ export declare function assertActionAllowedByPolicy(action: PolicyAction, policy?: EffectiveServerPolicy): void;
139
61
  export declare function assertPackageAllowedByPolicy(packageId: string, policy?: EffectiveServerPolicy): void;
140
- /**
141
- * 构造受信任的服务端执行来源快照。
142
- *
143
- * 仅挑选 channel、viewName(默认 "default")与 authenticated(视图配置非空有效令牌)。
144
- * 严禁暴露令牌原文、片段或摘要。
145
- */
62
+ /** 只记录受信任的执行来源,不能包含令牌原文、片段或摘要。 */
146
63
  export declare function createServerRunOrigin(policy: Pick<EffectiveServerPolicy, "viewName" | "token">, channel: RunOrigin["channel"]): RunOrigin;