@codedology/code-forge 0.1.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/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/code-forge.mjs +145 -0
- package/docs/README.md +37 -0
- package/docs/reference/blocks.json +288 -0
- package/docs/reference/config.md +162 -0
- package/examples/first-use/README.md +11 -0
- package/examples/first-use/clamp.acceptance.yml +8 -0
- package/examples/first-use/clamp.brief.md +16 -0
- package/examples/first-use/coder/src/math.mjs +31 -0
- package/examples/first-use/coder/test/clamp.test.mjs +26 -0
- package/examples/node-lib/.env.example +3 -0
- package/examples/node-lib/.gitattributes +3 -0
- package/examples/node-lib/.github/workflows/test.yml +11 -0
- package/examples/node-lib/CHANGELOG.md +13 -0
- package/examples/node-lib/README.md +17 -0
- package/examples/node-lib/gitignore +3 -0
- package/examples/node-lib/package.json +19 -0
- package/examples/node-lib/src/list.mjs +23 -0
- package/examples/node-lib/src/math.mjs +21 -0
- package/examples/node-lib/src/strings.mjs +34 -0
- package/examples/node-lib/test/list.test.mjs +12 -0
- package/examples/node-lib/test/math.test.mjs +13 -0
- package/examples/node-lib/test/package.test.mjs +18 -0
- package/examples/node-lib/test/strings.test.mjs +28 -0
- package/package.json +52 -0
- package/schema/code-forge.schema.json +331 -0
- package/schema/facts.schema.json +28 -0
- package/scripts/first-use.mjs +517 -0
- package/scripts/gen-config-doc.mjs +241 -0
- package/skill/SKILL.md +79 -0
- package/skill/references/adapters/claude-code.md +19 -0
- package/skill/references/adapters/solo.md +34 -0
- package/skill/references/adapters/subprocess.md +15 -0
- package/skill/references/code.md +64 -0
- package/skill/references/continuity.md +31 -0
- package/skill/references/decisions.md +50 -0
- package/skill/references/degraded.md +40 -0
- package/skill/references/harden.md +26 -0
- package/skill/references/ledger.md +23 -0
- package/skill/references/plan.md +43 -0
- package/skill/references/proof.md +34 -0
- package/skill/references/review.md +42 -0
- package/skill/references/rules-core.md +72 -0
- package/skill/references/security.md +29 -0
- package/skill/scripts/forge +40 -0
- package/skill/templates/block-record.md +19 -0
- package/skill/templates/facts-sheet.md +22 -0
- package/skill/templates/plan.md +53 -0
- package/src/cli/author.mjs +87 -0
- package/src/cli/block.mjs +262 -0
- package/src/cli/doctor.mjs +74 -0
- package/src/cli/facts.mjs +124 -0
- package/src/cli/gates.mjs +280 -0
- package/src/cli/help.mjs +48 -0
- package/src/cli/init.mjs +14 -0
- package/src/cli/jev.mjs +175 -0
- package/src/cli/keys.mjs +170 -0
- package/src/cli/ledger.mjs +160 -0
- package/src/cli/list.mjs +68 -0
- package/src/cli/models.mjs +131 -0
- package/src/cli/plan.mjs +70 -0
- package/src/cli/proof.mjs +316 -0
- package/src/cli/remove.mjs +76 -0
- package/src/cli/report.mjs +73 -0
- package/src/cli/resolve.mjs +79 -0
- package/src/cli/review-file.mjs +152 -0
- package/src/cli/run.mjs +159 -0
- package/src/cli/s2.mjs +93 -0
- package/src/cli/spawn.mjs +104 -0
- package/src/cli/upgrade.mjs +103 -0
- package/src/cli/validate.mjs +108 -0
- package/src/cli/version.mjs +18 -0
- package/src/cli/worker.mjs +76 -0
- package/src/config/defaults/anthropic.mjs +18 -0
- package/src/config/defaults/index.mjs +24 -0
- package/src/config/defaults/openai.mjs +16 -0
- package/src/config/defaults/xai.mjs +18 -0
- package/src/config/known-ids.mjs +171 -0
- package/src/config/load.mjs +135 -0
- package/src/config/migrate.mjs +46 -0
- package/src/config/refresh.mjs +276 -0
- package/src/config/schema-compile.mjs +211 -0
- package/src/config/schema-paths.mjs +84 -0
- package/src/config/secret-patterns.mjs +85 -0
- package/src/config/validate.mjs +694 -0
- package/src/decide/author-loop.mjs +90 -0
- package/src/decide/escalation.mjs +262 -0
- package/src/decide/fallback-rules.mjs +172 -0
- package/src/decide/jev-client.mjs +216 -0
- package/src/decide/questions.mjs +161 -0
- package/src/decide/thresholds.mjs +199 -0
- package/src/doctor/index.mjs +105 -0
- package/src/doctor/local.mjs +162 -0
- package/src/doctor/probes.mjs +358 -0
- package/src/doctor/rows.mjs +51 -0
- package/src/doctor/worker-probe.mjs +125 -0
- package/src/engines/alias-map.mjs +78 -0
- package/src/engines/builders/claude.mjs +224 -0
- package/src/engines/builders/codex.mjs +242 -0
- package/src/engines/builders/grok.mjs +132 -0
- package/src/engines/builders/index.mjs +35 -0
- package/src/engines/builders/validate-params.mjs +34 -0
- package/src/engines/codex-home.mjs +230 -0
- package/src/engines/isolation.mjs +84 -0
- package/src/engines/probe.mjs +116 -0
- package/src/engines/provider-cli.mjs +24 -0
- package/src/engines/render-rules.mjs +41 -0
- package/src/engines/sentinel.mjs +133 -0
- package/src/engines/usage-parse.mjs +149 -0
- package/src/gates/acceptance.mjs +95 -0
- package/src/gates/detect.mjs +187 -0
- package/src/gates/invariants.mjs +43 -0
- package/src/gates/run.mjs +72 -0
- package/src/gates/safe-edit.mjs +266 -0
- package/src/gates/scope.mjs +154 -0
- package/src/gates/secret-scan.mjs +79 -0
- package/src/gates/transcript-grep.mjs +27 -0
- package/src/install/agent-env.mjs +76 -0
- package/src/install/detect.mjs +121 -0
- package/src/install/harnesses.mjs +131 -0
- package/src/install/link.mjs +463 -0
- package/src/install/wizard/answers.mjs +389 -0
- package/src/install/wizard/flags.mjs +248 -0
- package/src/install/wizard/project-file.mjs +128 -0
- package/src/install/wizard/run.mjs +366 -0
- package/src/install/wizard/steps.mjs +201 -0
- package/src/keys/backends/constants.mjs +7 -0
- package/src/keys/backends/file.mjs +99 -0
- package/src/keys/backends/keychain.mjs +81 -0
- package/src/keys/backends/security-cli.mjs +105 -0
- package/src/keys/onepassword.mjs +46 -0
- package/src/keys/store.mjs +347 -0
- package/src/ledger/calibration.mjs +109 -0
- package/src/ledger/export.mjs +52 -0
- package/src/ledger/outcome.mjs +397 -0
- package/src/ledger/paths.mjs +30 -0
- package/src/ledger/prices.mjs +49 -0
- package/src/ledger/report.mjs +250 -0
- package/src/ledger/write.mjs +279 -0
- package/src/proof/export.mjs +358 -0
- package/src/proof/lock.mjs +138 -0
- package/src/proof/red-green.mjs +519 -0
- package/src/proof/red-parse.mjs +103 -0
- package/src/proof/tiers.mjs +62 -0
- package/src/review/budget.mjs +241 -0
- package/src/review/context.mjs +156 -0
- package/src/review/engine.mjs +264 -0
- package/src/review/finding.schema.json +47 -0
- package/src/review/fixloop.mjs +467 -0
- package/src/review/gate-check.mjs +249 -0
- package/src/review/lenses/A.md +22 -0
- package/src/review/lenses/B.md +23 -0
- package/src/review/lenses/full.md +23 -0
- package/src/review/lenses/judge.md +21 -0
- package/src/review/lenses/quick.md +21 -0
- package/src/review/lenses/recheck.md +23 -0
- package/src/review/packet.mjs +355 -0
- package/src/review/triage.mjs +149 -0
- package/src/review/validate-review.mjs +70 -0
- package/src/session/author.mjs +228 -0
- package/src/session/facts.mjs +855 -0
- package/src/session/plan-check.mjs +294 -0
- package/src/session/s2.mjs +164 -0
- package/src/session/spawn.mjs +610 -0
- package/src/state/block.mjs +289 -0
- package/src/state/cli-args.mjs +67 -0
- package/src/state/paths.mjs +92 -0
- package/src/state/registry.mjs +189 -0
- package/src/state/run.mjs +266 -0
- package/src/state/signer.mjs +132 -0
- package/src/util/exec.mjs +315 -0
- package/src/util/forbidden.mjs +787 -0
- package/src/util/git.mjs +381 -0
- package/src/util/log.mjs +100 -0
- package/src/util/reaper.mjs +300 -0
- package/src/util/redact.mjs +212 -0
- package/src/util/tmp.mjs +409 -0
- package/src/worker/engine.mjs +338 -0
- package/src/worker/loop.mjs +392 -0
- package/src/worker/queue.mjs +186 -0
- package/src/worker/review-state.mjs +145 -0
- package/src/worker/ticket.mjs +195 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place in the package that spawns a child process.
|
|
3
|
+
*
|
|
4
|
+
* Rules (see plan §8.2, §8.4):
|
|
5
|
+
* - argv only, never a shell string — `exec("rm -rf /")` is refused before anything spawns.
|
|
6
|
+
* - `shell: true` is refused outright — a shell reintroduces string parsing and injection.
|
|
7
|
+
* - `timeoutMs`, when given, must be a finite positive number; when omitted, the child can run
|
|
8
|
+
* indefinitely (callers that spawn a reviewer/coder CLI are expected to always pass one —
|
|
9
|
+
* this module does not impose a default, since a default here would silently apply to every
|
|
10
|
+
* caller, including ones with a legitimately long-running command).
|
|
11
|
+
* - On timeout the whole process GROUP is sent SIGTERM, then SIGKILL — the SIGKILL goes to the
|
|
12
|
+
* group as soon as the direct child exits (or after a grace period), so a grandchild that
|
|
13
|
+
* traps SIGTERM cannot outlive the call. The result is `failed`, never a partial success.
|
|
14
|
+
* - stdin is `ignore`d by default, so a child that reads stdin when it isn't a TTY (`claude -p`,
|
|
15
|
+
* for one) sees an immediate EOF instead of blocking forever. With `input` (a string or bytes),
|
|
16
|
+
* stdin is a pipe: the content is written verbatim and then the pipe is closed, so the child
|
|
17
|
+
* reads exactly those bytes followed by EOF (plan V3 — closed-book roles get the packet on
|
|
18
|
+
* stdin, never in argv).
|
|
19
|
+
* - Every child is recorded in the pid registry of this process's run root
|
|
20
|
+
* (`<run-root>/pids/<pid>.json`, `./tmp.mjs` + `./reaper.mjs`) and the entry is removed when
|
|
21
|
+
* the call settles. An entry that outlives its run (the parent was killed) is what the reaper
|
|
22
|
+
* uses to find and kill the orphan later. Registry I/O failing never fails the spawn: the
|
|
23
|
+
* registry is hygiene, the child is the caller's work.
|
|
24
|
+
* - This module does not know about the forbidden-command list (`./forbidden.mjs`); callers
|
|
25
|
+
* that spawn on a coder's behalf are expected to check argv against it first (`isForbidden`).
|
|
26
|
+
* `exec` only enforces the structural rules above (argv shape, no shell, valid timeout).
|
|
27
|
+
*
|
|
28
|
+
* ## Process groups and the parent's own death
|
|
29
|
+
*
|
|
30
|
+
* Every child is spawned `detached: true`, i.e. as the leader of its own process group, so a kill
|
|
31
|
+
* reaches its grandchildren (npx wrappers, git hooks, the agent CLI's own subprocesses). The price
|
|
32
|
+
* is that the child is no longer in the terminal's foreground group: Ctrl-C (SIGINT), a SIGTERM
|
|
33
|
+
* or a SIGHUP sent to code-forge would NOT reach it, and it would run on as an orphan. So this
|
|
34
|
+
* module keeps a registry of live child groups and, while any is live:
|
|
35
|
+
* - forwards SIGINT/SIGTERM/SIGHUP received by the parent to every live group; if no other
|
|
36
|
+
* listener handles that signal, it then removes its own handlers and re-raises the signal on
|
|
37
|
+
* the parent, so the parent still dies of it exactly as it would have by default;
|
|
38
|
+
* - on the parent's `exit`, sends SIGTERM to every group still live.
|
|
39
|
+
* The handlers are installed with the first live child and removed with the last, so a process
|
|
40
|
+
* that has no child running keeps Node's default signal behaviour.
|
|
41
|
+
*
|
|
42
|
+
* @typedef {object} ExecResult
|
|
43
|
+
* @property {"ok"|"failed"} result
|
|
44
|
+
* @property {number|null} code
|
|
45
|
+
* @property {NodeJS.Signals|null} signal
|
|
46
|
+
* @property {string} stdout
|
|
47
|
+
* @property {string} stderr
|
|
48
|
+
* @property {boolean} timedOut
|
|
49
|
+
* @property {string} [error]
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { spawn } from 'node:child_process';
|
|
53
|
+
import { registerPid, unregisterPid } from './reaper.mjs';
|
|
54
|
+
import { pidsDir } from './tmp.mjs';
|
|
55
|
+
|
|
56
|
+
/** Grace period after SIGTERM before escalating to SIGKILL, in milliseconds. */
|
|
57
|
+
const KILL_GRACE_MS = 2000;
|
|
58
|
+
|
|
59
|
+
/** Default cap per stream (stdout, stderr independently) before a runaway child is killed. */
|
|
60
|
+
const DEFAULT_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
|
|
61
|
+
|
|
62
|
+
/** Signals the parent forwards to every live child group. */
|
|
63
|
+
const FORWARDED_SIGNALS = /** @type {const} */ (['SIGINT', 'SIGTERM', 'SIGHUP']);
|
|
64
|
+
|
|
65
|
+
/** Pids of live children — each is also the id of the child's process group. */
|
|
66
|
+
const liveGroups = new Set();
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Send `signal` to a whole process group; ignore a group that is already gone (ESRCH) or a
|
|
70
|
+
* platform without process groups.
|
|
71
|
+
* @param {number} pid
|
|
72
|
+
* @param {NodeJS.Signals} signal
|
|
73
|
+
* @returns {boolean} whether the group signal was delivered.
|
|
74
|
+
*/
|
|
75
|
+
function signalGroup(pid, signal) {
|
|
76
|
+
try {
|
|
77
|
+
process.kill(-pid, signal);
|
|
78
|
+
return true;
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** @type {Map<NodeJS.Signals, () => void>} */
|
|
85
|
+
const signalHandlers = new Map();
|
|
86
|
+
|
|
87
|
+
const onParentExit = () => {
|
|
88
|
+
for (const pid of liveGroups) {
|
|
89
|
+
signalGroup(pid, 'SIGTERM');
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function installParentHandlers() {
|
|
94
|
+
if (signalHandlers.size > 0) return;
|
|
95
|
+
for (const signal of FORWARDED_SIGNALS) {
|
|
96
|
+
const handler = () => {
|
|
97
|
+
for (const pid of liveGroups) {
|
|
98
|
+
signalGroup(pid, signal);
|
|
99
|
+
}
|
|
100
|
+
// Ours is the only listener: Node's default action (terminate) was suppressed by our
|
|
101
|
+
// listener's mere presence, so restore it and re-raise — the parent dies of the signal.
|
|
102
|
+
if (process.listenerCount(signal) === 1) {
|
|
103
|
+
uninstallParentHandlers();
|
|
104
|
+
process.kill(process.pid, signal);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
signalHandlers.set(signal, handler);
|
|
108
|
+
process.on(signal, handler);
|
|
109
|
+
}
|
|
110
|
+
process.on('exit', onParentExit);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function uninstallParentHandlers() {
|
|
114
|
+
for (const [signal, handler] of signalHandlers) {
|
|
115
|
+
process.removeListener(signal, handler);
|
|
116
|
+
}
|
|
117
|
+
signalHandlers.clear();
|
|
118
|
+
process.removeListener('exit', onParentExit);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** @param {number} pid */
|
|
122
|
+
function trackChild(pid) {
|
|
123
|
+
liveGroups.add(pid);
|
|
124
|
+
installParentHandlers();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** @param {number} pid */
|
|
128
|
+
function untrackChild(pid) {
|
|
129
|
+
liveGroups.delete(pid);
|
|
130
|
+
if (liveGroups.size === 0) {
|
|
131
|
+
uninstallParentHandlers();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Spawn `argv[0]` with `argv.slice(1)` as arguments. Never invokes a shell.
|
|
137
|
+
*
|
|
138
|
+
* @param {string[]} argv - The full command line, e.g. `["git", "status"]`.
|
|
139
|
+
* @param {object} [opts]
|
|
140
|
+
* @param {string} [opts.cwd]
|
|
141
|
+
* @param {NodeJS.ProcessEnv} [opts.env]
|
|
142
|
+
* @param {number} [opts.timeoutMs] - Kill the child (and its process group) if it runs longer
|
|
143
|
+
* than this. Must be a finite number > 0 when given.
|
|
144
|
+
* @param {number[]} [opts.okExitCodes] - Exit codes treated as `result: "ok"` (default `[0]`).
|
|
145
|
+
* Some read-only git subcommands (e.g. `diff --no-index`, `merge-base --is-ancestor`) use a
|
|
146
|
+
* non-zero exit code to report a normal "no" answer, not a failure.
|
|
147
|
+
* @param {number} [opts.maxBufferBytes] - Per-stream cap (default 10 MiB). Exceeding it on
|
|
148
|
+
* either stream kills the child and resolves `result: "failed"`; the stream's collected text is
|
|
149
|
+
* cut at exactly `maxBufferBytes` bytes.
|
|
150
|
+
* @param {string | Uint8Array} [opts.input] - Content written to the child's stdin, followed by
|
|
151
|
+
* EOF. Omitted ⇒ stdin is `ignore`.
|
|
152
|
+
* @param {boolean} [opts.shell] - Forbidden. Only exists so passing it throws immediately.
|
|
153
|
+
* @returns {Promise<ExecResult>}
|
|
154
|
+
*/
|
|
155
|
+
export function exec(argv, opts = {}) {
|
|
156
|
+
if (!Array.isArray(argv) || argv.length === 0 || !argv.every((a) => typeof a === 'string')) {
|
|
157
|
+
throw new TypeError('exec: argv must be a non-empty array of strings, not a shell string');
|
|
158
|
+
}
|
|
159
|
+
if (opts.shell) {
|
|
160
|
+
throw new TypeError('exec: the "shell" option is forbidden — pass argv as an array instead');
|
|
161
|
+
}
|
|
162
|
+
if (opts.timeoutMs !== undefined && !(Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0)) {
|
|
163
|
+
throw new TypeError('exec: timeoutMs must be a finite number greater than 0 when given');
|
|
164
|
+
}
|
|
165
|
+
if (opts.input !== undefined && typeof opts.input !== 'string' && !(opts.input instanceof Uint8Array)) {
|
|
166
|
+
throw new TypeError('exec: input must be a string or a Uint8Array/Buffer when given');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const { cwd, env, timeoutMs, input, okExitCodes = [0], maxBufferBytes = DEFAULT_MAX_BUFFER_BYTES } = opts;
|
|
170
|
+
const [command, ...args] = argv;
|
|
171
|
+
|
|
172
|
+
return new Promise((resolve) => {
|
|
173
|
+
const stdinMode = input === undefined ? 'ignore' : 'pipe';
|
|
174
|
+
const child = spawn(command, args, { cwd, env, shell: false, detached: true, stdio: [stdinMode, 'pipe', 'pipe'] });
|
|
175
|
+
const pid = child.pid;
|
|
176
|
+
/** @type {string | null} */
|
|
177
|
+
let registryDir = null;
|
|
178
|
+
if (typeof pid === 'number') {
|
|
179
|
+
trackChild(pid);
|
|
180
|
+
try {
|
|
181
|
+
registryDir = pidsDir();
|
|
182
|
+
registerPid(registryDir, pid, command);
|
|
183
|
+
} catch {
|
|
184
|
+
registryDir = null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (input !== undefined && child.stdin) {
|
|
188
|
+
// A child that exits without reading its stdin makes the write fail with EPIPE; that is
|
|
189
|
+
// the child's business (its exit code says what happened), not an unhandled error here.
|
|
190
|
+
child.stdin.on('error', () => {});
|
|
191
|
+
child.stdin.end(input);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** @type {Buffer[]} */
|
|
195
|
+
const stdoutChunks = [];
|
|
196
|
+
/** @type {Buffer[]} */
|
|
197
|
+
const stderrChunks = [];
|
|
198
|
+
const byteCounts = { stdout: 0, stderr: 0 };
|
|
199
|
+
let timedOut = false;
|
|
200
|
+
let overBuffer = false;
|
|
201
|
+
let settled = false;
|
|
202
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
203
|
+
let termTimer;
|
|
204
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
205
|
+
let killTimer;
|
|
206
|
+
|
|
207
|
+
/** Kill the child's whole process group; falls back to the direct child (e.g. on Windows). */
|
|
208
|
+
const killGroup = (/** @type {NodeJS.Signals} */ signal) => {
|
|
209
|
+
if (typeof pid === 'number' && signalGroup(pid, signal)) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
child.kill(signal);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const stdoutText = () => Buffer.concat(stdoutChunks).toString('utf8');
|
|
216
|
+
const stderrText = () => Buffer.concat(stderrChunks).toString('utf8');
|
|
217
|
+
|
|
218
|
+
/** @param {ExecResult} result */
|
|
219
|
+
const settle = (result) => {
|
|
220
|
+
if (settled) return;
|
|
221
|
+
settled = true;
|
|
222
|
+
if (termTimer) clearTimeout(termTimer);
|
|
223
|
+
if (killTimer) clearTimeout(killTimer);
|
|
224
|
+
if (typeof pid === 'number') {
|
|
225
|
+
untrackChild(pid);
|
|
226
|
+
if (registryDir !== null) {
|
|
227
|
+
try {
|
|
228
|
+
unregisterPid(registryDir, pid);
|
|
229
|
+
} catch {
|
|
230
|
+
// hygiene only — see the module comment
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
child.stdin?.destroy();
|
|
235
|
+
child.stdout?.destroy();
|
|
236
|
+
child.stderr?.destroy();
|
|
237
|
+
resolve(result);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
if (timeoutMs) {
|
|
241
|
+
termTimer = setTimeout(() => {
|
|
242
|
+
timedOut = true;
|
|
243
|
+
killGroup('SIGTERM');
|
|
244
|
+
killTimer = setTimeout(() => {
|
|
245
|
+
killGroup('SIGKILL');
|
|
246
|
+
}, KILL_GRACE_MS);
|
|
247
|
+
}, timeoutMs);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* @param {'stdout'|'stderr'} stream
|
|
252
|
+
* @param {Buffer[]} chunks
|
|
253
|
+
* @param {Buffer} chunk
|
|
254
|
+
*/
|
|
255
|
+
const collect = (stream, chunks, chunk) => {
|
|
256
|
+
if (overBuffer) return;
|
|
257
|
+
const room = maxBufferBytes - byteCounts[stream];
|
|
258
|
+
if (chunk.length > room) {
|
|
259
|
+
// A pipe read can merge many child writes into one event, so cut to the exact cap
|
|
260
|
+
// rather than appending the whole event and overshooting it.
|
|
261
|
+
chunks.push(chunk.subarray(0, room));
|
|
262
|
+
byteCounts[stream] = maxBufferBytes;
|
|
263
|
+
overBuffer = true;
|
|
264
|
+
killGroup('SIGKILL');
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
chunks.push(chunk);
|
|
268
|
+
byteCounts[stream] += chunk.length;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// Not optional chaining: stdout/stderr are always 'pipe' above, so child.stdout
|
|
272
|
+
// and child.stderr are always real Readable streams, never null — Node only sets a stdio
|
|
273
|
+
// stream to null for 'ignore'/'inherit' on that fd, neither of which is used for fds 1/2.
|
|
274
|
+
child.stdout.on('data', (/** @type {Buffer} */ chunk) => collect('stdout', stdoutChunks, chunk));
|
|
275
|
+
child.stderr.on('data', (/** @type {Buffer} */ chunk) => collect('stderr', stderrChunks, chunk));
|
|
276
|
+
|
|
277
|
+
child.on('error', (err) => {
|
|
278
|
+
settle({
|
|
279
|
+
result: 'failed',
|
|
280
|
+
code: null,
|
|
281
|
+
signal: null,
|
|
282
|
+
stdout: stdoutText(),
|
|
283
|
+
stderr: stderrText(),
|
|
284
|
+
timedOut,
|
|
285
|
+
error: err.message,
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Once we've decided to kill (timeout or over-buffer), don't wait for 'close' — a
|
|
290
|
+
// grandchild that inherited the pipes can keep them open indefinitely, and 'close' would
|
|
291
|
+
// then never fire even though the child we care about is dead. 'exit' fires as soon as the
|
|
292
|
+
// child process itself has terminated, which is what "killed after timeout_ms" means. Before
|
|
293
|
+
// settling (which cancels the grace timer), SIGKILL the group: a grandchild that trapped the
|
|
294
|
+
// SIGTERM must not survive just because its parent died first.
|
|
295
|
+
child.on('exit', (code, signal) => {
|
|
296
|
+
if (timedOut || overBuffer) {
|
|
297
|
+
killGroup('SIGKILL');
|
|
298
|
+
settle({
|
|
299
|
+
result: 'failed',
|
|
300
|
+
code,
|
|
301
|
+
signal,
|
|
302
|
+
stdout: stdoutText(),
|
|
303
|
+
stderr: stderrText(),
|
|
304
|
+
timedOut,
|
|
305
|
+
...(overBuffer ? { error: 'exec: maxBufferBytes exceeded' } : {}),
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
child.on('close', (code, signal) => {
|
|
311
|
+
const result = !timedOut && !overBuffer && code !== null && okExitCodes.includes(code) ? 'ok' : 'failed';
|
|
312
|
+
settle({ result, code, signal, stdout: stdoutText(), stderr: stderrText(), timedOut });
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
}
|