@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,8 +1,9 @@
1
1
  import { decodeStateKey } from "@actiondock/sdk";
2
2
  import { filterWithFallbackInfo } from "../utils/intent.js";
3
- import { resolveExecutionHint } from "../errors.js";
4
- import { resolveActionInput, formatActionDetail, buildActionDescribePayload, mapInputValidationFailure, } from "../input/index.js";
3
+ import { ActionDockError, INVALID_ARGUMENT, resolveExecutionHint } from "../errors.js";
4
+ import { resolveInput, resolveValueInput, formatActionDetail, buildActionDescribePayload, mapInputValidationFailure, } from "../input/index.js";
5
5
  import { validateActionInputValue } from "../value-validator.js";
6
+ import { assertConcreteConfigKey } from "../project/config-declarations.js";
6
7
  import { parseDuration } from "../utils/index.js";
7
8
  import { ExitCode } from "./standalone.js";
8
9
  export async function handleList(ctx, subArgs) {
@@ -148,7 +149,7 @@ export async function handleRun(ctx, subArgs, actionArgs, signal) {
148
149
  }
149
150
  let input;
150
151
  try {
151
- input = await resolveActionInput({
152
+ input = await resolveInput({
152
153
  input: inputStr,
153
154
  inputFile,
154
155
  stdinField,
@@ -273,7 +274,57 @@ function isServiceCapabilityError(err) {
273
274
  return (code === "CAPABILITY_UNAVAILABLE" ||
274
275
  code === "TARGET_CAPABILITY_UNAVAILABLE");
275
276
  }
276
- export async function handleConfig(ctx, subArgs) {
277
+ function parseSetInput(subArgs, isState) {
278
+ const positional = [];
279
+ const input = {};
280
+ let namespace;
281
+ let ttl;
282
+ for (let i = 1; i < subArgs.length; i++) {
283
+ const arg = subArgs[i];
284
+ if (arg === "--json")
285
+ continue;
286
+ if (arg === "--stdin") {
287
+ if (input.fromStdin)
288
+ throw new ActionDockError(INVALID_ARGUMENT, "Duplicate --stdin option");
289
+ input.fromStdin = true;
290
+ continue;
291
+ }
292
+ const equals = arg.indexOf("=");
293
+ const option = equals < 0 ? arg : arg.slice(0, equals);
294
+ if (option === "--stdin-field" || (isState && ["-n", "--namespace", "--ttl"].includes(option))) {
295
+ const value = equals < 0 ? subArgs[++i] : arg.slice(equals + 1);
296
+ if (value === undefined) {
297
+ throw new ActionDockError(INVALID_ARGUMENT, `Option '${option}' requires a value`);
298
+ }
299
+ if (option === "--stdin-field") {
300
+ if (input.stdinField !== undefined) {
301
+ throw new ActionDockError(INVALID_ARGUMENT, "Duplicate --stdin-field option");
302
+ }
303
+ input.stdinField = value;
304
+ }
305
+ else if (option === "--ttl") {
306
+ ttl = parseInt(value, 10);
307
+ }
308
+ else {
309
+ namespace = value;
310
+ }
311
+ }
312
+ else if (arg.startsWith("-") && !/^-\d/.test(arg)) {
313
+ throw new ActionDockError(INVALID_ARGUMENT, "Unknown set option");
314
+ }
315
+ else {
316
+ positional.push(arg);
317
+ if (positional.length > 2) {
318
+ throw new ActionDockError(INVALID_ARGUMENT, "Only one complete positional value is allowed");
319
+ }
320
+ }
321
+ }
322
+ if (!positional[0])
323
+ throw new ActionDockError(INVALID_ARGUMENT, "Key is required");
324
+ input.value = positional[1];
325
+ return { key: positional[0], input, namespace, ttl };
326
+ }
327
+ export async function handleConfig(ctx, subArgs, flatArgs, signal, stdin) {
277
328
  const sub = subArgs[0] || "list";
278
329
  if (!ctx.service.management) {
279
330
  ctx.writeErr("Error: management port is not enabled on this service");
@@ -281,10 +332,15 @@ export async function handleConfig(ctx, subArgs) {
281
332
  }
282
333
  if (sub === "list") {
283
334
  const views = await ctx.service.management.config.list(ctx.options.packageId);
284
- const dict = {};
335
+ const dict = Object.create(null);
285
336
  for (const v of views) {
286
- if (v.configured && v.value !== undefined) {
287
- dict[v.key] = v.value;
337
+ if (v.configured) {
338
+ if (v.secret && v.value === undefined) {
339
+ dict[v.key] = "********";
340
+ }
341
+ else if (v.value !== undefined) {
342
+ dict[v.key] = v.value;
343
+ }
288
344
  }
289
345
  }
290
346
  ctx.writeOut(JSON.stringify(dict, null, 2));
@@ -298,7 +354,15 @@ export async function handleConfig(ctx, subArgs) {
298
354
  }
299
355
  try {
300
356
  const item = await ctx.service.management.config.get(ctx.options.packageId, key);
301
- ctx.writeOut(item.configured ? JSON.stringify(item.value) : "undefined");
357
+ if (item.redactedPaths.includes("") || (item.configured && item.value === undefined)) {
358
+ ctx.writeOut("<hidden>");
359
+ }
360
+ else if (item.value === undefined) {
361
+ ctx.writeOut("<not configured>");
362
+ }
363
+ else {
364
+ ctx.writeOut(JSON.stringify(item.value));
365
+ }
302
366
  return ExitCode.SUCCESS;
303
367
  }
304
368
  catch (err) {
@@ -311,21 +375,27 @@ export async function handleConfig(ctx, subArgs) {
311
375
  }
312
376
  }
313
377
  if (sub === "set") {
314
- const key = subArgs[1];
315
- const rawVal = subArgs[2];
316
- if (!key || rawVal === undefined) {
317
- ctx.writeErr("Error: key and value required");
318
- return ExitCode.INVALID_ARGUMENT;
319
- }
320
- let parsed = rawVal;
378
+ let parsed;
379
+ let value;
321
380
  try {
322
- parsed = JSON.parse(rawVal);
381
+ parsed = parseSetInput(subArgs, false);
382
+ assertConcreteConfigKey(parsed.key);
383
+ value = await resolveValueInput({
384
+ ...parsed.input,
385
+ flatArgs,
386
+ signal,
387
+ stdin,
388
+ policy: { sanitizeInputErrors: true },
389
+ });
390
+ signal?.throwIfAborted();
323
391
  }
324
- catch {
325
- parsed = rawVal;
392
+ catch (err) {
393
+ return ctx.emitError(subArgs.includes("--json"), err?.code || "INVALID_ARGUMENT", err?.message || String(err), ExitCode.INVALID_ARGUMENT, {
394
+ details: err?.details,
395
+ });
326
396
  }
327
- await ctx.service.management.config.set(ctx.options.packageId, key, parsed);
328
- ctx.writeOut(`Config '${key}' updated`);
397
+ await ctx.service.management.config.set(ctx.options.packageId, parsed.key, value);
398
+ ctx.writeOut(`Config '${parsed.key}' updated`);
329
399
  return ExitCode.SUCCESS;
330
400
  }
331
401
  if (sub === "delete") {
@@ -349,7 +419,7 @@ function decodeStateKeyArg(key) {
349
419
  return { namespace: "", key };
350
420
  }
351
421
  }
352
- export async function handleState(ctx, subArgs) {
422
+ export async function handleState(ctx, subArgs, flatArgs, signal, stdin) {
353
423
  const sub = subArgs[0] || "list";
354
424
  let namespace;
355
425
  let isAll = false;
@@ -405,36 +475,32 @@ export async function handleState(ctx, subArgs) {
405
475
  return ExitCode.SUCCESS;
406
476
  }
407
477
  if (sub === "set") {
408
- const key = subArgs[1];
409
- const rawVal = subArgs[2];
410
- if (!key || rawVal === undefined) {
411
- ctx.writeErr("Error: key and value required");
412
- return ExitCode.INVALID_ARGUMENT;
413
- }
414
- let ns = namespace || "";
415
- let actualKey = key;
416
- if (namespace === undefined) {
417
- const decoded = decodeStateKeyArg(key);
418
- ns = decoded.namespace;
419
- actualKey = decoded.key;
420
- }
421
- let parsed = rawVal;
478
+ let parsed;
479
+ let value;
422
480
  try {
423
- parsed = JSON.parse(rawVal);
481
+ parsed = parseSetInput(subArgs, true);
482
+ value = await resolveValueInput({
483
+ ...parsed.input,
484
+ flatArgs,
485
+ signal,
486
+ stdin,
487
+ policy: { sanitizeInputErrors: true },
488
+ });
489
+ signal?.throwIfAborted();
424
490
  }
425
- catch {
426
- parsed = rawVal;
491
+ catch (err) {
492
+ return ctx.emitError(isJson, err?.code || "INVALID_ARGUMENT", err?.message || String(err), ExitCode.INVALID_ARGUMENT, {
493
+ details: err?.details,
494
+ });
427
495
  }
428
- let ttl;
429
- for (let i = 3; i < subArgs.length; i++) {
430
- if (subArgs[i] === "--ttl" && i + 1 < subArgs.length) {
431
- ttl = parseInt(subArgs[++i], 10);
432
- }
433
- else if (subArgs[i].startsWith("--ttl=")) {
434
- ttl = parseInt(subArgs[i].slice(6), 10);
435
- }
496
+ let ns = parsed.namespace || "";
497
+ let actualKey = parsed.key;
498
+ if (parsed.namespace === undefined) {
499
+ const decoded = decodeStateKeyArg(parsed.key);
500
+ ns = decoded.namespace;
501
+ actualKey = decoded.key;
436
502
  }
437
- await ctx.service.management.state.set(ctx.options.packageId, "", actualKey, parsed, { namespace: ns, ttl });
503
+ await ctx.service.management.state.set(ctx.options.packageId, "", actualKey, value, { namespace: ns, ttl: parsed.ttl });
438
504
  const displayKey = ns ? `${ns}:${actualKey}` : actualKey;
439
505
  ctx.writeOut(`State '${displayKey}' updated`);
440
506
  return ExitCode.SUCCESS;
@@ -1,3 +1,4 @@
1
+ export { parseConfigOverrideAssignment } from "../input/config-override.js";
1
2
  export interface ParsedArgs {
2
3
  controlArgs: string[];
3
4
  actionArgs: string[];
@@ -1,3 +1,5 @@
1
+ import { parseConfigOverrideAssignment } from "../input/config-override.js";
2
+ export { parseConfigOverrideAssignment } from "../input/config-override.js";
1
3
  export function parseStandaloneArgs(argv) {
2
4
  const separator = argv.indexOf("--");
3
5
  const controlArgs = separator >= 0 ? argv.slice(0, separator) : argv;
@@ -13,17 +15,15 @@ export function parseStandaloneArgs(argv) {
13
15
  else if (arg.startsWith("--data-dir=")) {
14
16
  dataDir = arg.slice(11);
15
17
  }
16
- else if (arg === "--config" && i + 1 < controlArgs.length) {
17
- const pair = controlArgs[++i];
18
- const [k, ...v] = pair.split("=");
19
- if (k)
20
- configOverrides[k] = v.join("=");
18
+ else if (arg === "--config" || arg === "-c") {
19
+ const pair = controlArgs[++i] ?? "";
20
+ const { key, value } = parseConfigOverrideAssignment(pair);
21
+ Object.defineProperty(configOverrides, key, { value, enumerable: true, writable: true, configurable: true });
21
22
  }
22
23
  else if (arg.startsWith("--config=")) {
23
24
  const pair = arg.slice(9);
24
- const [k, ...v] = pair.split("=");
25
- if (k)
26
- configOverrides[k] = v.join("=");
25
+ const { key, value } = parseConfigOverrideAssignment(pair);
26
+ Object.defineProperty(configOverrides, key, { value, enumerable: true, writable: true, configurable: true });
27
27
  }
28
28
  else {
29
29
  filteredArgs.push(arg);
@@ -45,10 +45,16 @@ export function printHelp(writeOut, options) {
45
45
  writeOut(" <cmd> run <id> [--stdin-field <field>] [-- <assignments...>]");
46
46
  writeOut(" Bind raw stdin text to an input field");
47
47
  writeOut(" <cmd> config list/get/set/delete Manage package configuration");
48
+ writeOut(" <cmd> config set <key> [<value> | --stdin]");
49
+ writeOut(" <cmd> config set <key> [--stdin-field <field>] [-- <assignments...>]");
48
50
  writeOut(" <cmd> state list/get/set/delete Manage shared state store");
51
+ writeOut(" <cmd> state set <key> [<value> | --stdin] [-n <namespace>] [--ttl <seconds>]");
52
+ writeOut(" <cmd> state set <key> [--stdin-field <field>] [-n <namespace>] [--ttl <seconds>] [-- <assignments...>]");
53
+ writeOut(" set 整键替换;完整值、--stdin 与结构化输入互斥");
54
+ writeOut(" 扁平赋值:field=text 或 field:=json;--stdin-field 保留原始正文");
49
55
  writeOut("\nGlobal options:");
50
56
  writeOut(" --data-dir <path> Custom runtime database directory");
51
- writeOut(" --config <KEY=val> Temporary config override");
57
+ writeOut(" --config <KEY=val|KEY:=json> Temporary config override");
52
58
  }
53
59
  export function emitError(writeOut, writeErr, isJson, code, message, exitCode, options) {
54
60
  if (isJson) {
@@ -149,10 +149,10 @@ export class StandaloneDispatcher {
149
149
  code = await handleRun(ctx, subArgs, actionArgs, control?.signal);
150
150
  break;
151
151
  case "config":
152
- code = await handleConfig(ctx, subArgs);
152
+ code = await handleConfig(ctx, subArgs, argv.includes("--") ? actionArgs : undefined, control?.signal);
153
153
  break;
154
154
  case "state":
155
- code = await handleState(ctx, subArgs);
155
+ code = await handleState(ctx, subArgs, argv.includes("--") ? actionArgs : undefined, control?.signal);
156
156
  break;
157
157
  default:
158
158
  this.writeErr(`Unknown command: '${command}'`);
@@ -0,0 +1,54 @@
1
+ /**
2
+ * 配置加密密钥材料。
3
+ *
4
+ * key 为 32 字节 AES-256 原始密钥;id 为密钥的稳定非敏感指纹
5
+ * (由 configKeyId 计算),随密文信封持久化,用于存储域密钥身份校验。
6
+ */
7
+ export interface ConfigEncryptionKey {
8
+ /** 32 字节 AES-256 原始密钥材料 */
9
+ key: Uint8Array;
10
+ /** 密钥的 SHA-256 十六进制指纹(非敏感,可持久化) */
11
+ id: string;
12
+ }
13
+ /**
14
+ * 配置加密密钥提供器。
15
+ *
16
+ * create 为 true 时允许在缺失时生成并持久化本地密钥与身份标记;
17
+ * 为 false 时绝不生成任何密钥或标记,缺失即抛出 CONFIG_ENCRYPTION_ERROR。
18
+ * 同一提供器实例在首次成功加载后缓存材料,后续调用不再读取环境或文件。
19
+ */
20
+ export type ConfigKeyProvider = (create: boolean) => ConfigEncryptionKey;
21
+ /**
22
+ * 计算加密密钥的稳定非敏感指纹。
23
+ *
24
+ * 输出为密钥字节 SHA-256 摘要的 64 位小写十六进制字符串(256 位熵的指纹),
25
+ * 可安全持久化与打印,不会泄露密钥材料本身。
26
+ */
27
+ export declare function configKeyId(key: Uint8Array): string;
28
+ /**
29
+ * 判定存储值是否为加密信封。
30
+ *
31
+ * 识别任何 adenc: 前缀(含未知版本),调用方必须将命中值交给
32
+ * decryptConfigValue 严格解密;未知版本会在解密时明确报错,绝不降级为明文。
33
+ */
34
+ export declare function isEncryptedConfigValue(raw: string): boolean;
35
+ /**
36
+ * 加密单个配置值并返回规范密文信封。
37
+ *
38
+ * 信封格式:adenc:v1:<keyId>:<base64url(iv|ciphertext|authTag)>。
39
+ * 使用 AES-256-GCM 与随机 12 字节初始向量,认证数据无歧义绑定
40
+ * 格式版本、密钥指纹、配置域 scope 与配置键 key。
41
+ * 值必须严格 canonical JSON 可序列化(复用 validateJsonValue 单一事实源),
42
+ * 任何不可精确往返的输入都会抛出 CONFIG_ENCRYPTION_ERROR,错误信息与
43
+ * details 不包含值本身、校验器 reason/path 或密钥材料。
44
+ */
45
+ export declare function encryptConfigValue(value: unknown, scope: string, key: string, material: ConfigEncryptionKey): string;
46
+ /**
47
+ * 严格解密 encryptConfigValue 产生的密文信封并返回原始值。
48
+ *
49
+ * 拒绝一切不规范输入:未知版本、非规范指纹、非规范或非未填充的
50
+ * base64url、负载截断、密钥身份不符、认证校验失败与非法 JSON 负载,
51
+ * 全部抛出 CONFIG_DECRYPT_FAILED,绝不将密文降级当作明文返回。
52
+ * 错误信息不包含明文片段、密文负载或密钥材料。
53
+ */
54
+ export declare function decryptConfigValue(ciphertext: string, scope: string, key: string, material: ConfigEncryptionKey): unknown;
@@ -0,0 +1,190 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
2
+ import { ActionDockError, CONFIG_DECRYPT_FAILED, CONFIG_ENCRYPTION_ERROR } from "../errors.js";
3
+ import { parseJsonWithoutDuplicates } from "../project/digest.js";
4
+ import { validateJsonValue } from "../value-validator.js";
5
+ /** 密文信封统一前缀,任何以该前缀起始的存储值都按密文处理 */
6
+ const ENVELOPE_PREFIX = "adenc:";
7
+ /** 当前支持的密文格式版本 */
8
+ const CURRENT_VERSION = "v1";
9
+ /** AES-256-GCM 密钥字节长度 */
10
+ const KEY_BYTES = 32;
11
+ /** AES-GCM 随机初始向量字节长度 */
12
+ const IV_BYTES = 12;
13
+ /** AES-GCM 认证标签字节长度 */
14
+ const AUTH_TAG_BYTES = 16;
15
+ /** 密钥指纹的规范形式:64 位十六进制字符(SHA-256 摘要) */
16
+ const KEY_ID_PATTERN = /^[0-9a-f]{64}$/;
17
+ /** 密文信封整体规范形式:版本段、指纹段与未填充 base64url 负载段 */
18
+ const ENVELOPE_PATTERN = /^adenc:(v[0-9]+):([0-9a-f]{64}):([A-Za-z0-9_-]+)$/;
19
+ /** 未填充 base64url 字符集(RFC 4648 第 5 节) */
20
+ const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
21
+ /** 序列化递归深度上限,与 canonical JSON 校验单一事实源的最大深度保持一致 */
22
+ const MAX_SERIALIZE_DEPTH = 512;
23
+ /**
24
+ * 计算加密密钥的稳定非敏感指纹。
25
+ *
26
+ * 输出为密钥字节 SHA-256 摘要的 64 位小写十六进制字符串(256 位熵的指纹),
27
+ * 可安全持久化与打印,不会泄露密钥材料本身。
28
+ */
29
+ export function configKeyId(key) {
30
+ if (!(key instanceof Uint8Array) || key.byteLength === 0) {
31
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, "Cannot compute config key id: key material must be a non-empty Uint8Array");
32
+ }
33
+ return createHash("sha256").update(key).digest("hex");
34
+ }
35
+ /**
36
+ * 判定存储值是否为加密信封。
37
+ *
38
+ * 识别任何 adenc: 前缀(含未知版本),调用方必须将命中值交给
39
+ * decryptConfigValue 严格解密;未知版本会在解密时明确报错,绝不降级为明文。
40
+ */
41
+ export function isEncryptedConfigValue(raw) {
42
+ return typeof raw === "string" && raw.startsWith(ENVELOPE_PREFIX);
43
+ }
44
+ /**
45
+ * 构造 AES-GCM 附加认证数据。
46
+ *
47
+ * 以 JSON 数组序列化无歧义绑定格式版本、密钥指纹、配置域与配置键,
48
+ * 防止 scope 或 key 含分隔符时的拼接歧义,阻止跨域跨键的密文挪用。
49
+ */
50
+ function buildAdditionalAuthenticatedData(version, id, scope, key) {
51
+ return Buffer.from(JSON.stringify([`${ENVELOPE_PREFIX}${version}`, id, scope, key]), "utf8");
52
+ }
53
+ /**
54
+ * 校验并规范化密钥材料。
55
+ *
56
+ * 要求 key 恰为 32 字节,id 为规范指纹且与 key 实际指纹一致,
57
+ * 身份不符时立即拒绝,防止用错误身份写出无法解密的密文。
58
+ */
59
+ function normalizeMaterial(material) {
60
+ if (material === null || typeof material !== "object") {
61
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, "Invalid config encryption material: expected ConfigEncryptionKey object");
62
+ }
63
+ const { key, id } = material;
64
+ if (!(key instanceof Uint8Array) || key.byteLength !== KEY_BYTES) {
65
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Invalid config encryption key: expected ${KEY_BYTES} bytes, got ${key instanceof Uint8Array ? key.byteLength : typeof key}`);
66
+ }
67
+ if (typeof id !== "string" || !KEY_ID_PATTERN.test(id)) {
68
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, "Invalid config encryption key id: expected 64 lowercase hex characters");
69
+ }
70
+ if (configKeyId(key) !== id) {
71
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, "Config encryption key id does not match key material fingerprint");
72
+ }
73
+ return { keyBytes: Buffer.from(key.buffer, key.byteOffset, key.byteLength), id };
74
+ }
75
+ /**
76
+ * 校验配置域与配置键参数。
77
+ */
78
+ function assertBindingFields(scope, key) {
79
+ if (typeof scope !== "string" || scope.length === 0) {
80
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, "Config encryption scope must be a non-empty string");
81
+ }
82
+ if (typeof key !== "string" || key.length === 0) {
83
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, "Config encryption key name must be a non-empty string");
84
+ }
85
+ }
86
+ /**
87
+ * 严格校验并序列化配置值。
88
+ *
89
+ * 复用 validateJsonValue 作为 canonical JSON 规则的单一事实源:拒绝
90
+ * undefined、函数、符号、BigInt、非有限数值、访问器属性(仅检查描述符,
91
+ * 绝不执行 getter)、非枚举属性、Symbol 键、稀疏数组、非 plain 对象
92
+ * (Date、Map 等)、循环引用与超深嵌套,保证密文解密后能够精确还原。
93
+ * 校验失败只携带固定文案与机器可读的校验错误码,不携带 validator 的
94
+ * reason/path(两者可能嵌入用户对象抛出的异常内容),杜绝秘密外带。
95
+ */
96
+ function serializeConfigValue(value, scope, key) {
97
+ const validation = validateJsonValue(value, { maxDepth: MAX_SERIALIZE_DEPTH });
98
+ if (!validation.valid) {
99
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Config value for key '${key}' in scope '${scope}' is not strictly JSON serializable`, { scope, key, code: validation.code });
100
+ }
101
+ try {
102
+ return JSON.stringify(value);
103
+ }
104
+ catch {
105
+ throw new ActionDockError(CONFIG_ENCRYPTION_ERROR, `Config value for key '${key}' in scope '${scope}' is not strictly JSON serializable`, { scope, key });
106
+ }
107
+ }
108
+ /**
109
+ * 加密单个配置值并返回规范密文信封。
110
+ *
111
+ * 信封格式:adenc:v1:<keyId>:<base64url(iv|ciphertext|authTag)>。
112
+ * 使用 AES-256-GCM 与随机 12 字节初始向量,认证数据无歧义绑定
113
+ * 格式版本、密钥指纹、配置域 scope 与配置键 key。
114
+ * 值必须严格 canonical JSON 可序列化(复用 validateJsonValue 单一事实源),
115
+ * 任何不可精确往返的输入都会抛出 CONFIG_ENCRYPTION_ERROR,错误信息与
116
+ * details 不包含值本身、校验器 reason/path 或密钥材料。
117
+ */
118
+ export function encryptConfigValue(value, scope, key, material) {
119
+ assertBindingFields(scope, key);
120
+ const { keyBytes, id } = normalizeMaterial(material);
121
+ const plaintext = serializeConfigValue(value, scope, key);
122
+ const iv = randomBytes(IV_BYTES);
123
+ const cipher = createCipheriv("aes-256-gcm", keyBytes, iv, { authTagLength: AUTH_TAG_BYTES });
124
+ cipher.setAAD(buildAdditionalAuthenticatedData(CURRENT_VERSION, id, scope, key));
125
+ const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
126
+ const authTag = cipher.getAuthTag();
127
+ const payload = Buffer.concat([iv, ciphertext, authTag]).toString("base64url");
128
+ return `${ENVELOPE_PREFIX}${CURRENT_VERSION}:${id}:${payload}`;
129
+ }
130
+ /**
131
+ * 严格解密 encryptConfigValue 产生的密文信封并返回原始值。
132
+ *
133
+ * 拒绝一切不规范输入:未知版本、非规范指纹、非规范或非未填充的
134
+ * base64url、负载截断、密钥身份不符、认证校验失败与非法 JSON 负载,
135
+ * 全部抛出 CONFIG_DECRYPT_FAILED,绝不将密文降级当作明文返回。
136
+ * 错误信息不包含明文片段、密文负载或密钥材料。
137
+ */
138
+ export function decryptConfigValue(ciphertext, scope, key, material) {
139
+ assertBindingFields(scope, key);
140
+ const { keyBytes, id } = normalizeMaterial(material);
141
+ if (typeof ciphertext !== "string" || !ciphertext.startsWith(ENVELOPE_PREFIX)) {
142
+ throw new ActionDockError(CONFIG_DECRYPT_FAILED, `Stored config value for key '${key}' in scope '${scope}' is not an encrypted envelope`, { scope, key });
143
+ }
144
+ const match = ciphertext.match(ENVELOPE_PATTERN);
145
+ if (!match) {
146
+ const rest = ciphertext.slice(ENVELOPE_PREFIX.length);
147
+ const colon = rest.indexOf(":");
148
+ const version = colon === -1 ? rest : rest.slice(0, colon);
149
+ if (/^v[0-9]+$/.test(version) && version !== CURRENT_VERSION) {
150
+ throw new ActionDockError(CONFIG_DECRYPT_FAILED, `Encrypted config envelope version '${version}' is not supported (expected '${CURRENT_VERSION}')`, { scope, key, version });
151
+ }
152
+ throw new ActionDockError(CONFIG_DECRYPT_FAILED, `Encrypted config envelope for key '${key}' in scope '${scope}' is malformed`, { scope, key });
153
+ }
154
+ const [, version, envelopeId, payload] = match;
155
+ if (version !== CURRENT_VERSION) {
156
+ throw new ActionDockError(CONFIG_DECRYPT_FAILED, `Encrypted config envelope version '${version}' is not supported (expected '${CURRENT_VERSION}')`, { scope, key, version });
157
+ }
158
+ if (envelopeId !== id) {
159
+ throw new ActionDockError(CONFIG_DECRYPT_FAILED, `Encrypted config envelope for key '${key}' in scope '${scope}' was written with a different key identity`, { scope, key, envelopeKeyId: envelopeId });
160
+ }
161
+ if (!BASE64URL_PATTERN.test(payload)) {
162
+ throw new ActionDockError(CONFIG_DECRYPT_FAILED, `Encrypted config envelope payload for key '${key}' in scope '${scope}' is not canonical base64url`, { scope, key });
163
+ }
164
+ const raw = Buffer.from(payload, "base64url");
165
+ if (raw.byteLength < IV_BYTES + AUTH_TAG_BYTES + 1) {
166
+ throw new ActionDockError(CONFIG_DECRYPT_FAILED, `Encrypted config envelope payload for key '${key}' in scope '${scope}' is truncated`, { scope, key });
167
+ }
168
+ if (raw.toString("base64url") !== payload) {
169
+ throw new ActionDockError(CONFIG_DECRYPT_FAILED, `Encrypted config envelope payload for key '${key}' in scope '${scope}' is not canonical base64url`, { scope, key });
170
+ }
171
+ const iv = raw.subarray(0, IV_BYTES);
172
+ const authTag = raw.subarray(raw.byteLength - AUTH_TAG_BYTES);
173
+ const encrypted = raw.subarray(IV_BYTES, raw.byteLength - AUTH_TAG_BYTES);
174
+ let plaintext;
175
+ try {
176
+ const decipher = createDecipheriv("aes-256-gcm", keyBytes, iv, { authTagLength: AUTH_TAG_BYTES });
177
+ decipher.setAAD(buildAdditionalAuthenticatedData(version, envelopeId, scope, key));
178
+ decipher.setAuthTag(authTag);
179
+ plaintext = Buffer.concat([decipher.update(encrypted), decipher.final()]);
180
+ }
181
+ catch {
182
+ throw new ActionDockError(CONFIG_DECRYPT_FAILED, `Decryption of config value for key '${key}' in scope '${scope}' failed authentication`, { scope, key });
183
+ }
184
+ try {
185
+ return parseJsonWithoutDuplicates(plaintext.toString("utf8"));
186
+ }
187
+ catch {
188
+ throw new ActionDockError(CONFIG_DECRYPT_FAILED, `Decrypted config payload for key '${key}' in scope '${scope}' is not valid JSON`, { scope, key });
189
+ }
190
+ }
@@ -0,0 +1,35 @@
1
+ import { type ConfigKeyProvider } from "./config-cipher.js";
2
+ /**
3
+ * 配置加密密钥提供器构造选项。
4
+ */
5
+ export interface ConfigKeyProviderOptions {
6
+ /** 覆盖用户主目录定位(与 getActionDockHome 的 customHome 语义一致) */
7
+ customHome?: string;
8
+ /** 数据目录定位:密钥落盘为 <dataDir>/keys/config.key,优先于主目录定位 */
9
+ dataDir?: string;
10
+ /** 显式注入密钥材料:纯内存使用,不读取环境变量,也不访问或创建任何真实目录 */
11
+ key?: Uint8Array;
12
+ /** 环境变量快照来源(缺省读取 process.env),至少须支持 ACTIONDOCK_ENCRYPTION_KEY */
13
+ env?: Record<string, string | undefined>;
14
+ }
15
+ /**
16
+ * 创建配置加密密钥提供器。
17
+ *
18
+ * 来源优先级:
19
+ * - 显式注入 key:纯内存材料,绝不读取环境变量或真实用户目录,也无需钉住持久身份;
20
+ * - 环境变量 ACTIONDOCK_ENCRYPTION_KEY:必须为恰好 64 位十六进制字符,
21
+ * 只要被设置(含空串或空白)就必须严格合法,非法取值立即报错,
22
+ * 绝不静默回退到本地密钥;
23
+ * - 本地密钥文件:dataDir 有值时为 <dataDir>/keys/config.key,
24
+ * 否则为 getActionDockHome(customHome)/.actiondock/keys/config.key。
25
+ *
26
+ * 构造时不发生任何环境读取、文件访问或目录创建;全部定位与加载在
27
+ * 首次调用时完成并缓存,此后同一提供器实例不再感知环境变化(宿主快照)。
28
+ * 若首次调用以 create 为 false 加载了未钉住身份的材料,后续 create 为 true
29
+ * 的调用会先原子创建或校验身份标记再返回缓存材料,保证允许写入前
30
+ * 持久域身份一定已经落盘。
31
+ *
32
+ * 身份保护:标记存在而密钥材料丢失时直接报错,绝不为同一存储域重新
33
+ * 生成新密钥;提供密钥与标记指纹不符时同样拒绝。
34
+ */
35
+ export declare function createConfigKeyProvider(options?: ConfigKeyProviderOptions): ConfigKeyProvider;