dsh-switchman 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/host/teams.js CHANGED
@@ -25,6 +25,15 @@
25
25
  * Enforcement is a per-new-session snapshot, so a sync only affects
26
26
  * top-level sessions composed after it.
27
27
  *
28
+ * - The teams-gated `child_message` bridge tool: enabling the agent-team
29
+ * bundle REPLACES the global send_message with a team variant that
30
+ * resolves TEAMMATE NAMES only, after which subagent child session UUIDs
31
+ * are not addressable at all. The bridge restores child addressing through
32
+ * ctx.subagents.sendMessage (sender = the exact live calling agent,
33
+ * exec.agent), registered while the live `teamsMode` setting reads ON and
34
+ * disposed again the moment it reads OFF (same live-reference gating as
35
+ * the doctrine section).
36
+ *
28
37
  * Trigger points: plugin apply (applyTeams schedules once), every successful
29
38
  * POST /api/dsh-switchman/config (routes.js calls scheduleTeamsOrchestration),
30
39
  * and settings/document-updated for this plugin's own namespace (volatile
@@ -398,10 +407,116 @@ function pumpTeamsOrchestration(ctx, config) {
398
407
  scheduleTeamsOrchestration(ctx, config);
399
408
  }
400
409
 
410
+ // ---- child_message bridge tool ----------------------------------------------
411
+
412
+ /** Map one ctx.subagents.sendMessage rejection to a readable reason. The
413
+ * continuation runtime throws typed SubagentErrors whose stable `code`
414
+ * names the failure mode: UNAUTHORIZED (the sender is not the exact live
415
+ * agent, or the target is not the sender's direct continuable child /
416
+ * direct parent), NOT_RESUMABLE (no continuable session resolves to the
417
+ * id), CONTINUATION_UNAVAILABLE (this composition exposes no continuation
418
+ * runtime), DRAINING (admission is closing), and ACTIVATION_CLOSING (the
419
+ * resident child is mid-disposal). */
420
+ function describeChildMessageError(error) {
421
+ const message = error?.message ?? String(error);
422
+ switch (typeof error?.code === "string" ? error.code : "") {
423
+ case "UNAUTHORIZED":
424
+ return `wrong sender or lineage: ${message} (childId must be one of your own direct continuable children — teammate names and other agents' children are not addressable here)`;
425
+ case "NOT_RESUMABLE":
426
+ return `unknown or non-continuable child: ${message}`;
427
+ case "CONTINUATION_UNAVAILABLE":
428
+ return `service unavailable: ${message}`;
429
+ case "DRAINING":
430
+ return `continuable subagents are draining: ${message}`;
431
+ case "ACTIVATION_CLOSING":
432
+ return `the child is closing: ${message}`;
433
+ default:
434
+ return error?.name === "AbortError" ? `cancelled before delivery: ${message}` : message;
435
+ }
436
+ }
437
+
438
+ /** WHY this tool exists: enabling the agent-team-profile bundle REPLACES the
439
+ * global send_message with a team variant that resolves TEAMMATE NAMES
440
+ * only, after which subagent child session UUIDs are not addressable at
441
+ * all — background children dispatched before the switch (or recorded by a
442
+ * context handover) would become uncollectable silos. This bridge restores
443
+ * child addressing through the subagents service's public sendMessage,
444
+ * passing the exact live calling agent (exec.agent) as the sender — the
445
+ * same authority contract the factory send_message tool uses; the service
446
+ * itself validates the sender identity and the direct-child lineage.
447
+ * Registration uses the register-level shape (bare ctx.tools.register runs
448
+ * no defineTool DSL compilation, so parameters is a full object-root JSON
449
+ * Schema), mirroring context-watch.js's ctx_handover tool.
450
+ * @returns the registration disposer. */
451
+ function registerChildMessageTool(ctx) {
452
+ if (typeof ctx.tools?.register !== "function") throw new Error("the tools registry is unavailable");
453
+ const dispose = ctx.tools.register({
454
+ name: "child_message",
455
+ description:
456
+ "Send a message to one of your subagent child sessions by its session UUID. Needed while Agent Teams mode is active: the team send_message resolves teammate names only, so subagent child ids must go through this bridge. Delivers to a direct continuable child — a running child takes the message at its nearest step boundary, an idle child starts a turn, and an absent continuable child is cold-resumed from persistence. Returns delivery confirmation, not the child's answer.",
457
+ parameters: {
458
+ type: "object",
459
+ properties: {
460
+ childId: {
461
+ type: "string",
462
+ description: "The child session UUID to address (the id returned when the subagent was started).",
463
+ },
464
+ content: {
465
+ type: "string",
466
+ description: "The message to deliver to the child.",
467
+ },
468
+ },
469
+ required: ["childId", "content"],
470
+ },
471
+ output: {
472
+ schema: { type: "string" },
473
+ render: (_args, value) => [{ type: "text", text: value }],
474
+ },
475
+ async execute(args, exec) {
476
+ const sender = exec?.agent;
477
+ if (sender === undefined || sender === null)
478
+ return "child_message failed: no calling agent (exec.agent was undefined) — the subagents service requires the exact live sender.";
479
+ const childId = typeof args?.childId === "string" ? args.childId.trim() : "";
480
+ const content = typeof args?.content === "string" ? args.content : "";
481
+ if (childId === "" || content === "")
482
+ return "child_message failed: childId and content are required non-empty strings.";
483
+ const service = ctx.subagents;
484
+ if (service === undefined || service === null || typeof service.sendMessage !== "function")
485
+ return "child_message failed: the subagents service is unavailable in this composition.";
486
+ try {
487
+ const messageId = await service.sendMessage(
488
+ sender,
489
+ childId,
490
+ [{ type: "text", text: content }],
491
+ // The service requires options.signal (it calls
492
+ // throwIfAborted unconditionally); the tool turn's own
493
+ // signal cancels only pre-acceptance work.
494
+ { signal: exec?.signal ?? new AbortController().signal },
495
+ );
496
+ return `child_message: message ${messageId} accepted for child ${childId} — a running child takes it at the nearest step boundary, an idle or absent continuable child starts (or cold-resumes into) a turn. This confirms delivery only, not the child's answer.`;
497
+ } catch (error) {
498
+ return `child_message failed: ${describeChildMessageError(error)}`;
499
+ }
500
+ },
501
+ presentCall: (args) => ({
502
+ card: "generic",
503
+ title: "Switchman child message",
504
+ kind: "other",
505
+ rawInput: args,
506
+ }),
507
+ });
508
+ // If the registry registered the tool but handed back a non-function
509
+ // disposer, latch a no-op instead of throwing: throwing would leave a
510
+ // live tool unlatched, and a later retry would register it twice.
511
+ return typeof dispose === "function" ? dispose : () => {};
512
+ }
513
+
401
514
  /** Mount the teams layer: the dynamic `[SWITCHMAN:TEAMS]` section (empty
402
- * while OFF — the live reference makes toggling instant) plus the
403
- * orchestration triggers (apply-time scheduling and settings-document
404
- * updates for this plugin's own namespace). */
515
+ * while OFF — the live reference makes toggling instant), the child_message
516
+ * bridge tool (same live teamsMode gating: registered while ON, disposed
517
+ * the moment it reads OFF), plus the orchestration triggers (apply-time
518
+ * scheduling and settings-document updates for this plugin's own
519
+ * namespace). */
405
520
  function applyTeams(ctx, config) {
406
521
  ctx.systemPrompt.section({
407
522
  name: "switchman:teams",
@@ -416,15 +531,53 @@ function applyTeams(ctx, config) {
416
531
  }
417
532
  },
418
533
  });
534
+ // child_message lifecycle: reconcile the registration against the live
535
+ // teamsMode read (idempotent — a no-op whenever the state already
536
+ // matches). The closure holds the disposer so every plugin mount starts
537
+ // clean; the registry entry itself is scope-tracked to this fiber, so a
538
+ // remount never leaves a stale tool behind. A registration failure only
539
+ // warns and leaves the state unlatched, and a failed removal keeps the
540
+ // latch — the next toggle retries either way.
541
+ let childMessageDispose = null;
542
+ const syncChildMessageTool = () => {
543
+ const wanted = readBool(config?.teamsMode) === true;
544
+ if (wanted === (childMessageDispose !== null)) return;
545
+ if (!wanted) {
546
+ // Clear the latch only after a successful dispose: a throwing
547
+ // disposer keeps the latch set, so the next OFF sync retries the
548
+ // removal instead of no-oping while the tool stays live.
549
+ const dispose = childMessageDispose;
550
+ try {
551
+ dispose();
552
+ childMessageDispose = null;
553
+ ctx.logger?.info?.("dsh-switchman: child_message tool removed (teams mode off)");
554
+ } catch (error) {
555
+ ctx.logger?.warn?.(`dsh-switchman: child_message removal failed: ${error?.message ?? error}`);
556
+ }
557
+ return;
558
+ }
559
+ try {
560
+ childMessageDispose = registerChildMessageTool(ctx);
561
+ ctx.logger?.info?.("dsh-switchman: child_message tool registered (teams mode on)");
562
+ } catch (error) {
563
+ childMessageDispose = null;
564
+ ctx.logger?.warn?.(`dsh-switchman: child_message registration failed: ${error?.message ?? error}`);
565
+ }
566
+ };
419
567
  // Volatile commits never remount the plugin, so teamsMode/syncWhitelist
420
568
  // (or pool) edits through DSH's OWN settings UI — which bypass our POST
421
- // config route — must re-trigger the orchestration too. The settings
422
- // service emits this app-wide with no emitting thisArg, so a listener on
423
- // this plugin's context receives every namespace's event; filter on ns.
569
+ // config route — must re-trigger the orchestration (and re-sync the
570
+ // child_message tool) too. The settings service emits this app-wide with
571
+ // no emitting thisArg, so a listener on this plugin's context receives
572
+ // every namespace's event; filter on ns.
424
573
  ctx.on("settings/document-updated", (ns) => {
425
- if (ns === SWITCHMAN_SETTINGS_NS) scheduleTeamsOrchestration(ctx, config);
574
+ if (ns === SWITCHMAN_SETTINGS_NS) {
575
+ scheduleTeamsOrchestration(ctx, config);
576
+ syncChildMessageTool();
577
+ }
426
578
  });
427
579
  scheduleTeamsOrchestration(ctx, config);
580
+ syncChildMessageTool();
428
581
  }
429
582
 
430
583
  export { applyTeams, pumpTeamsOrchestration, SWITCHMAN_SETTINGS_NS, scheduleTeamsOrchestration, teamsBundleState, teamsSyncSnapshot };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-switchman",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Autonomous Agent Teams doctrine for the four shipped DeepSeek Harness presets, plus language preferences, dispatch pools with model ranking, and context watermark control.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -25,7 +25,7 @@
25
25
  "icon": "./icon.svg",
26
26
  "engines": {
27
27
  "node": ">=24",
28
- "dsh": ">=0.2.0-rc.0"
28
+ "dsh": ">=0.2.1-rc.1"
29
29
  },
30
30
  "exports": {
31
31
  ".": "./index.js",
@@ -2,7 +2,7 @@
2
2
  "name": "db-query-skill",
3
3
  "version": "1.0.0",
4
4
  "private": true,
5
- "description": "pi 技能:数据库只读查询核验(MySQL / Redis)",
5
+ "description": "Read-only database query verification skill (MySQL / Redis)",
6
6
  "engines": {
7
7
  "node": ">=18"
8
8
  },
@@ -1,9 +1,9 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 严格 CLI 参数解析器
5
- * 支持 --key=value、--key value、布尔 flag、-- 分隔 SQL/Redis 命令
6
- * 未知选项、重复冲突、越界数字 → 立即失败(fail-closed)
4
+ * Strict CLI argument parser
5
+ * Supports --key=value, --key value, boolean flags, and -- to separate SQL/Redis commands
6
+ * Unknown options, duplicate conflicts, out-of-range numbers → fail immediately (fail-closed)
7
7
  */
8
8
 
9
9
  class CliError extends Error {
@@ -11,16 +11,16 @@ class CliError extends Error {
11
11
  }
12
12
 
13
13
  /**
14
- * 解析 CLI 参数,返回 { options: {}, rest: string[] }
14
+ * Parse CLI arguments and return { options: {}, rest: string[] }
15
15
  * @param {string[]} argv - process.argv.slice(2)
16
- * @param {object} spec - 参数规格 { name: { type, default, min, max, alias } }
16
+ * @param {object} spec - option spec { name: { type, default, min, max, alias } }
17
17
  */
18
18
  function parseCli(argv, spec) {
19
19
  const options = {};
20
20
  const rest = [];
21
21
  const seen = new Set();
22
22
 
23
- // 填充默认值
23
+ // Fill in defaults
24
24
  for (const [key, def] of Object.entries(spec)) {
25
25
  if (def.default !== undefined) options[key] = def.default;
26
26
  }
@@ -29,13 +29,13 @@ function parseCli(argv, spec) {
29
29
  while (i < argv.length) {
30
30
  const arg = argv[i];
31
31
 
32
- // -- 分隔符:后续全部作为位置参数
32
+ // -- separator: everything after it is positional
33
33
  if (arg === '--') {
34
34
  rest.push(...argv.slice(i + 1));
35
35
  break;
36
36
  }
37
37
 
38
- // --option=value 或 --option value 形式
38
+ // --option=value or --option value form
39
39
  if (arg.startsWith('--')) {
40
40
  let key, value;
41
41
  const eqIdx = arg.indexOf('=');
@@ -44,29 +44,29 @@ function parseCli(argv, spec) {
44
44
  value = arg.slice(eqIdx + 1);
45
45
  } else {
46
46
  key = arg.slice(2);
47
- // 查找规格中该选项的类型
47
+ // Look up the option's type in the spec
48
48
  const s = findSpec(spec, key);
49
- if (!s) throw new CliError(`未知选项: --${key}`);
49
+ if (!s) throw new CliError(`Unknown option: --${key}`);
50
50
  if (s.type === 'boolean') {
51
51
  value = true;
52
52
  } else {
53
53
  i++;
54
- if (i >= argv.length) throw new CliError(`--${key} 需要值`);
54
+ if (i >= argv.length) throw new CliError(`--${key} requires a value`);
55
55
  value = argv[i];
56
56
  }
57
57
  }
58
58
 
59
59
  const resolved = resolveAlias(spec, key);
60
- if (!resolved) throw new CliError(`未知选项: --${key}`);
60
+ if (!resolved) throw new CliError(`Unknown option: --${key}`);
61
61
 
62
- // 重复选项检查
63
- if (seen.has(resolved)) throw new CliError(`重复选项: --${resolved}`);
62
+ // Duplicate option check
63
+ if (seen.has(resolved)) throw new CliError(`Duplicate option: --${resolved}`);
64
64
  seen.add(resolved);
65
65
 
66
66
  const s = spec[resolved];
67
67
  options[resolved] = coerceValue(resolved, value, s);
68
68
  } else {
69
- // 非选项参数 → 位置参数
69
+ // Non-option argument → positional
70
70
  rest.push(arg);
71
71
  }
72
72
  i++;
@@ -75,7 +75,7 @@ function parseCli(argv, spec) {
75
75
  return { options, rest };
76
76
  }
77
77
 
78
- /** 在 spec 或 alias 中查找选项名 */
78
+ /** Find an option name in the spec or its aliases */
79
79
  function findSpec(spec, key) {
80
80
  if (spec[key]) return spec[key];
81
81
  for (const s of Object.values(spec)) {
@@ -84,7 +84,7 @@ function findSpec(spec, key) {
84
84
  return null;
85
85
  }
86
86
 
87
- /** 解析别名到规范名 */
87
+ /** Resolve an alias to its canonical name */
88
88
  function resolveAlias(spec, key) {
89
89
  if (spec[key]) return key;
90
90
  for (const [name, s] of Object.entries(spec)) {
@@ -93,23 +93,23 @@ function resolveAlias(spec, key) {
93
93
  return null;
94
94
  }
95
95
 
96
- /** 类型转换 + 边界检查 */
96
+ /** Type coercion + bounds checking */
97
97
  function coerceValue(key, raw, spec) {
98
98
  if (spec.type === 'boolean') {
99
99
  if (raw === 'true' || raw === true) return true;
100
100
  if (raw === 'false' || raw === false) return false;
101
- throw new CliError(`--${key} 期望布尔值,收到: ${raw}`);
101
+ throw new CliError(`--${key} expects a boolean, got: ${raw}`);
102
102
  }
103
103
  if (spec.type === 'integer') {
104
104
  const n = Number(raw);
105
- if (!Number.isInteger(n)) throw new CliError(`--${key} 期望整数,收到: ${raw}`);
106
- if (spec.min !== undefined && n < spec.min) throw new CliError(`--${key} 最小值 ${spec.min},收到: ${n}`);
107
- if (spec.max !== undefined && n > spec.max) throw new CliError(`--${key} 最大值 ${spec.max},收到: ${n}`);
105
+ if (!Number.isInteger(n)) throw new CliError(`--${key} expects an integer, got: ${raw}`);
106
+ if (spec.min !== undefined && n < spec.min) throw new CliError(`--${key} minimum is ${spec.min}, got: ${n}`);
107
+ if (spec.max !== undefined && n > spec.max) throw new CliError(`--${key} maximum is ${spec.max}, got: ${n}`);
108
108
  return n;
109
109
  }
110
110
  if (spec.type === 'string' && spec.choices) {
111
111
  const lower = String(raw).toLowerCase();
112
- if (!spec.choices.includes(lower)) throw new CliError(`--${key} 必须为 ${spec.choices.join('|')} 之一,收到: ${raw}`);
112
+ if (!spec.choices.includes(lower)) throw new CliError(`--${key} must be one of ${spec.choices.join('|')}, got: ${raw}`);
113
113
  return lower;
114
114
  }
115
115
  return String(raw);
@@ -1,39 +1,43 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 配置解析:env 文件 / 环境变量 / 脱敏 / 安全错误格式化
5
- * dotenv 只用 parse(),不污染 process.env;POSIX 下检查 env 文件权限
4
+ * Configuration: env file / environment variables / sanitizing / safe error formatting
5
+ * Only dotenv.parse() is used so process.env stays untouched; env file
6
+ * permissions are checked on POSIX
6
7
  */
7
8
 
8
9
  const fs = require('fs');
9
10
  const path = require('path');
10
11
  const dotenv = require('dotenv');
11
12
 
12
- // ── env 文件加载(不污染 process.env)──
13
+ // ── Env file loading (does not touch process.env) ──
13
14
 
14
15
  /**
15
- * 加载指定 env 文件,返回键值对
16
- * 不读 cwd 的 .env,防止意外泄露;group/other 可读时直接拒绝(凭据安全)
17
- * @param {string|undefined} envFile - env 文件路径
18
- * @returns {object} 键值对
16
+ * Load the given env file and return key/value pairs
17
+ * Never reads .env from cwd to avoid accidental leaks; rejects outright when
18
+ * group/other can read it (credential safety)
19
+ * @param {string|undefined} envFile - env file path
20
+ * @returns {object} key/value pairs
19
21
  */
20
22
  function loadEnvFile(envFile) {
21
23
  if (!envFile) return {};
22
24
  const resolved = path.resolve(envFile);
23
25
  if (!fs.existsSync(resolved)) {
24
- throw new Error(`env 文件不存在: ${resolved}`);
26
+ throw new Error(`Env file not found: ${resolved}`);
25
27
  }
26
28
 
27
- // POSIX 下检查 group/other 权限:同机其他用户可读即泄露密码,直接拒绝
29
+ // Check group/other permissions on POSIX: if other local users can read the
30
+ // file the password leaks — reject outright
28
31
  try {
29
32
  const stat = fs.statSync(resolved);
30
33
  // eslint-disable-next-line no-bitwise
31
34
  if ((stat.mode & 0o077) !== 0) {
32
- throw new ConfigError(`env 文件 ${resolved} 权限过宽(group/other 可访问),请 chmod 600`);
35
+ throw new ConfigError(`Env file ${resolved} permissions too broad (accessible by group/other), run chmod 600`);
33
36
  }
34
37
  } catch (e) {
35
- // 仅跳过 Windows 等非 POSIX 系统的权限检查错误;其余错误(权限过宽)往上抛
36
- if (e.message.includes('权限过宽')) throw e;
38
+ // Only skip the permission-check error on Windows and other non-POSIX
39
+ // systems; rethrow everything else (permissions too broad)
40
+ if (e.message.includes('permissions too broad')) throw e;
37
41
  }
38
42
 
39
43
  const content = fs.readFileSync(resolved, 'utf8');
@@ -41,28 +45,30 @@ function loadEnvFile(envFile) {
41
45
  }
42
46
 
43
47
  /**
44
- * 按 SKILL.md 优先级读取配置值:CLI > env 文件 > 进程环境变量 > undefined
45
- * @param {object} envPairs - dotenv.parse() 结果(显式 --env-file,权限受控)
46
- * @param {string} envKey - 环境变量名(如 DB_QUERY_MYSQL_HOST)
47
- * @param {*} cliValue - CLI 传入的值;undefined 表示 CLI 未指定
48
- * @returns {*} CLI 优先,其次 env 文件,再次进程环境变量,否则 undefined
48
+ * Read a config value with SKILL.md precedence: CLI > env file > process env > undefined
49
+ * @param {object} envPairs - dotenv.parse() result (explicit --env-file, permission-controlled)
50
+ * @param {string} envKey - environment variable name (e.g. DB_QUERY_MYSQL_HOST)
51
+ * @param {*} cliValue - value from the CLI; undefined means not given on the CLI
52
+ * @returns {*} CLI first, then env file, then process env, otherwise undefined
49
53
  */
50
54
  function resolveEnv(envPairs, envKey, cliValue) {
51
55
  if (cliValue !== undefined) return cliValue;
52
- // env 文件优先(显式指定、权限受控 0600),其次进程环境变量
53
- // (loadEnvFile 刻意不污染 process.env,必须在此显式读取兜底,否则 export DB_QUERY_* 不生效)
56
+ // Env file first (explicitly given, permission-controlled 0600), then the
57
+ // process environment (loadEnvFile deliberately leaves process.env
58
+ // untouched, so fall back to it here or `export DB_QUERY_*` would not work)
54
59
  if (envPairs[envKey] !== undefined) return envPairs[envKey];
55
60
  if (process.env[envKey] !== undefined) return process.env[envKey];
56
61
  return undefined;
57
62
  }
58
63
 
59
64
  /**
60
- * 从 env/CLI 解析数值并校验 min/max 边界
61
- * 所有来源(CLI / env 文件 / 环境变量)统一跑边界检查,防止 env 文件绕过硬上限
65
+ * Parse a number from env/CLI and validate min/max bounds
66
+ * Every source (CLI / env file / environment) runs the same bounds check so
67
+ * an env file cannot bypass hard limits
62
68
  *
63
- * @param {object} envPairs - dotenv.parse() 结果
64
- * @param {string} envKey - 环境变量名
65
- * @param {*} cliValue - CLI 传入的值(已由 cli.js 做过边界校验)
69
+ * @param {object} envPairs - dotenv.parse() result
70
+ * @param {string} envKey - environment variable name
71
+ * @param {*} cliValue - value from the CLI (bounds already checked by cli.js)
66
72
  * @param {object} bounds - { min?: number, max?: number }
67
73
  * @returns {number}
68
74
  */
@@ -71,29 +77,31 @@ class ConfigError extends Error {
71
77
  }
72
78
 
73
79
  function resolveEnvNumber(envPairs, envKey, cliValue, bounds) {
74
- // CLI 来源:cli.js 的 coerceValue 已做过边界检查,直接返回
80
+ // CLI source: cli.js coerceValue already checked the bounds, return as-is
75
81
  if (cliValue !== undefined) return cliValue;
76
- // env 文件优先,其次进程环境变量(与 resolveEnv 优先级一致,符合 SKILL.md 承诺);
77
- // env 来源的值需统一解析 + 边界校验,防止 env 绕过硬上限
82
+ // Env file first, then process env (same precedence as resolveEnv, per the
83
+ // SKILL.md contract); env-sourced values are parsed and bounds-checked
84
+ // uniformly so env cannot bypass hard limits
78
85
  const raw = envPairs[envKey] !== undefined ? envPairs[envKey] : process.env[envKey];
79
86
  if (raw === undefined) return undefined;
80
87
  const n = Number(raw);
81
88
  if (!Number.isInteger(n)) {
82
- throw new ConfigError(`环境变量 ${envKey}=${raw} 不是合法整数`);
89
+ throw new ConfigError(`Environment variable ${envKey}=${raw} is not a valid integer`);
83
90
  }
84
91
  if (bounds && bounds.min !== undefined && n < bounds.min) {
85
- throw new ConfigError(`环境变量 ${envKey}=${n} 低于最小值 ${bounds.min}`);
92
+ throw new ConfigError(`Environment variable ${envKey}=${n} is below the minimum ${bounds.min}`);
86
93
  }
87
94
  if (bounds && bounds.max !== undefined && n > bounds.max) {
88
- throw new ConfigError(`环境变量 ${envKey}=${n} 超过最大值 ${bounds.max}`);
95
+ throw new ConfigError(`Environment variable ${envKey}=${n} exceeds the maximum ${bounds.max}`);
89
96
  }
90
97
  return n;
91
98
  }
92
99
 
93
- // ── 连接目标脱敏 ──
100
+ // ── Connection target sanitizing ──
94
101
 
95
102
  /**
96
- * 生成脱敏的连接目标信息,绝不包含密码或含密码 URI
103
+ * Build sanitized connection target info; never includes the password or a
104
+ * password-bearing URI
97
105
  * @param {object} opts - { host, port, user, database }
98
106
  * @returns {string}
99
107
  */
@@ -104,14 +112,16 @@ function sanitizeTarget(opts) {
104
112
  return parts.join(', ');
105
113
  }
106
114
 
107
- // ── 安全错误格式化 ──
115
+ // ── Safe error formatting ──
108
116
 
109
- // 屏蔽 URL/连接串里内联的密码(://user:password@host → ://user:***@host)。
110
- // 用户名段用 * 以覆盖 redis 无用户名的标准形式(://:password@host),防御性脱敏
117
+ // Mask passwords inlined in URLs/connection strings (://user:password@host → ://user:***@host).
118
+ // The username segment uses * to also cover the standard no-username Redis
119
+ // form (://:password@host); defensive sanitizing
111
120
  const URL_CRED_PATTERN = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/\s:@]*:)[^/\s@]+(@)/g;
112
121
 
113
122
  /**
114
- * 取错误 message 首行,脱敏截断:屏蔽内联凭据 + 截断到 200 字符防意外泄露
123
+ * Take the first line of an error message, sanitized and truncated: mask
124
+ * inline credentials + cut at 200 chars to prevent accidental leaks
115
125
  * @param {string} s
116
126
  * @returns {string}
117
127
  */
@@ -121,27 +131,28 @@ function sanitizeErrorLine(s) {
121
131
  return masked.length > 200 ? masked.slice(0, 200) + '…' : masked;
122
132
  }
123
133
 
124
- // 连接/认证/握手阶段错误码 —— 归为「连接失败」,明确区分于查询执行错误
134
+ // Connection/auth/handshake error codes — classified as "connection failure",
135
+ // clearly distinct from query execution errors
125
136
  const CONNECTION_CODES = new Set([
126
- // Node 网络与系统错误
137
+ // Node network and system errors
127
138
  'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND',
128
139
  'EHOSTUNREACH', 'ENETUNREACH', 'EPIPE', 'ECONNABORTED', 'EDESTADDRREQ',
129
- // DNS 解析错误
140
+ // DNS resolution errors
130
141
  'EAI_AGAIN', 'EAI_NONAME', 'EAI_SERVICE', 'EAI_FAIL', 'EAI_BADFLAGS',
131
- // TLS 错误
142
+ // TLS errors
132
143
  'ERR_TLS_CERT_ALTNAME_INVALID', 'CERT_HAS_EXPIRED',
133
144
  'DEPTH_ZERO_SELF_SIGNED_CERT', 'ERR_TLS_INVALID_PROTOCOL_METHOD',
134
145
  'ERR_TLS_PROTOCOL_VERSION_CONFLICT',
135
- // mysql2 连接/握手/选库阶段
146
+ // mysql2 connection/handshake/db-selection stage
136
147
  'HANDSHAKE_ERROR', 'PROTOCOL_CONNECTION_LOST',
137
148
  'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR', 'PROTOCOL_SEQUENCE_TIMEOUT',
138
149
  'ER_ACCESS_DENIED_ERROR', 'ER_DBACCESS_DENIED_ERROR',
139
150
  'ER_BAD_DB_ERROR', 'ER_BAD_HOST_ERROR',
140
- // Redis 认证阶段
151
+ // Redis auth stage
141
152
  'WRONGPASS', 'NOAUTH',
142
153
  ]);
143
154
 
144
- // node-redis 命令执行阶段错误码(与连接阶段区分)
155
+ // node-redis command execution error codes (distinct from the connection stage)
145
156
  const REDIS_EXEC_CODES = new Set([
146
157
  'ERR', 'WRONGTYPE', 'WRONGINT', 'WRONGBIT', 'WRONGKEY',
147
158
  'EXECABORT', 'NOREPLICAS', 'MISCONF', 'BUSYGROUP', 'NOGROUP',
@@ -150,14 +161,18 @@ const REDIS_EXEC_CODES = new Set([
150
161
  ]);
151
162
 
152
163
  /**
153
- * 安全地格式化数据库错误,让排障 agent 能立刻看清真正的失败原因。
164
+ * Format a database error safely so a triaging agent can see the real failure
165
+ * at a glance.
154
166
  *
155
- * 关键安全前提:mysql2 的 ER_* / node-redis 的命令错误,其 message 来自
156
- * 数据库服务端,不含客户端凭据(服务端不感知客户端密码);Node 网络错误
157
- * message 形如 "connect ECONNREFUSED 127.0.0.1:3306" 也不含密码。因此按
158
- * code 分类后暴露 code + message 首行(已脱敏截断)是安全的,且能让 agent
159
- * 一次区分「连不上」还是「SQL 写错」,避免无效的二分排查。
160
- * 仅对「既无 code 也无 message」的极端异常给中性兜底,且不再误报成「连接失败」。
167
+ * Key security premise: messages of mysql2 ER_* / node-redis command errors
168
+ * come from the database server and contain no client credentials (the server
169
+ * never sees the client password); Node network errors such as
170
+ * "connect ECONNREFUSED 127.0.0.1:3306" contain no password either. So
171
+ * exposing the code plus the sanitized first message line, classified by
172
+ * code, is safe and lets the agent tell "cannot connect" from "bad SQL" in
173
+ * one step, avoiding pointless bisection. Only pathological errors with
174
+ * neither code nor message get a neutral fallback, no longer misreported as
175
+ * "connection failure".
161
176
  * @param {Error} err
162
177
  * @returns {string}
163
178
  */
@@ -168,29 +183,31 @@ function safeMessage(err) {
168
183
  if (code) {
169
184
  const codeStr = String(code);
170
185
  if (CONNECTION_CODES.has(codeStr)) {
171
- return `连接失败 [${codeStr}]: ${line}`;
186
+ return `Connection failed [${codeStr}]: ${line}`;
172
187
  }
173
- // mysql2 服务端执行错误:ER_BAD_FIELD_ERROR(1054)/ER_PARSE_ERROR/ER_NO_SUCH_TABLE 等
188
+ // mysql2 server-side execution errors: ER_BAD_FIELD_ERROR(1054)/ER_PARSE_ERROR/ER_NO_SUCH_TABLE etc.
174
189
  if (/^ER_/.test(codeStr)) {
175
- return `查询执行错误 [${codeStr}]: ${line}`;
190
+ return `Query execution error [${codeStr}]: ${line}`;
176
191
  }
177
- // node-redis 命令执行错误
192
+ // node-redis command execution errors
178
193
  if (REDIS_EXEC_CODES.has(codeStr)) {
179
- return `查询执行错误 [${codeStr}]: ${line}`;
194
+ return `Query execution error [${codeStr}]: ${line}`;
180
195
  }
181
- // 未知 code:仍暴露 code + 首行(已脱敏截断),比「详情已隐藏」更利于排障
196
+ // Unknown code: still expose code + first line (sanitized and
197
+ // truncated); more useful for triage than "details hidden"
182
198
  return `[${codeStr}] ${line}`;
183
199
  }
184
200
 
185
- // 无 code:无法分类,给中性措辞,不再误导成「连接失败」
201
+ // No code: cannot classify, use neutral wording instead of a misleading
202
+ // "connection failure"
186
203
  const name = err.name || 'Error';
187
- return `${name}: ${line || '(无错误信息,请检查 SQL/命令与连接参数)'}`;
204
+ return `${name}: ${line || '(no error message; check the SQL/command and connection parameters)'}`;
188
205
  }
189
206
 
190
- // ── TLS 选项构建 ──
207
+ // ── TLS options ──
191
208
 
192
209
  /**
193
- * 从 ca-file 路径构建 TLS 选项
210
+ * Build TLS options from a ca-file path
194
211
  * @param {string|undefined} caFile
195
212
  * @returns {object|undefined}
196
213
  */
@@ -198,7 +215,7 @@ function buildTlsOptions(caFile) {
198
215
  if (!caFile) return undefined;
199
216
  const resolved = path.resolve(caFile);
200
217
  if (!fs.existsSync(resolved)) {
201
- throw new Error(`CA 证书文件不存在: ${resolved}`);
218
+ throw new Error(`CA certificate file not found: ${resolved}`);
202
219
  }
203
220
  const ca = fs.readFileSync(resolved);
204
221
  return { ca, rejectUnauthorized: true };