@agent_forge/forge-dsh 0.1.0 → 0.1.1
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.md +4 -1
- package/lib/runner.js +94 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,6 +35,9 @@ task-verify, review-stop, skill-trigger, and the session-start group.
|
|
|
35
35
|
- The `forge` CLI on `PATH` (`npm install -g @agent_forge/forge`), with the project
|
|
36
36
|
initialized (`forge init`) for task gates to have state to enforce.
|
|
37
37
|
- DeepSeek Harness `0.1.0-rc.x` (verified against `0.1.0-rc.7`), Node.js ≥ 18.
|
|
38
|
+
- Windows works out of the box: npm lays out `forge` as a `forge.cmd` shim, which
|
|
39
|
+
the plugin spawns through `cmd.exe` (a bare spawn cannot execute it); timeout
|
|
40
|
+
kills tear down the whole child tree via `taskkill /T`.
|
|
38
41
|
|
|
39
42
|
## Install
|
|
40
43
|
|
|
@@ -93,7 +96,7 @@ Raise `timeoutMs` on big projects.
|
|
|
93
96
|
|
|
94
97
|
```sh
|
|
95
98
|
npm install # dev-only: @deepseek-ai/cordis for the wiring tests
|
|
96
|
-
npm test #
|
|
99
|
+
npm test # mapping, runner (incl. cross-OS planSpawn units), decision folding, real-cordis wiring
|
|
97
100
|
```
|
|
98
101
|
|
|
99
102
|
`test/doubles/fake-forge.mjs` stands in for the forge binary; the wiring suite
|
package/lib/runner.js
CHANGED
|
@@ -29,6 +29,38 @@ import { spawn } from "node:child_process";
|
|
|
29
29
|
* @property {string} [error] - infrastructure failure note (fail-open path).
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Plan the spawn for one hook invocation, platform-aware. Exported for the
|
|
34
|
+
* cross-OS unit tests — CI's Linux runners cannot execute the win32 route, so
|
|
35
|
+
* the planning logic must be assertable without spawning.
|
|
36
|
+
*
|
|
37
|
+
* Windows: npm lays out forge as forge + forge.cmd (no forge.exe), and
|
|
38
|
+
* CreateProcess cannot execute a .cmd shim — a bare spawn("forge") is ENOENT
|
|
39
|
+
* and EVERY gate silently fails open. cmd.exe resolves forge.cmd via PATHEXT
|
|
40
|
+
* (and a bare forge.exe directly), so win32 routes through it. argv holds
|
|
41
|
+
* spec constants only (safe charset). The binary is operator config and is
|
|
42
|
+
* quoted, never escaped — see the metacharacter note inside planSpawn.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} forgeBin - binary name/path from config.
|
|
45
|
+
* @param {string[]} argv - hook arguments (spec constants).
|
|
46
|
+
* @param {string} [platform] - process.platform override (tests).
|
|
47
|
+
* @returns {{file: string, args: string[], shell: boolean}}
|
|
48
|
+
*/
|
|
49
|
+
export function planSpawn(forgeBin, argv, platform = process.platform) {
|
|
50
|
+
if (platform !== "win32") {
|
|
51
|
+
return { file: forgeBin, args: argv, shell: false };
|
|
52
|
+
}
|
|
53
|
+
// cmd.exe metacharacters: an unquoted & | ( ) < > ^ , ; = splits or redirects
|
|
54
|
+
// the token ("C:\A&B\forge.cmd" would execute a stray "B\forge.cmd"), so quote
|
|
55
|
+
// on any of them, not just spaces. Two things quoting cannot neutralize:
|
|
56
|
+
// %VAR% expansion (cmd expands it even inside quotes) and ! with delayed
|
|
57
|
+
// expansion (off by default). Both are accepted residual risk because
|
|
58
|
+
// forgeBin is trusted operator config — the same trust level as PATH itself.
|
|
59
|
+
const needsQuotes = /[&|()<>^,;=\s]/.test(forgeBin);
|
|
60
|
+
const file = needsQuotes && !forgeBin.includes('"') ? `"${forgeBin}"` : forgeBin;
|
|
61
|
+
return { file, args: argv, shell: true };
|
|
62
|
+
}
|
|
63
|
+
|
|
32
64
|
/**
|
|
33
65
|
* Run one forge hook command.
|
|
34
66
|
*
|
|
@@ -44,14 +76,26 @@ export function runForgeHook(command, payload, opts = {}) {
|
|
|
44
76
|
const forgeBin = opts.forgeBin ?? "forge";
|
|
45
77
|
const timeoutMs = opts.timeoutMs ?? 30000;
|
|
46
78
|
const parts = command.split(" "); // ["forge","hook","task-guard"]
|
|
47
|
-
const
|
|
79
|
+
const plan = planSpawn(forgeBin, parts.slice(1));
|
|
48
80
|
return new Promise((resolve) => {
|
|
49
81
|
let child;
|
|
50
82
|
try {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
83
|
+
if (plan.shell) {
|
|
84
|
+
// Single command STRING (not file+args): passing an args array with
|
|
85
|
+
// shell:true is DEP0190 — args are concatenated unescaped. The pieces
|
|
86
|
+
// here are spec constants plus the pre-quoted binary (planSpawn), so
|
|
87
|
+
// the joined line is exactly what cmd.exe should parse.
|
|
88
|
+
child = spawn([plan.file, ...plan.args].join(" "), {
|
|
89
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
90
|
+
cwd: opts.cwd,
|
|
91
|
+
shell: true,
|
|
92
|
+
});
|
|
93
|
+
} else {
|
|
94
|
+
child = spawn(plan.file, plan.args, {
|
|
95
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
96
|
+
cwd: opts.cwd,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
55
99
|
} catch (error) {
|
|
56
100
|
resolve({ block: false, error: note(error) });
|
|
57
101
|
return;
|
|
@@ -64,10 +108,39 @@ export function runForgeHook(command, payload, opts = {}) {
|
|
|
64
108
|
const timer = setTimeout(() => {
|
|
65
109
|
if (settled) return;
|
|
66
110
|
settled = true;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
111
|
+
// On the win32 shell route the direct child is cmd.exe and the forge
|
|
112
|
+
// grandchild holds the task index lock AND the inherited stdio pipes:
|
|
113
|
+
// killing only the shell ORPHANS the grandchild (a dead parent's tree
|
|
114
|
+
// is unfindable for /T) — it survives with the pipes open, pinning this
|
|
115
|
+
// process's event loop forever. taskkill /T /F is therefore the primary
|
|
116
|
+
// kill: it tears down shell + descendants atomically. kill() (wrapper
|
|
117
|
+
// only) is the FALLBACK for when taskkill itself fails — spawn error
|
|
118
|
+
// (not on PATH) or nonzero exit (access denied); killing just the
|
|
119
|
+
// wrapper then still beats killing nothing. Non-shell routes kill the
|
|
120
|
+
// forge process directly.
|
|
121
|
+
if (plan.shell && child.pid) {
|
|
122
|
+
const fallback = () => {
|
|
123
|
+
try {
|
|
124
|
+
child.kill();
|
|
125
|
+
} catch {
|
|
126
|
+
// already exited — nothing to kill
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
|
|
131
|
+
killer.on("error", fallback);
|
|
132
|
+
killer.on("close", (c) => {
|
|
133
|
+
if (c !== 0) fallback();
|
|
134
|
+
});
|
|
135
|
+
} catch {
|
|
136
|
+
fallback();
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
try {
|
|
140
|
+
child.kill();
|
|
141
|
+
} catch {
|
|
142
|
+
// already exited — nothing to kill
|
|
143
|
+
}
|
|
71
144
|
}
|
|
72
145
|
resolve({ block: false, error: `timeout after ${timeoutMs}ms` });
|
|
73
146
|
}, timeoutMs);
|
|
@@ -79,9 +152,19 @@ export function runForgeHook(command, payload, opts = {}) {
|
|
|
79
152
|
};
|
|
80
153
|
child.stdout.on("data", (d) => (out += d.toString()));
|
|
81
154
|
child.on("error", (error) => settle({ block: false, error: note(error) }));
|
|
82
|
-
child.on("close", () => {
|
|
155
|
+
child.on("close", (code, signal) => {
|
|
83
156
|
const text = out.trim();
|
|
84
|
-
if (text === "")
|
|
157
|
+
if (text === "") {
|
|
158
|
+
// Exit 0 with no stdout is forge's clean silent allow. A NONZERO exit
|
|
159
|
+
// with no stdout is an infrastructure failure: on the win32 cmd.exe
|
|
160
|
+
// route a missing binary prints its error to stderr and exits — the
|
|
161
|
+
// spawn "error" event never fires because cmd.exe itself started
|
|
162
|
+
// fine. Fail open WITH an error note so /forge-status surfaces it.
|
|
163
|
+
// signal kills report code === null ("exited with code null" reads
|
|
164
|
+
// like a bug; name the signal instead).
|
|
165
|
+
const how = code === null ? `signal ${signal}` : `code ${code}`;
|
|
166
|
+
return settle(code === 0 ? { block: false } : { block: false, error: `forge exited with ${how} and no output` });
|
|
167
|
+
}
|
|
85
168
|
try {
|
|
86
169
|
const j = JSON.parse(text);
|
|
87
170
|
const context = j?.hookSpecificOutput?.additionalContext;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent_forge/forge-dsh",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Forge quality gates for DeepSeek Harness (dsh): task gates, read-before-edit, bash hazard interception and quality scoring, driven by the forge CLI through DSH's typed interception points.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|