@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.
Files changed (3) hide show
  1. package/README.md +4 -1
  2. package/lib/runner.js +94 -11
  3. 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 # 33 tests: mapping, runner, decision folding, real-cordis wiring
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 argv = [forgeBin, ...parts.slice(1)];
79
+ const plan = planSpawn(forgeBin, parts.slice(1));
48
80
  return new Promise((resolve) => {
49
81
  let child;
50
82
  try {
51
- child = spawn(argv[0], argv.slice(1), {
52
- stdio: ["pipe", "pipe", "pipe"],
53
- cwd: opts.cwd,
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
- try {
68
- child.kill();
69
- } catch {
70
- // already exitednothing to kill
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 === "") return settle({ block: false });
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.0",
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",