@wrongstack/tools 0.313.0 → 0.316.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/dist/_edit-match.d.ts +3 -2
- package/dist/_output-spool.d.ts +4 -3
- package/dist/_shell-pick.d.ts +2 -1
- package/dist/_spawn-stream.d.ts +2 -1
- package/dist/_syntax-check.d.ts +2 -1
- package/dist/audit.js +10 -4055
- package/dist/bash-kill-guard.d.ts +3 -6
- package/dist/bash.js +9 -2308
- package/dist/batch-tool-use.js +2 -187
- package/dist/browser/index.js +25 -1339
- package/dist/browser/network-guard-proxy.d.ts +2 -1
- package/dist/builtin.js +69 -33949
- package/dist/chunk-22B7GE5T.js +260 -0
- package/dist/chunk-2VSZC6QM.js +113 -0
- package/dist/chunk-3PVYQFRQ.js +472 -0
- package/dist/chunk-42BHI7TN.js +337 -0
- package/dist/chunk-4BHUSMNL.js +104 -0
- package/dist/chunk-4I4LWFOO.js +431 -0
- package/dist/chunk-4UESMQDP.js +122 -0
- package/dist/chunk-553IQUMO.js +478 -0
- package/dist/chunk-5H4GSJAI.js +223 -0
- package/dist/chunk-6RNIFLG4.js +305 -0
- package/dist/chunk-7EY5DCRO.js +434 -0
- package/dist/chunk-A6BRWIXN.js +317 -0
- package/dist/chunk-AHHDWNXC.js +200 -0
- package/dist/chunk-BSLVR56S.js +199 -0
- package/dist/chunk-BTSFTGIL.js +7 -0
- package/dist/chunk-D33S4RQG.js +222 -0
- package/dist/chunk-DA4KNE2H.js +4412 -0
- package/dist/chunk-DR7JOQTC.js +520 -0
- package/dist/chunk-EEOFQDT6.js +898 -0
- package/dist/chunk-EJWSCQ6D.js +137 -0
- package/dist/chunk-EMRGN4KM.js +170 -0
- package/dist/chunk-FQZOHLG3.js +89 -0
- package/dist/chunk-GOIZNNOA.js +221 -0
- package/dist/chunk-HFXSNKCV.js +196 -0
- package/dist/chunk-HYX2TZ7O.js +1272 -0
- package/dist/chunk-I64BS3VC.js +15 -0
- package/dist/chunk-IVWV2UM6.js +955 -0
- package/dist/chunk-IXMNTNW5.js +238 -0
- package/dist/chunk-IYK7XGDZ.js +892 -0
- package/dist/chunk-J5SKD6LN.js +235 -0
- package/dist/chunk-JNNVRHMV.js +2111 -0
- package/dist/chunk-JOHXDF3D.js +295 -0
- package/dist/chunk-JRJJX3L5.js +424 -0
- package/dist/chunk-K3WNSHUF.js +1371 -0
- package/dist/chunk-L25CVUVR.js +2830 -0
- package/dist/chunk-LIJQMYYX.js +59 -0
- package/dist/chunk-LWKUTMD5.js +197 -0
- package/dist/chunk-MQX2KXVW.js +54 -0
- package/dist/chunk-MZZN7VCD.js +359 -0
- package/dist/chunk-NBDZMMG2.js +169 -0
- package/dist/chunk-NKJW5GFO.js +366 -0
- package/dist/chunk-NKNOBUBU.js +522 -0
- package/dist/chunk-O4MBV4AX.js +134 -0
- package/dist/chunk-OUBDL3WH.js +3515 -0
- package/dist/chunk-PQKK5JH7.js +101 -0
- package/dist/chunk-PY2IOS7C.js +1000 -0
- package/dist/chunk-Q5JPPCZC.js +12 -0
- package/dist/chunk-QBV725YG.js +128 -0
- package/dist/chunk-QZQSPUBF.js +315 -0
- package/dist/chunk-RGISBZBD.js +231 -0
- package/dist/chunk-RJZQWEMS.js +84 -0
- package/dist/chunk-RXISBFR5.js +315 -0
- package/dist/chunk-S5CCLZL3.js +99 -0
- package/dist/chunk-SDLVH5RZ.js +178 -0
- package/dist/chunk-T2F4S7U4.js +1723 -0
- package/dist/chunk-TEBQ4VPZ.js +163 -0
- package/dist/chunk-U4D5S3VQ.js +21 -0
- package/dist/chunk-U4VAATKP.js +193 -0
- package/dist/chunk-UCYQTZ7F.js +113 -0
- package/dist/chunk-UKU2ZX4C.js +203 -0
- package/dist/chunk-UNSKG6DC.js +159 -0
- package/dist/chunk-V43EQ6VR.js +68 -0
- package/dist/chunk-VB3MJGCV.js +610 -0
- package/dist/chunk-VFKPWSOX.js +154 -0
- package/dist/chunk-VJNHFZ7J.js +190 -0
- package/dist/chunk-VXECAJJ7.js +1091 -0
- package/dist/chunk-WHIIIREL.js +223 -0
- package/dist/chunk-Z6DAKQ3U.js +1 -0
- package/dist/chunk-ZJQ4IGXK.js +474 -0
- package/dist/chunk-ZLTFLC25.js +385 -0
- package/dist/circuit-breaker.js +3 -191
- package/dist/codebase-index/ast-invariant-engine.d.ts +5 -4
- package/dist/codebase-index/binary-frame.d.ts +0 -2
- package/dist/codebase-index/bm25.d.ts +1 -1
- package/dist/codebase-index/circuit-breaker.d.ts +2 -1
- package/dist/codebase-index/codebase-ast-replace-tool.d.ts +3 -2
- package/dist/codebase-index/codebase-impact-analysis-tool.d.ts +4 -3
- package/dist/codebase-index/codebase-invariant-check-tool.d.ts +3 -2
- package/dist/codebase-index/codebase-repo-map-tool.d.ts +3 -2
- package/dist/codebase-index/codebase-skeleton-tool.d.ts +3 -2
- package/dist/codebase-index/codebase-targeted-test-tool.d.ts +3 -2
- package/dist/codebase-index/content-hash.d.ts +0 -2
- package/dist/codebase-index/import-extractor.d.ts +0 -13
- package/dist/codebase-index/index-service.d.ts +1 -1
- package/dist/codebase-index/index.js +84 -13688
- package/dist/codebase-index/languages.d.ts +2 -1
- package/dist/codebase-index/module-roots.d.ts +3 -2
- package/dist/codebase-index/parser-batch.d.ts +0 -5
- package/dist/codebase-index/parser-dispatch.d.ts +2 -1
- package/dist/codebase-index/parser-output.d.ts +4 -3
- package/dist/codebase-index/parser-worker-pool.d.ts +0 -2
- package/dist/codebase-index/parser-worker-script.d.ts +1 -16
- package/dist/codebase-index/parser-worker-script.js +8 -3995
- package/dist/codebase-index/project-server-client-state.d.ts +2 -2
- package/dist/codebase-index/project-server-endpoint.d.ts +0 -15
- package/dist/codebase-index/project-server.js +60 -8548
- package/dist/codebase-index/tree-sitter/visitor.d.ts +2 -1
- package/dist/codebase-index/tree-sitter-parser.d.ts +0 -2
- package/dist/codebase-index/ts-parser.d.ts +1 -1
- package/dist/codebase-index/vector-search.d.ts +2 -1
- package/dist/codebase-index/wal-maintenance.d.ts +3 -2
- package/dist/codebase-index/worker.js +20 -8416
- package/dist/codebase-index/writer-admin.d.ts +0 -1
- package/dist/codebase-index/writer-search.d.ts +0 -9
- package/dist/design.js +2 -299
- package/dist/diff.js +4 -256
- package/dist/document.js +4 -222
- package/dist/e2e.js +5 -476
- package/dist/edit.js +14 -10200
- package/dist/exec.js +16 -3131
- package/dist/fetch.js +8 -398
- package/dist/format.js +10 -3750
- package/dist/generic-parser-VUXDIAOZ.js +13 -0
- package/dist/git.js +4 -380
- package/dist/glob.js +6 -280
- package/dist/go-parser-JXYKJHRC.js +469 -0
- package/dist/grep.js +7 -604
- package/dist/index.js +318 -35387
- package/dist/install.js +10 -4091
- package/dist/json-parser-IBHZ4BZC.js +213 -0
- package/dist/json.js +4 -665
- package/dist/kanban-contract-actions.d.ts +1 -1
- package/dist/kanban-detail-actions.d.ts +2 -1
- package/dist/kanban-presence.d.ts +2 -1
- package/dist/kanban-serializer.d.ts +0 -8
- package/dist/kanban-split-task-handler.d.ts +2 -2
- package/dist/kanban-tool-types.d.ts +2 -1
- package/dist/kanban.js +3 -2096
- package/dist/languages/diagnostics.d.ts +2 -1
- package/dist/languages/index.js +23 -4664
- package/dist/languages/legacy-bridge.d.ts +3 -2
- package/dist/languages/types.d.ts +2 -1
- package/dist/legacy-bridge-7M6QLUQN.js +18 -0
- package/dist/lint.js +10 -3706
- package/dist/logs.js +4 -287
- package/dist/memory.js +6 -215
- package/dist/mode.js +3 -99
- package/dist/outdated.js +4 -5170
- package/dist/pack.js +65 -33897
- package/dist/parser-dispatch-QH4XPGG6.js +14 -0
- package/dist/patch.js +13 -9935
- package/dist/plan.js +5 -3286
- package/dist/process-registry.js +8 -651
- package/dist/ps-slash.js +12 -1439
- package/dist/pwsh.d.ts +0 -1
- package/dist/py-parser-IS4HKYMF.js +16 -0
- package/dist/read.js +13 -9816
- package/dist/replace.js +15 -10014
- package/dist/rs-parser-XHUUO2KZ.js +81 -0
- package/dist/scaffold.js +4 -253
- package/dist/search.js +5 -585
- package/dist/session-kanban-sync.d.ts +0 -6
- package/dist/session-kanban.js +30 -972
- package/dist/skill.js +2 -215
- package/dist/spawn-background.d.ts +1 -1
- package/dist/task.js +5 -3410
- package/dist/test.js +10 -3773
- package/dist/todo.js +4 -2891
- package/dist/tool-help.js +3 -123
- package/dist/tool-search.js +3 -79
- package/dist/tool-tier.js +68 -33984
- package/dist/tool-use.js +2 -93
- package/dist/tree-sitter-parser-AIERW3YI.js +15 -0
- package/dist/tree.js +4 -288
- package/dist/ts-parser-RCTYKSVI.js +232 -0
- package/dist/typecheck.js +10 -3753
- package/dist/win32.js +6 -60
- package/dist/write.js +14 -9882
- package/dist/yaml-parser-4SAXV3PL.js +157 -0
- package/package.json +5 -5
package/dist/bash.js
CHANGED
|
@@ -1,2312 +1,13 @@
|
|
|
1
|
-
// src/bash.ts
|
|
2
|
-
import { spawn as spawn2 } from "node:child_process";
|
|
3
|
-
import * as os4 from "node:os";
|
|
4
|
-
import { StringDecoder } from "node:string_decoder";
|
|
5
1
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
import
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
import { createWriteStream, mkdirSync } from "node:fs";
|
|
16
|
-
import * as fsp from "node:fs/promises";
|
|
17
|
-
import * as path from "node:path";
|
|
18
|
-
import { wstackGlobalRoot } from "@wrongstack/core/utils";
|
|
19
|
-
var SPOOL_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
20
|
-
var SPOOL_WRITE_HWM_BYTES = 4 * 1024 * 1024;
|
|
21
|
-
var sweepStarted = false;
|
|
22
|
-
function toolOutputDir() {
|
|
23
|
-
return path.join(wstackGlobalRoot(), "tool-output");
|
|
24
|
-
}
|
|
25
|
-
function sweepOldSpoolFiles(dir) {
|
|
26
|
-
if (sweepStarted) return;
|
|
27
|
-
sweepStarted = true;
|
|
28
|
-
void (async () => {
|
|
29
|
-
try {
|
|
30
|
-
const now = Date.now();
|
|
31
|
-
for (const name of await fsp.readdir(dir)) {
|
|
32
|
-
if (!name.endsWith(".log")) continue;
|
|
33
|
-
const p = path.join(dir, name);
|
|
34
|
-
try {
|
|
35
|
-
const st = await fsp.stat(p);
|
|
36
|
-
if (now - st.mtimeMs > SPOOL_RETENTION_MS) await fsp.unlink(p);
|
|
37
|
-
} catch {
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
} catch {
|
|
41
|
-
}
|
|
42
|
-
})();
|
|
43
|
-
}
|
|
44
|
-
function spoolNote(info) {
|
|
45
|
-
const dropped = info.droppedBytes > 0 ? `, ~${info.droppedBytes} bytes dropped under backpressure` : "";
|
|
46
|
-
return `
|
|
47
|
-
[output truncated \u2014 full ${info.bytes} bytes at ${info.path}${dropped}; read/grep that file selectively instead of re-running with more output]`;
|
|
48
|
-
}
|
|
49
|
-
function createOutputSpool(opts) {
|
|
50
|
-
const threshold = opts.thresholdBytes ?? 32768;
|
|
51
|
-
const safeTool = opts.tool.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 40) || "tool";
|
|
52
|
-
let head = "";
|
|
53
|
-
let headBytes = 0;
|
|
54
|
-
let totalBytes = 0;
|
|
55
|
-
let droppedBytes = 0;
|
|
56
|
-
let stream = null;
|
|
57
|
-
let filePath = null;
|
|
58
|
-
let failed = false;
|
|
59
|
-
let finalized = false;
|
|
60
|
-
const open = () => {
|
|
61
|
-
if (stream || failed) return;
|
|
62
|
-
try {
|
|
63
|
-
const dir = toolOutputDir();
|
|
64
|
-
mkdirSync(dir, { recursive: true });
|
|
65
|
-
sweepOldSpoolFiles(dir);
|
|
66
|
-
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
67
|
-
const rand = Math.random().toString(36).slice(2, 6);
|
|
68
|
-
filePath = path.join(dir, `${stamp}-${safeTool}-${rand}.log`);
|
|
69
|
-
stream = createWriteStream(filePath, { flags: "w", encoding: "utf8" });
|
|
70
|
-
stream.on("error", () => {
|
|
71
|
-
failed = true;
|
|
72
|
-
stream = null;
|
|
73
|
-
filePath = null;
|
|
74
|
-
});
|
|
75
|
-
stream.write(head);
|
|
76
|
-
} catch {
|
|
77
|
-
failed = true;
|
|
78
|
-
stream = null;
|
|
79
|
-
filePath = null;
|
|
80
|
-
}
|
|
81
|
-
};
|
|
82
|
-
return {
|
|
83
|
-
write(text) {
|
|
84
|
-
if (finalized || !text) return;
|
|
85
|
-
const textBytes = Buffer.byteLength(text, "utf8");
|
|
86
|
-
totalBytes += textBytes;
|
|
87
|
-
if (!stream && !failed) {
|
|
88
|
-
if (headBytes + textBytes <= threshold) {
|
|
89
|
-
head += text;
|
|
90
|
-
headBytes += textBytes;
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
head += text;
|
|
94
|
-
open();
|
|
95
|
-
head = "";
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
if (stream) {
|
|
99
|
-
if (stream.writableLength > SPOOL_WRITE_HWM_BYTES) {
|
|
100
|
-
droppedBytes += textBytes;
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
stream.write(text);
|
|
104
|
-
}
|
|
105
|
-
},
|
|
106
|
-
finalize() {
|
|
107
|
-
if (finalized) {
|
|
108
|
-
return filePath ? { path: filePath, bytes: totalBytes, droppedBytes } : null;
|
|
109
|
-
}
|
|
110
|
-
finalized = true;
|
|
111
|
-
head = "";
|
|
112
|
-
if (!stream || !filePath) return null;
|
|
113
|
-
try {
|
|
114
|
-
stream.end();
|
|
115
|
-
} catch {
|
|
116
|
-
}
|
|
117
|
-
return { path: filePath, bytes: totalBytes, droppedBytes };
|
|
118
|
-
}
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// src/_shell-pick.ts
|
|
123
|
-
var POSIX_DEFAULT = "cmd";
|
|
124
|
-
function pickShell(platform4, command, env) {
|
|
125
|
-
if (platform4 !== "win32") return POSIX_DEFAULT;
|
|
126
|
-
const override = env.get("WRONGSTACK_SHELL")?.trim().toLowerCase();
|
|
127
|
-
if (override === "cmd" || override === "cmd.exe") return "cmd";
|
|
128
|
-
if (override === "powershell" || override === "powershell.exe") return "powershell";
|
|
129
|
-
if (override === "pwsh" || override === "pwsh.exe") return "pwsh";
|
|
130
|
-
if (looksLikePowerShell(command)) return "pwsh";
|
|
131
|
-
return "cmd";
|
|
132
|
-
}
|
|
133
|
-
function looksLikePowerShell(command) {
|
|
134
|
-
if (!command) return false;
|
|
135
|
-
const trimmed = command.trimStart();
|
|
136
|
-
if (/\.ps1\b/i.test(trimmed)) return true;
|
|
137
|
-
if (/^\s*#requires\s/i.test(trimmed)) return true;
|
|
138
|
-
if (/^\s*param\s*\(/i.test(trimmed)) return true;
|
|
139
|
-
if (/\$[\w:{]/i.test(trimmed)) return true;
|
|
140
|
-
if (/\$\(/.test(trimmed)) return true;
|
|
141
|
-
if (/@\s*['"]/.test(trimmed)) return true;
|
|
142
|
-
if (/&\s+\$/.test(trimmed)) return true;
|
|
143
|
-
if (/(^|\s)@\s*\(/.test(trimmed)) return true;
|
|
144
|
-
if (/(^|\s)@\{/.test(trimmed)) return true;
|
|
145
|
-
if (/(?:^|[\s[({,;])(?:-eq|-ne|-lt|-gt|-le|-ge|-like|-notlike|-match|-notmatch|-contains|-notcontains|-in|-notin|-and|-or|-not|-band|-bor|-bxor|-replace|-isplit|-csplit|-osplit|-join|-is|-as|-f)(?:$|[\s\])},;])/i.test(
|
|
146
|
-
trimmed
|
|
147
|
-
)) {
|
|
148
|
-
return true;
|
|
149
|
-
}
|
|
150
|
-
if (PS_VERB_RE.test(trimmed)) return true;
|
|
151
|
-
if (/(?:^|[\s;&|])(gci|gi|gp|gcm|gps)\b/i.test(trimmed)) {
|
|
152
|
-
return true;
|
|
153
|
-
}
|
|
154
|
-
if (looksLikePowerShellExtended(command)) return true;
|
|
155
|
-
return false;
|
|
156
|
-
}
|
|
157
|
-
function looksLikePowerShellExtended(command) {
|
|
158
|
-
if (!command) return false;
|
|
159
|
-
const trimmed = command.trimStart();
|
|
160
|
-
if (/(?:^|\s)[-/](?:WhatIf|Confirm|ErrorAction)(?::[^\s]+|\s|=|$)/i.test(trimmed)) {
|
|
161
|
-
return true;
|
|
162
|
-
}
|
|
163
|
-
if (/(?:^|[\s;&|])(Where-Object|ForEach-Object|Select-Object|Sort-Object|Group-Object|Measure-Object|Compare-Object|Tee-Object)(?:\s|$)/i.test(
|
|
164
|
-
trimmed
|
|
165
|
-
)) {
|
|
166
|
-
return true;
|
|
167
|
-
}
|
|
168
|
-
if (/\bWrite-(?:Host|Output|Error|Warning|Verbose|Debug|Information)(?:\s|$)/i.test(trimmed)) {
|
|
169
|
-
return true;
|
|
170
|
-
}
|
|
171
|
-
if (/HK(?:LM|CU|CR|U|CC|DD|PD):\\/i.test(trimmed)) return true;
|
|
172
|
-
if (/\[(?:string|int|bool|xml|double|float|decimal|char|byte|long|System\.)/i.test(trimmed)) {
|
|
173
|
-
return true;
|
|
174
|
-
}
|
|
175
|
-
if (/^\s*<#|#>\s*$/m.test(trimmed)) return true;
|
|
176
|
-
if (/(?:^|\s)[-//](?:AsPlainText|PipelineVariable|pv|FilterHashtable|OutVariable|ov)(?:\s|=|$)/i.test(
|
|
177
|
-
trimmed
|
|
178
|
-
)) {
|
|
179
|
-
return true;
|
|
180
|
-
}
|
|
181
|
-
return false;
|
|
182
|
-
}
|
|
183
|
-
function wrapPowerShellScript(command) {
|
|
184
|
-
const bootstrap = "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;$ConfirmPreference='None';$WhatIfPreference=$false";
|
|
185
|
-
return bootstrap + "\n$ErrorActionPreference='Stop'\n" + command + "\nif ($LASTEXITCODE -is [int]) { exit $LASTEXITCODE }";
|
|
186
|
-
}
|
|
187
|
-
var PS_VERB_RE = new RegExp(
|
|
188
|
-
// Boundaries: start-of-string, whitespace, `;`, `&`, `|`, `(`, `{`, `,`.
|
|
189
|
-
"(?:^|[\\s;&|\\(\\{,])(?:Get|Set|New|Remove|Add|Clear|Copy|Move|Rename|Test|Update|Write|Read|Push|Pop|Invoke|Start|Stop|Wait|Out|Format|Group|Measure|Compare|Resolve|ConvertTo|ConvertFrom|Convert|Import|Export|Select|Where|ForEach|Sort|Tee|Split|Join|Limit|Skip|Step|Trace|Debug|Register|Unregister|Enable|Disable|Restart|Suspend|Resume|Save|Open|Close|Lock|Unlock|Mount|Dismount|Enter|Exit|Use|Show|Hide|Find|Search|Watch|Initialize|Optimize|Compress|Expand|Merge|Checkpoint|Undo|Redo|Approve|Deny|Block|Grant|Revoke|Assert|Confirm|Receive|Send|Connect|Disconnect|Reset|Backup|Restore|Publish|Unpublish|Install|Uninstall|Build|Rebuild|Deploy|Submit|Process|Complete|Approve|Revoke|Pay|Refund|Decline|Receive|Send)-[A-Za-z][A-Za-z0-9]+(?:[\\-\\+][A-Za-z][A-Za-z0-9]+)*(?:$|[\\s\\-\\;\\&\\|\\(\\)\\{\\},])",
|
|
190
|
-
"i"
|
|
191
|
-
);
|
|
192
|
-
function shellArgs(shell) {
|
|
193
|
-
if (shell === "powershell" || shell === "pwsh") {
|
|
194
|
-
return ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand"];
|
|
195
|
-
}
|
|
196
|
-
return ["/c"];
|
|
197
|
-
}
|
|
198
|
-
function diagnoseBashism(command, shell) {
|
|
199
|
-
if (!command) return void 0;
|
|
200
|
-
const isCmd = shell === "cmd";
|
|
201
|
-
const hints = [];
|
|
202
|
-
const add = (h) => {
|
|
203
|
-
if (!hints.includes(h)) hints.push(h);
|
|
204
|
-
};
|
|
205
|
-
if (/\/dev\/null/.test(command)) {
|
|
206
|
-
add(
|
|
207
|
-
isCmd ? "use `nul` instead of `/dev/null` (e.g. `2>nul`)" : "use `$null` instead of `/dev/null` (e.g. `2>$null`)"
|
|
208
|
-
);
|
|
209
|
-
}
|
|
210
|
-
if (/(^|[;&|]\s*)export\s+[A-Za-z_]\w*=/.test(command)) {
|
|
211
|
-
add(
|
|
212
|
-
isCmd ? "set env vars with `set NAME=value`, not `export`" : "set env vars with `$env:NAME = 'value'`, not `export`"
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
if (/<<-?\s*['"]?[A-Za-z_]\w*/.test(command)) {
|
|
216
|
-
add(
|
|
217
|
-
isCmd ? "cmd has no heredocs \u2014 write the content to a file or use multiple `echo` lines" : "PowerShell has no heredocs \u2014 use a single-quoted here-string `@'\u2026'@` (closing `'@` at column 0)"
|
|
218
|
-
);
|
|
219
|
-
}
|
|
220
|
-
if (shell === "powershell" && /(&&|\|\|)/.test(command)) {
|
|
221
|
-
add(
|
|
222
|
-
"Windows PowerShell 5.1 has no `&&`/`||` \u2014 separate commands with `;` (check `$LASTEXITCODE`)"
|
|
223
|
-
);
|
|
224
|
-
}
|
|
225
|
-
if (/\brm\s+-[A-Za-z]*[rf]/.test(command)) {
|
|
226
|
-
add(
|
|
227
|
-
isCmd ? "`rm` is not a cmd builtin \u2014 use `del` (files) or `rmdir /s /q` (dirs)" : "use `Remove-Item -Recurse -Force` \u2014 the `rm -rf` bash flags don't exist in PowerShell"
|
|
228
|
-
);
|
|
229
|
-
}
|
|
230
|
-
if (/\bwhich\s+\S/.test(command)) {
|
|
231
|
-
add(
|
|
232
|
-
isCmd ? "use `where <cmd>` instead of `which`" : "use `Get-Command <cmd>` instead of `which`"
|
|
233
|
-
);
|
|
234
|
-
}
|
|
235
|
-
if (hints.length === 0) return void 0;
|
|
236
|
-
const label = isCmd ? "cmd.exe" : shell === "pwsh" ? "PowerShell 7" : "Windows PowerShell";
|
|
237
|
-
return `[wrongstack] This command failed and contains bash/POSIX syntax that ${label} does not accept \u2014 ${hints.join("; ")}. Rewrite it in ${isCmd ? "cmd" : "PowerShell"} syntax and retry.`;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// src/_util.ts
|
|
241
|
-
import * as Core from "@wrongstack/core/utils";
|
|
242
|
-
var COMMAND_OUTPUT_MAX_BYTES = 32768;
|
|
243
|
-
var REPEAT_RUN_THRESHOLD = 3;
|
|
244
|
-
function collapseCarriageReturns(text) {
|
|
245
|
-
const lf = text.replace(/\r\n/g, "\n");
|
|
246
|
-
if (!lf.includes("\r")) return lf;
|
|
247
|
-
return lf.split("\n").map((line) => line.includes("\r") ? line.slice(line.lastIndexOf("\r") + 1) : line).join("\n");
|
|
248
|
-
}
|
|
249
|
-
function collapseConsecutiveDuplicates(text, minRun = REPEAT_RUN_THRESHOLD) {
|
|
250
|
-
const lines = text.split("\n");
|
|
251
|
-
const out = [];
|
|
252
|
-
let i = 0;
|
|
253
|
-
while (i < lines.length) {
|
|
254
|
-
let j = i + 1;
|
|
255
|
-
while (j < lines.length && lines[j] === lines[i]) j++;
|
|
256
|
-
const run = j - i;
|
|
257
|
-
if (run >= minRun) {
|
|
258
|
-
out.push(lines[i], `\u2026 \u27E8repeated ${run}\xD7\u27E9`);
|
|
259
|
-
} else {
|
|
260
|
-
for (let k = i; k < j; k++) out.push(lines[k]);
|
|
261
|
-
}
|
|
262
|
-
i = j;
|
|
263
|
-
}
|
|
264
|
-
return out.join("\n");
|
|
265
|
-
}
|
|
266
|
-
function takeHeadBytes(s, maxBytes) {
|
|
267
|
-
if (maxBytes <= 0) return "";
|
|
268
|
-
if (Buffer.byteLength(s, "utf8") <= maxBytes) return s;
|
|
269
|
-
let lo = 0;
|
|
270
|
-
let hi = s.length;
|
|
271
|
-
while (lo < hi) {
|
|
272
|
-
const mid = Math.ceil((lo + hi) / 2);
|
|
273
|
-
if (Buffer.byteLength(s.slice(0, mid), "utf8") <= maxBytes) lo = mid;
|
|
274
|
-
else hi = mid - 1;
|
|
275
|
-
}
|
|
276
|
-
return s.slice(0, lo);
|
|
277
|
-
}
|
|
278
|
-
function takeTailBytes(s, maxBytes) {
|
|
279
|
-
if (maxBytes <= 0) return "";
|
|
280
|
-
if (Buffer.byteLength(s, "utf8") <= maxBytes) return s;
|
|
281
|
-
let lo = 0;
|
|
282
|
-
let hi = s.length;
|
|
283
|
-
while (lo < hi) {
|
|
284
|
-
const mid = Math.ceil((lo + hi) / 2);
|
|
285
|
-
if (Buffer.byteLength(s.slice(s.length - mid), "utf8") <= maxBytes) lo = mid;
|
|
286
|
-
else hi = mid - 1;
|
|
287
|
-
}
|
|
288
|
-
return s.slice(s.length - lo);
|
|
289
|
-
}
|
|
290
|
-
function truncateHeadTail(s, maxBytes) {
|
|
291
|
-
const total = Buffer.byteLength(s, "utf8");
|
|
292
|
-
if (total <= maxBytes) return s;
|
|
293
|
-
const MARKER_RESERVE = 64;
|
|
294
|
-
const avail = Math.max(0, maxBytes - MARKER_RESERVE);
|
|
295
|
-
const headBudget = Math.floor(avail * 0.45);
|
|
296
|
-
const head = takeHeadBytes(s, headBudget);
|
|
297
|
-
const tail = takeTailBytes(s, avail - Buffer.byteLength(head, "utf8"));
|
|
298
|
-
const kept = Buffer.byteLength(head, "utf8") + Buffer.byteLength(tail, "utf8");
|
|
299
|
-
return `${head}
|
|
300
|
-
\u2026[truncated ${total - kept} bytes]\u2026
|
|
301
|
-
${tail}`;
|
|
302
|
-
}
|
|
303
|
-
function normalizeCommandOutput(raw, opts = {}) {
|
|
304
|
-
if (!raw) return raw;
|
|
305
|
-
let text = Core.stripAnsi(raw);
|
|
306
|
-
text = collapseCarriageReturns(text);
|
|
307
|
-
text = text.replace(/[ \t]+$/gm, "");
|
|
308
|
-
text = collapseConsecutiveDuplicates(text);
|
|
309
|
-
text = text.replace(/\n{3,}/g, "\n\n");
|
|
310
|
-
return truncateHeadTail(text, opts.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// src/_win32-resolve.ts
|
|
314
|
-
import * as fs from "node:fs";
|
|
315
|
-
import * as path2 from "node:path";
|
|
316
|
-
function resolveWin32Command(cmd) {
|
|
317
|
-
if (process.platform !== "win32") return cmd;
|
|
318
|
-
if (cmd.includes("/") || cmd.includes("\\") || path2.extname(cmd.replace(/\//g, "\\"))) {
|
|
319
|
-
return cmd;
|
|
320
|
-
}
|
|
321
|
-
const pathext = (process.env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD;.VBS;.JS;.WS;.MSC").toLowerCase().split(";");
|
|
322
|
-
const pathDirs = (process.env["PATH"] ?? "").split(path2.delimiter);
|
|
323
|
-
for (const dir of pathDirs) {
|
|
324
|
-
const base = path2.join(dir, cmd);
|
|
325
|
-
for (const ext of pathext) {
|
|
326
|
-
const full = `${base}${ext}`;
|
|
327
|
-
try {
|
|
328
|
-
fs.accessSync(full, fs.constants.X_OK);
|
|
329
|
-
return full;
|
|
330
|
-
} catch {
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
return cmd;
|
|
335
|
-
}
|
|
336
|
-
function resolvePowerShell(cmd) {
|
|
337
|
-
if (process.platform !== "win32") return cmd;
|
|
338
|
-
const lower = cmd.toLowerCase();
|
|
339
|
-
if (lower !== "pwsh" && lower !== "powershell" && lower !== "pwsh.exe" && lower !== "powershell.exe") {
|
|
340
|
-
return resolveWin32Command(cmd);
|
|
341
|
-
}
|
|
342
|
-
const primary = lower.startsWith("pwsh") ? "pwsh" : "powershell";
|
|
343
|
-
const fallback = lower.startsWith("pwsh") ? "powershell" : "pwsh";
|
|
344
|
-
const resolved = resolveWin32Command(primary);
|
|
345
|
-
if (resolved === primary) {
|
|
346
|
-
const fb = resolveWin32Command(fallback);
|
|
347
|
-
return fb === fallback ? cmd : fb;
|
|
348
|
-
}
|
|
349
|
-
return resolved;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// src/bash-kill-guard.ts
|
|
353
|
-
import * as os3 from "node:os";
|
|
354
|
-
|
|
355
|
-
// src/process-registry-persistent.ts
|
|
356
|
-
import * as fs2 from "node:fs/promises";
|
|
357
|
-
import * as os2 from "node:os";
|
|
358
|
-
import { wstackGlobalRoot as wstackGlobalRoot3 } from "@wrongstack/core/utils";
|
|
359
|
-
import * as path3 from "node:path";
|
|
360
|
-
|
|
361
|
-
// src/process-registry.ts
|
|
362
|
-
import { spawn } from "node:child_process";
|
|
363
|
-
import * as os from "node:os";
|
|
364
|
-
|
|
365
|
-
// src/circuit-breaker.ts
|
|
366
|
-
var DEFAULT_MAX_CONSECUTIVE_FAILURES = 5;
|
|
367
|
-
var DEFAULT_SLOW_CALL_THRESHOLD_MS = 18e4;
|
|
368
|
-
var DEFAULT_MAX_SLOW_CALLS = 3;
|
|
369
|
-
var DEFAULT_WINDOW_MS = 6e4;
|
|
370
|
-
var DEFAULT_MAX_CALLS_PER_WINDOW = 30;
|
|
371
|
-
var DEFAULT_COOLDOWN_MS = 3e4;
|
|
372
|
-
var CircuitBreaker = class {
|
|
373
|
-
maxConsecutiveFailures;
|
|
374
|
-
slowCallThresholdMs;
|
|
375
|
-
maxSlowCalls;
|
|
376
|
-
windowMs;
|
|
377
|
-
maxCallsPerWindow;
|
|
378
|
-
cooldownMs;
|
|
379
|
-
state = "closed";
|
|
380
|
-
consecutiveFailures = 0;
|
|
381
|
-
window = [];
|
|
382
|
-
lastFailureAt = null;
|
|
383
|
-
lastSlowAt = null;
|
|
384
|
-
/** Timestamp when the breaker was opened (for cooldown calculation). */
|
|
385
|
-
openedAt = null;
|
|
386
|
-
/**
|
|
387
|
-
* Master enable flag. When false the breaker is bypassed: `beforeCall`
|
|
388
|
-
* always returns true and `afterCall` records nothing. The class itself
|
|
389
|
-
* defaults to enabled (so the standalone unit tests exercise tripping); the
|
|
390
|
-
* ProcessRegistry flips this off until the user opts in via `/settings`.
|
|
391
|
-
*/
|
|
392
|
-
enabled = true;
|
|
393
|
-
/**
|
|
394
|
-
* Fired (best-effort) when the breaker transitions into the `open` state.
|
|
395
|
-
* The registry uses this to arm its auto kill/reset countdown.
|
|
396
|
-
*/
|
|
397
|
-
onTrip;
|
|
398
|
-
/**
|
|
399
|
-
* Fired (best-effort) when the breaker returns to `closed` after having been
|
|
400
|
-
* open/half-open. The registry uses this to cancel a pending kill/reset.
|
|
401
|
-
*/
|
|
402
|
-
onReset;
|
|
403
|
-
constructor(config = {}) {
|
|
404
|
-
this.maxConsecutiveFailures = config.maxConsecutiveFailures ?? DEFAULT_MAX_CONSECUTIVE_FAILURES;
|
|
405
|
-
this.slowCallThresholdMs = config.slowCallThresholdMs ?? DEFAULT_SLOW_CALL_THRESHOLD_MS;
|
|
406
|
-
this.maxSlowCalls = config.maxSlowCalls ?? DEFAULT_MAX_SLOW_CALLS;
|
|
407
|
-
this.windowMs = config.windowMs ?? DEFAULT_WINDOW_MS;
|
|
408
|
-
this.maxCallsPerWindow = config.maxCallsPerWindow ?? DEFAULT_MAX_CALLS_PER_WINDOW;
|
|
409
|
-
this.cooldownMs = config.cooldownMs ?? DEFAULT_COOLDOWN_MS;
|
|
410
|
-
}
|
|
411
|
-
/** Toggle the master enable. Disabling resets to a clean `closed` state. */
|
|
412
|
-
setEnabled(enabled) {
|
|
413
|
-
if (this.enabled === enabled) return;
|
|
414
|
-
this.enabled = enabled;
|
|
415
|
-
if (!enabled) this._reset();
|
|
416
|
-
}
|
|
417
|
-
get isEnabled() {
|
|
418
|
-
return this.enabled;
|
|
419
|
-
}
|
|
420
|
-
/**
|
|
421
|
-
* Returns true if the circuit allows a new call to proceed.
|
|
422
|
-
* When false, callers should abort the tool call and return a
|
|
423
|
-
* circuit-breaker error instead of spawning a process.
|
|
424
|
-
*/
|
|
425
|
-
get canProceed() {
|
|
426
|
-
if (!this.enabled) return true;
|
|
427
|
-
this._checkStateTransition();
|
|
428
|
-
return this.state !== "open";
|
|
429
|
-
}
|
|
430
|
-
/**
|
|
431
|
-
* Snapshot of the current breaker state for observability (`/kill`).
|
|
432
|
-
*/
|
|
433
|
-
snapshot() {
|
|
434
|
-
this._checkStateTransition();
|
|
435
|
-
const now = Date.now();
|
|
436
|
-
let cooldownRemaining = null;
|
|
437
|
-
if (this.openedAt !== null && this.state === "open") {
|
|
438
|
-
const elapsed = now - this.openedAt;
|
|
439
|
-
cooldownRemaining = Math.max(0, this.cooldownMs - elapsed);
|
|
440
|
-
}
|
|
441
|
-
return {
|
|
442
|
-
state: this.state,
|
|
443
|
-
consecutiveFailures: this.consecutiveFailures,
|
|
444
|
-
slowCallsInWindow: this.window.filter((c) => c.slow).length,
|
|
445
|
-
callsInWindow: this.window.length,
|
|
446
|
-
windowMs: this.windowMs,
|
|
447
|
-
cooldownRemainingMs: cooldownRemaining,
|
|
448
|
-
lastFailureAt: this.lastFailureAt,
|
|
449
|
-
lastSlowAt: this.lastSlowAt
|
|
450
|
-
};
|
|
451
|
-
}
|
|
452
|
-
/**
|
|
453
|
-
* Call this BEFORE spawning a bash/exec process.
|
|
454
|
-
* Returns true if the call is allowed; false if the breaker is open.
|
|
455
|
-
* When false, callers MUST NOT spawn a process.
|
|
456
|
-
*
|
|
457
|
-
* @param bypass - If true, skip the circuit breaker check entirely.
|
|
458
|
-
* Use for background/fire-and-forget processes that should
|
|
459
|
-
* not affect breaker state.
|
|
460
|
-
*/
|
|
461
|
-
beforeCall(bypass = false) {
|
|
462
|
-
if (bypass || !this.enabled) return true;
|
|
463
|
-
this._checkStateTransition();
|
|
464
|
-
if (this.state === "open") return false;
|
|
465
|
-
return true;
|
|
466
|
-
}
|
|
467
|
-
/**
|
|
468
|
-
* Call this AFTER a bash/exec process finishes (success or failure).
|
|
469
|
-
* `durationMs` is the wall-clock time the process ran.
|
|
470
|
-
* `failed` is true when the process returned a non-zero exit code or
|
|
471
|
-
* threw an exception before spawning.
|
|
472
|
-
*
|
|
473
|
-
* @param bypass - If true, do not update breaker state.
|
|
474
|
-
* Use for background/fire-and-forget processes.
|
|
475
|
-
*/
|
|
476
|
-
afterCall(durationMs, failed, bypass = false) {
|
|
477
|
-
if (bypass || !this.enabled) return;
|
|
478
|
-
const now = Date.now();
|
|
479
|
-
if (this.state === "half-open") {
|
|
480
|
-
if (failed) {
|
|
481
|
-
this._trip();
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
484
|
-
this._reset();
|
|
485
|
-
return;
|
|
486
|
-
}
|
|
487
|
-
this._pruneWindow(now);
|
|
488
|
-
const slow = durationMs >= this.slowCallThresholdMs;
|
|
489
|
-
this.window.push({ at: now, failed, slow });
|
|
490
|
-
if (failed) {
|
|
491
|
-
this.consecutiveFailures++;
|
|
492
|
-
this.lastFailureAt = now;
|
|
493
|
-
if (this.consecutiveFailures >= this.maxConsecutiveFailures) {
|
|
494
|
-
this._trip();
|
|
495
|
-
}
|
|
496
|
-
return;
|
|
497
|
-
}
|
|
498
|
-
this.consecutiveFailures = 0;
|
|
499
|
-
if (slow) {
|
|
500
|
-
this.lastSlowAt = now;
|
|
501
|
-
const slowCount = this.window.filter((c) => c.slow).length;
|
|
502
|
-
if (slowCount >= this.maxSlowCalls) {
|
|
503
|
-
this._trip();
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
const callCount = this.window.length;
|
|
507
|
-
if (callCount >= this.maxCallsPerWindow) {
|
|
508
|
-
this._trip();
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
/** Force the breaker open. Used by /kill force and Ctrl+C. */
|
|
512
|
-
forceOpen() {
|
|
513
|
-
this._trip();
|
|
514
|
-
}
|
|
515
|
-
/** Force a reset to closed. Used by tests and /kill reset. */
|
|
516
|
-
forceReset() {
|
|
517
|
-
this._reset();
|
|
518
|
-
}
|
|
519
|
-
_trip() {
|
|
520
|
-
if (this.state === "open") return;
|
|
521
|
-
this.state = "open";
|
|
522
|
-
this.openedAt = Date.now();
|
|
523
|
-
this.window = [];
|
|
524
|
-
try {
|
|
525
|
-
this.onTrip?.();
|
|
526
|
-
} catch {
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
_reset() {
|
|
530
|
-
const wasRecovering = this.state !== "closed";
|
|
531
|
-
this.state = "closed";
|
|
532
|
-
this.consecutiveFailures = 0;
|
|
533
|
-
this.window = [];
|
|
534
|
-
this.openedAt = null;
|
|
535
|
-
if (wasRecovering) {
|
|
536
|
-
try {
|
|
537
|
-
this.onReset?.();
|
|
538
|
-
} catch {
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
/** Transition from open → half-open when cooldown elapses. */
|
|
543
|
-
_checkStateTransition() {
|
|
544
|
-
if (this.state !== "open" || this.openedAt === null) return;
|
|
545
|
-
const elapsed = Date.now() - this.openedAt;
|
|
546
|
-
if (elapsed >= this.cooldownMs) {
|
|
547
|
-
this.state = "half-open";
|
|
548
|
-
this.openedAt = null;
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
_pruneWindow(now) {
|
|
552
|
-
const cutoff = now - this.windowMs;
|
|
553
|
-
this.window = this.window.filter((c) => c.at >= cutoff);
|
|
554
|
-
}
|
|
555
|
-
};
|
|
556
|
-
|
|
557
|
-
// src/_redact-command.ts
|
|
558
|
-
import { expectDefined } from "@wrongstack/core/utils";
|
|
559
|
-
var SENSITIVE_FLAG_PATTERNS = [
|
|
560
|
-
// --flag=value or --flag "value" (value captured up to next space or comma)
|
|
561
|
-
/--(?:token|password|passwd|pwd|secret|api[-_]?key|api[-_]?secret|auth|credential|private[-_]?key|access[-_]?key|github[-_]?token|gh[-_]?token|bearer|jwt|oauth|pin|pincode|passphrase|access[-_]?token)(?:[=\s,][^\s]*)?/gi,
|
|
562
|
-
// -t short flag (token): attached (-tVALUE), separated (-t VALUE), or -t=VALUE.
|
|
563
|
-
// (?<![-\w]) anchors to a token start so we don't match the `-t` inside `--token`.
|
|
564
|
-
// The value must be token-like (>= 8 chars) so ordinary combined flags such
|
|
565
|
-
// as `tar -tf` / `ssh -tt` are not eaten. Global flag: EVERY occurrence is
|
|
566
|
-
// redacted, not just the first.
|
|
567
|
-
// NOTE: synced with @wrongstack/core observability/redact-command.ts.
|
|
568
|
-
/(?<![-\w])-t(?:[=\s]+)?[^\s,-]{8,}/g,
|
|
569
|
-
// -p|-password|-a (redis auth) short flags: attached + separated + =value.
|
|
570
|
-
// Same token-start anchor; over-redaction is an accepted tradeoff for a
|
|
571
|
-
// redaction function. Synced with core copy.
|
|
572
|
-
/(?<![-\w])-(?:password|p|a)(?:[=\s]+)?[^\s,-]+/gi,
|
|
573
|
-
// env var–style secrets: TOKEN=x, API_KEY=y, etc.
|
|
574
|
-
/(?:TOKEN|API_KEY|API_SECRET|AUTH_TOKEN|GITHUB_TOKEN|GH_TOKEN|BEARER|JWT|OAUTH|CREDENTIAL|SECRET|PRIVATE_KEY|PASSWORD|PASSWD|PASSPHRASE)\s*[=:]\s*[^\s,]+/gi,
|
|
575
|
-
// Generic high-entropy look: base64 strings >32 chars or hex strings >32 digits — but only
|
|
576
|
-
// when preceded by a flag name (e.g. --github-token=EyJ...). Global flag so
|
|
577
|
-
// every such flag in the command line is redacted, not just the first.
|
|
578
|
-
/--\w*(?:token|key|secret|password|passwd|auth|credential)\w*[=\s,][A-Za-z0-9+/=]{32,}/g
|
|
579
|
-
];
|
|
580
|
-
function redactCommand(cmd) {
|
|
581
|
-
let result = cmd;
|
|
582
|
-
for (const pattern of SENSITIVE_FLAG_PATTERNS) {
|
|
583
|
-
result = result.replace(pattern, (match) => {
|
|
584
|
-
const eq = match.indexOf("=");
|
|
585
|
-
const sp = match.search(/\s/);
|
|
586
|
-
const delim = eq !== -1 ? "=" : sp !== -1 ? match[sp] : null;
|
|
587
|
-
if (delim !== null) {
|
|
588
|
-
const flag = match.slice(0, match.indexOf(expectDefined(delim)) + 1);
|
|
589
|
-
return `${flag}[REDACTED]`;
|
|
590
|
-
}
|
|
591
|
-
if (match.startsWith("--")) {
|
|
592
|
-
return match;
|
|
593
|
-
}
|
|
594
|
-
return `${match.slice(0, 2)}[REDACTED]`;
|
|
595
|
-
});
|
|
596
|
-
}
|
|
597
|
-
return result;
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
// src/process-registry.ts
|
|
601
|
-
var DEFAULT_GRACE_MS = 2e3;
|
|
602
|
-
var WIN32_TASKKILL_TIMEOUT_MS = 5e3;
|
|
603
|
-
function killWin32Tree(pid, opts = {}) {
|
|
604
|
-
try {
|
|
605
|
-
const child = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
|
|
606
|
-
stdio: "ignore",
|
|
607
|
-
windowsHide: true
|
|
608
|
-
});
|
|
609
|
-
let settled = false;
|
|
610
|
-
let timeout;
|
|
611
|
-
const settle = () => {
|
|
612
|
-
if (settled) return;
|
|
613
|
-
settled = true;
|
|
614
|
-
if (timeout) clearTimeout(timeout);
|
|
615
|
-
try {
|
|
616
|
-
opts.onSettled?.();
|
|
617
|
-
} catch {
|
|
618
|
-
}
|
|
619
|
-
};
|
|
620
|
-
child.on("error", settle);
|
|
621
|
-
child.on("close", settle);
|
|
622
|
-
timeout = setTimeout(
|
|
623
|
-
() => {
|
|
624
|
-
try {
|
|
625
|
-
child.kill();
|
|
626
|
-
} catch {
|
|
627
|
-
}
|
|
628
|
-
settle();
|
|
629
|
-
},
|
|
630
|
-
Math.max(1, opts.timeoutMs ?? WIN32_TASKKILL_TIMEOUT_MS)
|
|
631
|
-
);
|
|
632
|
-
timeout.unref?.();
|
|
633
|
-
child.unref();
|
|
634
|
-
return true;
|
|
635
|
-
} catch {
|
|
636
|
-
return false;
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
var ProcessRegistryImpl = class {
|
|
640
|
-
processes = /* @__PURE__ */ new Map();
|
|
641
|
-
breaker;
|
|
642
|
-
/**
|
|
643
|
-
* Auto kill/reset config. When the breaker trips and `autoKillResetMs > 0`,
|
|
644
|
-
* a countdown is armed; on expiry all tracked processes are killed and the
|
|
645
|
-
* breaker is reset to closed (forced recovery). Zero means manual recovery
|
|
646
|
-
* only (`/kill reset`).
|
|
647
|
-
*/
|
|
648
|
-
autoKillResetMs = 0;
|
|
649
|
-
autoKillTimer = null;
|
|
650
|
-
autoKillArmedAt = null;
|
|
651
|
-
breakerCountdownListeners = [];
|
|
652
|
-
constructor(breakerConfig) {
|
|
653
|
-
this.breaker = new CircuitBreaker(breakerConfig);
|
|
654
|
-
this.breaker.onTrip = () => this._armAutoKillReset();
|
|
655
|
-
this.breaker.onReset = () => this._cancelAutoKillReset();
|
|
656
|
-
this.breaker.setEnabled(false);
|
|
657
|
-
}
|
|
658
|
-
register(info) {
|
|
659
|
-
this.processes.set(info.pid, {
|
|
660
|
-
...info,
|
|
661
|
-
killed: false,
|
|
662
|
-
protected: info.protected ?? false,
|
|
663
|
-
background: info.background ?? false
|
|
664
|
-
});
|
|
665
|
-
}
|
|
666
|
-
_isSafeSignalPid(pid) {
|
|
667
|
-
return Number.isInteger(pid) && pid > 1 && pid !== process.pid && pid !== process.ppid;
|
|
668
|
-
}
|
|
669
|
-
_canSignalProcessGroup(p) {
|
|
670
|
-
return os.platform() !== "win32" && p.processGroupLeader === true && this._isSafeSignalPid(p.pid) && p.child !== null && typeof p.child.pid === "number" && p.child.pid === p.pid;
|
|
671
|
-
}
|
|
672
|
-
_killChildDirect(p, signal) {
|
|
673
|
-
try {
|
|
674
|
-
if (p.child) {
|
|
675
|
-
p.child.kill(signal);
|
|
676
|
-
return;
|
|
677
|
-
}
|
|
678
|
-
if (this._isSafeSignalPid(p.pid)) process.kill(p.pid, signal);
|
|
679
|
-
} catch {
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
_killPosix(p, signal) {
|
|
683
|
-
if (this._canSignalProcessGroup(p)) {
|
|
684
|
-
try {
|
|
685
|
-
process.kill(-p.pid, signal);
|
|
686
|
-
return;
|
|
687
|
-
} catch {
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
this._killChildDirect(p, signal);
|
|
691
|
-
}
|
|
692
|
-
/** Unregister a process by PID. Called on 'close' / 'exit' events. */
|
|
693
|
-
unregister(pid) {
|
|
694
|
-
this.processes.delete(pid);
|
|
695
|
-
}
|
|
696
|
-
/** Get a single process by PID. */
|
|
697
|
-
get(pid) {
|
|
698
|
-
this._pruneStale(pid);
|
|
699
|
-
return this.processes.get(pid);
|
|
700
|
-
}
|
|
701
|
-
/** Get all tracked processes. */
|
|
702
|
-
list() {
|
|
703
|
-
this._pruneAllStale();
|
|
704
|
-
return Array.from(this.processes.values());
|
|
705
|
-
}
|
|
706
|
-
/** Get processes filtered by name (e.g. 'bash', 'exec'). */
|
|
707
|
-
byName(name) {
|
|
708
|
-
return this.list().filter((p) => p.name === name);
|
|
709
|
-
}
|
|
710
|
-
/** Get processes filtered by session. */
|
|
711
|
-
bySession(sessionId) {
|
|
712
|
-
return this.list().filter((p) => p.sessionId === sessionId);
|
|
713
|
-
}
|
|
714
|
-
/** Count of active (non-killed) processes. */
|
|
715
|
-
get activeCount() {
|
|
716
|
-
let n = 0;
|
|
717
|
-
for (const p of this.processes.values()) {
|
|
718
|
-
if (!p.killed) n++;
|
|
719
|
-
}
|
|
720
|
-
return n;
|
|
721
|
-
}
|
|
722
|
-
/** Count of active jobs explicitly launched in background mode. */
|
|
723
|
-
get activeBackgroundCount() {
|
|
724
|
-
let n = 0;
|
|
725
|
-
for (const p of this.processes.values()) {
|
|
726
|
-
if (p.background && !p.killed) n++;
|
|
727
|
-
}
|
|
728
|
-
return n;
|
|
729
|
-
}
|
|
730
|
-
/**
|
|
731
|
-
* Combined stats for observability — used by /ps and the TUI status bar.
|
|
732
|
-
*/
|
|
733
|
-
stats() {
|
|
734
|
-
this._pruneAllStale();
|
|
735
|
-
return {
|
|
736
|
-
activeCount: this.activeCount,
|
|
737
|
-
backgroundCount: this.activeBackgroundCount,
|
|
738
|
-
totalCount: this.processes.size,
|
|
739
|
-
breaker: this.breaker.snapshot()
|
|
740
|
-
};
|
|
741
|
-
}
|
|
742
|
-
/**
|
|
743
|
-
* Returns true if the circuit allows a new bash/exec call to proceed.
|
|
744
|
-
* When false, callers MUST NOT spawn a process.
|
|
745
|
-
*/
|
|
746
|
-
get canProceed() {
|
|
747
|
-
return this.breaker.canProceed;
|
|
748
|
-
}
|
|
749
|
-
/**
|
|
750
|
-
* Called before spawning a process. Returns true if allowed; false if
|
|
751
|
-
* the circuit breaker is open.
|
|
752
|
-
*
|
|
753
|
-
* @param bypass - If true, skip circuit breaker check (for background processes).
|
|
754
|
-
*/
|
|
755
|
-
beforeCall(bypass = false) {
|
|
756
|
-
return this.breaker.beforeCall(bypass);
|
|
757
|
-
}
|
|
758
|
-
/**
|
|
759
|
-
* Called after a process finishes. `durationMs` is wall-clock time;
|
|
760
|
-
* `failed` is true for non-zero exit codes.
|
|
761
|
-
*
|
|
762
|
-
* @param bypass - If true, do not update circuit breaker state (for background processes).
|
|
763
|
-
*/
|
|
764
|
-
afterCall(durationMs, failed, bypass = false) {
|
|
765
|
-
this.breaker.afterCall(durationMs, failed, bypass);
|
|
766
|
-
}
|
|
767
|
-
/** Force-open the circuit breaker (Ctrl+C, /kill force). */
|
|
768
|
-
forceBreakerOpen() {
|
|
769
|
-
this.breaker.forceOpen();
|
|
770
|
-
}
|
|
771
|
-
/** Force-reset the circuit breaker to closed (/kill reset). */
|
|
772
|
-
forceBreakerReset() {
|
|
773
|
-
this.breaker.forceReset();
|
|
774
|
-
}
|
|
775
|
-
/**
|
|
776
|
-
* Configure circuit-breaker protection at runtime. Called from `/settings`
|
|
777
|
-
* (instant, all modes) and on TUI mount (applies persisted config).
|
|
778
|
-
*
|
|
779
|
-
* - `enabled` toggles whether the breaker gates `bash`/`exec`.
|
|
780
|
-
* - `autoKillResetMs` arms the auto kill/reset countdown when the breaker
|
|
781
|
-
* trips (0 = manual recovery only).
|
|
782
|
-
*
|
|
783
|
-
* Re-applies cleanly on every call: cancels a pending countdown when the
|
|
784
|
-
* timeout is cleared or protection disabled, and re-arms if the breaker is
|
|
785
|
-
* currently open under the new settings.
|
|
786
|
-
*/
|
|
787
|
-
setBreakerConfig(cfg) {
|
|
788
|
-
if (cfg.enabled !== void 0) this.breaker.setEnabled(cfg.enabled);
|
|
789
|
-
if (cfg.autoKillResetMs !== void 0) this.autoKillResetMs = Math.max(0, cfg.autoKillResetMs);
|
|
790
|
-
if (this.autoKillResetMs <= 0) {
|
|
791
|
-
this._cancelAutoKillReset();
|
|
792
|
-
return;
|
|
793
|
-
}
|
|
794
|
-
if (this.breaker.isEnabled && this.breaker.snapshot().state === "open") {
|
|
795
|
-
this._armAutoKillReset();
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
/**
|
|
799
|
-
* Live countdown to the next auto kill/reset, or null when nothing is armed.
|
|
800
|
-
* The TUI polls this on a 1s tick while armed so the statusline decrements.
|
|
801
|
-
*/
|
|
802
|
-
getBreakerCountdown() {
|
|
803
|
-
if (this.autoKillArmedAt === null || this.autoKillResetMs <= 0) return null;
|
|
804
|
-
const elapsed = Date.now() - this.autoKillArmedAt;
|
|
805
|
-
return {
|
|
806
|
-
remainingMs: Math.max(0, this.autoKillResetMs - elapsed),
|
|
807
|
-
totalMs: this.autoKillResetMs
|
|
808
|
-
};
|
|
809
|
-
}
|
|
810
|
-
/**
|
|
811
|
-
* Subscribe to countdown arm/cancel events. Returns an unsubscribe function.
|
|
812
|
-
* Use {@link getBreakerCountdown} for the live ticking value between events.
|
|
813
|
-
*/
|
|
814
|
-
onBreakerCountdownChange(listener) {
|
|
815
|
-
this.breakerCountdownListeners.push(listener);
|
|
816
|
-
return () => {
|
|
817
|
-
this.breakerCountdownListeners = this.breakerCountdownListeners.filter((l) => l !== listener);
|
|
818
|
-
};
|
|
819
|
-
}
|
|
820
|
-
_emitBreakerCountdown() {
|
|
821
|
-
const snap = this.getBreakerCountdown();
|
|
822
|
-
for (const l of this.breakerCountdownListeners) {
|
|
823
|
-
try {
|
|
824
|
-
l(snap);
|
|
825
|
-
} catch {
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
/**
|
|
830
|
-
* Arm the auto kill/reset countdown. Idempotent: re-arming resets the window
|
|
831
|
-
* (a fresh trip after a failed half-open probe restarts the clock). No-op
|
|
832
|
-
* when protection is off or no timeout is configured.
|
|
833
|
-
*/
|
|
834
|
-
_armAutoKillReset() {
|
|
835
|
-
if (this.autoKillResetMs <= 0 || !this.breaker.isEnabled) return;
|
|
836
|
-
this._clearAutoKillTimer();
|
|
837
|
-
this.autoKillArmedAt = Date.now();
|
|
838
|
-
this.autoKillTimer = setTimeout(() => {
|
|
839
|
-
this.autoKillTimer = null;
|
|
840
|
-
this.autoKillArmedAt = null;
|
|
841
|
-
this.killAll({ force: false, preserveBackground: true });
|
|
842
|
-
this.breaker.forceReset();
|
|
843
|
-
this._emitBreakerCountdown();
|
|
844
|
-
}, this.autoKillResetMs);
|
|
845
|
-
this.autoKillTimer.unref?.();
|
|
846
|
-
this._emitBreakerCountdown();
|
|
847
|
-
}
|
|
848
|
-
_cancelAutoKillReset() {
|
|
849
|
-
const wasArmed = this.autoKillArmedAt !== null;
|
|
850
|
-
this._clearAutoKillTimer();
|
|
851
|
-
if (wasArmed) {
|
|
852
|
-
this.autoKillArmedAt = null;
|
|
853
|
-
this._emitBreakerCountdown();
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
_clearAutoKillTimer() {
|
|
857
|
-
if (this.autoKillTimer !== null) {
|
|
858
|
-
clearTimeout(this.autoKillTimer);
|
|
859
|
-
this.autoKillTimer = null;
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
/** Kill a single process by PID.
|
|
863
|
-
*
|
|
864
|
-
* On POSIX: sends SIGTERM to the *process group* (-pid) so that
|
|
865
|
-
* runaway grandchild processes (`sleep 9999 & disown`) are also killed.
|
|
866
|
-
* After `graceMs` a SIGKILL is sent if the process hasn't exited.
|
|
867
|
-
*
|
|
868
|
-
* On Windows: `child.kill()` maps to TerminateProcess — process groups
|
|
869
|
-
* are not meaningfully supported. A second `force=true` call sends
|
|
870
|
-
* SIGKILL (which maps to TerminateProcess again — the distinction is
|
|
871
|
-
* in the exit code, not the signal).
|
|
872
|
-
*
|
|
873
|
-
* Returns true if the process was found and kill was attempted.
|
|
874
|
-
*/
|
|
875
|
-
kill(pid, opts = {}) {
|
|
876
|
-
this._pruneStale(pid);
|
|
877
|
-
const p = this.processes.get(pid);
|
|
878
|
-
if (!p) return false;
|
|
879
|
-
if (p.killed && opts.force !== true) return true;
|
|
880
|
-
if (p.protected && opts.includeProtected !== true) return false;
|
|
881
|
-
if (opts.preserveBackground && p.background) return false;
|
|
882
|
-
const { force = false, graceMs = DEFAULT_GRACE_MS } = opts;
|
|
883
|
-
const isWin2 = os.platform() === "win32";
|
|
884
|
-
if (isWin2) {
|
|
885
|
-
const liveRealChild = p.child === null || p.child.exitCode === null && typeof p.child.pid === "number";
|
|
886
|
-
const directFallback = () => {
|
|
887
|
-
if (p.child && p.child.exitCode === null) {
|
|
888
|
-
try {
|
|
889
|
-
p.child.kill("SIGKILL");
|
|
890
|
-
} catch {
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
};
|
|
894
|
-
if (liveRealChild && killWin32Tree(pid, {
|
|
895
|
-
timeoutMs: Math.max(graceMs, WIN32_TASKKILL_TIMEOUT_MS),
|
|
896
|
-
onSettled: directFallback
|
|
897
|
-
})) {
|
|
898
|
-
} else {
|
|
899
|
-
this._killChildDirect(p, force ? "SIGKILL" : "SIGTERM");
|
|
900
|
-
}
|
|
901
|
-
p.killed = true;
|
|
902
|
-
return true;
|
|
903
|
-
}
|
|
904
|
-
try {
|
|
905
|
-
if (force) {
|
|
906
|
-
this._killPosix(p, "SIGKILL");
|
|
907
|
-
} else {
|
|
908
|
-
this._killPosix(p, "SIGTERM");
|
|
909
|
-
const timer = setTimeout(() => {
|
|
910
|
-
if (this.processes.has(pid) && !p.child?.killed) {
|
|
911
|
-
this._killPosix(p, "SIGKILL");
|
|
912
|
-
}
|
|
913
|
-
}, graceMs);
|
|
914
|
-
timer.unref?.();
|
|
915
|
-
}
|
|
916
|
-
} catch {
|
|
917
|
-
}
|
|
918
|
-
p.killed = true;
|
|
919
|
-
return true;
|
|
920
|
-
}
|
|
921
|
-
/**
|
|
922
|
-
* Kill all tracked processes.
|
|
923
|
-
* Returns the PIDs that were kill()ed.
|
|
924
|
-
*/
|
|
925
|
-
killAll(opts = {}) {
|
|
926
|
-
const pids = Array.from(this.processes.keys());
|
|
927
|
-
const killed = [];
|
|
928
|
-
const includeProtected = opts.includeProtected === true;
|
|
929
|
-
for (const pid of pids) {
|
|
930
|
-
const p = this.processes.get(pid);
|
|
931
|
-
if (!p) continue;
|
|
932
|
-
if (p.protected && !includeProtected) continue;
|
|
933
|
-
if (opts.preserveBackground && p.background) continue;
|
|
934
|
-
if (this.kill(pid, opts)) killed.push(pid);
|
|
935
|
-
}
|
|
936
|
-
return killed;
|
|
937
|
-
}
|
|
938
|
-
/**
|
|
939
|
-
* Kill all processes for a specific session.
|
|
940
|
-
* Returns the PIDs that were kill()ed.
|
|
941
|
-
*/
|
|
942
|
-
killSession(sessionId, opts = {}) {
|
|
943
|
-
const pids = this.bySession(sessionId).map((p) => p.pid);
|
|
944
|
-
const killed = [];
|
|
945
|
-
for (const pid of pids) {
|
|
946
|
-
if (this.kill(pid, opts)) killed.push(pid);
|
|
947
|
-
}
|
|
948
|
-
return killed;
|
|
949
|
-
}
|
|
950
|
-
/**
|
|
951
|
-
* Check whether a tracked process entry is stale — the child has exited
|
|
952
|
-
* (exitCode !== null) AND it's been in the registry long enough that the
|
|
953
|
-
* OS may have reused the PID for a new, unrelated process.
|
|
954
|
-
*
|
|
955
|
-
* P3 #24 (before-release.md): on POSIX, PIDs are reused after process
|
|
956
|
-
* exit. If a tracked process exits but its 'close' event hasn't fired yet
|
|
957
|
-
* (or was missed), the registry still holds the entry. A new process
|
|
958
|
-
* gets the same PID, and PID-based lookups (get, kill, shouldBlockKill)
|
|
959
|
-
* may incorrectly protect or target the wrong process.
|
|
960
|
-
*
|
|
961
|
-
* The 60s threshold is conservative — the OS typically waits much longer
|
|
962
|
-
* before reusing a PID, but we want to clean up before that becomes a risk.
|
|
963
|
-
*/
|
|
964
|
-
_isStaleEntry(entry) {
|
|
965
|
-
if (entry.child === null) {
|
|
966
|
-
if (Date.now() - entry.startedAt <= 6e4) return false;
|
|
967
|
-
if (os.platform() === "win32") return false;
|
|
968
|
-
try {
|
|
969
|
-
process.kill(entry.pid, 0);
|
|
970
|
-
return false;
|
|
971
|
-
} catch (err) {
|
|
972
|
-
return err.code !== "EPERM";
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
return entry.child.exitCode !== null && Date.now() - entry.startedAt > 6e4;
|
|
976
|
-
}
|
|
977
|
-
/**
|
|
978
|
-
* Remove a stale entry for a specific PID before any PID-based lookup.
|
|
979
|
-
* This prevents PID reuse from causing the registry to act on a dead
|
|
980
|
-
* process that has been replaced by a new one with the same PID.
|
|
981
|
-
*/
|
|
982
|
-
_pruneStale(pid) {
|
|
983
|
-
const entry = this.processes.get(pid);
|
|
984
|
-
if (entry && this._isStaleEntry(entry)) {
|
|
985
|
-
this.processes.delete(pid);
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
/**
|
|
989
|
-
* Remove every stale entry, not just one PID. `list()`/`stats()` — the
|
|
990
|
-
* surfaces the TUI status bar and `/ps` poll — must prune too: a child
|
|
991
|
-
* whose 'close' event never fires (e.g. Windows grandchildren holding stdio
|
|
992
|
-
* open) would otherwise linger in the registry until someone looks up its
|
|
993
|
-
* exact PID, and PID reuse meanwhile makes `get()`/`kill()` target the
|
|
994
|
-
* wrong process. RAM-leak audit 2026-07-31, MEDIUM.
|
|
995
|
-
*/
|
|
996
|
-
_pruneAllStale() {
|
|
997
|
-
for (const [pid, entry] of this.processes) {
|
|
998
|
-
if (this._isStaleEntry(entry)) this.processes.delete(pid);
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
};
|
|
1002
|
-
var _registry;
|
|
1003
|
-
function getProcessRegistry() {
|
|
1004
|
-
if (!_registry) {
|
|
1005
|
-
_registry = new ProcessRegistryImpl();
|
|
1006
|
-
}
|
|
1007
|
-
return _registry;
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
// src/process-registry-persistent.ts
|
|
1011
|
-
var REGISTRY_FILE = "process-registry.json";
|
|
1012
|
-
function toErrorMessage(err) {
|
|
1013
|
-
return err instanceof Error ? err.message : String(err);
|
|
1014
|
-
}
|
|
1015
|
-
function emitStructuredLog(level, event, message, error) {
|
|
1016
|
-
const payload = {
|
|
1017
|
-
level,
|
|
1018
|
-
event,
|
|
1019
|
-
message,
|
|
1020
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1021
|
-
};
|
|
1022
|
-
if (error !== void 0) {
|
|
1023
|
-
payload.error = toErrorMessage(error);
|
|
1024
|
-
}
|
|
1025
|
-
console.log(JSON.stringify(payload));
|
|
1026
|
-
}
|
|
1027
|
-
var HEARTBEAT_INTERVAL_MS = 5e3;
|
|
1028
|
-
var STALE_THRESHOLD_MS = 3e4;
|
|
1029
|
-
var LOCK_STALE_MS = 3e4;
|
|
1030
|
-
var LOCKFILE = ".process-registry.lock";
|
|
1031
|
-
function generateInstanceId() {
|
|
1032
|
-
const hostname2 = os2.hostname();
|
|
1033
|
-
const pid = process.pid;
|
|
1034
|
-
const random = Math.random().toString(36).slice(2, 8);
|
|
1035
|
-
return `${hostname2}:${pid}:${random}`;
|
|
1036
|
-
}
|
|
1037
|
-
function isNodeError(err) {
|
|
1038
|
-
return typeof err === "object" && err !== null && "code" in err;
|
|
1039
|
-
}
|
|
1040
|
-
async function acquireLock(lockfilePath, timeoutMs = 5e3) {
|
|
1041
|
-
const start = Date.now();
|
|
1042
|
-
const pidStr = String(process.pid);
|
|
1043
|
-
const hostStr = os2.hostname();
|
|
1044
|
-
try {
|
|
1045
|
-
await fs2.mkdir(path3.dirname(lockfilePath), { recursive: true });
|
|
1046
|
-
} catch {
|
|
1047
|
-
}
|
|
1048
|
-
while (Date.now() - start < timeoutMs) {
|
|
1049
|
-
try {
|
|
1050
|
-
await fs2.writeFile(lockfilePath, `${pidStr}:${hostStr}:${Date.now()}`, { flag: "wx" });
|
|
1051
|
-
return async () => {
|
|
1052
|
-
try {
|
|
1053
|
-
await fs2.unlink(lockfilePath);
|
|
1054
|
-
} catch {
|
|
1055
|
-
}
|
|
1056
|
-
};
|
|
1057
|
-
} catch (err) {
|
|
1058
|
-
if (isNodeError(err) && err.code === "EEXIST") {
|
|
1059
|
-
try {
|
|
1060
|
-
const content = await fs2.readFile(lockfilePath, "utf-8");
|
|
1061
|
-
const parts = content.split(":");
|
|
1062
|
-
const lockPid = parseInt(parts[0] ?? "0", 10);
|
|
1063
|
-
const lockTs = Number(parts[parts.length - 1]);
|
|
1064
|
-
const staleByAge = Number.isFinite(lockTs) && Date.now() - lockTs > LOCK_STALE_MS;
|
|
1065
|
-
let holderDead = false;
|
|
1066
|
-
if (process.platform !== "win32" && Number.isFinite(lockPid) && lockPid > 0) {
|
|
1067
|
-
try {
|
|
1068
|
-
process.kill(lockPid, 0);
|
|
1069
|
-
} catch {
|
|
1070
|
-
holderDead = true;
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
if (holderDead || staleByAge) {
|
|
1074
|
-
await fs2.unlink(lockfilePath).catch(() => {
|
|
1075
|
-
});
|
|
1076
|
-
continue;
|
|
1077
|
-
}
|
|
1078
|
-
} catch {
|
|
1079
|
-
await fs2.unlink(lockfilePath).catch(() => {
|
|
1080
|
-
});
|
|
1081
|
-
continue;
|
|
1082
|
-
}
|
|
1083
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
1084
|
-
continue;
|
|
1085
|
-
}
|
|
1086
|
-
throw err;
|
|
1087
|
-
}
|
|
1088
|
-
}
|
|
1089
|
-
throw new Error(`Failed to acquire lock after ${timeoutMs}ms`);
|
|
1090
|
-
}
|
|
1091
|
-
function freshRegistryData() {
|
|
1092
|
-
return {
|
|
1093
|
-
version: 1,
|
|
1094
|
-
instances: /* @__PURE__ */ new Map(),
|
|
1095
|
-
protectedPatterns: ["wrongstack", "node"],
|
|
1096
|
-
lastCleanup: Date.now()
|
|
1097
|
-
};
|
|
1098
|
-
}
|
|
1099
|
-
async function readRegistryFile(filePath) {
|
|
1100
|
-
let content;
|
|
1101
|
-
try {
|
|
1102
|
-
content = await fs2.readFile(filePath, "utf-8");
|
|
1103
|
-
} catch (err) {
|
|
1104
|
-
if (isNodeError(err) && err.code === "ENOENT") return freshRegistryData();
|
|
1105
|
-
throw err;
|
|
1106
|
-
}
|
|
1107
|
-
try {
|
|
1108
|
-
const parsed = JSON.parse(content);
|
|
1109
|
-
if (!parsed || typeof parsed !== "object") return freshRegistryData();
|
|
1110
|
-
const base = freshRegistryData();
|
|
1111
|
-
return {
|
|
1112
|
-
version: 1,
|
|
1113
|
-
instances: Array.isArray(parsed.instances) ? new Map(parsed.instances) : base.instances,
|
|
1114
|
-
protectedPatterns: Array.isArray(parsed.protectedPatterns) ? parsed.protectedPatterns : base.protectedPatterns,
|
|
1115
|
-
lastCleanup: typeof parsed.lastCleanup === "number" ? parsed.lastCleanup : base.lastCleanup
|
|
1116
|
-
};
|
|
1117
|
-
} catch {
|
|
1118
|
-
return freshRegistryData();
|
|
1119
|
-
}
|
|
1120
|
-
}
|
|
1121
|
-
async function writeRegistryFile(filePath, data) {
|
|
1122
|
-
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
1123
|
-
const content = JSON.stringify(
|
|
1124
|
-
data,
|
|
1125
|
-
(_k, v) => {
|
|
1126
|
-
if (v instanceof Map) {
|
|
1127
|
-
return Array.from(v.entries());
|
|
1128
|
-
}
|
|
1129
|
-
return v;
|
|
1130
|
-
},
|
|
1131
|
-
2
|
|
1132
|
-
);
|
|
1133
|
-
await fs2.writeFile(tmpPath, content, "utf-8");
|
|
1134
|
-
await fs2.rename(tmpPath, filePath);
|
|
1135
|
-
}
|
|
1136
|
-
var PersistentProcessRegistry = class {
|
|
1137
|
-
instanceId;
|
|
1138
|
-
registryPath;
|
|
1139
|
-
lockPath;
|
|
1140
|
-
baseRegistry;
|
|
1141
|
-
heartbeatInterval = null;
|
|
1142
|
-
cleanupInterval = null;
|
|
1143
|
-
heartbeatRunning = false;
|
|
1144
|
-
cleanupRunning = false;
|
|
1145
|
-
isShuttingDown = false;
|
|
1146
|
-
onProcessExit = () => {
|
|
1147
|
-
this.runInBackground(
|
|
1148
|
-
this.syncToPersistent(),
|
|
1149
|
-
"process_registry.exit_sync_failed",
|
|
1150
|
-
"PersistentProcessRegistry: exit sync failed"
|
|
1151
|
-
);
|
|
1152
|
-
};
|
|
1153
|
-
constructor(baseRegistry) {
|
|
1154
|
-
this.instanceId = generateInstanceId();
|
|
1155
|
-
const globalRoot = wstackGlobalRoot3();
|
|
1156
|
-
this.registryPath = path3.join(globalRoot, REGISTRY_FILE);
|
|
1157
|
-
this.lockPath = path3.join(globalRoot, LOCKFILE);
|
|
1158
|
-
this.baseRegistry = baseRegistry ?? getProcessRegistry();
|
|
1159
|
-
this.ensureDirectory().catch((err) => {
|
|
1160
|
-
emitStructuredLog(
|
|
1161
|
-
"warn",
|
|
1162
|
-
"process_registry.dir_create_failed",
|
|
1163
|
-
"PersistentProcessRegistry: failed to create .wrongstack directory",
|
|
1164
|
-
err
|
|
1165
|
-
);
|
|
1166
|
-
});
|
|
1167
|
-
}
|
|
1168
|
-
async ensureDirectory() {
|
|
1169
|
-
const dir = path3.dirname(this.registryPath);
|
|
1170
|
-
try {
|
|
1171
|
-
await fs2.mkdir(dir, { recursive: true });
|
|
1172
|
-
} catch (err) {
|
|
1173
|
-
if (!isNodeError(err) || err.code !== "EEXIST") throw err;
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
runInBackground(operation, event, message) {
|
|
1177
|
-
void operation.catch((err) => {
|
|
1178
|
-
emitStructuredLog("warn", event, message, err);
|
|
1179
|
-
});
|
|
1180
|
-
}
|
|
1181
|
-
/**
|
|
1182
|
-
* Start the heartbeat and periodic cleanup tasks.
|
|
1183
|
-
*/
|
|
1184
|
-
start() {
|
|
1185
|
-
if (this.heartbeatInterval) return;
|
|
1186
|
-
this.isShuttingDown = false;
|
|
1187
|
-
this.heartbeat();
|
|
1188
|
-
this.heartbeatInterval = setInterval(() => {
|
|
1189
|
-
this.heartbeat();
|
|
1190
|
-
}, HEARTBEAT_INTERVAL_MS);
|
|
1191
|
-
this.heartbeatInterval.unref?.();
|
|
1192
|
-
this.cleanupInterval = setInterval(() => {
|
|
1193
|
-
this.cleanup();
|
|
1194
|
-
}, STALE_THRESHOLD_MS);
|
|
1195
|
-
this.cleanupInterval.unref?.();
|
|
1196
|
-
this.registerMainProcess();
|
|
1197
|
-
process.on("exit", this.onProcessExit);
|
|
1198
|
-
}
|
|
1199
|
-
/**
|
|
1200
|
-
* Stop the heartbeat and clean up.
|
|
1201
|
-
*/
|
|
1202
|
-
stop() {
|
|
1203
|
-
this.isShuttingDown = true;
|
|
1204
|
-
if (this.heartbeatInterval) {
|
|
1205
|
-
clearInterval(this.heartbeatInterval);
|
|
1206
|
-
this.heartbeatInterval = null;
|
|
1207
|
-
}
|
|
1208
|
-
if (this.cleanupInterval) {
|
|
1209
|
-
clearInterval(this.cleanupInterval);
|
|
1210
|
-
this.cleanupInterval = null;
|
|
1211
|
-
}
|
|
1212
|
-
process.off("exit", this.onProcessExit);
|
|
1213
|
-
this.runInBackground(
|
|
1214
|
-
this.syncToPersistent(),
|
|
1215
|
-
"process_registry.stop_sync_failed",
|
|
1216
|
-
"PersistentProcessRegistry: stop sync failed"
|
|
1217
|
-
);
|
|
1218
|
-
}
|
|
1219
|
-
/**
|
|
1220
|
-
* Register the main WrongStack process as protected.
|
|
1221
|
-
*/
|
|
1222
|
-
registerMainProcess() {
|
|
1223
|
-
const mainPid = process.pid;
|
|
1224
|
-
this.runInBackground(
|
|
1225
|
-
this.updatePersistentEntry({
|
|
1226
|
-
pid: mainPid,
|
|
1227
|
-
name: "wrongstack-main",
|
|
1228
|
-
command: process.argv.slice(0, 3).join(" "),
|
|
1229
|
-
startedAt: Date.now(),
|
|
1230
|
-
lastHeartbeat: Date.now(),
|
|
1231
|
-
instanceId: this.instanceId,
|
|
1232
|
-
hostname: os2.hostname(),
|
|
1233
|
-
protected: true,
|
|
1234
|
-
spawnMode: "main",
|
|
1235
|
-
parentPid: process.ppid,
|
|
1236
|
-
platform: process.platform
|
|
1237
|
-
}),
|
|
1238
|
-
"process_registry.main_register_failed",
|
|
1239
|
-
"PersistentProcessRegistry: failed to register main process"
|
|
1240
|
-
);
|
|
1241
|
-
}
|
|
1242
|
-
/**
|
|
1243
|
-
* Register a spawned child process with the persistent registry.
|
|
1244
|
-
*/
|
|
1245
|
-
registerChildProcess(pid, name, command, sessionId, spawnMode = "spawn") {
|
|
1246
|
-
const entry = {
|
|
1247
|
-
pid,
|
|
1248
|
-
name,
|
|
1249
|
-
command,
|
|
1250
|
-
startedAt: Date.now(),
|
|
1251
|
-
lastHeartbeat: Date.now(),
|
|
1252
|
-
instanceId: this.instanceId,
|
|
1253
|
-
hostname: os2.hostname(),
|
|
1254
|
-
protected: true,
|
|
1255
|
-
// All WrongStack child processes are protected by default
|
|
1256
|
-
spawnMode,
|
|
1257
|
-
parentPid: process.pid,
|
|
1258
|
-
platform: process.platform
|
|
1259
|
-
};
|
|
1260
|
-
if (sessionId) {
|
|
1261
|
-
entry.sessionId = sessionId;
|
|
1262
|
-
}
|
|
1263
|
-
this.runInBackground(
|
|
1264
|
-
this.updatePersistentEntry(entry),
|
|
1265
|
-
"process_registry.child_register_failed",
|
|
1266
|
-
"PersistentProcessRegistry: failed to register child process"
|
|
1267
|
-
);
|
|
1268
|
-
}
|
|
1269
|
-
/**
|
|
1270
|
-
* Update or add an entry in the persistent registry.
|
|
1271
|
-
*/
|
|
1272
|
-
async updatePersistentEntry(entry) {
|
|
1273
|
-
const release = await acquireLock(this.lockPath);
|
|
1274
|
-
try {
|
|
1275
|
-
const data = await readRegistryFile(this.registryPath);
|
|
1276
|
-
data.instances.set(String(entry.pid), entry);
|
|
1277
|
-
this.baseRegistry.register({
|
|
1278
|
-
pid: entry.pid,
|
|
1279
|
-
name: entry.name,
|
|
1280
|
-
command: entry.command,
|
|
1281
|
-
startedAt: entry.startedAt,
|
|
1282
|
-
sessionId: entry.sessionId,
|
|
1283
|
-
protected: entry.protected,
|
|
1284
|
-
child: null
|
|
1285
|
-
});
|
|
1286
|
-
await writeRegistryFile(this.registryPath, data);
|
|
1287
|
-
} finally {
|
|
1288
|
-
await release();
|
|
1289
|
-
}
|
|
1290
|
-
}
|
|
1291
|
-
/**
|
|
1292
|
-
* Unregister a process from the persistent registry.
|
|
1293
|
-
*/
|
|
1294
|
-
async unregister(pid) {
|
|
1295
|
-
const release = await acquireLock(this.lockPath);
|
|
1296
|
-
try {
|
|
1297
|
-
const data = await readRegistryFile(this.registryPath);
|
|
1298
|
-
data.instances.delete(String(pid));
|
|
1299
|
-
await writeRegistryFile(this.registryPath, data);
|
|
1300
|
-
} finally {
|
|
1301
|
-
await release();
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1304
|
-
/**
|
|
1305
|
-
* Send heartbeat to mark all this instance's processes as alive.
|
|
1306
|
-
*/
|
|
1307
|
-
heartbeat() {
|
|
1308
|
-
if (this.isShuttingDown || this.heartbeatRunning) return;
|
|
1309
|
-
this.heartbeatRunning = true;
|
|
1310
|
-
void this.syncToPersistent().catch((err) => {
|
|
1311
|
-
emitStructuredLog(
|
|
1312
|
-
"warn",
|
|
1313
|
-
"process_registry.heartbeat_failed",
|
|
1314
|
-
"PersistentProcessRegistry: heartbeat failed",
|
|
1315
|
-
err
|
|
1316
|
-
);
|
|
1317
|
-
}).finally(() => {
|
|
1318
|
-
this.heartbeatRunning = false;
|
|
1319
|
-
});
|
|
1320
|
-
}
|
|
1321
|
-
cleanup() {
|
|
1322
|
-
if (this.isShuttingDown || this.cleanupRunning) return;
|
|
1323
|
-
this.cleanupRunning = true;
|
|
1324
|
-
void this.cleanupStaleEntries().catch((err) => {
|
|
1325
|
-
emitStructuredLog(
|
|
1326
|
-
"warn",
|
|
1327
|
-
"process_registry.periodic_cleanup_failed",
|
|
1328
|
-
"PersistentProcessRegistry: periodic cleanup failed",
|
|
1329
|
-
err
|
|
1330
|
-
);
|
|
1331
|
-
}).finally(() => {
|
|
1332
|
-
this.cleanupRunning = false;
|
|
1333
|
-
});
|
|
1334
|
-
}
|
|
1335
|
-
/**
|
|
1336
|
-
* Sync this instance's processes to the persistent registry.
|
|
1337
|
-
*/
|
|
1338
|
-
async syncToPersistent() {
|
|
1339
|
-
const release = await acquireLock(this.lockPath);
|
|
1340
|
-
try {
|
|
1341
|
-
const data = await readRegistryFile(this.registryPath);
|
|
1342
|
-
const now = Date.now();
|
|
1343
|
-
const updatedInstances = /* @__PURE__ */ new Map();
|
|
1344
|
-
for (const [_pidStr, entry] of data.instances) {
|
|
1345
|
-
if (entry.instanceId === this.instanceId) {
|
|
1346
|
-
entry.lastHeartbeat = now;
|
|
1347
|
-
}
|
|
1348
|
-
if (entry.instanceId === this.instanceId || now - entry.lastHeartbeat < STALE_THRESHOLD_MS) {
|
|
1349
|
-
updatedInstances.set(_pidStr, entry);
|
|
1350
|
-
}
|
|
1351
|
-
}
|
|
1352
|
-
data.instances = updatedInstances;
|
|
1353
|
-
data.lastCleanup = now;
|
|
1354
|
-
await writeRegistryFile(this.registryPath, data);
|
|
1355
|
-
} catch (err) {
|
|
1356
|
-
emitStructuredLog(
|
|
1357
|
-
"warn",
|
|
1358
|
-
"process_registry.sync_failed",
|
|
1359
|
-
"PersistentProcessRegistry: sync failed",
|
|
1360
|
-
err
|
|
1361
|
-
);
|
|
1362
|
-
} finally {
|
|
1363
|
-
await release();
|
|
1364
|
-
}
|
|
1365
|
-
}
|
|
1366
|
-
/**
|
|
1367
|
-
* Remove entries for processes that are no longer running.
|
|
1368
|
-
*/
|
|
1369
|
-
async cleanupStaleEntries() {
|
|
1370
|
-
const release = await acquireLock(this.lockPath);
|
|
1371
|
-
try {
|
|
1372
|
-
const data = await readRegistryFile(this.registryPath);
|
|
1373
|
-
const now = Date.now();
|
|
1374
|
-
const stalePids = [];
|
|
1375
|
-
for (const [_pidStr, entry] of data.instances) {
|
|
1376
|
-
const age = now - entry.lastHeartbeat;
|
|
1377
|
-
if (age > STALE_THRESHOLD_MS) {
|
|
1378
|
-
try {
|
|
1379
|
-
if (process.platform !== "win32") {
|
|
1380
|
-
process.kill(entry.pid, 0);
|
|
1381
|
-
} else {
|
|
1382
|
-
emitStructuredLog(
|
|
1383
|
-
"debug",
|
|
1384
|
-
"process_registry.stale_pid_check",
|
|
1385
|
-
`PersistentProcessRegistry: checking stale pid ${entry.pid} (${age}ms old)`
|
|
1386
|
-
);
|
|
1387
|
-
}
|
|
1388
|
-
} catch {
|
|
1389
|
-
stalePids.push(_pidStr);
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1392
|
-
}
|
|
1393
|
-
if (stalePids.length > 0) {
|
|
1394
|
-
for (const pidStr of stalePids) {
|
|
1395
|
-
data.instances.delete(pidStr);
|
|
1396
|
-
}
|
|
1397
|
-
await writeRegistryFile(this.registryPath, data);
|
|
1398
|
-
}
|
|
1399
|
-
} catch (err) {
|
|
1400
|
-
emitStructuredLog(
|
|
1401
|
-
"warn",
|
|
1402
|
-
"process_registry.cleanup_failed",
|
|
1403
|
-
"PersistentProcessRegistry: cleanup failed",
|
|
1404
|
-
err
|
|
1405
|
-
);
|
|
1406
|
-
} finally {
|
|
1407
|
-
await release();
|
|
1408
|
-
}
|
|
1409
|
-
}
|
|
1410
|
-
/**
|
|
1411
|
-
* Check if a PID belongs to a WrongStack process and should be protected.
|
|
1412
|
-
*/
|
|
1413
|
-
async isProtectedPid(pid) {
|
|
1414
|
-
const release = await acquireLock(this.lockPath);
|
|
1415
|
-
try {
|
|
1416
|
-
const data = await readRegistryFile(this.registryPath);
|
|
1417
|
-
const entry = data.instances.get(String(pid));
|
|
1418
|
-
if (!entry) return false;
|
|
1419
|
-
if (Date.now() - entry.lastHeartbeat > STALE_THRESHOLD_MS) {
|
|
1420
|
-
return false;
|
|
1421
|
-
}
|
|
1422
|
-
return entry.protected;
|
|
1423
|
-
} finally {
|
|
1424
|
-
await release();
|
|
1425
|
-
}
|
|
1426
|
-
}
|
|
1427
|
-
/**
|
|
1428
|
-
* Get all protected PIDs from all WrongStack instances.
|
|
1429
|
-
*/
|
|
1430
|
-
async getAllProtectedPids() {
|
|
1431
|
-
const release = await acquireLock(this.lockPath);
|
|
1432
|
-
try {
|
|
1433
|
-
const data = await readRegistryFile(this.registryPath);
|
|
1434
|
-
const now = Date.now();
|
|
1435
|
-
const protectedPids = [];
|
|
1436
|
-
for (const [_pidStr, entry] of data.instances) {
|
|
1437
|
-
if (entry.protected && now - entry.lastHeartbeat < STALE_THRESHOLD_MS) {
|
|
1438
|
-
protectedPids.push(entry.pid);
|
|
1439
|
-
}
|
|
1440
|
-
}
|
|
1441
|
-
return protectedPids;
|
|
1442
|
-
} finally {
|
|
1443
|
-
await release();
|
|
1444
|
-
}
|
|
1445
|
-
}
|
|
1446
|
-
/**
|
|
1447
|
-
* Get complete status of all tracked processes across all instances.
|
|
1448
|
-
*/
|
|
1449
|
-
async getGlobalStatus() {
|
|
1450
|
-
const release = await acquireLock(this.lockPath);
|
|
1451
|
-
try {
|
|
1452
|
-
const data = await readRegistryFile(this.registryPath);
|
|
1453
|
-
const now = Date.now();
|
|
1454
|
-
const instances = /* @__PURE__ */ new Map();
|
|
1455
|
-
let protectedCount = 0;
|
|
1456
|
-
let staleCount = 0;
|
|
1457
|
-
for (const [_pidStr, entry] of data.instances) {
|
|
1458
|
-
const instanceEntries = instances.get(entry.instanceId) ?? [];
|
|
1459
|
-
instanceEntries.push(entry);
|
|
1460
|
-
instances.set(entry.instanceId, instanceEntries);
|
|
1461
|
-
if (entry.protected) protectedCount++;
|
|
1462
|
-
if (now - entry.lastHeartbeat > STALE_THRESHOLD_MS) staleCount++;
|
|
1463
|
-
}
|
|
1464
|
-
return {
|
|
1465
|
-
instances,
|
|
1466
|
-
totalProcesses: data.instances.size,
|
|
1467
|
-
protectedCount,
|
|
1468
|
-
staleCount
|
|
1469
|
-
};
|
|
1470
|
-
} finally {
|
|
1471
|
-
await release();
|
|
1472
|
-
}
|
|
1473
|
-
}
|
|
1474
|
-
/**
|
|
1475
|
-
* Get the instance ID for this process.
|
|
1476
|
-
*/
|
|
1477
|
-
getInstanceId() {
|
|
1478
|
-
return this.instanceId;
|
|
1479
|
-
}
|
|
1480
|
-
/**
|
|
1481
|
-
* Check if a kill command should be blocked.
|
|
1482
|
-
* Returns true if the kill should be blocked (target is a WrongStack process).
|
|
1483
|
-
*/
|
|
1484
|
-
async shouldBlockKill(pid) {
|
|
1485
|
-
const protectedPids = await this.getAllProtectedPids();
|
|
1486
|
-
return protectedPids.includes(pid);
|
|
1487
|
-
}
|
|
1488
|
-
/**
|
|
1489
|
-
* Add a pattern-based protection rule.
|
|
1490
|
-
* Processes whose command matches any protected pattern are protected.
|
|
1491
|
-
*/
|
|
1492
|
-
async addProtectedPattern(pattern) {
|
|
1493
|
-
const release = await acquireLock(this.lockPath);
|
|
1494
|
-
try {
|
|
1495
|
-
const data = await readRegistryFile(this.registryPath);
|
|
1496
|
-
if (!data.protectedPatterns.includes(pattern)) {
|
|
1497
|
-
data.protectedPatterns.push(pattern);
|
|
1498
|
-
await writeRegistryFile(this.registryPath, data);
|
|
1499
|
-
}
|
|
1500
|
-
} finally {
|
|
1501
|
-
await release();
|
|
1502
|
-
}
|
|
1503
|
-
}
|
|
1504
|
-
};
|
|
1505
|
-
var _persistentRegistry;
|
|
1506
|
-
function getPersistentProcessRegistry() {
|
|
1507
|
-
if (!_persistentRegistry) {
|
|
1508
|
-
_persistentRegistry = new PersistentProcessRegistry();
|
|
1509
|
-
}
|
|
1510
|
-
return _persistentRegistry;
|
|
1511
|
-
}
|
|
1512
|
-
|
|
1513
|
-
// src/bash-kill-guard.ts
|
|
1514
|
-
var isWin = os3.platform() === "win32";
|
|
1515
|
-
var SCRIPT_KILL_RE = /^(?:\.\\|\.\/)?(?:kill|terminate|stop)\S*\.(?:ps1|bat|cmd|sh)(?:\s|$)/i;
|
|
1516
|
-
var SCRIPT_KILL_RE_POSIX = /^(?:\.\/)?(?:kill|terminate|stop)\S*\.sh(?:\s|$)/i;
|
|
1517
|
-
var SCRIPT_KILL_FALLBACK_RE = /^\S*(?:kill|terminate|stop)\S*\.(?:ps1|bat|cmd|sh)\b/i;
|
|
1518
|
-
function extractKillCommand(command) {
|
|
1519
|
-
const normalized = command.replace(/\s+/g, " ").trim();
|
|
1520
|
-
const shellCMatch = normalized.match(/^.+?\s+-c\s+(['"])([\s\S]+)\1$/);
|
|
1521
|
-
if (shellCMatch?.[2]) {
|
|
1522
|
-
const inner = shellCMatch[2].trim();
|
|
1523
|
-
return isKillRelatedCommand(inner) ? inner : null;
|
|
1524
|
-
}
|
|
1525
|
-
const shellCUnquoted = normalized.match(
|
|
1526
|
-
/^.+?\s+-c\s+(kill(?:\s+-s\s+[a-zA-Z0-9]+|\s+-[a-zA-Z0-9]+)?\s+\d+)$/
|
|
1527
|
-
);
|
|
1528
|
-
if (shellCUnquoted?.[1]) {
|
|
1529
|
-
return shellCUnquoted[1];
|
|
1530
|
-
}
|
|
1531
|
-
return null;
|
|
1532
|
-
}
|
|
1533
|
-
function isKillRelatedCommand(cmd) {
|
|
1534
|
-
const normalized = cmd.toLowerCase().replace(/\s+/g, " ").trim();
|
|
1535
|
-
if (isWin) {
|
|
1536
|
-
if (/^taskkill\s/i.test(normalized)) return true;
|
|
1537
|
-
if (/^tskill\s/i.test(normalized)) return true;
|
|
1538
|
-
if (/^(stop-process|kill|stop)\s/i.test(normalized)) return true;
|
|
1539
|
-
if (/^wmic\s+process\s/i.test(normalized) && /\bdelete\b/i.test(normalized)) return true;
|
|
1540
|
-
if (SCRIPT_KILL_RE.test(normalized)) return true;
|
|
1541
|
-
return false;
|
|
1542
|
-
}
|
|
1543
|
-
if (/^kill(\s|$)/.test(normalized)) return true;
|
|
1544
|
-
if (/^(pkill|killall|pgrep|skill)\s/.test(normalized)) return true;
|
|
1545
|
-
if (/^\/proc\/\d+\/(?:kill|fd)/.test(normalized)) return true;
|
|
1546
|
-
if (SCRIPT_KILL_RE_POSIX.test(normalized)) return true;
|
|
1547
|
-
return false;
|
|
1548
|
-
}
|
|
1549
|
-
function parseKillCommand(command) {
|
|
1550
|
-
const normalized = command.replace(/\s+/g, " ").trim();
|
|
1551
|
-
if (isWin) {
|
|
1552
|
-
const hasTaskkillForce = /(?:^|\s)\/F(?=\s|$)/i.test(normalized);
|
|
1553
|
-
const isSimpleTaskkill = /^taskkill\s+/i.test(normalized) && !/[|&<>]/.test(normalized);
|
|
1554
|
-
const taskkillPidMatch = isSimpleTaskkill ? normalized.match(/(?:^|\s)\/PID\s+(\d+)(?=\s|$)/i) : null;
|
|
1555
|
-
if (taskkillPidMatch?.[1]) {
|
|
1556
|
-
return {
|
|
1557
|
-
pid: parseInt(taskkillPidMatch[1], 10),
|
|
1558
|
-
signal: hasTaskkillForce ? "FORCE" : "TERM",
|
|
1559
|
-
isGroupKill: false,
|
|
1560
|
-
isAllKill: false,
|
|
1561
|
-
originalCommand: command
|
|
1562
|
-
};
|
|
1563
|
-
}
|
|
1564
|
-
const taskkillImMatch = isSimpleTaskkill ? normalized.match(/(?:^|\s)\/IM\s+([^\s/]+)(?=\s|$)/i) : null;
|
|
1565
|
-
if (taskkillImMatch?.[1]) {
|
|
1566
|
-
return {
|
|
1567
|
-
name: taskkillImMatch[1],
|
|
1568
|
-
signal: hasTaskkillForce ? "FORCE" : "TERM",
|
|
1569
|
-
isGroupKill: false,
|
|
1570
|
-
isAllKill: false,
|
|
1571
|
-
originalCommand: command
|
|
1572
|
-
};
|
|
1573
|
-
}
|
|
1574
|
-
const taskkillFiMatch = isSimpleTaskkill ? normalized.match(/(?:^|\s)\/FI\s+"IMAGENAME\s+eq\s+([^"]+)"(?=\s|$)/i) : null;
|
|
1575
|
-
if (taskkillFiMatch?.[1]) {
|
|
1576
|
-
return {
|
|
1577
|
-
name: taskkillFiMatch[1],
|
|
1578
|
-
signal: hasTaskkillForce ? "FORCE" : "TERM",
|
|
1579
|
-
isGroupKill: false,
|
|
1580
|
-
isAllKill: false,
|
|
1581
|
-
originalCommand: command
|
|
1582
|
-
};
|
|
1583
|
-
}
|
|
1584
|
-
const tskillMatch = normalized.match(/^tskill\s+(\d+)/i);
|
|
1585
|
-
if (tskillMatch?.[1]) {
|
|
1586
|
-
return {
|
|
1587
|
-
pid: parseInt(tskillMatch[1], 10),
|
|
1588
|
-
signal: "TERM",
|
|
1589
|
-
isGroupKill: false,
|
|
1590
|
-
isAllKill: false,
|
|
1591
|
-
originalCommand: command
|
|
1592
|
-
};
|
|
1593
|
-
}
|
|
1594
|
-
const isStopProcIdCommand = /^(?:stop-process|kill)(?:\s+-(?:id|pid)\s+\d+|\s+-[a-zA-Z]+)+$/i.test(normalized);
|
|
1595
|
-
const stopProcIdMatch = normalized.match(/(?:^|\s)-(?:id|pid)\s+(\d+)(?=\s|$)/i);
|
|
1596
|
-
if (isStopProcIdCommand && stopProcIdMatch?.[1]) {
|
|
1597
|
-
return {
|
|
1598
|
-
pid: parseInt(stopProcIdMatch[1], 10),
|
|
1599
|
-
signal: "FORCE",
|
|
1600
|
-
isGroupKill: false,
|
|
1601
|
-
isAllKill: false,
|
|
1602
|
-
originalCommand: command
|
|
1603
|
-
};
|
|
1604
|
-
}
|
|
1605
|
-
const killSignalOptionMatch = normalized.match(/^kill\s+-s\s+([a-zA-Z0-9]+)\s+(\d+)$/i);
|
|
1606
|
-
if (killSignalOptionMatch?.[1] && killSignalOptionMatch[2]) {
|
|
1607
|
-
return {
|
|
1608
|
-
pid: parseInt(killSignalOptionMatch[2], 10),
|
|
1609
|
-
signal: killSignalOptionMatch[1].toUpperCase(),
|
|
1610
|
-
isGroupKill: false,
|
|
1611
|
-
isAllKill: false,
|
|
1612
|
-
originalCommand: command
|
|
1613
|
-
};
|
|
1614
|
-
}
|
|
1615
|
-
const killPosixMatch = normalized.match(/^kill\s+(?:(-[a-zA-Z0-9]+)\s+)?(\d+)$/);
|
|
1616
|
-
if (killPosixMatch?.[2]) {
|
|
1617
|
-
const sig = killPosixMatch[1] ? killPosixMatch[1].slice(1).toUpperCase() : "TERM";
|
|
1618
|
-
return {
|
|
1619
|
-
pid: parseInt(killPosixMatch[2], 10),
|
|
1620
|
-
signal: sig,
|
|
1621
|
-
isGroupKill: false,
|
|
1622
|
-
isAllKill: false,
|
|
1623
|
-
originalCommand: command
|
|
1624
|
-
};
|
|
1625
|
-
}
|
|
1626
|
-
const stopProcNameMatch = normalized.match(
|
|
1627
|
-
/^(?:stop-process|kill)\s+-(?:name|n)\s+(?:['"]([a-zA-Z0-9_.-]+)['"]|([a-zA-Z0-9_.-]+))(?:\s|$)/i
|
|
1628
|
-
);
|
|
1629
|
-
const stopProcName = stopProcNameMatch?.[1] ?? stopProcNameMatch?.[2];
|
|
1630
|
-
if (stopProcName) {
|
|
1631
|
-
return {
|
|
1632
|
-
name: stopProcName,
|
|
1633
|
-
signal: "FORCE",
|
|
1634
|
-
isGroupKill: false,
|
|
1635
|
-
isAllKill: false,
|
|
1636
|
-
originalCommand: command
|
|
1637
|
-
};
|
|
1638
|
-
}
|
|
1639
|
-
const stopProcStandalone = normalized.match(
|
|
1640
|
-
/^(?:stop-process|kill)\s+['"]?([a-zA-Z][a-zA-Z0-9_.-]+)['"]?$/i
|
|
1641
|
-
);
|
|
1642
|
-
if (stopProcStandalone?.[1]) {
|
|
1643
|
-
return {
|
|
1644
|
-
name: stopProcStandalone[1],
|
|
1645
|
-
signal: "FORCE",
|
|
1646
|
-
isGroupKill: false,
|
|
1647
|
-
isAllKill: false,
|
|
1648
|
-
originalCommand: command
|
|
1649
|
-
};
|
|
1650
|
-
}
|
|
1651
|
-
const wmicMatch = normalized.match(
|
|
1652
|
-
/^wmic\s+process\s+where\s+['"]?(?:name\s*=\s*['"]?)([a-zA-Z0-9_.-]+)/i
|
|
1653
|
-
);
|
|
1654
|
-
if (wmicMatch?.[1]) {
|
|
1655
|
-
return {
|
|
1656
|
-
name: wmicMatch[1],
|
|
1657
|
-
signal: "FORCE",
|
|
1658
|
-
isGroupKill: false,
|
|
1659
|
-
isAllKill: false,
|
|
1660
|
-
originalCommand: command
|
|
1661
|
-
};
|
|
1662
|
-
}
|
|
1663
|
-
const wmicPidMatch = normalized.match(
|
|
1664
|
-
/^wmic\s+process\s+where\s+['"]?processid\s*=\s*['"]?(\d+)/i
|
|
1665
|
-
);
|
|
1666
|
-
if (wmicPidMatch?.[1]) {
|
|
1667
|
-
return {
|
|
1668
|
-
pid: parseInt(wmicPidMatch[1], 10),
|
|
1669
|
-
signal: "FORCE",
|
|
1670
|
-
isGroupKill: false,
|
|
1671
|
-
isAllKill: false,
|
|
1672
|
-
originalCommand: command
|
|
1673
|
-
};
|
|
1674
|
-
}
|
|
1675
|
-
const killScriptMatch = normalized.match(SCRIPT_KILL_RE);
|
|
1676
|
-
if (killScriptMatch) {
|
|
1677
|
-
return {
|
|
1678
|
-
name: "kill-script",
|
|
1679
|
-
// sentinel — isKillProtected always blocks "kill-script"
|
|
1680
|
-
signal: "FORCE",
|
|
1681
|
-
isGroupKill: false,
|
|
1682
|
-
isAllKill: false,
|
|
1683
|
-
originalCommand: command
|
|
1684
|
-
};
|
|
1685
|
-
}
|
|
1686
|
-
return null;
|
|
1687
|
-
}
|
|
1688
|
-
const signalOptionMatch = normalized.match(/^kill\s+-s\s+([a-zA-Z0-9]+)\s+(\d+|-?\d+)$/i);
|
|
1689
|
-
if (signalOptionMatch?.[1] && signalOptionMatch[2]) {
|
|
1690
|
-
const pidOrGroup = signalOptionMatch[2];
|
|
1691
|
-
const isGroupKill = pidOrGroup.startsWith("-");
|
|
1692
|
-
return {
|
|
1693
|
-
pid: parseInt(isGroupKill ? pidOrGroup.slice(1) : pidOrGroup, 10),
|
|
1694
|
-
signal: signalOptionMatch[1].toUpperCase(),
|
|
1695
|
-
isGroupKill,
|
|
1696
|
-
isAllKill: false,
|
|
1697
|
-
originalCommand: command
|
|
1698
|
-
};
|
|
1699
|
-
}
|
|
1700
|
-
const simpleMatch = normalized.match(/^kill\s+(?:(-[a-zA-Z0-9]+)\s+)?(\d+|-?\d+)$/);
|
|
1701
|
-
if (simpleMatch) {
|
|
1702
|
-
const signal = simpleMatch[1] ?? "-TERM";
|
|
1703
|
-
const pidOrGroup = simpleMatch[2];
|
|
1704
|
-
if (!pidOrGroup) return null;
|
|
1705
|
-
const isGroupKill = pidOrGroup.startsWith("-");
|
|
1706
|
-
const pid = isGroupKill ? parseInt(pidOrGroup.slice(1), 10) : parseInt(pidOrGroup, 10);
|
|
1707
|
-
return {
|
|
1708
|
-
pid,
|
|
1709
|
-
signal: signal.slice(1),
|
|
1710
|
-
isGroupKill,
|
|
1711
|
-
isAllKill: false,
|
|
1712
|
-
originalCommand: command
|
|
1713
|
-
};
|
|
1714
|
-
}
|
|
1715
|
-
const pkillMatch = normalized.match(/^pkill\s+(?:(-[a-zA-Z]+)\s+)?(.+)$/);
|
|
1716
|
-
if (pkillMatch?.[2]) {
|
|
1717
|
-
const name = pkillMatch[2];
|
|
1718
|
-
const signalMatch = pkillMatch[1];
|
|
1719
|
-
return {
|
|
1720
|
-
name,
|
|
1721
|
-
signal: signalMatch ? signalMatch.slice(1) : "TERM",
|
|
1722
|
-
isGroupKill: false,
|
|
1723
|
-
isAllKill: false,
|
|
1724
|
-
originalCommand: command
|
|
1725
|
-
};
|
|
1726
|
-
}
|
|
1727
|
-
const killallMatch = normalized.match(/^killall\s+(?:(-[a-zA-Z]+)\s+)?(.+)$/);
|
|
1728
|
-
if (killallMatch?.[2]) {
|
|
1729
|
-
const name = killallMatch[2];
|
|
1730
|
-
const signalMatch = killallMatch[1];
|
|
1731
|
-
return {
|
|
1732
|
-
name,
|
|
1733
|
-
signal: signalMatch ? signalMatch.slice(1) : "TERM",
|
|
1734
|
-
isGroupKill: false,
|
|
1735
|
-
isAllKill: false,
|
|
1736
|
-
originalCommand: command
|
|
1737
|
-
};
|
|
1738
|
-
}
|
|
1739
|
-
const pgrepMatch = normalized.match(/^pgrep\s+(.+)$/);
|
|
1740
|
-
if (pgrepMatch) {
|
|
1741
|
-
return null;
|
|
1742
|
-
}
|
|
1743
|
-
const posixScriptMatch = normalized.match(SCRIPT_KILL_RE_POSIX);
|
|
1744
|
-
if (posixScriptMatch) {
|
|
1745
|
-
return {
|
|
1746
|
-
name: "kill-script",
|
|
1747
|
-
signal: "FORCE",
|
|
1748
|
-
isGroupKill: false,
|
|
1749
|
-
isAllKill: false,
|
|
1750
|
-
originalCommand: command
|
|
1751
|
-
};
|
|
1752
|
-
}
|
|
1753
|
-
return null;
|
|
1754
|
-
}
|
|
1755
|
-
async function getProtectedEntries() {
|
|
1756
|
-
const registry = getPersistentProcessRegistry();
|
|
1757
|
-
const status = await registry.getGlobalStatus();
|
|
1758
|
-
const entries = [];
|
|
1759
|
-
for (const instanceEntries of status.instances.values()) {
|
|
1760
|
-
for (const entry of instanceEntries) {
|
|
1761
|
-
if (entry.protected && Date.now() - entry.lastHeartbeat < 3e4) {
|
|
1762
|
-
entries.push(entry);
|
|
1763
|
-
}
|
|
1764
|
-
}
|
|
1765
|
-
}
|
|
1766
|
-
return entries;
|
|
1767
|
-
}
|
|
1768
|
-
async function isKillProtected(kill) {
|
|
1769
|
-
const registry = getPersistentProcessRegistry();
|
|
1770
|
-
if (kill.name === "kill-script") {
|
|
1771
|
-
return true;
|
|
1772
|
-
}
|
|
1773
|
-
if (kill.name) {
|
|
1774
|
-
const entries = await getProtectedEntries();
|
|
1775
|
-
const killNameLower = kill.name.toLowerCase();
|
|
1776
|
-
for (const entry of entries) {
|
|
1777
|
-
if (entry.name?.toLowerCase().includes(killNameLower)) {
|
|
1778
|
-
return true;
|
|
1779
|
-
}
|
|
1780
|
-
}
|
|
1781
|
-
if (killNameLower.includes("wrongstack")) {
|
|
1782
|
-
return true;
|
|
1783
|
-
}
|
|
1784
|
-
if (killNameLower.includes("node") && entries.length > 0) {
|
|
1785
|
-
return true;
|
|
1786
|
-
}
|
|
1787
|
-
return false;
|
|
1788
|
-
}
|
|
1789
|
-
if (kill.isGroupKill) {
|
|
1790
|
-
const protectedPids = await registry.getAllProtectedPids();
|
|
1791
|
-
return protectedPids.length > 0;
|
|
1792
|
-
}
|
|
1793
|
-
if (kill.pid !== void 0) {
|
|
1794
|
-
if (await registry.shouldBlockKill(kill.pid)) return true;
|
|
1795
|
-
if (kill.pid === process.pid || kill.pid === process.ppid) return true;
|
|
1796
|
-
return false;
|
|
1797
|
-
}
|
|
1798
|
-
return false;
|
|
1799
|
-
}
|
|
1800
|
-
async function checkAndBlockKillCommand(command) {
|
|
1801
|
-
const normalized = command.replace(/\s+/g, " ").trim();
|
|
1802
|
-
const killCmd = extractKillCommand(normalized) || (isKillRelatedCommand(normalized) ? normalized : null);
|
|
1803
|
-
if (!killCmd) {
|
|
1804
|
-
return { blocked: false };
|
|
1805
|
-
}
|
|
1806
|
-
const parsed = parseKillCommand(killCmd);
|
|
1807
|
-
if (!parsed) {
|
|
1808
|
-
if (killCmd.includes("kill") && /kill\s+.*\|/.test(killCmd)) {
|
|
1809
|
-
return {
|
|
1810
|
-
blocked: true,
|
|
1811
|
-
reason: `Blocked: complex kill pipeline detected \u2014 "${killCmd.slice(0, 50)}..."`
|
|
1812
|
-
};
|
|
1813
|
-
}
|
|
1814
|
-
if (SCRIPT_KILL_FALLBACK_RE.test(killCmd)) {
|
|
1815
|
-
return {
|
|
1816
|
-
blocked: true,
|
|
1817
|
-
reason: `Blocked: script-based kill detected \u2014 "${killCmd.slice(0, 80)}" may target protected WrongStack processes (cannot inspect script body).`
|
|
1818
|
-
};
|
|
1819
|
-
}
|
|
1820
|
-
return { blocked: false };
|
|
1821
|
-
}
|
|
1822
|
-
if (await isKillProtected(parsed)) {
|
|
1823
|
-
let target;
|
|
1824
|
-
if (parsed.name) {
|
|
1825
|
-
target = `process name "${parsed.name}"`;
|
|
1826
|
-
} else if (parsed.pid !== void 0) {
|
|
1827
|
-
target = `PID ${parsed.pid}`;
|
|
1828
|
-
} else {
|
|
1829
|
-
target = "(unknown target)";
|
|
1830
|
-
}
|
|
1831
|
-
const signal = parsed.signal ? ` (${parsed.signal})` : "";
|
|
1832
|
-
const groupNote = parsed.isGroupKill ? " (process group)" : "";
|
|
1833
|
-
return {
|
|
1834
|
-
blocked: true,
|
|
1835
|
-
reason: `Blocked: kill${signal} ${target}${groupNote} targets a protected WrongStack process.`
|
|
1836
|
-
};
|
|
1837
|
-
}
|
|
1838
|
-
return { blocked: false };
|
|
1839
|
-
}
|
|
1840
|
-
|
|
1841
|
-
// src/bash.ts
|
|
1842
|
-
var MAX_OUTPUT = 32768;
|
|
1843
|
-
var DEFAULT_TIMEOUT_MS = 3e5;
|
|
1844
|
-
var STREAM_FLUSH_INTERVAL_MS = 200;
|
|
1845
|
-
var STREAM_FLUSH_BYTES = 4 * 1024;
|
|
1846
|
-
var MAX_QUEUE_CHUNKS = 500;
|
|
1847
|
-
var bashTool = {
|
|
1848
|
-
name: "bash",
|
|
1849
|
-
category: "Shell",
|
|
1850
|
-
description: "Execute an arbitrary command in the user's default shell (bash/zsh/pwsh/cmd). stdout and stderr are merged into one stream. This is the most powerful and dangerous tool \u2014 it gives the model full access to the developer's machine. Prefer specialized tools whenever possible.",
|
|
1851
|
-
usageHint: "SECURITY WARNING: This tool runs with the full privileges of the current user.\n\nBest practices for the model:\n- Strongly prefer `exec` for known safe commands (node, npm, pnpm, tsc, git, etc.).\n- Use bash only when you genuinely need shell features (pipes, redirection, complex one-liners).\n- Prefer single focused commands over huge `&&` chains.\n- Use `background: true` only for long-running processes (dev servers, watchers).\n- The working directory is the session working dir (changed via `set_working_dir`), defaulting to the project root.\n- Output may be truncated in the middle for very large results.",
|
|
1852
|
-
selection: {
|
|
1853
|
-
doNotUseWhen: "the command is allowlisted and does not require pipes, redirection, or shell expansion.",
|
|
1854
|
-
useInstead: ["exec"]
|
|
1855
|
-
},
|
|
1856
|
-
permission: "confirm",
|
|
1857
|
-
mutating: true,
|
|
1858
|
-
riskTier: "destructive",
|
|
1859
|
-
icon: "terminal",
|
|
1860
|
-
// Trust rules match on the literal `command` string. Without subjectKey
|
|
1861
|
-
// the policy heuristic would have done the same here, but declaring it
|
|
1862
|
-
// explicitly removes the implicit cross-tool aliasing.
|
|
1863
|
-
subjectKey: "command",
|
|
1864
|
-
capabilities: ["shell.arbitrary"],
|
|
1865
|
-
// Executor-level abort ceiling. Must sit ABOVE the per-call `timeout_ms`
|
|
1866
|
-
// ceiling (600_000): the tool's own timer tree-kills and returns a
|
|
1867
|
-
// structured `timed_out: true` result, while the executor's
|
|
1868
|
-
// AbortSignal.timeout is a blunt abort. The old value (300_000) meant any
|
|
1869
|
-
// timeout_ms > 5min was silently cut short by the executor. The 10s margin
|
|
1870
|
-
// covers the kill/teardown window. (The executor additionally clamps to
|
|
1871
|
-
// config `tools.maxToolTimeoutMs`.)
|
|
1872
|
-
timeoutMs: 61e4,
|
|
1873
|
-
maxOutputBytes: MAX_OUTPUT,
|
|
1874
|
-
estimatedDurationMs: 3e4,
|
|
1875
|
-
inputSchema: {
|
|
1876
|
-
type: "object",
|
|
1877
|
-
properties: {
|
|
1878
|
-
command: {
|
|
1879
|
-
type: "string",
|
|
1880
|
-
description: "The exact shell command to run. Prefer simple, focused commands."
|
|
1881
|
-
},
|
|
1882
|
-
timeout_ms: {
|
|
1883
|
-
type: "integer",
|
|
1884
|
-
description: "Optional timeout for this specific command in milliseconds (default 300000, max 600000)."
|
|
1885
|
-
},
|
|
1886
|
-
background: {
|
|
1887
|
-
type: "boolean",
|
|
1888
|
-
description: "If true, launch the process in the background and return the PID immediately."
|
|
1889
|
-
}
|
|
1890
|
-
},
|
|
1891
|
-
required: ["command"]
|
|
1892
|
-
},
|
|
1893
|
-
async execute(input, ctx, opts) {
|
|
1894
|
-
let final;
|
|
1895
|
-
const executeStream = bashTool.executeStream;
|
|
1896
|
-
if (!executeStream) throw new Error("bashTool: stream execution unavailable");
|
|
1897
|
-
for await (const ev of executeStream(input, ctx, opts)) {
|
|
1898
|
-
if (ev.type === "final") final = ev.output;
|
|
1899
|
-
}
|
|
1900
|
-
if (!final) throw new Error("bash: stream ended without final event");
|
|
1901
|
-
return final;
|
|
1902
|
-
},
|
|
1903
|
-
async *executeStream(input, ctx, opts) {
|
|
1904
|
-
if (!input?.command) throw new Error("bash: command is required");
|
|
1905
|
-
const registry = getProcessRegistry();
|
|
1906
|
-
const bypassBreaker = !!input.background;
|
|
1907
|
-
if (!registry.beforeCall(bypassBreaker)) {
|
|
1908
|
-
yield {
|
|
1909
|
-
type: "final",
|
|
1910
|
-
output: {
|
|
1911
|
-
output: "",
|
|
1912
|
-
exit_code: 1,
|
|
1913
|
-
timed_out: false,
|
|
1914
|
-
pid: null,
|
|
1915
|
-
error: "bash: circuit breaker open \u2014 too many consecutive failures or slow calls. Use /kill to inspect or /kill reset to recover."
|
|
1916
|
-
}
|
|
1917
|
-
};
|
|
1918
|
-
return;
|
|
1919
|
-
}
|
|
1920
|
-
const killCheck = await checkAndBlockKillCommand(input.command);
|
|
1921
|
-
if (killCheck.blocked) {
|
|
1922
|
-
yield {
|
|
1923
|
-
type: "final",
|
|
1924
|
-
output: {
|
|
1925
|
-
output: "",
|
|
1926
|
-
exit_code: 1,
|
|
1927
|
-
timed_out: false,
|
|
1928
|
-
pid: null,
|
|
1929
|
-
error: killCheck.reason || "Kill command blocked: targets a protected WrongStack process."
|
|
1930
|
-
}
|
|
1931
|
-
};
|
|
1932
|
-
return;
|
|
1933
|
-
}
|
|
1934
|
-
const PIPE_TO_SHELL_PATTERN = /\|\s*(sh|bash|ksh|zsh|fish|cmd|powershell|pwsh)/i;
|
|
1935
|
-
const pipeToShellNote = PIPE_TO_SHELL_PATTERN.test(input.command) ? "\n\n[wrongstack] Caution: this command pipes output into a shell interpreter (pipe-to-shell). Piped content executes as arbitrary code \u2014 review the source before trusting the result, and prefer downloading to a file and inspecting it first." : "";
|
|
1936
|
-
const timeoutMs = Math.max(1, Math.min(input.timeout_ms ?? DEFAULT_TIMEOUT_MS, 6e5));
|
|
1937
|
-
const isWin2 = os4.platform() === "win32";
|
|
1938
|
-
let plan;
|
|
1939
|
-
let winShellKind;
|
|
1940
|
-
if (isWin2) {
|
|
1941
|
-
const shell2 = pickShell("win32", input.command, {
|
|
1942
|
-
get: (k) => process.env[k]
|
|
1943
|
-
});
|
|
1944
|
-
winShellKind = shell2;
|
|
1945
|
-
const bin = shell2 === "powershell" ? resolvePowerShell("powershell.exe") : shell2 === "pwsh" ? resolvePowerShell("pwsh.exe") : process.env["COMSPEC"] ?? "cmd.exe";
|
|
1946
|
-
plan = {
|
|
1947
|
-
bin,
|
|
1948
|
-
argv: shellArgs(shell2),
|
|
1949
|
-
commandArg: shell2 === "powershell" || shell2 === "pwsh" ? Buffer.from(wrapPowerShellScript(input.command), "utf16le").toString("base64") : input.command
|
|
1950
|
-
};
|
|
1951
|
-
} else {
|
|
1952
|
-
const explicit = process.env["WRONGSTACK_SHELL"];
|
|
1953
|
-
let bin;
|
|
1954
|
-
if (explicit) bin = explicit;
|
|
1955
|
-
else {
|
|
1956
|
-
const fromEnv = process.env["SHELL"];
|
|
1957
|
-
if (fromEnv) {
|
|
1958
|
-
const name = fromEnv.split("/").pop() ?? "";
|
|
1959
|
-
if (["bash", "zsh", "sh", "dash", "fish"].includes(name)) bin = fromEnv;
|
|
1960
|
-
else bin = "/bin/bash";
|
|
1961
|
-
} else bin = "/bin/bash";
|
|
1962
|
-
}
|
|
1963
|
-
plan = { bin, argv: ["-c"], commandArg: input.command };
|
|
1964
|
-
}
|
|
1965
|
-
const shell = plan.bin;
|
|
1966
|
-
const args = [...plan.argv, plan.commandArg];
|
|
1967
|
-
const env = buildChildEnv(ctx.session?.id);
|
|
1968
|
-
const spawnCwd = ctx.workingDir ?? ctx.projectRoot;
|
|
1969
|
-
const detached = !isWin2;
|
|
1970
|
-
const startedAt = Date.now();
|
|
1971
|
-
if (input.background) {
|
|
1972
|
-
const child2 = spawn2(shell, args, {
|
|
1973
|
-
cwd: spawnCwd,
|
|
1974
|
-
env,
|
|
1975
|
-
stdio: ["ignore", "ignore", "ignore"],
|
|
1976
|
-
detached: !isWin2,
|
|
1977
|
-
windowsHide: true
|
|
1978
|
-
});
|
|
1979
|
-
const pid2 = child2.pid;
|
|
1980
|
-
const stdoutBytes2 = 0;
|
|
1981
|
-
const stderrBytes2 = 0;
|
|
1982
|
-
let telemetryCompleted2 = false;
|
|
1983
|
-
emitProcessStarted({
|
|
1984
|
-
...pid2 !== void 0 ? { pid: pid2 } : {},
|
|
1985
|
-
parentPid: process.pid,
|
|
1986
|
-
command: redactCommand(`${shell} ${args.join(" ")}`),
|
|
1987
|
-
args: redactCommand(args.join(" ")).split(" ").filter(Boolean),
|
|
1988
|
-
cwd: spawnCwd,
|
|
1989
|
-
background: true,
|
|
1990
|
-
startedAt: new Date(startedAt).toISOString()
|
|
1991
|
-
});
|
|
1992
|
-
const completeBackground = (exitCode, signal) => {
|
|
1993
|
-
if (telemetryCompleted2) return;
|
|
1994
|
-
telemetryCompleted2 = true;
|
|
1995
|
-
emitProcessCompleted({
|
|
1996
|
-
...pid2 !== void 0 ? { pid: pid2 } : {},
|
|
1997
|
-
exitCode,
|
|
1998
|
-
...signal ? { signal } : {},
|
|
1999
|
-
durationMs: Date.now() - startedAt,
|
|
2000
|
-
stdoutBytes: stdoutBytes2,
|
|
2001
|
-
stderrBytes: stderrBytes2,
|
|
2002
|
-
timedOut: false,
|
|
2003
|
-
endedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2004
|
-
});
|
|
2005
|
-
};
|
|
2006
|
-
if (typeof pid2 === "number") {
|
|
2007
|
-
registry.register({
|
|
2008
|
-
pid: pid2,
|
|
2009
|
-
name: "bash",
|
|
2010
|
-
command: redactCommand(input.command),
|
|
2011
|
-
startedAt: Date.now(),
|
|
2012
|
-
sessionId: ctx.session?.id,
|
|
2013
|
-
child: child2,
|
|
2014
|
-
processGroupLeader: detached && child2.pid === pid2,
|
|
2015
|
-
background: true
|
|
2016
|
-
});
|
|
2017
|
-
child2.on("close", () => registry.unregister(pid2));
|
|
2018
|
-
}
|
|
2019
|
-
child2.on("error", () => {
|
|
2020
|
-
if (typeof pid2 === "number") registry.unregister(pid2);
|
|
2021
|
-
registry.afterCall(Date.now() - startedAt, true, bypassBreaker);
|
|
2022
|
-
completeBackground(1);
|
|
2023
|
-
});
|
|
2024
|
-
child2.on("close", (code, signal) => {
|
|
2025
|
-
registry.afterCall(Date.now() - startedAt, false, bypassBreaker);
|
|
2026
|
-
completeBackground(code ?? (signal ? 1 : 0), signal ?? void 0);
|
|
2027
|
-
});
|
|
2028
|
-
if (typeof pid2 === "number") child2.unref();
|
|
2029
|
-
yield {
|
|
2030
|
-
type: "final",
|
|
2031
|
-
output: {
|
|
2032
|
-
// Background runs have no captured output; the pipe-to-shell caution
|
|
2033
|
-
// (when present) is the only thing worth surfacing.
|
|
2034
|
-
output: pipeToShellNote.trim(),
|
|
2035
|
-
exit_code: null,
|
|
2036
|
-
timed_out: false,
|
|
2037
|
-
pid: pid2
|
|
2038
|
-
}
|
|
2039
|
-
};
|
|
2040
|
-
ctx.recordSideEffect?.({
|
|
2041
|
-
toolUseId: `bash-bg-${Date.now()}`,
|
|
2042
|
-
toolName: "bash",
|
|
2043
|
-
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2044
|
-
input: { command: redactCommand(input.command), background: true },
|
|
2045
|
-
outcome: `launched (pid ${pid2 ?? "unknown"})`,
|
|
2046
|
-
risk: "shell"
|
|
2047
|
-
});
|
|
2048
|
-
return;
|
|
2049
|
-
}
|
|
2050
|
-
const child = spawn2(shell, args, {
|
|
2051
|
-
cwd: spawnCwd,
|
|
2052
|
-
env,
|
|
2053
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
2054
|
-
detached,
|
|
2055
|
-
windowsHide: true,
|
|
2056
|
-
...isWin2 ? {} : { signal: opts.signal }
|
|
2057
|
-
});
|
|
2058
|
-
const pid = child.pid;
|
|
2059
|
-
let stdoutBytes = 0;
|
|
2060
|
-
let stderrBytes = 0;
|
|
2061
|
-
let telemetryCompleted = false;
|
|
2062
|
-
emitProcessStarted({
|
|
2063
|
-
...pid !== void 0 ? { pid } : {},
|
|
2064
|
-
parentPid: process.pid,
|
|
2065
|
-
command: redactCommand(`${shell} ${args.join(" ")}`),
|
|
2066
|
-
args: redactCommand(args.join(" ")).split(" ").filter(Boolean),
|
|
2067
|
-
cwd: spawnCwd,
|
|
2068
|
-
background: false,
|
|
2069
|
-
startedAt: new Date(startedAt).toISOString()
|
|
2070
|
-
});
|
|
2071
|
-
const completeForeground = (exitCode, signal) => {
|
|
2072
|
-
if (telemetryCompleted) return;
|
|
2073
|
-
telemetryCompleted = true;
|
|
2074
|
-
emitProcessCompleted({
|
|
2075
|
-
...pid !== void 0 ? { pid } : {},
|
|
2076
|
-
exitCode,
|
|
2077
|
-
...signal ? { signal } : {},
|
|
2078
|
-
durationMs: Date.now() - startedAt,
|
|
2079
|
-
stdoutBytes,
|
|
2080
|
-
stderrBytes,
|
|
2081
|
-
timedOut,
|
|
2082
|
-
endedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2083
|
-
});
|
|
2084
|
-
};
|
|
2085
|
-
if (typeof pid === "number") {
|
|
2086
|
-
registry.register({
|
|
2087
|
-
pid,
|
|
2088
|
-
name: "bash",
|
|
2089
|
-
command: redactCommand(input.command),
|
|
2090
|
-
startedAt: Date.now(),
|
|
2091
|
-
sessionId: ctx.session?.id,
|
|
2092
|
-
child,
|
|
2093
|
-
processGroupLeader: detached && child.pid === pid
|
|
2094
|
-
});
|
|
2095
|
-
}
|
|
2096
|
-
let buf = "";
|
|
2097
|
-
let pending = "";
|
|
2098
|
-
let timedOut = false;
|
|
2099
|
-
const timers = [];
|
|
2100
|
-
const spool = createOutputSpool({ tool: "bash", thresholdBytes: MAX_OUTPUT });
|
|
2101
|
-
function killWithTimeout(child2, timeoutMs2) {
|
|
2102
|
-
if (isWin2) {
|
|
2103
|
-
if (typeof child2.pid === "number" && child2.exitCode === null) {
|
|
2104
|
-
const attempted = registry.kill(child2.pid, { force: true, graceMs: timeoutMs2 });
|
|
2105
|
-
if (!attempted) {
|
|
2106
|
-
try {
|
|
2107
|
-
child2.kill();
|
|
2108
|
-
} catch {
|
|
2109
|
-
}
|
|
2110
|
-
}
|
|
2111
|
-
} else {
|
|
2112
|
-
try {
|
|
2113
|
-
child2.kill();
|
|
2114
|
-
} catch {
|
|
2115
|
-
}
|
|
2116
|
-
}
|
|
2117
|
-
return;
|
|
2118
|
-
}
|
|
2119
|
-
if (typeof child2.pid === "number") {
|
|
2120
|
-
registry.kill(child2.pid, { graceMs: timeoutMs2 });
|
|
2121
|
-
} else {
|
|
2122
|
-
try {
|
|
2123
|
-
child2.kill("SIGTERM");
|
|
2124
|
-
} catch {
|
|
2125
|
-
}
|
|
2126
|
-
const killTimer = setTimeout(() => {
|
|
2127
|
-
try {
|
|
2128
|
-
child2.kill("SIGKILL");
|
|
2129
|
-
} catch {
|
|
2130
|
-
}
|
|
2131
|
-
}, timeoutMs2);
|
|
2132
|
-
timers.push(killTimer);
|
|
2133
|
-
killTimer.unref?.();
|
|
2134
|
-
}
|
|
2135
|
-
}
|
|
2136
|
-
const timer = setTimeout(() => {
|
|
2137
|
-
timedOut = true;
|
|
2138
|
-
killWithTimeout(child, 2e3);
|
|
2139
|
-
}, timeoutMs);
|
|
2140
|
-
timers.push(timer);
|
|
2141
|
-
timer.unref?.();
|
|
2142
|
-
const onAbort = () => killWithTimeout(child, 2e3);
|
|
2143
|
-
if (isWin2) {
|
|
2144
|
-
if (opts.signal.aborted) onAbort();
|
|
2145
|
-
else opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
2146
|
-
}
|
|
2147
|
-
const queue = [];
|
|
2148
|
-
let resolveNext = null;
|
|
2149
|
-
const push = (c) => {
|
|
2150
|
-
if (resolveNext) {
|
|
2151
|
-
const r = resolveNext;
|
|
2152
|
-
resolveNext = null;
|
|
2153
|
-
r(c);
|
|
2154
|
-
} else {
|
|
2155
|
-
queue.push(c);
|
|
2156
|
-
}
|
|
2157
|
-
};
|
|
2158
|
-
const next = () => new Promise((resolve) => {
|
|
2159
|
-
const c = queue.shift();
|
|
2160
|
-
if (c) resolve(c);
|
|
2161
|
-
else resolveNext = resolve;
|
|
2162
|
-
});
|
|
2163
|
-
let lastFlush = Date.now();
|
|
2164
|
-
const flush = () => {
|
|
2165
|
-
if (pending.length === 0) return null;
|
|
2166
|
-
const text = pending;
|
|
2167
|
-
pending = "";
|
|
2168
|
-
lastFlush = Date.now();
|
|
2169
|
-
return text;
|
|
2170
|
-
};
|
|
2171
|
-
let paused = false;
|
|
2172
|
-
const pauseIfFlooded = () => {
|
|
2173
|
-
if (!paused && queue.length >= MAX_QUEUE_CHUNKS) {
|
|
2174
|
-
paused = true;
|
|
2175
|
-
child.stdout?.pause();
|
|
2176
|
-
child.stderr?.pause();
|
|
2177
|
-
}
|
|
2178
|
-
};
|
|
2179
|
-
const resumeIfDrained = () => {
|
|
2180
|
-
if (paused && queue.length < MAX_QUEUE_CHUNKS) {
|
|
2181
|
-
paused = false;
|
|
2182
|
-
child.stdout?.resume();
|
|
2183
|
-
child.stderr?.resume();
|
|
2184
|
-
}
|
|
2185
|
-
};
|
|
2186
|
-
const stdoutDecoder = new StringDecoder("utf8");
|
|
2187
|
-
const stderrDecoder = new StringDecoder("utf8");
|
|
2188
|
-
const onData = (chunk, stream) => {
|
|
2189
|
-
const text = (stream === "stdout" ? stdoutDecoder : stderrDecoder).write(chunk);
|
|
2190
|
-
if (stream === "stdout") stdoutBytes += chunk.byteLength;
|
|
2191
|
-
else stderrBytes += chunk.byteLength;
|
|
2192
|
-
emitProcessOutput({ pid, stream, chunk });
|
|
2193
|
-
if (buf.length < MAX_OUTPUT) {
|
|
2194
|
-
buf += text.slice(0, MAX_OUTPUT - buf.length);
|
|
2195
|
-
}
|
|
2196
|
-
spool.write(text);
|
|
2197
|
-
pending += text;
|
|
2198
|
-
push({ kind: "data", text });
|
|
2199
|
-
pauseIfFlooded();
|
|
2200
|
-
};
|
|
2201
|
-
const onStdoutData = (chunk) => onData(chunk, "stdout");
|
|
2202
|
-
const onStderrData = (chunk) => onData(chunk, "stderr");
|
|
2203
|
-
child.stdout?.on("data", onStdoutData);
|
|
2204
|
-
child.stderr?.on("data", onStderrData);
|
|
2205
|
-
child.on("error", (err) => {
|
|
2206
|
-
for (const t of timers) clearTimeout(t);
|
|
2207
|
-
registry.afterCall(Date.now() - startedAt, true);
|
|
2208
|
-
completeForeground(1);
|
|
2209
|
-
push({ kind: "error", err });
|
|
2210
|
-
});
|
|
2211
|
-
child.on("close", (code, signal) => {
|
|
2212
|
-
for (const t of timers) clearTimeout(t);
|
|
2213
|
-
if (typeof pid === "number") registry.unregister(pid);
|
|
2214
|
-
registry.afterCall(Date.now() - startedAt, code !== 0 && code !== null);
|
|
2215
|
-
completeForeground(timedOut ? 124 : code ?? (signal ? 1 : 0), signal ?? void 0);
|
|
2216
|
-
const tail = stdoutDecoder.end() + stderrDecoder.end();
|
|
2217
|
-
if (tail) {
|
|
2218
|
-
if (buf.length < MAX_OUTPUT) buf += tail.slice(0, MAX_OUTPUT - buf.length);
|
|
2219
|
-
spool.write(tail);
|
|
2220
|
-
pending += tail;
|
|
2221
|
-
}
|
|
2222
|
-
push({ kind: "end", code });
|
|
2223
|
-
});
|
|
2224
|
-
try {
|
|
2225
|
-
while (true) {
|
|
2226
|
-
const c = await next();
|
|
2227
|
-
resumeIfDrained();
|
|
2228
|
-
if (c.kind === "error") throw c.err;
|
|
2229
|
-
if (c.kind === "end") {
|
|
2230
|
-
const remainder = flush();
|
|
2231
|
-
if (remainder !== null) {
|
|
2232
|
-
yield { type: "partial_output", text: remainder };
|
|
2233
|
-
}
|
|
2234
|
-
const spooled = spool.finalize();
|
|
2235
|
-
const hint = !timedOut && typeof c.code === "number" && c.code !== 0 && winShellKind ? diagnoseBashism(input.command, winShellKind) : void 0;
|
|
2236
|
-
yield {
|
|
2237
|
-
type: "final",
|
|
2238
|
-
output: {
|
|
2239
|
-
output: normalizeCommandOutput(buf) + (spooled ? spoolNote(spooled) : "") + (hint ? `
|
|
2240
|
-
|
|
2241
|
-
${hint}` : "") + pipeToShellNote,
|
|
2242
|
-
exit_code: c.code,
|
|
2243
|
-
timed_out: timedOut
|
|
2244
|
-
}
|
|
2245
|
-
};
|
|
2246
|
-
ctx.recordSideEffect?.({
|
|
2247
|
-
toolUseId: `bash-${Date.now()}`,
|
|
2248
|
-
toolName: "bash",
|
|
2249
|
-
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2250
|
-
input: { command: redactCommand(input.command) },
|
|
2251
|
-
outcome: timedOut ? `timed out (exit ${c.code})` : `exit ${c.code}`,
|
|
2252
|
-
risk: "shell"
|
|
2253
|
-
});
|
|
2254
|
-
return;
|
|
2255
|
-
}
|
|
2256
|
-
const now = Date.now();
|
|
2257
|
-
if (pending.length >= STREAM_FLUSH_BYTES || now - lastFlush >= STREAM_FLUSH_INTERVAL_MS) {
|
|
2258
|
-
const text = flush();
|
|
2259
|
-
if (text) yield { type: "partial_output", text };
|
|
2260
|
-
}
|
|
2261
|
-
}
|
|
2262
|
-
} finally {
|
|
2263
|
-
for (const t of timers) clearTimeout(t);
|
|
2264
|
-
spool.finalize();
|
|
2265
|
-
if (isWin2) opts.signal.removeEventListener("abort", onAbort);
|
|
2266
|
-
child.stdout?.off("data", onStdoutData);
|
|
2267
|
-
child.stderr?.off("data", onStderrData);
|
|
2268
|
-
child.stdout?.destroy();
|
|
2269
|
-
child.stderr?.destroy();
|
|
2270
|
-
if (child.exitCode === null && !child.killed) {
|
|
2271
|
-
if (typeof pid === "number") registry.kill(pid, { force: true });
|
|
2272
|
-
else killWithTimeout(child, 2e3);
|
|
2273
|
-
}
|
|
2274
|
-
}
|
|
2275
|
-
},
|
|
2276
|
-
/**
|
|
2277
|
-
* Tool-level teardown fired by `ToolExecutor.runToolCleanup()` when the
|
|
2278
|
-
* tool's run is aborted/timeout'd. The generator's `finally` block above
|
|
2279
|
-
* already force-kills the direct child, but that only runs if the
|
|
2280
|
-
* executor closes the async iterator (via `iter.return()`). When the
|
|
2281
|
-
* executor tears down without iterating — or a re-entrant abort races
|
|
2282
|
-
* with the generator — a bash-spawned process tree can survive in the
|
|
2283
|
-
* ProcessRegistry with `killed === false`, continuing to write files,
|
|
2284
|
-
* consume CPU, or hold inherited stdio pipes open for the rest of the
|
|
2285
|
-
* session.
|
|
2286
|
-
*
|
|
2287
|
-
* This is the defensive layer the executor calls via `tool.cleanup()`
|
|
2288
|
-
* (see `types/tool.ts`): kill every bash-owned process still tracked
|
|
2289
|
-
* for this session that hasn't exited yet. `registry.kill()` already
|
|
2290
|
-
* handles process-group / taskkill tree-kill and the SIGTERM→SIGKILL
|
|
2291
|
-
* grace window, so this just scopes the registry's existing kill path
|
|
2292
|
-
* to "this session's runaway bash children". Idempotent — a process
|
|
2293
|
-
* that already exited is skipped by `kill()` (it returns false), and a
|
|
2294
|
-
* `protected` infrastructure process (dev server the user intentionally
|
|
2295
|
-
* backgrounded) is left alone by design.
|
|
2296
|
-
*/
|
|
2297
|
-
async cleanup(_input, ctx) {
|
|
2298
|
-
const registry = getProcessRegistry();
|
|
2299
|
-
const sessionId = ctx.session?.id;
|
|
2300
|
-
if (!sessionId) return;
|
|
2301
|
-
for (const entry of registry.bySession(sessionId)) {
|
|
2302
|
-
if (entry.name !== "bash") continue;
|
|
2303
|
-
if (entry.child && entry.child.exitCode !== null) continue;
|
|
2304
|
-
if (entry.background) continue;
|
|
2305
|
-
if (entry.protected) continue;
|
|
2306
|
-
registry.kill(entry.pid, { force: true });
|
|
2307
|
-
}
|
|
2308
|
-
}
|
|
2309
|
-
};
|
|
2
|
+
bashTool
|
|
3
|
+
} from "./chunk-IVWV2UM6.js";
|
|
4
|
+
import "./chunk-BTSFTGIL.js";
|
|
5
|
+
import "./chunk-NKNOBUBU.js";
|
|
6
|
+
import "./chunk-2VSZC6QM.js";
|
|
7
|
+
import "./chunk-3PVYQFRQ.js";
|
|
8
|
+
import "./chunk-HFXSNKCV.js";
|
|
9
|
+
import "./chunk-V43EQ6VR.js";
|
|
10
|
+
import "./chunk-D33S4RQG.js";
|
|
2310
11
|
export {
|
|
2311
12
|
bashTool
|
|
2312
13
|
};
|