dsh-switchman 1.5.0 → 1.6.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/README.de.md +45 -55
- package/README.es.md +21 -31
- package/README.fr.md +23 -33
- package/README.it.md +23 -33
- package/README.ja.md +42 -52
- package/README.ko.md +43 -53
- package/README.md +41 -51
- package/README.pt.md +23 -33
- package/README.ru.md +21 -31
- package/README.zh-TW.md +35 -45
- package/README.zh.md +25 -35
- package/client.js +1464 -43
- package/host/config.js +8 -0
- package/host/context-watch.js +197 -56
- package/host/routes.js +6 -2
- package/host/subagents.js +86 -70
- package/host/teams.js +160 -7
- package/package.json +1 -1
- package/skills/db-query/package.json +1 -1
- package/skills/db-query/scripts/lib/cli.js +23 -23
- package/skills/db-query/scripts/lib/config.js +78 -61
- package/skills/db-query/scripts/lib/mysql-readonly.js +167 -158
- package/skills/db-query/scripts/lib/output.js +56 -55
- package/skills/db-query/scripts/lib/redis-readonly.js +116 -111
- package/skills/db-query/scripts/mysql-query.js +46 -42
- package/skills/db-query/scripts/redis-query.js +32 -29
- package/skills/db-query/scripts/setup.sh +16 -16
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)
|
|
403
|
-
*
|
|
404
|
-
*
|
|
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
|
|
422
|
-
// service emits this app-wide with
|
|
423
|
-
// this plugin's context receives
|
|
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)
|
|
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.
|
|
3
|
+
"version": "1.6.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": [
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
14
|
+
* Parse CLI arguments and return { options: {}, rest: string[] }
|
|
15
15
|
* @param {string[]} argv - process.argv.slice(2)
|
|
16
|
-
* @param {object} spec -
|
|
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
|
|
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(
|
|
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(
|
|
60
|
+
if (!resolved) throw new CliError(`Unknown option: --${key}`);
|
|
61
61
|
|
|
62
|
-
//
|
|
63
|
-
if (seen.has(resolved)) throw new CliError(
|
|
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
|
-
/**
|
|
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}
|
|
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}
|
|
106
|
-
if (spec.min !== undefined && n < spec.min) throw new CliError(`--${key}
|
|
107
|
-
if (spec.max !== undefined && n > spec.max) throw new CliError(`--${key}
|
|
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}
|
|
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
|
-
*
|
|
5
|
-
* dotenv
|
|
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
|
-
// ──
|
|
13
|
+
// ── Env file loading (does not touch process.env) ──
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* @
|
|
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(`
|
|
26
|
+
throw new Error(`Env file not found: ${resolved}`);
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
//
|
|
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(`
|
|
35
|
+
throw new ConfigError(`Env file ${resolved} permissions too broad (accessible by group/other), run chmod 600`);
|
|
33
36
|
}
|
|
34
37
|
} catch (e) {
|
|
35
|
-
//
|
|
36
|
-
|
|
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
|
-
*
|
|
45
|
-
* @param {object} envPairs - dotenv.parse()
|
|
46
|
-
* @param {string} envKey -
|
|
47
|
-
* @param {*} cliValue - CLI
|
|
48
|
-
* @returns {*} CLI
|
|
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
|
-
//
|
|
53
|
-
//
|
|
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
|
-
*
|
|
61
|
-
*
|
|
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
|
|
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
|
|
80
|
+
// CLI source: cli.js coerceValue already checked the bounds, return as-is
|
|
75
81
|
if (cliValue !== undefined) return cliValue;
|
|
76
|
-
// env
|
|
77
|
-
// 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(
|
|
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(
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
//
|
|
110
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
164
|
+
* Format a database error safely so a triaging agent can see the real failure
|
|
165
|
+
* at a glance.
|
|
154
166
|
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
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
|
|
186
|
+
return `Connection failed [${codeStr}]: ${line}`;
|
|
172
187
|
}
|
|
173
|
-
// mysql2
|
|
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
|
|
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
|
|
194
|
+
return `Query execution error [${codeStr}]: ${line}`;
|
|
180
195
|
}
|
|
181
|
-
//
|
|
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
|
-
//
|
|
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 || '
|
|
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
|
-
*
|
|
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
|
|
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 };
|