refactor(process): adopt Execa execution layer (#105939)

* refactor(process): adopt execa execution layer

* fix(process): preserve launch error semantics

* chore(process): ratchet exec size baseline

* fix(process): preserve Windows and error contracts

* chore(plugin-sdk): ratchet wildcard surface budget

* test(process): allow resolved Windows executable paths

* fix(process): preserve Windows shim completion

* test(process): isolate Execa mocks

* style(process): format Windows exec test

* style(process): apply Windows test formatting

* test(process): normalize Windows system path casing

* fix(process): harden execa edge handling

* test(tui): preserve ESM fixture semantics

* fix(process): preserve PATHEXT lookup contract

* fix(process): keep invocation type internal
This commit is contained in:
Peter Steinberger
2026-07-13 02:21:08 -07:00
committed by GitHub
parent bc691eabbc
commit 4f287dd740
26 changed files with 1754 additions and 1730 deletions
+201 -4
View File
@@ -35,6 +35,7 @@
"diff": "9.0.0",
"dotenv": "17.4.2",
"entities": "8.0.0",
"execa": "9.6.1",
"express": "5.2.1",
"file-type": "22.0.1",
"grammy": "1.44.0",
@@ -529,12 +530,30 @@
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"license": "BSD-3-Clause"
},
"node_modules/@sec-ant/readable-stream": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
"integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
"license": "MIT"
},
"node_modules/@silvia-odwyer/photon-node": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz",
"integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==",
"license": "Apache-2.0"
},
"node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
"integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
@@ -1349,6 +1368,32 @@
"node": ">=18.0.0"
}
},
"node_modules/execa": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
"integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==",
"license": "MIT",
"dependencies": {
"@sindresorhus/merge-streams": "^4.0.0",
"cross-spawn": "^7.0.6",
"figures": "^6.1.0",
"get-stream": "^9.0.0",
"human-signals": "^8.0.1",
"is-plain-obj": "^4.1.0",
"is-stream": "^4.0.1",
"npm-run-path": "^6.0.0",
"pretty-ms": "^9.2.0",
"signal-exit": "^4.1.0",
"strip-final-newline": "^4.0.0",
"yoctocolors": "^2.1.1"
},
"engines": {
"node": "^18.19.0 || >=20.5.0"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
@@ -1491,6 +1536,21 @@
"node": "^12.20 || >= 14.13"
}
},
"node_modules/figures": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
"integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==",
"license": "MIT",
"dependencies": {
"is-unicode-supported": "^2.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/file-type": {
"version": "22.0.1",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-22.0.1.tgz",
@@ -1668,6 +1728,22 @@
"node": ">= 0.4"
}
},
"node_modules/get-stream": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz",
"integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==",
"license": "MIT",
"dependencies": {
"@sec-ant/readable-stream": "^0.4.1",
"is-stream": "^4.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/google-auth-library": {
"version": "10.9.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz",
@@ -1880,6 +1956,15 @@
"node": ">= 14"
}
},
"node_modules/human-signals": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz",
"integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
@@ -1964,12 +2049,48 @@
"node": ">=8"
}
},
"node_modules/is-plain-obj": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
"integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/is-stream": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
"integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-unicode-supported": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
"integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@@ -2343,6 +2464,34 @@
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/npm-run-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
"integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==",
"license": "MIT",
"dependencies": {
"path-key": "^4.0.0",
"unicorn-magic": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/npm-run-path/node_modules/path-key": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
@@ -2635,6 +2784,12 @@
"node": ">= 4"
}
},
"node_modules/proper-lockfile/node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/protobufjs": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.3.tgz",
@@ -3094,10 +3249,16 @@
}
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/sisteransi": {
"version": "1.0.5",
@@ -3263,6 +3424,18 @@
"node": ">=8"
}
},
"node_modules/strip-final-newline": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz",
"integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strtok3": {
"version": "10.3.5",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
@@ -3454,6 +3627,18 @@
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"license": "MIT"
},
"node_modules/unicorn-magic": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
"integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -3665,6 +3850,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yoctocolors": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz",
"integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+1
View File
@@ -2029,6 +2029,7 @@
"diff": "9.0.0",
"dotenv": "17.4.2",
"entities": "8.0.0",
"execa": "9.6.1",
"express": "5.2.1",
"file-type": "22.0.1",
"grammy": "1.44.0",
+92 -6
View File
@@ -121,6 +121,9 @@ importers:
entities:
specifier: 8.0.0
version: 8.0.0
execa:
specifier: 9.6.1
version: 9.6.1
express:
specifier: 5.2.1
version: 5.2.1
@@ -4201,6 +4204,9 @@ packages:
'@scure/bip39@2.0.1':
resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==}
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@shikijs/core@4.3.0':
resolution: {integrity: sha512-EooU3i9F6IAE8kEu+AnGf9DFZWkQBZ+hJn3tLVbsH+61mtQiva5biai66fAA6nvFPXkLgvrh7BrR7YcJU83xQQ==}
engines: {node: '>=20'}
@@ -4269,6 +4275,10 @@ packages:
'@simple-git/argv-parser@1.1.1':
resolution: {integrity: sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==}
'@sindresorhus/merge-streams@4.0.0':
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
'@slack/bolt@4.7.3':
resolution: {integrity: sha512-bODs8q/yNDWUPoxmQhFrRqLMA5vhB/PDizYWqb6CkQhLWEUo5JFtfJcmeU4ElGl6qSt++OKjSYNa4MPc77CleQ==}
engines: {node: '>=18', npm: '>=8.6.0'}
@@ -5551,6 +5561,10 @@ packages:
resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==}
engines: {node: '>=10'}
execa@9.6.1:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0}
expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
@@ -5613,6 +5627,10 @@ packages:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
figures@6.1.0:
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
engines: {node: '>=18'}
file-type@22.0.1:
resolution: {integrity: sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA==}
engines: {node: '>=22'}
@@ -5718,6 +5736,10 @@ packages:
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
engines: {node: '>=8'}
get-stream@9.0.1:
resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
engines: {node: '>=18'}
get-tsconfig@5.0.0-beta.5:
resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==}
engines: {node: '>=20.20.0'}
@@ -5844,6 +5866,10 @@ packages:
resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==}
engines: {node: '>=8.12.0'}
human-signals@8.0.1:
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
engines: {node: '>=18.18.0'}
iconv-lite@0.7.2:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
@@ -5976,6 +6002,10 @@ packages:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
is-stream@4.0.1:
resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
engines: {node: '>=18'}
is-unicode-supported@2.1.0:
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
engines: {node: '>=18'}
@@ -6661,6 +6691,10 @@ packages:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
npm-run-path@6.0.0:
resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
engines: {node: '>=18'}
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
@@ -6853,6 +6887,10 @@ packages:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
path-key@4.0.0:
resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
engines: {node: '>=12'}
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
@@ -7483,6 +7521,10 @@ packages:
resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==}
engines: {node: '>=6'}
strip-final-newline@4.0.0:
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
engines: {node: '>=18'}
strip-json-comments@2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
@@ -7717,6 +7759,10 @@ packages:
unhomoglyph@1.0.6:
resolution: {integrity: sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg==}
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -10224,6 +10270,8 @@ snapshots:
'@noble/hashes': 2.0.1
'@scure/base': 2.0.0
'@sec-ant/readable-stream@0.4.1': {}
'@shikijs/core@4.3.0':
dependencies:
'@shikijs/primitive': 4.3.0
@@ -10311,6 +10359,8 @@ snapshots:
'@simple-git/args-pathspec': 1.0.3
optional: true
'@sindresorhus/merge-streams@4.0.0': {}
'@slack/bolt@4.7.3(@types/express@5.0.6)':
dependencies:
'@slack/logger': 4.0.1
@@ -11658,6 +11708,21 @@ snapshots:
signal-exit: 3.0.7
strip-final-newline: 2.0.0
execa@9.6.1:
dependencies:
'@sindresorhus/merge-streams': 4.0.0
cross-spawn: 7.0.6
figures: 6.1.0
get-stream: 9.0.1
human-signals: 8.0.1
is-plain-obj: 4.1.0
is-stream: 4.0.1
npm-run-path: 6.0.0
pretty-ms: 9.3.0
signal-exit: 4.1.0
strip-final-newline: 4.0.0
yoctocolors: 2.1.2
expect-type@1.4.0: {}
express-rate-limit@8.5.2(express@5.2.1):
@@ -11741,6 +11806,10 @@ snapshots:
node-domexception: '@nolyfill/domexception@1.0.28'
web-streams-polyfill: 3.3.3
figures@6.1.0:
dependencies:
is-unicode-supported: 2.1.0
file-type@22.0.1:
dependencies:
'@tokenizer/inflate': 0.4.1
@@ -11863,6 +11932,11 @@ snapshots:
dependencies:
pump: 3.0.4
get-stream@9.0.1:
dependencies:
'@sec-ant/readable-stream': 0.4.1
is-stream: 4.0.1
get-tsconfig@5.0.0-beta.5:
dependencies:
resolve-pkg-maps: 1.0.0
@@ -12042,6 +12116,8 @@ snapshots:
human-signals@1.1.1: {}
human-signals@8.0.1: {}
iconv-lite@0.7.2:
dependencies:
safer-buffer: 2.1.2
@@ -12177,8 +12253,9 @@ snapshots:
is-stream@2.0.1: {}
is-unicode-supported@2.1.0:
optional: true
is-stream@4.0.1: {}
is-unicode-supported@2.1.0: {}
is-wsl@3.1.1:
dependencies:
@@ -13105,6 +13182,11 @@ snapshots:
dependencies:
path-key: 3.1.1
npm-run-path@6.0.0:
dependencies:
path-key: 4.0.0
unicorn-magic: 0.3.0
nth-check@2.1.1:
dependencies:
boolbase: 1.0.0
@@ -13314,6 +13396,8 @@ snapshots:
path-key@3.1.1: {}
path-key@4.0.0: {}
path-parse@1.0.7: {}
path-scurry@2.0.2:
@@ -13844,8 +13928,7 @@ snapshots:
signal-exit@3.0.7: {}
signal-exit@4.1.0:
optional: true
signal-exit@4.1.0: {}
signal-polyfill@0.2.2: {}
@@ -14050,6 +14133,8 @@ snapshots:
strip-final-newline@2.0.0: {}
strip-final-newline@4.0.0: {}
strip-json-comments@2.0.1:
optional: true
@@ -14259,6 +14344,8 @@ snapshots:
unhomoglyph@1.0.6: {}
unicorn-magic@0.3.0: {}
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3
@@ -14516,8 +14603,7 @@ snapshots:
yocto-queue@1.2.2: {}
yoctocolors@2.1.2:
optional: true
yoctocolors@2.1.2: {}
zca-js@2.1.2:
dependencies:
+5
View File
@@ -108,6 +108,11 @@
"class": "core-runtime",
"risk": ["env-loading"]
},
"execa": {
"owner": "core:process-execution",
"class": "core-runtime",
"risk": ["process-execution", "large-transitive-cone"]
},
"express": {
"owner": "capability:http-route-host",
"class": "default-runtime-initially",
+29 -17
View File
@@ -3,18 +3,27 @@
import { EventEmitter } from "node:events";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { killProcessTreeMock, spawnMock, waitForChildProcessMock } = vi.hoisted(() => ({
const { completionMock, killProcessTreeMock, spawnMock } = vi.hoisted(() => ({
completionMock: vi.fn(),
killProcessTreeMock: vi.fn(),
spawnMock: vi.fn(),
waitForChildProcessMock: vi.fn(),
}));
vi.mock("node:child_process", () => ({
spawn: spawnMock,
vi.mock("../../process/child-process.js", () => ({
releaseChildProcessOutputAfterExit: vi.fn(() => vi.fn()),
}));
vi.mock("../utils/child-process.js", () => ({
waitForChildProcess: waitForChildProcessMock,
vi.mock("../../process/exec.js", () => ({
spawnCommand: (...args: unknown[]) => {
const child = spawnMock(...args) as StubChild;
const completion = completionMock(child).then((code: number | null) => ({
exitCode: code,
failed: false,
}));
// oxlint-disable-next-line unicorn/no-thenable -- Execa subprocesses are event emitters and promises.
child.then = completion.then.bind(completion);
return child;
},
}));
vi.mock("../../process/kill-tree.js", () => ({
@@ -26,6 +35,7 @@ type StubChild = EventEmitter & {
pid?: number;
stderr: EventEmitter;
stdout: EventEmitter;
then: Promise<unknown>["then"];
};
function createStubChild(): StubChild {
@@ -34,6 +44,8 @@ function createStubChild(): StubChild {
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = vi.fn();
// oxlint-disable-next-line unicorn/no-thenable -- Stub matches Execa's event-emitting promise shape.
child.then = vi.fn() as unknown as Promise<unknown>["then"];
return child;
}
@@ -51,7 +63,7 @@ describe("execCommand", () => {
beforeEach(() => {
killProcessTreeMock.mockReset();
spawnMock.mockReset();
waitForChildProcessMock.mockReset();
completionMock.mockReset();
vi.useRealTimers();
});
@@ -66,7 +78,7 @@ describe("execCommand", () => {
const child = createStubChild();
const wait = createDeferred<number | null>();
spawnMock.mockReturnValue(child);
waitForChildProcessMock.mockReturnValue(wait.promise);
completionMock.mockReturnValue(wait.promise);
const { execCommand } = await import("./exec.js");
const resultPromise = execCommand("cmd", [], "/tmp", { maxOutputChars: 256 });
@@ -88,19 +100,19 @@ describe("execCommand", () => {
const child = createStubChild();
const wait = createDeferred<number | null>();
spawnMock.mockReturnValue(child);
waitForChildProcessMock.mockReturnValue(wait.promise);
completionMock.mockReturnValue(wait.promise);
const { execCommand } = await import("./exec.js");
const resultPromise = execCommand("cmd", ["arg"], "/tmp");
wait.resolve(0);
await resultPromise;
expect(spawnMock).toHaveBeenCalledWith("cmd", ["arg"], {
expect(spawnMock).toHaveBeenCalledWith(["cmd", "arg"], {
buffer: false,
cwd: "/tmp",
detached: process.platform !== "win32",
shell: false,
reject: false,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
});
@@ -108,7 +120,7 @@ describe("execCommand", () => {
const child = createStubChild();
const wait = createDeferred<number | null>();
spawnMock.mockReturnValue(child);
waitForChildProcessMock.mockReturnValue(wait.promise);
completionMock.mockReturnValue(wait.promise);
const { execCommand } = await import("./exec.js");
const resultPromise = execCommand("cmd", [], "/tmp", { maxOutputChars: 3 });
@@ -125,7 +137,7 @@ describe("execCommand", () => {
const child = createStubChild();
const wait = createDeferred<number | null>();
spawnMock.mockReturnValue(child);
waitForChildProcessMock.mockReturnValue(wait.promise);
completionMock.mockReturnValue(wait.promise);
const { execCommand } = await import("./exec.js");
const resultPromise = execCommand("cmd", [], "/tmp", { maxOutputChars: 2 });
@@ -144,7 +156,7 @@ describe("execCommand", () => {
const child = createStubChild();
const wait = createDeferred<number | null>();
spawnMock.mockReturnValue(child);
waitForChildProcessMock.mockReturnValue(wait.promise);
completionMock.mockReturnValue(wait.promise);
const { execCommand } = await import("./exec.js");
const resultPromise = execCommand("cmd", [], "/tmp");
@@ -173,7 +185,7 @@ describe("execCommand", () => {
const child = createStubChild();
const wait = createDeferred<number | null>();
spawnMock.mockReturnValue(child);
waitForChildProcessMock.mockReturnValue(wait.promise);
completionMock.mockReturnValue(wait.promise);
const { execCommand } = await import("./exec.js");
const resultPromise = execCommand("cmd", [], "/tmp", { timeout: 10 });
@@ -193,7 +205,7 @@ describe("execCommand", () => {
const child = createStubChild();
const wait = createDeferred<number | null>();
spawnMock.mockReturnValue(child);
waitForChildProcessMock.mockReturnValue(wait.promise);
completionMock.mockReturnValue(wait.promise);
const { execCommand } = await import("./exec.js");
const resultPromise = execCommand("cmd", [], "/tmp");
+11 -11
View File
@@ -2,10 +2,10 @@
* Shared command execution utilities for extensions and custom tools.
*/
import { spawn } from "node:child_process";
import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { releaseChildProcessOutputAfterExit } from "../../process/child-process.js";
import { spawnCommand } from "../../process/exec.js";
import { killProcessTree } from "../../process/kill-tree.js";
import { waitForChildProcess } from "../utils/child-process.js";
const DEFAULT_OUTPUT_LIMIT_CHARS = 16 * 1024 * 1024;
const FORCE_KILL_GRACE_MS = 5000;
@@ -84,13 +84,14 @@ export async function execCommand(
options?: ExecOptions,
): Promise<ExecResult> {
return new Promise((resolve) => {
const proc = spawn(command, args, {
const proc = spawnCommand([command, ...args], {
buffer: false,
cwd,
detached: process.platform !== "win32",
shell: false,
reject: false,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
const releaseOutput = releaseChildProcessOutputAfterExit(proc);
let stdout: OutputCapture = { text: "", truncatedChars: 0 };
let stderr: OutputCapture = { text: "", truncatedChars: 0 };
@@ -197,14 +198,13 @@ export async function execCommand(
}
});
// Wait for process termination without hanging on inherited stdio handles
// held open by detached descendants.
waitForChildProcess(proc)
.then((code) => {
finish(code ?? 0);
void proc
.then((result) => {
finish(result.exitCode ?? (result.failed ? 1 : 0));
})
.catch(() => {
finish(1);
});
})
.finally(releaseOutput);
});
}
+17 -1
View File
@@ -1,8 +1,14 @@
import path from "node:path";
// Bash tool helper tests cover conversion from model-facing timeout seconds to
// timer-safe millisecond values.
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
import { describe, expect, it } from "vitest";
import { createBashTool, resolveBashTimeoutMs, type BashOperations } from "./bash.js";
import {
createBashTool,
createLocalBashOperations,
resolveBashTimeoutMs,
type BashOperations,
} from "./bash.js";
describe("bash tool timeout helpers", () => {
it("converts positive timeout seconds to timer-safe milliseconds", () => {
@@ -25,6 +31,16 @@ describe("bash tool timeout helpers", () => {
});
describe("bash tool output lifecycle", () => {
it.runIf(process.platform !== "win32")("surfaces a configured shell launch error", async () => {
const operations = createLocalBashOperations({
shellPath: path.join(process.cwd(), "package.json"),
});
await expect(operations.exec("echo ok", process.cwd(), { onData: () => {} })).rejects.toThrow(
/EACCES|permission denied/i,
);
});
it("ignores output callbacks after execution settles", async () => {
const operations: BashOperations = {
exec: async (_command, _cwd, { onData }) => {
+18 -10
View File
@@ -3,19 +3,19 @@
*
* Executes local shell commands with streaming output accumulation and TUI renderers.
*/
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui";
import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
import { Type } from "typebox";
import { toErrorObject } from "../../../infra/errors.js";
import { formatDurationSeconds } from "../../../infra/format-time/format-duration.js";
import { releaseChildProcessOutputAfterExit } from "../../../process/child-process.js";
import { spawnCommand } from "../../../process/exec.js";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate.js";
import { theme } from "../../modes/interactive/theme/theme.js";
import type { AgentTool } from "../../runtime/index.js";
import { getBashShellConfig, getShellEnv, killProcessTree } from "../../shell-utils.js";
import { waitForChildProcess } from "../../utils/child-process.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
import type { BashOperations } from "./bash-operations.js";
import { OutputAccumulator } from "./output-accumulator.js";
@@ -60,13 +60,16 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas
);
return;
}
const child = spawn(shell, [...args, command], {
const child = spawnCommand([shell, ...args, command], {
baseEnv: {},
buffer: false,
cwd,
detached: process.platform !== "win32",
env: env ?? getShellEnv(),
reject: false,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
const releaseOutput = releaseChildProcessOutputAfterExit(child);
let timedOut = false;
let timeoutHandle: NodeJS.Timeout | undefined;
const timeoutMs = resolveBashTimeoutMs(timeout);
@@ -94,10 +97,14 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas
signal.addEventListener("abort", onAbort, { once: true });
}
}
// Handle shell spawn errors and wait for the process to terminate without hanging
// on inherited stdio handles held by detached descendants.
waitForChildProcess(child)
.then((code) => {
void child
.then((result) => {
if (result.failed && result.exitCode === undefined && result.signal === undefined) {
if (result instanceof Error) {
throw result;
}
throw new Error(`Failed to launch shell: ${shell}`, { cause: result });
}
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
@@ -112,7 +119,7 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas
reject(new Error(`timeout:${timeout}`));
return;
}
resolve({ exitCode: code });
resolve({ exitCode: result.exitCode ?? (result.failed ? 1 : 0) });
})
.catch((err: unknown) => {
if (timeoutHandle) {
@@ -122,7 +129,8 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas
signal.removeEventListener("abort", onAbort);
}
reject(toErrorObject(err, "Non-Error rejection"));
});
})
.finally(releaseOutput);
});
},
};
+11 -10
View File
@@ -1,15 +1,16 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import fs from "node:fs/promises";
import path from "node:path";
import { PassThrough } from "node:stream";
import { afterEach, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../../test/helpers/temp-dir.js";
import { spawnCommand } from "../../../process/exec.js";
import { ensureTool } from "../../utils/tools-manager.js";
import { createFindToolDefinition } from "./find.js";
vi.mock("node:child_process", () => ({
spawn: vi.fn(),
vi.mock("../../../process/exec.js", () => ({
spawnCommand: vi.fn(),
}));
vi.mock("../../utils/tools-manager.js", () => ({
@@ -41,12 +42,12 @@ function createChild(): MockChild {
it("rejects partial fd output when fd exits with an error", async () => {
const child = createChild();
vi.mocked(spawn).mockReturnValue(child);
vi.mocked(spawnCommand).mockReturnValue(child as never);
vi.mocked(ensureTool).mockResolvedValue("fd");
const tool = createFindToolDefinition("/workspace");
const result = tool.execute("call-1", { pattern: "*.ts" }, undefined, undefined, {} as never);
await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce());
await vi.waitFor(() => expect(spawnCommand).toHaveBeenCalledOnce());
child.stdout.end("/workspace/partial.ts\n");
child.stderr.end("fd failed while reading subtree\n");
child.emit("close", 2, null);
@@ -58,12 +59,12 @@ it.each(["stdout", "stderr"] as const)(
"rejects and stops fd when %s emits an error",
async (stream) => {
const child = createChild();
vi.mocked(spawn).mockReturnValue(child);
vi.mocked(spawnCommand).mockReturnValue(child as never);
vi.mocked(ensureTool).mockResolvedValue("fd");
const tool = createFindToolDefinition("/workspace");
const result = tool.execute("call-1", { pattern: "*.ts" }, undefined, undefined, {} as never);
await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce());
await vi.waitFor(() => expect(spawnCommand).toHaveBeenCalledOnce());
child[stream].emit("error", new Error(`${stream} EPIPE`));
await expect(result).rejects.toThrow(`${stream} EPIPE`);
@@ -83,7 +84,7 @@ it.each([
}
const child = createChild();
vi.mocked(spawn).mockReturnValue(child);
vi.mocked(spawnCommand).mockReturnValue(child as never);
vi.mocked(ensureTool).mockResolvedValue("fd");
const tool = createFindToolDefinition(tempDir);
const result = tool.execute(
@@ -93,12 +94,12 @@ it.each([
undefined,
{} as never,
);
await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce());
await vi.waitFor(() => expect(spawnCommand).toHaveBeenCalledOnce());
child.stdout.end();
child.stderr.end();
child.emit("close", 0, null);
await result;
const args = vi.mocked(spawn).mock.calls[0]?.[1] as string[];
const args = vi.mocked(spawnCommand).mock.calls[0]?.[0] as string[];
expect(args.includes("--no-require-git")).toBe(expected);
});
+8 -2
View File
@@ -1,9 +1,10 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import { createInterface } from "node:readline";
import { Text } from "@earendil-works/pi-tui";
import { Type } from "typebox";
import { releaseChildProcessOutputAfterExit } from "../../../process/child-process.js";
import { spawnCommand } from "../../../process/exec.js";
/**
* Built-in find session tool.
*
@@ -275,7 +276,12 @@ export function createFindToolDefinition(
}
args.push("--", effectivePattern, searchPath);
const child = spawn(fdPath, args, { stdio: ["ignore", "pipe", "pipe"] });
const child = spawnCommand([fdPath, ...args], {
buffer: false,
reject: false,
stdio: ["ignore", "pipe", "pipe"],
});
releaseChildProcessOutputAfterExit(child);
const rl = createInterface({ input: child.stdout });
let stderr = "";
const lines: string[] = [];
@@ -1,14 +1,15 @@
// Grep tool stream error tests verify that stdout/stderr errors reject the tool
// promise instead of crashing the agent runtime.
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { afterEach, describe, expect, it, vi } from "vitest";
import { spawnCommand } from "../../../process/exec.js";
import { ensureTool } from "../../utils/tools-manager.js";
import { createGrepToolDefinition } from "./grep.js";
vi.mock("node:child_process", () => ({
spawn: vi.fn(),
vi.mock("../../../process/exec.js", () => ({
spawnCommand: vi.fn(),
}));
vi.mock("../../utils/tools-manager.js", () => ({
@@ -62,7 +63,7 @@ describe("grep tool stream errors", () => {
resolveEnsureTool?.("rg");
await Promise.resolve();
expect(spawn).not.toHaveBeenCalled();
expect(spawnCommand).not.toHaveBeenCalled();
});
it("does not spawn after an aborted search-path check later resolves", async () => {
@@ -93,12 +94,12 @@ describe("grep tool stream errors", () => {
resolveIsDirectory?.(true);
await Promise.resolve();
expect(spawn).not.toHaveBeenCalled();
expect(spawnCommand).not.toHaveBeenCalled();
});
it("removes the abort listener after normal settlement", async () => {
const child = createChild();
vi.mocked(spawn).mockReturnValue(child);
vi.mocked(spawnCommand).mockReturnValue(child as never);
vi.mocked(ensureTool).mockResolvedValue("rg");
const controller = new AbortController();
@@ -111,7 +112,7 @@ describe("grep tool stream errors", () => {
undefined,
{} as never,
);
await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce());
await vi.waitFor(() => expect(spawnCommand).toHaveBeenCalledOnce());
child.emit("close", 1);
await expect(result).resolves.toMatchObject({
@@ -124,7 +125,7 @@ describe("grep tool stream errors", () => {
it("settles an abort when the spawned child never closes", async () => {
const child = createChild();
vi.mocked(spawn).mockReturnValue(child);
vi.mocked(spawnCommand).mockReturnValue(child as never);
vi.mocked(ensureTool).mockResolvedValue("rg");
const controller = new AbortController();
@@ -136,7 +137,7 @@ describe("grep tool stream errors", () => {
undefined,
{} as never,
);
await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce());
await vi.waitFor(() => expect(spawnCommand).toHaveBeenCalledOnce());
controller.abort();
await expect(result).rejects.toThrow("Operation aborted");
@@ -145,7 +146,7 @@ describe("grep tool stream errors", () => {
it("preserves abort precedence during async match formatting", async () => {
const child = createChild();
vi.mocked(spawn).mockReturnValue(child);
vi.mocked(spawnCommand).mockReturnValue(child as never);
vi.mocked(ensureTool).mockResolvedValue("rg");
let resolveReadFile: ((value: string) => void) | undefined;
const readFile = vi.fn(
@@ -166,7 +167,7 @@ describe("grep tool stream errors", () => {
undefined,
{} as never,
);
await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce());
await vi.waitFor(() => expect(spawnCommand).toHaveBeenCalledOnce());
child.stdout.write(
`${JSON.stringify({
type: "match",
@@ -188,7 +189,7 @@ describe("grep tool stream errors", () => {
"rejects and terminates ripgrep when %s fails",
async (stream) => {
const child = createChild();
vi.mocked(spawn).mockReturnValue(child);
vi.mocked(spawnCommand).mockReturnValue(child as never);
vi.mocked(ensureTool).mockResolvedValue("rg");
const tool = createGrepToolDefinition(process.cwd());
@@ -199,7 +200,7 @@ describe("grep tool stream errors", () => {
undefined,
{} as never,
);
await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce());
await vi.waitFor(() => expect(spawnCommand).toHaveBeenCalledOnce());
child[stream].emit("error", new Error(`${stream} EPIPE`));
await expect(resultPromise).rejects.toThrow(`${stream} EPIPE`);
@@ -209,12 +210,12 @@ describe("grep tool stream errors", () => {
it("keeps stdout guarded after a stderr failure closes readline", async () => {
const child = createChild();
vi.mocked(spawn).mockReturnValue(child);
vi.mocked(spawnCommand).mockReturnValue(child as never);
vi.mocked(ensureTool).mockResolvedValue("rg");
const tool = createGrepToolDefinition(process.cwd());
const result = tool.execute("call-1", { pattern: "foo" }, undefined, undefined, {} as never);
await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce());
await vi.waitFor(() => expect(spawnCommand).toHaveBeenCalledOnce());
expect(() => {
child.stderr.emit("error", new Error("stderr first"));
+10 -3
View File
@@ -3,12 +3,14 @@
*
* Searches files with ripgrep/local operations, optional context, and bounded output rendering.
*/
import { spawn } from "node:child_process";
import type { ChildProcess } from "node:child_process";
import { readFileSync, statSync } from "node:fs";
import path from "node:path";
import { createInterface } from "node:readline";
import { Text } from "@earendil-works/pi-tui";
import { Type } from "typebox";
import { releaseChildProcessOutputAfterExit } from "../../../process/child-process.js";
import { spawnCommand } from "../../../process/exec.js";
import type { AgentTool } from "../../runtime/index.js";
import { ensureTool } from "../../utils/tools-manager.js";
import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.js";
@@ -159,7 +161,7 @@ export function createGrepToolDefinition(
// Keep cancellation live from the first await through async result formatting.
// Settlement owns listener cleanup; spawned children stop without waiting for close.
let settled = false;
let child: ReturnType<typeof spawn> | undefined;
let child: ChildProcess | undefined;
let childClosed = false;
let rl: ReturnType<typeof createInterface> | undefined;
let killedDueToLimit = false;
@@ -261,7 +263,12 @@ export function createGrepToolDefinition(
if (settled) {
return;
}
const spawnedChild = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
const spawnedChild = spawnCommand([rgPath, ...args], {
buffer: false,
reject: false,
stdio: ["ignore", "pipe", "pipe"],
});
releaseChildProcessOutputAfterExit(spawnedChild);
child = spawnedChild;
rl = createInterface({ input: spawnedChild.stdout });
let stderr = "";
-94
View File
@@ -1,94 +0,0 @@
import { type ChildProcess, spawn, type ChildProcessByStdio } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough, type Readable } from "node:stream";
import { afterEach, describe, expect, it, vi } from "vitest";
import { waitForChildProcess } from "./child-process.js";
describe.skipIf(process.platform === "win32")("waitForChildProcess", () => {
let child: ChildProcessByStdio<null, Readable, Readable> | undefined;
afterEach(() => {
if (child?.pid) {
try {
process.kill(-child.pid, "SIGKILL");
} catch {}
}
child = undefined;
});
it("drains active descendant output after the parent exits", async () => {
const command =
'printf "HEAD\\n"; ( for i in 1 2 3 4 5 6; do sleep 0.05; printf "TICK$i\\n"; done ) &';
child = spawn("/bin/sh", ["-c", command], {
stdio: ["ignore", "pipe", "pipe"],
detached: true,
});
let output = "";
child.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
await expect(waitForChildProcess(child)).resolves.toBe(0);
expect(output).toContain("HEAD");
expect(output).toContain("TICK6");
});
it("releases a quiet inherited pipe after the idle grace", async () => {
child = spawn("/bin/sh", ["-c", 'printf "DONE\\n"; ( sleep 30 ) &'], {
stdio: ["ignore", "pipe", "pipe"],
detached: true,
});
let output = "";
child.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
const startedAt = Date.now();
await expect(waitForChildProcess(child)).resolves.toBe(0);
expect(output).toContain("DONE");
expect(Date.now() - startedAt).toBeLessThan(2_000);
});
it("bounds draining from a continuously writing descendant", async () => {
vi.useFakeTimers();
try {
const stdout = new PassThrough();
const stderr = new PassThrough();
const fakeChild = Object.assign(new EventEmitter(), {
stdout,
stderr,
}) as unknown as ChildProcess;
let output = "";
stdout.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
const completion = waitForChildProcess(fakeChild);
fakeChild.emit("exit", 0);
const writer = setInterval(() => stdout.write("TICK\n"), 30);
await vi.advanceTimersByTimeAsync(1_000);
await expect(completion).resolves.toBe(0);
clearInterval(writer);
expect(output).toContain("TICK");
} finally {
vi.useRealTimers();
}
});
it("swallows stdout and stderr stream errors without rejecting", async () => {
const stdout = new PassThrough();
const stderr = new PassThrough();
const fakeChild = Object.assign(new EventEmitter(), {
stdout,
stderr,
}) as unknown as ChildProcess;
const completion = waitForChildProcess(fakeChild);
stdout.emit("error", new Error("stdout read failed"));
stderr.emit("error", new Error("stderr read failed"));
fakeChild.emit("exit", 0);
await expect(completion).resolves.toBe(0);
});
});
-131
View File
@@ -1,131 +0,0 @@
/**
* Child-process compatibility helpers for agent utilities.
*
* Wraps platform-specific spawn behavior and safe close handling for inherited stdio.
*/
import type { ChildProcess } from "node:child_process";
const EXIT_STDIO_GRACE_MS = 100;
const EXIT_STDIO_MAX_DRAIN_MS = 1_000;
/**
* Wait for a child process to terminate without hanging on inherited stdio handles.
*
* A detached descendant may keep stdout/stderr open after the child exits. Wait
* until those pipes are idle, re-arming the grace timer for every late chunk, so
* active output drains without hanging forever on an inherited handle.
*/
export function waitForChildProcess(child: ChildProcess): Promise<number | null> {
return new Promise((resolve, reject) => {
let settled = false;
let exited = false;
let exitCode: number | null = null;
let postExitTimer: NodeJS.Timeout | undefined;
let postExitDeadlineTimer: NodeJS.Timeout | undefined;
let stdoutEnded = child.stdout === null;
let stderrEnded = child.stderr === null;
const cleanup = () => {
if (postExitTimer) {
clearTimeout(postExitTimer);
postExitTimer = undefined;
}
if (postExitDeadlineTimer) {
clearTimeout(postExitDeadlineTimer);
postExitDeadlineTimer = undefined;
}
child.removeListener("error", onError);
child.removeListener("exit", onExit);
child.removeListener("close", onClose);
child.stdout?.removeListener("end", onStdoutEnd);
child.stderr?.removeListener("end", onStderrEnd);
child.stdout?.removeListener("data", onData);
child.stderr?.removeListener("data", onData);
child.stdout?.removeListener("error", onStreamError);
child.stderr?.removeListener("error", onStreamError);
};
const finalize = (code: number | null) => {
if (settled) {
return;
}
settled = true;
cleanup();
child.stdout?.destroy();
child.stderr?.destroy();
resolve(code);
};
const maybeFinalizeAfterExit = () => {
if (!exited || settled) {
return;
}
if (stdoutEnded && stderrEnded) {
finalize(exitCode);
}
};
const armIdleTimer = () => {
if (postExitTimer) {
clearTimeout(postExitTimer);
}
postExitTimer = setTimeout(() => finalize(exitCode), EXIT_STDIO_GRACE_MS);
};
const onData = () => {
if (exited && !settled) {
armIdleTimer();
}
};
const onStdoutEnd = () => {
stdoutEnded = true;
maybeFinalizeAfterExit();
};
const onStderrEnd = () => {
stderrEnded = true;
maybeFinalizeAfterExit();
};
const onError = (err: Error) => {
if (settled) {
return;
}
settled = true;
cleanup();
reject(err);
};
const onStreamError = () => {
// Stream read errors on stdout/stderr are non-fatal; the child process
// error/exit/close handlers report the real outcome.
};
const onExit = (code: number | null) => {
exited = true;
exitCode = code;
maybeFinalizeAfterExit();
if (!settled) {
// Drain finite descendant tails, but never let a chatty inherited pipe
// keep an already-exited command alive indefinitely.
postExitDeadlineTimer = setTimeout(() => finalize(exitCode), EXIT_STDIO_MAX_DRAIN_MS);
armIdleTimer();
}
};
const onClose = (code: number | null) => {
finalize(code);
};
child.stdout?.once("end", onStdoutEnd);
child.stderr?.once("end", onStderrEnd);
child.stdout?.on("error", onStreamError);
child.stderr?.on("error", onStreamError);
child.stdout?.on("data", onData);
child.stderr?.on("data", onData);
child.once("error", onError);
child.once("exit", onExit);
child.once("close", onClose);
});
}
+23
View File
@@ -6,6 +6,7 @@ import { withTempDir } from "../test-helpers/temp-dir.js";
import { withMockedPlatform } from "../test-utils/vitest-spies.js";
import {
isExecutableFile,
isRegularFile,
resolveExecutable,
resolveExecutableFromPathEnv,
resolveExecutablePath,
@@ -32,8 +33,11 @@ describe("executable path helpers", () => {
await fs.mkdir(dirPath);
expect(isExecutableFile(execPath)).toBe(true);
expect(isRegularFile(execPath)).toBe(true);
expect(isExecutableFile(filePath)).toBe(false);
expect(isRegularFile(filePath)).toBe(true);
expect(isExecutableFile(dirPath)).toBe(false);
expect(isRegularFile(dirPath)).toBe(false);
expect(isExecutableFile(path.join(base, "missing"))).toBe(false);
});
});
@@ -283,6 +287,25 @@ describe("caller env PATHEXT propagation", () => {
}
});
it("keeps PATHEXT checks for non-native explicit PATH extensions", async () => {
await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => {
const binDir = path.join(base, "bin");
await fs.mkdir(binDir, { recursive: true });
const exePath = path.join(binDir, "tool.exe");
const ps1Path = path.join(binDir, "tool.ps1");
await fs.writeFile(exePath, "exe\n", "utf8");
await fs.writeFile(ps1Path, 'Write-Output "ok"\n', "utf8");
withMockedPlatform("win32", () => {
expect(resolveExecutableFromPathEnv("tool.exe", binDir, { PATHEXT: ".CMD" })).toBe(exePath);
expect(
resolveExecutableFromPathEnv("tool.ps1", binDir, { PATHEXT: ".EXE" }),
).toBeUndefined();
expect(resolveExecutableFromPathEnv("tool.ps1", binDir, { PATHEXT: ".PS1" })).toBe(ps1Path);
});
});
});
it("resolveExecutablePath with path separator passes env to PATHEXT check on Windows", async () => {
const orig = process.env.PATHEXT;
process.env.PATHEXT = ".TXT";
+63 -31
View File
@@ -8,6 +8,24 @@ function isDriveLessWindowsRootedPath(value: string): boolean {
return process.platform === "win32" && /^:[\\/]/.test(value);
}
function resolveEnvironmentValue(
env: NodeJS.ProcessEnv | undefined,
name: string,
): string | undefined {
if (!env) {
return undefined;
}
const exactValue = env[name] ?? (name === "PATH" ? env.Path : undefined);
if (exactValue !== undefined) {
return exactValue;
}
if (process.platform !== "win32") {
return undefined;
}
const normalizedName = name.toLowerCase();
return Object.entries(env).find(([key]) => key.toLowerCase() === normalizedName)?.[1];
}
export function resolveExecutablePathCandidate(
rawExecutable: string,
options?: { cwd?: string; env?: NodeJS.ProcessEnv; requirePathSeparator?: boolean },
@@ -35,6 +53,7 @@ export function resolveExecutablePathCandidate(
function resolveWindowsExecutableExtensions(
executable: string,
env: NodeJS.ProcessEnv | undefined,
includeExtensionless = true,
): string[] {
if (process.platform !== "win32") {
return [""];
@@ -42,31 +61,21 @@ function resolveWindowsExecutableExtensions(
if (path.extname(executable).length > 0) {
return [""];
}
return [
"",
...(
env?.PATHEXT ??
env?.PathExt ??
env?.Pathext ??
process.env.PATHEXT ??
process.env.PathExt ??
process.env.Pathext ??
".EXE;.CMD;.BAT;.COM"
)
.split(";")
.map((ext) => normalizeLowercaseStringOrEmpty(ext)),
];
const extensions = (
resolveEnvironmentValue(env, "PATHEXT") ??
resolveEnvironmentValue(process.env, "PATHEXT") ??
".EXE;.CMD;.BAT;.COM"
)
.split(";")
.map((ext) => normalizeLowercaseStringOrEmpty(ext));
return includeExtensionless ? ["", ...extensions] : extensions;
}
function resolveWindowsExecutableExtSet(env: NodeJS.ProcessEnv | undefined): Set<string> {
return new Set(
(
env?.PATHEXT ??
env?.PathExt ??
env?.Pathext ??
process.env.PATHEXT ??
process.env.PathExt ??
process.env.Pathext ??
resolveEnvironmentValue(env, "PATHEXT") ??
resolveEnvironmentValue(process.env, "PATHEXT") ??
".EXE;.CMD;.BAT;.COM"
)
.split(";")
@@ -75,12 +84,19 @@ function resolveWindowsExecutableExtSet(env: NodeJS.ProcessEnv | undefined): Set
);
}
export function isRegularFile(filePath: string): boolean {
try {
return fs.statSync(filePath).isFile();
} catch {
return false;
}
}
export function isExecutableFile(filePath: string, options?: { env?: NodeJS.ProcessEnv }): boolean {
if (!isRegularFile(filePath)) {
return false;
}
try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) {
return false;
}
if (process.platform === "win32") {
const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath));
if (!ext) {
@@ -95,18 +111,32 @@ export function isExecutableFile(filePath: string, options?: { env?: NodeJS.Proc
}
}
const WINDOWS_NATIVE_EXECUTABLE_EXTENSIONS = new Set([".com", ".exe", ".bat", ".cmd"]);
export function resolveExecutableFromPathEnv(
executable: string,
pathEnv: string,
env?: NodeJS.ProcessEnv,
options?: { includeExtensionless?: boolean },
): string | undefined {
const delimiter = process.platform === "win32" ? ";" : path.delimiter;
const entries = pathEnv.split(delimiter).filter(Boolean);
const extensions = resolveWindowsExecutableExtensions(executable, env);
const extensions = resolveWindowsExecutableExtensions(
executable,
env,
options?.includeExtensionless,
);
const hasNativeWindowsExtension =
process.platform === "win32" &&
WINDOWS_NATIVE_EXECUTABLE_EXTENSIONS.has(
normalizeLowercaseStringOrEmpty(path.extname(executable)),
);
for (const entry of entries) {
for (const ext of extensions) {
const candidate = path.join(entry, executable + ext);
if (isExecutableFile(candidate, { env })) {
if (
hasNativeWindowsExtension ? isRegularFile(candidate) : isExecutableFile(candidate, { env })
) {
return candidate;
}
}
@@ -126,12 +156,12 @@ export function resolveExecutablePath(
return isExecutableFile(candidate, options) ? candidate : undefined;
}
const envPath =
options?.env?.PATH ?? options?.env?.Path ?? process.env.PATH ?? process.env.Path ?? "";
resolveEnvironmentValue(options?.env, "PATH") ??
resolveEnvironmentValue(process.env, "PATH") ??
"";
return resolveExecutableFromPathEnv(candidate, envPath, options?.env);
}
const KNOWN_PATHEXT = new Set([".com", ".exe", ".bat", ".cmd"]);
/**
* On Windows, resolves a bare command name to its full .cmd or .exe path by
* probing PATH/PATHEXT without executing another resolver. On non-Windows this
@@ -141,11 +171,13 @@ export function resolveExecutable(cmd: string): string {
if (process.platform !== "win32") {
return cmd;
}
if (KNOWN_PATHEXT.has(normalizeLowercaseStringOrEmpty(path.extname(cmd)))) {
if (
WINDOWS_NATIVE_EXECUTABLE_EXTENSIONS.has(normalizeLowercaseStringOrEmpty(path.extname(cmd)))
) {
return cmd;
}
const envPath = process.env.PATH ?? process.env.Path ?? "";
const envPath = resolveEnvironmentValue(process.env, "PATH") ?? "";
const entries = envPath.split(";").filter(Boolean);
const extensions = resolveWindowsExecutableExtensions(cmd, process.env);
const matches: string[] = [];
+9 -1
View File
@@ -1,5 +1,13 @@
// Public process helpers for plugins that spawn or probe local commands.
export * from "../process/exec.js";
export {
type CommandOptions,
resolveCommandEnv,
resolveProcessExitCode,
runCommandWithTimeout,
runExec,
shouldSpawnWithShell,
type SpawnResult,
} from "../process/exec.js";
export { prepareOomScoreAdjustedSpawn } from "../process/linux-oom-score.js";
export type { OomScoreAdjustedSpawn, OomWrapOptions } from "../process/linux-oom-score.js";
+81
View File
@@ -0,0 +1,81 @@
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { execa, type ResultPromise } from "execa";
import { afterEach, describe, expect, it, vi } from "vitest";
import { releaseChildProcessOutputAfterExit } from "./child-process.js";
describe.skipIf(process.platform === "win32")("releaseChildProcessOutputAfterExit", () => {
let child: ResultPromise | undefined;
afterEach(() => {
if (child?.pid) {
try {
process.kill(-child.pid, "SIGKILL");
} catch {}
}
child = undefined;
vi.useRealTimers();
});
it("drains active descendant output after the parent exits", async () => {
const command =
'printf "HEAD\\n"; ( for i in 1 2 3 4 5 6; do sleep 0.05; printf "TICK$i\\n"; done ) &';
child = execa("/bin/sh", ["-c", command], {
buffer: false,
detached: true,
reject: false,
stdio: ["ignore", "pipe", "pipe"],
});
const releaseOutput = releaseChildProcessOutputAfterExit(child);
let output = "";
child.stdout?.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
await child.finally(releaseOutput);
expect(output).toContain("HEAD");
expect(output).toContain("TICK6");
});
it("releases a quiet inherited pipe after the idle grace", async () => {
child = execa("/bin/sh", ["-c", 'printf "DONE\\n"; ( sleep 30 ) &'], {
buffer: false,
detached: true,
reject: false,
stdio: ["ignore", "pipe", "pipe"],
});
const releaseOutput = releaseChildProcessOutputAfterExit(child);
let output = "";
child.stdout?.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
const startedAt = Date.now();
await child.finally(releaseOutput);
expect(output).toContain("DONE");
expect(Date.now() - startedAt).toBeLessThan(2_000);
});
it("bounds draining from a continuously writing descendant", async () => {
vi.useFakeTimers();
const stdout = new PassThrough();
const stderr = new PassThrough();
const fakeChild = Object.assign(new EventEmitter(), {
stdout,
stderr,
}) as unknown as ChildProcess;
const cleanup = releaseChildProcessOutputAfterExit(fakeChild);
fakeChild.emit("exit", 0);
const writer = setInterval(() => stdout.write("TICK\n"), 30);
await vi.advanceTimersByTimeAsync(999);
expect(stdout.destroyed).toBe(false);
await vi.advanceTimersByTimeAsync(1);
expect(stdout.destroyed).toBe(true);
expect(stderr.destroyed).toBe(true);
clearInterval(writer);
cleanup();
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* Child-process output cleanup for commands whose descendants inherit pipes.
*/
import type { ChildProcess } from "node:child_process";
const EXIT_STDIO_GRACE_MS = 100;
const EXIT_STDIO_MAX_DRAIN_MS = 1_000;
/**
* Execa waits for stdout/stderr after the direct child exits. Bound that wait
* when detached descendants keep inherited pipes open, while still draining
* short output tails. The returned cleanup must run after awaiting the child.
*/
export function releaseChildProcessOutputAfterExit(child: ChildProcess): () => void {
let exited = false;
let idleTimer: NodeJS.Timeout | undefined;
let deadlineTimer: NodeJS.Timeout | undefined;
const clearTimers = () => {
if (idleTimer) {
clearTimeout(idleTimer);
idleTimer = undefined;
}
if (deadlineTimer) {
clearTimeout(deadlineTimer);
deadlineTimer = undefined;
}
};
const cleanup = () => {
clearTimers();
child.removeListener("exit", onExit);
child.stdout?.removeListener("data", onData);
child.stderr?.removeListener("data", onData);
};
const release = () => {
cleanup();
child.stdout?.destroy();
child.stderr?.destroy();
};
const armIdleTimer = () => {
if (idleTimer) {
clearTimeout(idleTimer);
}
idleTimer = setTimeout(release, EXIT_STDIO_GRACE_MS);
idleTimer.unref();
};
const onData = () => {
if (exited) {
armIdleTimer();
}
};
const onExit = () => {
exited = true;
armIdleTimer();
deadlineTimer = setTimeout(release, EXIT_STDIO_MAX_DRAIN_MS);
deadlineTimer.unref();
};
child.stdout?.on("data", onData);
child.stderr?.on("data", onData);
child.once("exit", onExit);
return cleanup;
}
+44 -136
View File
@@ -1,119 +1,43 @@
// No-output timer tests cover idle command timeout and output reset behavior.
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const spawnMock = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawn: spawnMock,
};
});
type ExecModule = typeof import("./exec.js");
let runCommandWithTimeout: ExecModule["runCommandWithTimeout"];
function createFakeSpawnedChild() {
const child = new EventEmitter() as EventEmitter & ChildProcess;
const stdout = new EventEmitter();
const stderr = new EventEmitter();
let killed = false;
const kill = vi.fn<(signal?: NodeJS.Signals) => boolean>(() => {
killed = true;
return true;
});
Object.defineProperty(child, "killed", {
get: () => killed,
configurable: true,
});
Object.defineProperty(child, "pid", {
value: 12345,
configurable: true,
});
child.stdout = stdout as ChildProcess["stdout"];
child.stderr = stderr as ChildProcess["stderr"];
child.stdin = null;
child.kill = kill as ChildProcess["kill"];
return { child, stdout, stderr, kill };
}
function emitProcessExit(
fake: ReturnType<typeof createFakeSpawnedChild>,
params?: {
code?: number | null;
signal?: NodeJS.Signals | null;
},
) {
const code = params?.code ?? null;
const signal = params?.signal ?? null;
fake.child.emit("exit", code, signal);
fake.child.emit("close", code, signal);
}
import { describe, expect, it } from "vitest";
import { runCommandWithTimeout } from "./exec.js";
describe("runCommandWithTimeout no-output timer", () => {
beforeAll(async () => {
vi.resetModules();
({ runCommandWithTimeout } = await import("./exec.js"));
});
beforeEach(() => {
spawnMock.mockClear();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("resets no-output timeout when spawned child keeps emitting stdout", async () => {
vi.useFakeTimers();
const fake = createFakeSpawnedChild();
spawnMock.mockReturnValue(fake.child);
const runPromise = runCommandWithTimeout(["node", "-e", "ignored"], {
timeoutMs: 1_000,
noOutputTimeoutMs: 80,
it("resets no-output timeout while the child emits stdout", async () => {
const script = [
"let count = 0",
"let timer",
"const emit = () => {",
" process.stdout.write('.')",
" if (++count === 6) { clearInterval(timer); process.exit(0) }",
"}",
"emit()",
"timer = setInterval(emit, 500)",
].join(";");
const result = await runCommandWithTimeout([process.execPath, "-e", script], {
timeoutMs: 10_000,
// Leave ample process-startup margin while keeping total runtime above
// this threshold, so only output-driven resets let the child finish.
noOutputTimeoutMs: 2_000,
});
fake.stdout.emit("data", Buffer.from("."));
await vi.advanceTimersByTimeAsync(40);
fake.stdout.emit("data", Buffer.from("."));
await vi.advanceTimersByTimeAsync(40);
fake.stdout.emit("data", Buffer.from("."));
await vi.advanceTimersByTimeAsync(20);
fake.child.emit("close", 0, null);
const result = await runPromise;
expect(result.code ?? 0).toBe(0);
expect(result.termination).toBe("exit");
expect(result.noOutputTimedOut).toBe(false);
expect(result.stdout).toBe("...");
expect(fake.kill).not.toHaveBeenCalled();
expect(result).toMatchObject({
code: 0,
noOutputTimedOut: false,
stdout: "......",
termination: "exit",
});
});
it("bounds captured stdout and stderr while keeping the newest output", async () => {
vi.useFakeTimers();
const fake = createFakeSpawnedChild();
spawnMock.mockReturnValue(fake.child);
const runPromise = runCommandWithTimeout(["node", "-e", "ignored"], {
timeoutMs: 1_000,
const script = ["process.stdout.write('abcdefgh')", "process.stderr.write('1234567')"].join(
";",
);
const result = await runCommandWithTimeout([process.execPath, "-e", script], {
timeoutMs: 2_000,
maxOutputBytes: 5,
});
fake.stdout.emit("data", Buffer.from("abcdef"));
fake.stdout.emit("data", Buffer.from("gh"));
fake.stderr.emit("data", Buffer.from("123"));
fake.stderr.emit("data", Buffer.from("4567"));
emitProcessExit(fake, { code: 0 });
const result = await runPromise;
expect(result.stdout).toBe("defgh");
expect(result.stderr).toBe("34567");
expect(result.stdoutTruncatedBytes).toBe(3);
@@ -121,44 +45,28 @@ describe("runCommandWithTimeout no-output timer", () => {
expect(result.termination).toBe("exit");
});
it("marks no-output timeout when the spawned child goes silent", async () => {
vi.useFakeTimers();
const fake = createFakeSpawnedChild();
spawnMock.mockReturnValue(fake.child);
const runPromise = runCommandWithTimeout(["node", "-e", "ignored"], {
timeoutMs: 1_000,
noOutputTimeoutMs: 80,
});
await vi.advanceTimersByTimeAsync(81);
expect(fake.kill).toHaveBeenCalledWith("SIGKILL");
emitProcessExit(fake, { signal: "SIGKILL" });
const result = await runPromise;
it("marks no-output timeout when the child goes silent", async () => {
const result = await runCommandWithTimeout(
[process.execPath, "-e", "setInterval(() => {}, 1_000)"],
{
timeoutMs: 2_000,
noOutputTimeoutMs: 100,
},
);
expect(result.termination).toBe("no-output-timeout");
expect(result.noOutputTimedOut).toBe(true);
expect(result.code).not.toBe(0);
expect(result.code).toBe(124);
});
it("marks global timeout when overall timeout elapses", async () => {
vi.useFakeTimers();
const fake = createFakeSpawnedChild();
spawnMock.mockReturnValue(fake.child);
const runPromise = runCommandWithTimeout(["node", "-e", "ignored"], {
timeoutMs: 80,
});
await vi.advanceTimersByTimeAsync(81);
expect(fake.kill).toHaveBeenCalledWith("SIGKILL");
emitProcessExit(fake, { signal: "SIGKILL" });
const result = await runPromise;
it("marks global timeout when the overall timeout elapses", async () => {
const result = await runCommandWithTimeout(
[process.execPath, "-e", "setInterval(() => {}, 1_000)"],
{ timeoutMs: 100 },
);
expect(result.termination).toBe("timeout");
expect(result.noOutputTimedOut).toBe(false);
expect(result.code).not.toBe(0);
expect(result.code).toBe(124);
});
});
+92 -263
View File
@@ -2,110 +2,18 @@
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import process from "node:process";
import { promisify } from "node:util";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js";
import { MAX_TIMER_TIMEOUT_MS } from "../shared/number-coercion.js";
const spawnMock = vi.hoisted(() => vi.fn());
const execFileMock = vi.hoisted(() => vi.fn());
const execFilePromiseMock = vi.hoisted(() => vi.fn());
let attachChildProcessBridge: typeof import("./child-process-bridge.js").attachChildProcessBridge;
let resolveCommandEnv: typeof import("./exec.js").resolveCommandEnv;
let resolveProcessExitCode: typeof import("./exec.js").resolveProcessExitCode;
let runExec: typeof import("./exec.js").runExec;
let runCommandWithTimeout: typeof import("./exec.js").runCommandWithTimeout;
let shouldSpawnWithShell: typeof import("./exec.js").shouldSpawnWithShell;
async function loadExecModules(options?: { mockSpawn?: boolean; mockExecFile?: boolean }) {
vi.resetModules();
if (options?.mockSpawn || options?.mockExecFile) {
vi.doMock("node:child_process", async () => {
const actual =
await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawn: options?.mockSpawn ? spawnMock : actual.spawn,
execFile: options?.mockExecFile ? execFileMock : actual.execFile,
};
});
} else {
vi.doUnmock("node:child_process");
}
({ attachChildProcessBridge } = await import("./child-process-bridge.js"));
({
resolveCommandEnv,
resolveProcessExitCode,
runCommandWithTimeout,
runExec,
shouldSpawnWithShell,
} = await import("./exec.js"));
}
import { attachChildProcessBridge } from "./child-process-bridge.js";
import {
resolveCommandEnv,
resolveProcessExitCode,
runCommandWithTimeout,
runExec,
shouldSpawnWithShell,
} from "./exec.js";
describe("runCommandWithTimeout", () => {
function createSilentIdleArgv(): string[] {
return [process.execPath, "-e", "setInterval(() => {}, 1_000)"];
}
function createKilledChild(signal: NodeJS.Signals = "SIGKILL"): ChildProcess {
const child = new EventEmitter() as EventEmitter & ChildProcess;
child.stdout = new EventEmitter() as EventEmitter & NonNullable<ChildProcess["stdout"]>;
child.stderr = new EventEmitter() as EventEmitter & NonNullable<ChildProcess["stderr"]>;
child.stdin = new EventEmitter() as EventEmitter & NonNullable<ChildProcess["stdin"]>;
child.stdin.write = vi.fn(() => true) as NonNullable<ChildProcess["stdin"]>["write"];
child.stdin.end = vi.fn() as NonNullable<ChildProcess["stdin"]>["end"];
let killed = false;
let exitCode: number | null = null;
let signalCode: NodeJS.Signals | null = null;
Object.defineProperties(child, {
pid: {
configurable: true,
enumerable: true,
get: () => 1234,
},
killed: {
configurable: true,
enumerable: true,
get: () => killed,
},
exitCode: {
configurable: true,
enumerable: true,
get: () => exitCode,
},
signalCode: {
configurable: true,
enumerable: true,
get: () => signalCode,
},
});
child.kill = vi.fn((receivedSignal?: NodeJS.Signals) => {
const resolvedSignal = receivedSignal ?? signal;
killed = true;
exitCode = null;
signalCode = resolvedSignal;
child.emit("exit", null, resolvedSignal);
child.emit("close", null, resolvedSignal);
return true;
}) as ChildProcess["kill"];
return child;
}
beforeEach(async () => {
vi.useRealTimers();
spawnMock.mockReset();
execFileMock.mockReset();
execFilePromiseMock.mockReset();
delete (execFileMock as { [promisify.custom]?: unknown })[promisify.custom];
await loadExecModules();
});
afterEach(() => {
vi.useRealTimers();
vi.doUnmock("node:child_process");
});
it("never enables shell execution (Windows cmd.exe injection hardening)", () => {
expect(
shouldSpawnWithShell({
@@ -157,45 +65,44 @@ describe("runCommandWithTimeout", () => {
const resolved = resolveCommandEnv({
argv: ["node", "script.js"],
platform: "linux",
baseEnv: {
Path: "/base/bin",
},
env: {
PATH: "/override/bin",
},
baseEnv: { Path: "/base/bin" },
env: { PATH: "/override/bin" },
});
expect(resolved.Path).toBe("/base/bin");
expect(resolved.PATH).toBe("/override/bin");
});
it("does not restore parent variables excluded from the child environment", async () => {
const key = "OPENCLAW_EXECA_PARENT_ONLY_TEST";
const previous = process.env[key];
process.env[key] = "parent-value";
try {
const result = await runCommandWithTimeout(
[process.execPath, "-e", `process.stdout.write(process.env.${key} ?? "missing")`],
{
timeoutMs: 2_000,
baseEnv: {},
},
);
expect(result.stdout).toBe("missing");
} finally {
if (previous === undefined) {
delete process.env[key];
} else {
process.env[key] = previous;
}
}
});
it("suppresses npm fund prompts for npm argv", () => {
const resolved = resolveCommandEnv({
argv: ["npm", "--version"],
baseEnv: {},
});
const resolved = resolveCommandEnv({ argv: ["npm", "--version"], baseEnv: {} });
expect(resolved.NPM_CONFIG_FUND).toBe("false");
expect(resolved.npm_config_fund).toBe("false");
});
it("caps oversized execFile timeouts", async () => {
execFilePromiseMock.mockResolvedValue({ stdout: Buffer.from("ok"), stderr: Buffer.from("") });
(execFileMock as { [promisify.custom]?: typeof execFilePromiseMock })[promisify.custom] =
execFilePromiseMock;
await loadExecModules({ mockExecFile: true });
await expect(
runExec("node", ["--version"], { timeoutMs: Number.MAX_SAFE_INTEGER }),
).resolves.toEqual({ stdout: "ok", stderr: "" });
expect(execFilePromiseMock).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
expect.objectContaining({ timeout: MAX_TIMER_TIMEOUT_MS }),
);
});
it("infers success for shimmed Windows commands when exit codes are missing", () => {
expect(
resolveProcessExitCode({
@@ -210,7 +117,7 @@ describe("runCommandWithTimeout", () => {
).toBe(0);
});
it("does not infer success when this process already issued a timeout kill", () => {
it("does not infer success after this process issued a timeout kill", () => {
expect(
resolveProcessExitCode({
explicitCode: null,
@@ -224,120 +131,52 @@ describe("runCommandWithTimeout", () => {
).toBeNull();
});
it("does not spawn when the abort signal is already aborted", async () => {
await loadExecModules({ mockSpawn: true });
it("returns without spawning when the abort signal is already aborted", async () => {
const controller = new AbortController();
controller.abort();
const result = await runCommandWithTimeout(createSilentIdleArgv(), {
timeoutMs: 2_000,
signal: controller.signal,
});
expect(spawnMock).not.toHaveBeenCalled();
expect(result).toMatchObject({
stdout: "",
stderr: "",
await expect(
runCommandWithTimeout([process.execPath, "-e", "process.exit(99)"], {
timeoutMs: 2_000,
signal: controller.signal,
}),
).resolves.toMatchObject({
code: null,
signal: null,
killed: false,
termination: "signal",
noOutputTimedOut: false,
signal: null,
stderr: "",
stdout: "",
termination: "signal",
});
expect(result.code).not.toBe(0);
});
it.runIf(process.platform === "win32")(
"rejects unresolved commands before Execa can fall through to ambient ComSpec",
async () => {
const command = `openclaw-missing-${process.pid}\r\ncalc.exe`;
const previousComspec = process.env.comspec;
process.env.comspec = process.execPath;
try {
await expect(runCommandWithTimeout([command], { timeoutMs: 2_000 })).rejects.toMatchObject({
code: "ENOENT",
path: command,
syscall: `spawn ${command}`,
});
} finally {
if (previousComspec === undefined) {
delete process.env.comspec;
} else {
process.env.comspec = previousComspec;
}
}
},
);
it.runIf(process.platform !== "win32")(
"kills command when no output timeout elapses",
"swallows stdin EPIPE when the child exits before input is consumed (#75438)",
{ timeout: 5_000 },
async () => {
vi.useFakeTimers();
const child = createKilledChild();
spawnMock.mockReturnValue(child);
await loadExecModules({ mockSpawn: true });
const resultPromise = runCommandWithTimeout(createSilentIdleArgv(), {
timeoutMs: 2_000,
noOutputTimeoutMs: 200,
});
await vi.advanceTimersByTimeAsync(250);
await vi.runAllTimersAsync();
const result = await resultPromise;
expect(result.termination).toBe("no-output-timeout");
expect(result.noOutputTimedOut).toBe(true);
expect(result.code).toBe(124);
},
);
it.runIf(process.platform !== "win32")(
"reports global timeout termination when overall timeout elapses",
{ timeout: 5_000 },
async () => {
vi.useFakeTimers();
const child = createKilledChild();
spawnMock.mockReturnValue(child);
await loadExecModules({ mockSpawn: true });
const resultPromise = runCommandWithTimeout(createSilentIdleArgv(), {
timeoutMs: 200,
});
await vi.advanceTimersByTimeAsync(250);
await vi.runAllTimersAsync();
const result = await resultPromise;
expect(result.termination).toBe("timeout");
expect(result.noOutputTimedOut).toBe(false);
expect(result.code).toBe(124);
},
);
it.runIf(process.platform !== "win32")(
"caps oversized process timeouts before arming timers",
async () => {
vi.useFakeTimers();
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
const child = createKilledChild();
spawnMock.mockReturnValue(child);
await loadExecModules({ mockSpawn: true });
const resultPromise = runCommandWithTimeout(createSilentIdleArgv(), {
timeoutMs: Number.MAX_SAFE_INTEGER,
});
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
child.emit("exit", 0, null);
child.emit("close", 0, null);
const result = await resultPromise;
expect(result.termination).toBe("exit");
},
);
it.runIf(process.platform !== "win32")(
"caps oversized no-output timeouts before arming timers",
async () => {
vi.useFakeTimers();
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
const child = createKilledChild();
spawnMock.mockReturnValue(child);
await loadExecModules({ mockSpawn: true });
const resultPromise = runCommandWithTimeout(createSilentIdleArgv(), {
timeoutMs: 1_000,
noOutputTimeoutMs: Number.MAX_SAFE_INTEGER,
});
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
child.emit("exit", 0, null);
child.emit("close", 0, null);
const result = await resultPromise;
expect(result.termination).toBe("exit");
},
);
it.runIf(process.platform !== "win32")(
"swallows stdin EPIPE when child exits before input is consumed (#75438)",
{ timeout: 5_000 },
async () => {
await loadExecModules();
const result = await runCommandWithTimeout([process.execPath, "-e", "process.exit(0)"], {
timeoutMs: 3_000,
input: "this input will EPIPE because the child ignores stdin\n",
@@ -346,22 +185,7 @@ describe("runCommandWithTimeout", () => {
},
);
it("does not crash when stdout or stderr emit an error event", async () => {
await loadExecModules({ mockSpawn: true });
const child = createKilledChild();
spawnMock.mockReturnValue(child);
const resultPromise = runCommandWithTimeout(createSilentIdleArgv(), { timeoutMs: 2_000 });
child.stdout?.emit("error", new Error("stdout read failed"));
child.stderr?.emit("error", new Error("stderr read failed"));
child.emit("exit", 0, null);
child.emit("close", 0, null);
await expect(resultPromise).resolves.toMatchObject({ code: 0, termination: "exit" });
});
it("preserves matching output lines even when the tail capture truncates them", async () => {
await loadExecModules();
it("preserves matching output lines even when tail capture truncates them", async () => {
const result = await runCommandWithTimeout(
[
process.execPath,
@@ -386,7 +210,6 @@ describe("runCommandWithTimeout", () => {
});
it("bounds preserved matching output for long lines without newlines", async () => {
await loadExecModules();
const result = await runCommandWithTimeout(
[process.execPath, "-e", "process.stdout.write('x'.repeat(10_000))"],
{
@@ -402,7 +225,6 @@ describe("runCommandWithTimeout", () => {
});
it("keeps preserved line tails on a UTF-8 boundary", async () => {
await loadExecModules();
const result = await runCommandWithTimeout(
[process.execPath, "-e", "process.stdout.write('😀' + 'x'.repeat(22))"],
{
@@ -416,27 +238,36 @@ describe("runCommandWithTimeout", () => {
});
});
describe("attachChildProcessBridge", () => {
function createFakeChild() {
const emitter = new EventEmitter() as EventEmitter & ChildProcess;
const kill = vi.fn<(signal?: NodeJS.Signals) => boolean>(() => true);
emitter.kill = kill as ChildProcess["kill"];
return { child: emitter, kill };
}
describe("runExec", () => {
it("captures stdout and stderr", async () => {
await expect(
runExec(process.execPath, ["-e", "process.stdout.write('ok'); process.stderr.write('warn')"]),
).resolves.toEqual({ stdout: "ok", stderr: "warn" });
});
it("preserves the numeric exit code on command failures", async () => {
await expect(runExec(process.execPath, ["-e", "process.exit(7)"])).rejects.toMatchObject({
code: 7,
exitCode: 7,
});
});
});
describe("attachChildProcessBridge", () => {
it("forwards SIGTERM to the wrapped child and detaches on exit", () => {
const beforeSigterm = new Set(process.listeners("SIGTERM"));
const { child, kill } = createFakeChild();
const child = new EventEmitter() as EventEmitter & ChildProcess;
const kill = vi.fn<(signal?: NodeJS.Signals) => boolean>(() => true);
child.kill = kill as ChildProcess["kill"];
const observedSignals: NodeJS.Signals[] = [];
const { detach } = attachChildProcessBridge(child, {
signals: ["SIGTERM"],
onSignal: (signal) => observedSignals.push(signal),
});
const afterSigterm = process.listeners("SIGTERM");
const addedSigterm = afterSigterm.find((listener) => !beforeSigterm.has(listener));
const addedSigterm = process
.listeners("SIGTERM")
.find((listener) => !beforeSigterm.has(listener));
if (!addedSigterm) {
throw new Error("expected SIGTERM listener");
}
@@ -447,8 +278,6 @@ describe("attachChildProcessBridge", () => {
child.emit("exit");
expect(process.listeners("SIGTERM")).toHaveLength(beforeSigterm.size);
// Detached already via exit; should remain a safe no-op.
detach();
});
});
+346 -425
View File
@@ -1,12 +1,9 @@
// Exec helpers run subprocesses with normalized output, timeout, and abort handling.
import { execFile, spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { StringDecoder } from "node:string_decoder";
import { promisify } from "node:util";
import { expectDefined } from "@openclaw/normalization-core";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { execa, type Options as ExecaOptions, type ResultPromise } from "execa";
import { danger, shouldLogVerbose } from "../globals.js";
import { markOpenClawExecEnv } from "../infra/openclaw-exec-env.js";
import {
@@ -17,16 +14,10 @@ import { getWindowsSystem32ExePath } from "../infra/windows-install-roots.js";
import { logDebug, logError } from "../logger.js";
import { resolveTimerTimeoutMs } from "../shared/number-coercion.js";
import { truncateUtf8Suffix } from "../utils/utf8-truncate.js";
import { releaseChildProcessOutputAfterExit } from "./child-process.js";
import { killProcessTree as terminateProcessTree } from "./kill-tree.js";
import { resolveCommandStdio } from "./spawn-utils.js";
import {
buildWindowsCmdExeCommandLine,
isWindowsBatchCommand,
resolveTrustedWindowsCmdExe,
resolveWindowsCommandShim,
} from "./windows-command.js";
const execFileAsync = promisify(execFile);
import { resolveSafeChildProcessInvocation } from "./windows-command.js";
function assignChildEnvValue(params: {
env: NodeJS.ProcessEnv;
@@ -63,78 +54,6 @@ function mergeChildEnv(params: {
return resolvedEnv;
}
/**
* On Windows, Node 18.20.2+ (CVE-2024-27980) rejects spawning .cmd/.bat directly
* without shell, causing EINVAL. Resolve npm/npx to node + cli script so we
* spawn node.exe instead of npm.cmd.
*/
function resolveNpmArgvForWindows(argv: string[]): string[] | null {
if (process.platform !== "win32" || argv.length === 0) {
return null;
}
const basename = normalizeLowercaseStringOrEmpty(
path.basename(expectDefined(argv[0], "argv entry at 0")),
).replace(/\.(cmd|exe|bat)$/, "");
const cliName = basename === "npx" ? "npx-cli.js" : basename === "npm" ? "npm-cli.js" : null;
if (!cliName) {
return null;
}
const nodeDir = path.dirname(process.execPath);
const cliPath = path.join(nodeDir, "node_modules", "npm", "bin", cliName);
if (!fs.existsSync(cliPath)) {
// Bun-based runs don't ship npm-cli.js next to process.execPath.
// Fall back to npm.cmd/npx.cmd so we still route through cmd wrapper
// (avoids direct .cmd spawn EINVAL on patched Node).
const command = argv[0] ?? "";
const ext = normalizeLowercaseStringOrEmpty(path.extname(command));
const shimmedCommand = ext ? command : `${command}.cmd`;
return [shimmedCommand, ...argv.slice(1)];
}
return [process.execPath, cliPath, ...argv.slice(1)];
}
/**
* Resolves a command for Windows compatibility.
* On Windows, non-.exe commands (like pnpm, yarn) are resolved to .cmd; npm/npx
* are handled by resolveNpmArgvForWindows to avoid spawn EINVAL (no direct .cmd).
*/
function resolveCommand(command: string): string {
return resolveWindowsCommandShim({
command,
cmdCommands: ["corepack", "pnpm", "yarn"],
});
}
function resolveChildProcessInvocation(params: {
argv: string[];
windowsVerbatimArguments?: boolean;
}): {
args: string[];
command: string;
usesWindowsExitCodeShim: boolean;
windowsHide: true;
windowsVerbatimArguments?: boolean;
} {
const finalArgv =
process.platform === "win32"
? (resolveNpmArgvForWindows(params.argv) ?? params.argv)
: params.argv;
const resolvedCommand =
finalArgv !== params.argv ? (finalArgv[0] ?? "") : resolveCommand(params.argv[0] ?? "");
const useCmdWrapper = isWindowsBatchCommand(resolvedCommand);
return {
command: useCmdWrapper ? resolveTrustedWindowsCmdExe() : resolvedCommand,
args: useCmdWrapper
? ["/d", "/s", "/c", buildWindowsCmdExeCommandLine(resolvedCommand, finalArgv.slice(1))]
: finalArgv.slice(1),
usesWindowsExitCodeShim:
process.platform === "win32" && (useCmdWrapper || finalArgv !== params.argv),
windowsHide: true,
windowsVerbatimArguments: useCmdWrapper ? true : params.windowsVerbatimArguments,
};
}
export function shouldSpawnWithShell(params: {
resolvedCommand: string;
platform: NodeJS.Platform;
@@ -148,34 +67,88 @@ export function shouldSpawnWithShell(params: {
return false;
}
export type SpawnCommandOptions = Omit<
ExecaOptions,
"env" | "extendEnv" | "shell" | "windowsHide" | "windowsVerbatimArguments"
> & {
baseEnv?: NodeJS.ProcessEnv;
env?: NodeJS.ProcessEnv;
windowsVerbatimArguments?: boolean;
};
function spawnCommandWithInvocation<OptionsType extends SpawnCommandOptions = SpawnCommandOptions>(
argv: string[],
options: OptionsType = {} as OptionsType,
): {
child: ResultPromise<OptionsType>;
invocation: ReturnType<typeof resolveSafeChildProcessInvocation>;
} {
const { baseEnv, env, windowsVerbatimArguments, ...execaOptions } = options;
const commandEnv = resolveCommandEnv({ argv, baseEnv, env });
const invocation = resolveSafeChildProcessInvocation({
argv,
cwd: execaOptions.cwd,
env: commandEnv,
windowsVerbatimArguments,
});
const child = execa(invocation.command, invocation.args, {
...execaOptions,
env: commandEnv,
extendEnv: false,
shell: false,
windowsHide: invocation.windowsHide,
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
}) as unknown as ResultPromise<OptionsType>;
return { child, invocation };
}
/** Spawn through the canonical argv, environment, and Windows safety boundary. */
export function spawnCommand<OptionsType extends SpawnCommandOptions = SpawnCommandOptions>(
argv: string[],
options: OptionsType = {} as OptionsType,
): ResultPromise<OptionsType> {
return spawnCommandWithInvocation(argv, options).child;
}
// Simple promise-wrapped execFile with optional verbosity logging.
export async function runExec(
command: string,
args: string[],
opts: number | { timeoutMs?: number; maxBuffer?: number; cwd?: string } = 10_000,
): Promise<{ stdout: string; stderr: string }> {
const options =
const timeout =
typeof opts === "number"
? { timeout: resolveTimerTimeoutMs(opts, 1), encoding: "buffer" as const }
: {
timeout:
typeof opts.timeoutMs === "number"
? resolveTimerTimeoutMs(opts.timeoutMs, 1)
: undefined,
maxBuffer: opts.maxBuffer,
cwd: opts.cwd,
encoding: "buffer" as const,
};
? resolveTimerTimeoutMs(opts, 1)
: typeof opts.timeoutMs === "number"
? resolveTimerTimeoutMs(opts.timeoutMs, 1)
: undefined;
const maxBuffer =
typeof opts === "number"
? DEFAULT_EXEC_MAX_BUFFER_BYTES
: (opts.maxBuffer ?? DEFAULT_EXEC_MAX_BUFFER_BYTES);
const cwd = typeof opts === "number" ? undefined : opts.cwd;
try {
const invocation = resolveChildProcessInvocation({ argv: [command, ...args] });
const { stdout, stderr } = (await execFileAsync(invocation.command, invocation.args, {
...options,
windowsHide: invocation.windowsHide,
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
})) as { stdout: Buffer; stderr: Buffer };
const subprocess = spawnCommand([command, ...args], {
cwd,
encoding: "buffer",
forceKillAfterDelay: COMMAND_PROCESS_TREE_KILL_GRACE_MS,
maxBuffer,
reject: true,
stdin: "ignore",
stripFinalNewline: false,
timeout,
});
const releaseOutput = releaseChildProcessOutputAfterExit(subprocess);
const { stdout, stderr } = await subprocess.finally(releaseOutput);
const windowsEncoding = resolveWindowsConsoleEncoding();
const decodedStdout = decodeWindowsOutputBuffer({ buffer: stdout, windowsEncoding });
const decodedStderr = decodeWindowsOutputBuffer({ buffer: stderr, windowsEncoding });
const decodedStdout = decodeWindowsOutputBuffer({
buffer: Buffer.from(stdout),
windowsEncoding,
});
const decodedStderr = decodeWindowsOutputBuffer({
buffer: Buffer.from(stderr),
windowsEncoding,
});
if (shouldLogVerbose()) {
if (decodedStdout.trim()) {
logDebug(decodedStdout.trim());
@@ -188,16 +161,24 @@ export async function runExec(
} catch (err) {
const windowsEncoding = resolveWindowsConsoleEncoding();
if (err && typeof err === "object") {
const errorWithOutput = err as { stdout?: unknown; stderr?: unknown };
if (Buffer.isBuffer(errorWithOutput.stdout)) {
const errorWithOutput = err as {
code?: string | number;
exitCode?: unknown;
stdout?: unknown;
stderr?: unknown;
};
if (errorWithOutput.code === undefined && typeof errorWithOutput.exitCode === "number") {
errorWithOutput.code = errorWithOutput.exitCode;
}
if (errorWithOutput.stdout instanceof Uint8Array) {
errorWithOutput.stdout = decodeWindowsOutputBuffer({
buffer: errorWithOutput.stdout,
buffer: Buffer.from(errorWithOutput.stdout),
windowsEncoding,
});
}
if (Buffer.isBuffer(errorWithOutput.stderr)) {
if (errorWithOutput.stderr instanceof Uint8Array) {
errorWithOutput.stderr = decodeWindowsOutputBuffer({
buffer: errorWithOutput.stderr,
buffer: Buffer.from(errorWithOutput.stderr),
windowsEncoding,
});
}
@@ -239,9 +220,10 @@ export type CommandOptions = {
killProcessTree?: boolean;
};
const COMMAND_PROCESS_TREE_KILL_GRACE_MS = 300;
const WINDOWS_CLOSE_STATE_SETTLE_TIMEOUT_MS = 250;
const WINDOWS_CLOSE_STATE_POLL_MS = 10;
const COMMAND_PROCESS_TREE_KILL_GRACE_MS = 300;
const DEFAULT_EXEC_MAX_BUFFER_BYTES = 1024 * 1024;
const TIMEOUT_EXIT_CODE = 124;
const DEFAULT_COMMAND_OUTPUT_MAX_BYTES = 16 * 1024 * 1024;
const MAX_PRESERVED_PENDING_LINE_BYTES = 8 * 1024;
@@ -418,12 +400,7 @@ export async function runCommandWithTimeout(
options;
const resolvedTimeoutMs = resolveTimerTimeoutMs(timeoutMs, 1);
const hasInput = input !== undefined;
const resolvedEnv = resolveCommandEnv({ argv, baseEnv, env });
const stdio = resolveCommandStdio({ hasInput, preferInherit: true });
const invocation = resolveChildProcessInvocation({
argv,
windowsVerbatimArguments: options.windowsVerbatimArguments,
});
if (signal?.aborted) {
return {
@@ -437,337 +414,281 @@ export async function runCommandWithTimeout(
};
}
const child = spawn(invocation.command, invocation.args, {
stdio,
const stdoutCapture: CapturedOutputBuffers = {
chunks: [],
bytes: 0,
truncatedBytes: 0,
preservedLines: [],
decoder: new StringDecoder("utf8"),
pendingLine: "",
};
const stderrCapture: CapturedOutputBuffers = {
chunks: [],
bytes: 0,
truncatedBytes: 0,
preservedLines: [],
decoder: new StringDecoder("utf8"),
pendingLine: "",
};
const maxOutputBytes = normalizeMaxOutputBytes(options.maxOutputBytes);
const maxPreservedPendingLineBytes = Math.min(maxOutputBytes, MAX_PRESERVED_PENDING_LINE_BYTES);
const maxPreservedOutputLines = Math.max(0, Math.floor(options.maxPreservedOutputLines ?? 16));
const windowsEncoding = resolveWindowsConsoleEncoding();
const cancelController = new AbortController();
let termination: SpawnResult["termination"] | undefined;
let childExitState: { code: number | null; signal: NodeJS.Signals | null } | undefined;
let childExited = false;
let noOutputTimer: NodeJS.Timeout | undefined;
let processTreeForceKillTimer: NodeJS.Timeout | undefined;
const { child, invocation } = spawnCommandWithInvocation(argv, {
buffer: false,
cancelSignal: cancelController.signal,
cwd,
env: resolvedEnv,
// Cron shell wrappers need their own process group so timeout/abort kills
// reach foreground children instead of leaving duplicate scheduled work.
...(killProcessTree && process.platform !== "win32" ? { detached: true } : {}),
windowsHide: invocation.windowsHide,
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
...(shouldSpawnWithShell({ resolvedCommand: invocation.command, platform: process.platform })
? { shell: true }
: {}),
detached: Boolean(killProcessTree && process.platform !== "win32"),
encoding: "buffer",
baseEnv,
env,
forceKillAfterDelay: COMMAND_PROCESS_TREE_KILL_GRACE_MS,
...(hasInput ? { input } : {}),
reject: false,
stdio,
stripFinalNewline: false,
windowsVerbatimArguments: options.windowsVerbatimArguments,
});
const releaseOutput = releaseChildProcessOutputAfterExit(child);
child.once("exit", (code, signalValue) => {
childExited = true;
childExitState = { code, signal: signalValue };
});
// Spawn with inherited stdin (TTY) so interactive tools stay usable when needed.
return await new Promise((resolve, reject) => {
const stdoutCapture: CapturedOutputBuffers = {
chunks: [],
bytes: 0,
truncatedBytes: 0,
preservedLines: [],
decoder: new StringDecoder("utf8"),
pendingLine: "",
};
const stderrCapture: CapturedOutputBuffers = {
chunks: [],
bytes: 0,
truncatedBytes: 0,
preservedLines: [],
decoder: new StringDecoder("utf8"),
pendingLine: "",
};
const maxOutputBytes = normalizeMaxOutputBytes(options.maxOutputBytes);
const maxPreservedPendingLineBytes = Math.min(maxOutputBytes, MAX_PRESERVED_PENDING_LINE_BYTES);
const maxPreservedOutputLines = Math.max(0, Math.floor(options.maxPreservedOutputLines ?? 16));
const windowsEncoding = resolveWindowsConsoleEncoding();
let settled = false;
let timedOut = false;
let noOutputTimedOut = false;
let killIssuedByTimeout = false;
let killIssuedByAbort = false;
let childExitState: { code: number | null; signal: NodeJS.Signals | null } | null = null;
let closeFallbackTimer: NodeJS.Timeout | null = null;
let processTreeForceKillTimer: NodeJS.Timeout | null = null;
let noOutputTimer: NodeJS.Timeout | null = null;
const shouldTrackOutputTimeout =
typeof noOutputTimeoutMs === "number" &&
Number.isFinite(noOutputTimeoutMs) &&
noOutputTimeoutMs > 0;
const resolvedNoOutputTimeoutMs = shouldTrackOutputTimeout
? resolveTimerTimeoutMs(noOutputTimeoutMs, 1)
: undefined;
let removeAbortListener: (() => void) | null = null;
const clearNoOutputTimer = () => {
if (!noOutputTimer) {
return;
}
const clearNoOutputTimer = () => {
if (noOutputTimer) {
clearTimeout(noOutputTimer);
noOutputTimer = null;
};
const clearCloseFallbackTimer = () => {
if (!closeFallbackTimer) {
return;
}
clearTimeout(closeFallbackTimer);
closeFallbackTimer = null;
};
const clearProcessTreeForceKillTimer = () => {
if (!processTreeForceKillTimer) {
return;
}
noOutputTimer = undefined;
}
};
const clearProcessTreeForceKillTimer = () => {
if (processTreeForceKillTimer) {
clearTimeout(processTreeForceKillTimer);
processTreeForceKillTimer = null;
};
const killDirectChild = () => {
if (settled || childExitState != null || child.exitCode != null || child.signalCode != null) {
return;
}
processTreeForceKillTimer = undefined;
}
};
const killDirectChild = () => {
if (!childExited && child.exitCode == null && child.signalCode == null) {
child.kill("SIGKILL");
};
const spawnTaskkillOrFallback = (args: string[], onSpawnError: () => void): boolean => {
try {
const taskkillChild = spawn(getWindowsSystem32ExePath("taskkill.exe"), args, {
stdio: "ignore",
windowsHide: true,
}
};
const spawnTaskkillOrFallback = (args: string[], onSpawnError: () => void): boolean => {
try {
const taskkillChild = spawnCommand([getWindowsSystem32ExePath("taskkill.exe"), ...args], {
baseEnv,
env,
reject: false,
stdio: "ignore",
});
void taskkillChild.then((result) => {
if (result.failed && result.exitCode === undefined) {
onSpawnError();
}
});
return true;
} catch {
onSpawnError();
return false;
}
};
const terminateChild = () => {
if (childExited || child.exitCode != null || child.signalCode != null) {
return;
}
if (process.platform === "win32" && typeof child.pid === "number") {
if (killProcessTree) {
const taskkillStarted = spawnTaskkillOrFallback(["/PID", String(child.pid), "/T"], () => {
clearProcessTreeForceKillTimer();
killDirectChild();
});
taskkillChild.once("error", onSpawnError);
return true;
} catch {
onSpawnError();
return false;
}
};
const killChild = (byTimeout = true) => {
if (settled || typeof child?.kill !== "function") {
return;
}
if (byTimeout) {
killIssuedByTimeout = true;
} else {
killIssuedByAbort = true;
}
if (killProcessTree && typeof child.pid === "number" && child.pid > 0) {
if (process.platform === "win32") {
const taskkillStarted = spawnTaskkillOrFallback(["/PID", String(child.pid), "/T"], () => {
clearProcessTreeForceKillTimer();
killDirectChild();
});
if (taskkillStarted) {
if (!processTreeForceKillTimer) {
processTreeForceKillTimer = setTimeout(() => {
processTreeForceKillTimer = null;
if (
settled ||
childExitState != null ||
child.exitCode != null ||
child.signalCode != null
) {
return;
}
spawnTaskkillOrFallback(["/PID", String(child.pid), "/T", "/F"], killDirectChild);
}, COMMAND_PROCESS_TREE_KILL_GRACE_MS);
processTreeForceKillTimer.unref();
if (taskkillStarted) {
processTreeForceKillTimer = setTimeout(() => {
processTreeForceKillTimer = undefined;
if (childExited || child.exitCode != null || child.signalCode != null) {
return;
}
}
return;
spawnTaskkillOrFallback(["/PID", String(child.pid), "/T", "/F"], killDirectChild);
}, COMMAND_PROCESS_TREE_KILL_GRACE_MS);
processTreeForceKillTimer.unref();
}
terminateProcessTree(child.pid, { graceMs: COMMAND_PROCESS_TREE_KILL_GRACE_MS });
return;
}
if (process.platform === "win32" && typeof child.pid === "number" && child.pid > 0) {
} else {
spawnTaskkillOrFallback(["/PID", String(child.pid), "/T", "/F"], killDirectChild);
return;
}
killDirectChild();
};
} else if (killProcessTree && typeof child.pid === "number") {
terminateProcessTree(child.pid, { graceMs: COMMAND_PROCESS_TREE_KILL_GRACE_MS });
}
};
const cancel = (reason: Exclude<SpawnResult["termination"], "exit">) => {
if (termination || childExited) {
return;
}
termination = reason;
terminateChild();
// Windows tree termination is owned by trusted taskkill. Aborting Execa here
// would terminate only the direct child before the tree gets its grace period.
if (process.platform !== "win32" || typeof child.pid !== "number") {
cancelController.abort();
}
};
const shouldTrackOutputTimeout =
typeof noOutputTimeoutMs === "number" &&
Number.isFinite(noOutputTimeoutMs) &&
noOutputTimeoutMs > 0;
const resolvedNoOutputTimeoutMs = shouldTrackOutputTimeout
? resolveTimerTimeoutMs(noOutputTimeoutMs, 1)
: undefined;
const armNoOutputTimer = () => {
if (resolvedNoOutputTimeoutMs === undefined || childExited) {
return;
}
clearNoOutputTimer();
noOutputTimer = setTimeout(() => cancel("no-output-timeout"), resolvedNoOutputTimeoutMs);
};
const armNoOutputTimer = () => {
if (!shouldTrackOutputTimeout || settled) {
return;
}
clearNoOutputTimer();
noOutputTimer = setTimeout(() => {
if (settled) {
return;
}
noOutputTimedOut = true;
killChild();
}, resolvedNoOutputTimeoutMs);
};
const timeoutTimer = setTimeout(() => cancel("timeout"), resolvedTimeoutMs);
const onAbort = () => cancel("signal");
signal?.addEventListener("abort", onAbort, { once: true });
armNoOutputTimer();
const timer = setTimeout(() => {
timedOut = true;
killChild();
}, resolvedTimeoutMs);
child.stdout?.on("data", (chunk) => {
appendPreservedOutputLines({
capture: stdoutCapture,
chunk,
stream: "stdout",
preserveOutputLine: options.preserveOutputLine,
maxPreservedOutputLines,
maxPendingLineBytes: maxPreservedPendingLineBytes,
});
appendCapturedOutput(stdoutCapture, chunk, maxOutputBytes);
armNoOutputTimer();
if (signal) {
const onAbort = () => killChild(false);
signal.addEventListener("abort", onAbort, { once: true });
removeAbortListener = () => signal.removeEventListener("abort", onAbort);
}
});
child.stderr?.on("data", (chunk) => {
appendPreservedOutputLines({
capture: stderrCapture,
chunk,
stream: "stderr",
preserveOutputLine: options.preserveOutputLine,
maxPreservedOutputLines,
maxPendingLineBytes: maxPreservedPendingLineBytes,
});
appendCapturedOutput(stderrCapture, chunk, maxOutputBytes);
armNoOutputTimer();
});
if (hasInput && child.stdin) {
// Swallow EPIPE from a prematurely-exited child; the exit handler
// reports the real status. (#75438)
child.stdin.on("error", () => {});
child.stdin.write(input ?? "");
child.stdin.end();
}
// Output pipes may fail independently; child exit/close remains authoritative.
const ignoreOutputStreamError = () => {};
child.stdout?.on("error", ignoreOutputStreamError);
child.stderr?.on("error", ignoreOutputStreamError);
child.stdout?.on("data", (d) => {
appendPreservedOutputLines({
capture: stdoutCapture,
chunk: d,
stream: "stdout",
preserveOutputLine: options.preserveOutputLine,
maxPreservedOutputLines,
maxPendingLineBytes: maxPreservedPendingLineBytes,
});
appendCapturedOutput(stdoutCapture, d, maxOutputBytes);
armNoOutputTimer();
});
child.stderr?.on("data", (d) => {
appendPreservedOutputLines({
capture: stderrCapture,
chunk: d,
stream: "stderr",
preserveOutputLine: options.preserveOutputLine,
maxPreservedOutputLines,
maxPendingLineBytes: maxPreservedPendingLineBytes,
});
appendCapturedOutput(stderrCapture, d, maxOutputBytes);
armNoOutputTimer();
});
child.on("error", (err) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
clearNoOutputTimer();
clearCloseFallbackTimer();
clearProcessTreeForceKillTimer();
removeAbortListener?.();
removeAbortListener = null;
reject(err);
});
child.on("exit", (code, signalResult) => {
childExitState = { code, signal: signalResult };
clearProcessTreeForceKillTimer();
if (settled || closeFallbackTimer) {
return;
}
closeFallbackTimer = setTimeout(() => {
if (settled) {
return;
}
child.stdout?.destroy();
child.stderr?.destroy();
}, 250);
});
const resolveFromClose = (code: number | null, signalValue: NodeJS.Signals | null) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
clearNoOutputTimer();
clearCloseFallbackTimer();
clearProcessTreeForceKillTimer();
removeAbortListener?.();
removeAbortListener = null;
const resolvedSignal = childExitState?.signal ?? signalValue ?? child.signalCode ?? null;
const resolvedCode = resolveProcessExitCode({
explicitCode: childExitState?.code ?? code,
childExitCode: child.exitCode,
resolvedSignal,
usesWindowsExitCodeShim: invocation.usesWindowsExitCodeShim,
timedOut,
noOutputTimedOut,
killIssuedByTimeout,
killIssuedByAbort,
});
const termination = noOutputTimedOut
? "no-output-timeout"
: timedOut
? "timeout"
: resolvedSignal != null || killIssuedByAbort
? "signal"
: "exit";
const normalizedCode =
termination === "timeout" || termination === "no-output-timeout"
? resolvedCode == null || resolvedCode === 0
? TIMEOUT_EXIT_CODE
: resolvedCode
: resolvedCode;
flushPreservedOutputLine({
capture: stdoutCapture,
stream: "stdout",
preserveOutputLine: options.preserveOutputLine,
maxPreservedOutputLines,
maxPendingLineBytes: maxPreservedPendingLineBytes,
});
flushPreservedOutputLine({
capture: stderrCapture,
stream: "stderr",
preserveOutputLine: options.preserveOutputLine,
maxPreservedOutputLines,
maxPendingLineBytes: maxPreservedPendingLineBytes,
});
resolve({
pid: child.pid ?? undefined,
stdout: decodeWindowsOutputBuffer({
buffer: Buffer.concat(stdoutCapture.chunks, stdoutCapture.bytes),
windowsEncoding,
}),
stderr: decodeWindowsOutputBuffer({
buffer: Buffer.concat(stderrCapture.chunks, stderrCapture.bytes),
windowsEncoding,
}),
stdoutTruncatedBytes: stdoutCapture.truncatedBytes || undefined,
stderrTruncatedBytes: stderrCapture.truncatedBytes || undefined,
preservedStdoutLines:
stdoutCapture.preservedLines.length > 0 ? stdoutCapture.preservedLines : undefined,
preservedStderrLines:
stderrCapture.preservedLines.length > 0 ? stderrCapture.preservedLines : undefined,
code: normalizedCode,
signal: resolvedSignal,
killed: child.killed,
termination,
noOutputTimedOut,
});
};
child.on("close", (code, signalLocal) => {
const result = await child.finally(() => {
clearTimeout(timeoutTimer);
clearNoOutputTimer();
clearProcessTreeForceKillTimer();
signal?.removeEventListener("abort", onAbort);
releaseOutput();
});
// Patched Node can report null/null after a cmd.exe shim exits. Execa turns
// that into a cause-less failure; preserve the shim fallback only post-spawn.
const isCauseLessWindowsShimResult =
!termination &&
invocation.usesWindowsExitCodeShim &&
typeof child.pid === "number" &&
result.code === undefined &&
result.cause === undefined &&
!result.timedOut &&
!result.isCanceled &&
!result.isMaxBuffer &&
!result.isTerminated;
if (isCauseLessWindowsShimResult) {
// A patched Windows runtime can populate exitCode shortly after close.
// Settle that state before the shim fallback can infer a clean exit.
for (
let elapsedMs = 0;
elapsedMs < WINDOWS_CLOSE_STATE_SETTLE_TIMEOUT_MS;
elapsedMs += WINDOWS_CLOSE_STATE_POLL_MS
) {
if (
process.platform !== "win32" ||
childExitState != null ||
code != null ||
signalLocal != null ||
childExitState?.code != null ||
childExitState?.signal != null ||
child.exitCode != null ||
child.signalCode != null
) {
resolveFromClose(code, signalLocal);
return;
break;
}
await new Promise<void>((resolve) => {
setTimeout(resolve, WINDOWS_CLOSE_STATE_POLL_MS);
});
}
}
if (
result.failed &&
!termination &&
result.exitCode === undefined &&
result.signal === undefined &&
!isCauseLessWindowsShimResult
) {
if (result instanceof Error) {
throw result;
}
throw new Error(`Failed to launch command: ${argv[0] ?? "<empty>"}`, { cause: result });
}
const startedAt = Date.now();
const waitForExitState = () => {
if (settled) {
return;
}
if (childExitState != null || child.exitCode != null || child.signalCode != null) {
resolveFromClose(code, signalLocal);
return;
}
if (Date.now() - startedAt >= WINDOWS_CLOSE_STATE_SETTLE_TIMEOUT_MS) {
resolveFromClose(code, signalLocal);
return;
}
setTimeout(waitForExitState, WINDOWS_CLOSE_STATE_POLL_MS);
};
waitForExitState();
});
const resolvedSignal = result.signal ?? childExitState?.signal ?? child.signalCode ?? null;
const resolvedCode = resolveProcessExitCode({
explicitCode: result.exitCode ?? childExitState?.code,
childExitCode: child.exitCode,
resolvedSignal,
usesWindowsExitCodeShim: invocation.usesWindowsExitCodeShim,
timedOut: termination === "timeout",
noOutputTimedOut: termination === "no-output-timeout",
killIssuedByTimeout: termination === "timeout" || termination === "no-output-timeout",
killIssuedByAbort: termination === "signal",
});
termination ??= resolvedSignal != null || result.isTerminated ? "signal" : "exit";
const normalizedCode =
termination === "timeout" || termination === "no-output-timeout"
? resolvedCode == null || resolvedCode === 0
? TIMEOUT_EXIT_CODE
: resolvedCode
: resolvedCode;
flushPreservedOutputLine({
capture: stdoutCapture,
stream: "stdout",
preserveOutputLine: options.preserveOutputLine,
maxPreservedOutputLines,
maxPendingLineBytes: maxPreservedPendingLineBytes,
});
flushPreservedOutputLine({
capture: stderrCapture,
stream: "stderr",
preserveOutputLine: options.preserveOutputLine,
maxPreservedOutputLines,
maxPendingLineBytes: maxPreservedPendingLineBytes,
});
return {
pid: child.pid,
stdout: decodeWindowsOutputBuffer({
buffer: Buffer.concat(stdoutCapture.chunks, stdoutCapture.bytes),
windowsEncoding,
}),
stderr: decodeWindowsOutputBuffer({
buffer: Buffer.concat(stderrCapture.chunks, stderrCapture.bytes),
windowsEncoding,
}),
stdoutTruncatedBytes: stdoutCapture.truncatedBytes || undefined,
stderrTruncatedBytes: stderrCapture.truncatedBytes || undefined,
preservedStdoutLines:
stdoutCapture.preservedLines.length > 0 ? stdoutCapture.preservedLines : undefined,
preservedStderrLines:
stderrCapture.preservedLines.length > 0 ? stderrCapture.preservedLines : undefined,
code: normalizedCode,
signal: resolvedSignal,
killed: child.killed,
termination,
noOutputTimedOut: termination === "no-output-timeout",
};
}
+319 -567
View File
@@ -1,197 +1,205 @@
// Windows exec tests cover command invocation behavior on Windows paths.
import type { execFile as execFileType } from "node:child_process";
// Windows exec tests cover trusted command wrapping, tree termination, and output decoding.
import { EventEmitter } from "node:events";
import fs from "node:fs";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { PassThrough } from "node:stream";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
resetWindowsInstallRootsForTests,
getWindowsInstallRoots,
getWindowsSystem32ExePath,
resetWindowsInstallRootsForTests,
} from "../infra/windows-install-roots.js";
import { withMockedWindowsPlatform, withRestoredMocks } from "../test-utils/vitest-spies.js";
import { withTempDir } from "../test-utils/temp-dir.js";
import { withMockedWindowsPlatform } from "../test-utils/vitest-spies.js";
const { spawnMock, spawnSyncMock, execFileMock, execFilePromisifyMock } = vi.hoisted(() => {
const execFilePromisifyMockLocal = vi.fn();
const execFileMockLocal = Object.assign(vi.fn(), {
[Symbol.for("nodejs.util.promisify.custom")]: execFilePromisifyMockLocal,
__promisify__: execFilePromisifyMockLocal,
});
return {
spawnMock: vi.fn(),
spawnSyncMock: vi.fn(),
execFileMock: execFileMockLocal,
execFilePromisifyMock: execFilePromisifyMockLocal,
};
});
const execaMock = vi.fn();
const isRegularFileMock = vi.fn();
const resolveExecutableFromPathEnvMock = vi.fn();
const resolveExecutablePathCandidateMock = vi.fn();
const spawnSyncMock = vi.fn();
vi.mock("node:child_process", async () => {
const { mockNodeBuiltinModule } = await import("openclaw/plugin-sdk/test-node-mocks");
return mockNodeBuiltinModule(
() => vi.importActual<typeof import("node:child_process")>("node:child_process"),
{
spawn: spawnMock,
spawnSync: spawnSyncMock,
execFile: execFileMock as unknown as typeof execFileType,
},
);
});
let runCommandWithTimeout: typeof import("./exec.js").runCommandWithTimeout;
let runExec: typeof import("./exec.js").runExec;
type MockChild = EventEmitter & {
exitCode?: number | null;
signalCode?: NodeJS.Signals | null;
stdout: EventEmitter;
stderr: EventEmitter;
stdin: { write: ReturnType<typeof vi.fn>; end: ReturnType<typeof vi.fn> };
kill: ReturnType<typeof vi.fn>;
pid?: number;
killed?: boolean;
type MockResult = {
cause?: unknown;
code?: string;
exitCode?: number;
failed: boolean;
isCanceled?: boolean;
isMaxBuffer?: boolean;
isTerminated: boolean;
signal?: NodeJS.Signals;
stderr?: Buffer;
stdout?: Buffer;
timedOut?: boolean;
};
type SpawnCall = [string, string[], Record<string, unknown>];
type MockSubprocess = EventEmitter & {
exitCode: number | null;
finish: (result?: Partial<MockResult>) => void;
kill: ReturnType<typeof vi.fn>;
killed: boolean;
pid: number;
signalCode: NodeJS.Signals | null;
stderr: PassThrough;
stdout: PassThrough;
catch: Promise<MockResult>["catch"];
finally: Promise<MockResult>["finally"];
then: Promise<MockResult>["then"];
};
function requireSpawnCall(callIndex: number): SpawnCall {
const call = spawnMock.mock.calls[callIndex];
if (!call) {
throw new Error(`expected spawn call ${callIndex}`);
}
return call as SpawnCall;
}
type ExecaCall = [string, string[], Record<string, unknown>];
function createMockChild(params?: {
closeCode?: number | null;
closeSignal?: NodeJS.Signals | null;
exitCode?: number | null;
exitCodeAfterClose?: number | null;
exitCodeAfterCloseDelayMs?: number;
signal?: NodeJS.Signals | null;
autoClose?: boolean;
}): MockChild {
const child = new EventEmitter() as MockChild;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.exitCode = params?.exitCode ?? params?.closeCode ?? 0;
child.signalCode = params?.signal ?? null;
child.stdin = {
write: vi.fn(),
end: vi.fn(),
};
child.kill = vi.fn(() => true);
function createMockSubprocess(params?: {
autoFinish?: boolean;
exitCode?: number;
signal?: NodeJS.Signals;
stderr?: Buffer;
stderrChunks?: Buffer[];
stdout?: Buffer;
stdoutChunks?: Buffer[];
}): MockSubprocess {
const child = new EventEmitter() as MockSubprocess;
child.pid = 1234;
child.exitCode = null;
child.signalCode = null;
child.killed = false;
if (params?.autoClose !== false) {
queueMicrotask(() => {
child.emit("close", params?.closeCode ?? 0, params?.closeSignal ?? params?.signal ?? null);
if (params?.exitCodeAfterClose !== undefined) {
setTimeout(() => {
child.exitCode = params.exitCodeAfterClose ?? null;
}, params.exitCodeAfterCloseDelayMs ?? 0);
}
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = vi.fn(() => {
child.killed = true;
return true;
});
let resolve!: (result: MockResult) => void;
const completion = new Promise<MockResult>((resolvePromise) => {
resolve = resolvePromise;
});
// oxlint-disable-next-line unicorn/no-thenable -- Stub matches Execa's event-emitting promise shape.
child.then = completion.then.bind(completion);
child.catch = completion.catch.bind(completion);
child.finally = completion.finally.bind(completion);
child.finish = (overrides = {}) => {
for (const chunk of params?.stdoutChunks ?? []) {
child.stdout.write(chunk);
}
for (const chunk of params?.stderrChunks ?? []) {
child.stderr.write(chunk);
}
const exitCode = Object.hasOwn(overrides, "exitCode")
? overrides.exitCode
: (params?.exitCode ?? 0);
const signal = overrides.signal ?? params?.signal;
child.exitCode = signal ? null : (exitCode ?? null);
child.signalCode = signal ?? null;
child.emit("exit", child.exitCode, child.signalCode);
resolve({
exitCode: signal ? undefined : exitCode,
failed: signal !== undefined || exitCode !== 0,
isTerminated: signal !== undefined,
signal,
stderr: params?.stderr ?? Buffer.concat(params?.stderrChunks ?? []),
stdout: params?.stdout ?? Buffer.concat(params?.stdoutChunks ?? []),
...overrides,
});
};
if (params?.autoFinish !== false) {
queueMicrotask(() => child.finish());
}
return child;
}
type ExecCall = [
string,
string[],
Record<string, unknown>,
(err: Error | null, stdout: string, stderr: string) => void,
];
function requireExecFileCall(callIndex: number): ExecCall {
const call = execFileMock.mock.calls[callIndex];
function requireExecaCall(index: number): ExecaCall {
const call = execaMock.mock.calls[index];
if (!call) {
throw new Error(`expected execFile call ${callIndex}`);
throw new Error(`expected execa call ${index}`);
}
return call as ExecCall;
}
function expectCmdWrappedInvocation(params: {
captured: SpawnCall | ExecCall;
expectedComSpec: string;
}) {
expect(params.captured[0]).toBe(params.expectedComSpec);
expect(params.captured[1].slice(0, 3)).toEqual(["/d", "/s", "/c"]);
expect(params.captured[1][3]).toContain("pnpm.cmd --version");
expect(params.captured[2].windowsHide).toBe(true);
expect(params.captured[2].windowsVerbatimArguments).toBe(true);
return call as ExecaCall;
}
function expectedTrustedCmdExe(): string {
return path.win32.join(getWindowsInstallRoots().systemRoot, "System32", "cmd.exe");
}
async function expectShimmedWindowsCommandWithoutExitCodeSucceeds(params?: { killed?: boolean }) {
const child = createMockChild({
closeCode: null,
exitCode: null,
});
child.killed = params?.killed ?? false;
spawnMock.mockImplementation(() => child);
await withMockedWindowsPlatform(async () => {
const result = await runCommandWithTimeout(["npm", "--version"], { timeoutMs: 1000 });
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.termination).toBe("exit");
function expectCmdWrappedInvocation(call: ExecaCall, commandFragment = "pnpm.cmd") {
expect(call[0]).toBe(expectedTrustedCmdExe());
expect(call[1].slice(0, 3)).toEqual(["/d", "/s", "/c"]);
expect(call[1][3]).toContain(commandFragment);
expect(call[1][3]).toContain("--version");
expect(call[2]).toMatchObject({
shell: false,
windowsHide: true,
windowsVerbatimArguments: true,
});
}
describe("windows command wrapper behavior", () => {
let runCommandWithTimeout: typeof import("./exec.js").runCommandWithTimeout;
let runExec: typeof import("./exec.js").runExec;
let spawnCommand: typeof import("./exec.js").spawnCommand;
describe("Windows command execution", () => {
beforeAll(async () => {
({ runCommandWithTimeout, runExec } = await import("./exec.js"));
vi.resetModules();
vi.doMock("execa", () => ({ execa: execaMock }));
vi.doMock("../infra/executable-path.js", async () => {
const actual = await vi.importActual<typeof import("../infra/executable-path.js")>(
"../infra/executable-path.js",
);
return {
...actual,
isRegularFile: isRegularFileMock,
resolveExecutableFromPathEnv: resolveExecutableFromPathEnvMock,
resolveExecutablePathCandidate: resolveExecutablePathCandidateMock,
};
});
vi.doMock("node:child_process", async () => {
const actual =
await vi.importActual<typeof import("node:child_process")>("node:child_process");
return { ...actual, spawnSync: spawnSyncMock };
});
({ runCommandWithTimeout, runExec, spawnCommand } = await import("./exec.js"));
});
afterAll(() => {
vi.doUnmock("execa");
vi.doUnmock("../infra/executable-path.js");
vi.doUnmock("node:child_process");
vi.resetModules();
});
beforeEach(() => {
// Stub the registry probe so install-root resolution is fully driven by
// process.env in tests; on real Windows runners the registry returns the
// canonical SystemRoot and would shadow the test's env setup.
resetWindowsInstallRootsForTests({ queryRegistryValue: () => null });
spawnMock.mockReset();
execaMock.mockReset();
execaMock.mockImplementation(() => createMockSubprocess());
isRegularFileMock.mockReset();
isRegularFileMock.mockReturnValue(true);
resolveExecutableFromPathEnvMock.mockReset();
resolveExecutableFromPathEnvMock.mockImplementation((command: string) => {
const basename = path.win32.basename(command).toLowerCase();
if (["corepack", "pnpm", "yarn"].includes(basename)) {
return undefined;
}
if (command.includes("\\")) {
return command;
}
const extension = path.extname(command) || path.win32.extname(command);
return path.win32.join(
"C:\\openclaw-test-bin",
extension ? command : `${path.win32.basename(command)}.exe`,
);
});
resolveExecutablePathCandidateMock.mockReset();
resolveExecutablePathCandidateMock.mockImplementation((command: string) => command);
spawnSyncMock.mockReset();
spawnSyncMock.mockReturnValue({ stdout: "Active code page: 936", stderr: "" });
execFileMock.mockReset();
execFilePromisifyMock.mockReset();
execFilePromisifyMock.mockImplementation(
(command: string, args: string[], options: Record<string, unknown>) =>
new Promise((resolve, reject) => {
execFileMock(
command,
args,
options,
(err: Error | null, stdout: string | Buffer, stderr: string | Buffer) => {
if (err) {
reject(err);
return;
}
resolve({ stdout, stderr });
},
);
}),
);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("wraps .cmd commands via cmd.exe in runCommandWithTimeout", async () => {
const expectedComSpec = expectedTrustedCmdExe();
spawnMock.mockImplementation(
(_command: string, _args: string[], _options: Record<string, unknown>) => createMockChild(),
);
it("wraps .cmd commands through trusted cmd.exe", async () => {
await withMockedWindowsPlatform(async () => {
const result = await runCommandWithTimeout(["pnpm", "--version"], { timeoutMs: 1000 });
expect(result.code).toBe(0);
const captured = requireSpawnCall(0);
expectCmdWrappedInvocation({ captured, expectedComSpec });
await runCommandWithTimeout(["pnpm", "--version"], { timeoutMs: 1_000 });
expectCmdWrappedInvocation(requireExecaCall(0));
});
});
@@ -200,20 +208,10 @@ describe("windows command wrapper behavior", () => {
const previousSystemRoot = process.env.SystemRoot;
process.env.ComSpec = "C:\\workspace\\evil\\cmd.exe";
process.env.SystemRoot = "C:\\Windows";
spawnMock.mockImplementation(
(_command: string, _args: string[], _options: Record<string, unknown>) => createMockChild(),
);
try {
await withMockedWindowsPlatform(async () => {
const result = await runCommandWithTimeout(["pnpm", "--version"], { timeoutMs: 1000 });
expect(result.code).toBe(0);
const captured = requireSpawnCall(0);
expectCmdWrappedInvocation({
captured,
expectedComSpec: path.win32.join("C:\\Windows", "System32", "cmd.exe"),
});
await runCommandWithTimeout(["pnpm", "--version"], { timeoutMs: 1_000 });
expect(requireExecaCall(0)[0].toLowerCase()).toBe("c:\\windows\\system32\\cmd.exe");
});
} finally {
if (previousComSpec === undefined) {
@@ -229,455 +227,209 @@ describe("windows command wrapper behavior", () => {
}
});
it("rejects unsafe Windows root values when selecting the command wrapper", async () => {
const previousSystemRoot = process.env.SystemRoot;
const previousWindir = process.env.WINDIR;
it("resolves implicit batch shims before Execa can consult ComSpec", async () => {
await withTempDir("openclaw-execa-windows-shim-", async (binDir) => {
const shimPath = path.join(binDir, "custom-shim.cmd");
fs.writeFileSync(shimPath, "@echo off\r\n", "utf8");
resolveExecutableFromPathEnvMock.mockReturnValueOnce(shimPath);
spawnMock.mockImplementation(
(_command: string, _args: string[], _options: Record<string, unknown>) => createMockChild(),
);
try {
await withMockedWindowsPlatform(async () => {
for (const unsafeRoot of [
"\\\\evil\\share",
"C:\\Windows;C:\\evil",
"\\Windows",
"relative\\path",
]) {
resetWindowsInstallRootsForTests({ queryRegistryValue: () => null });
// Set every install-root env source to the unsafe value so the
// resolver rejects each one and falls through to the safe default.
// Deleting WINDIR here is unreliable on real Windows runners, so
// overwrite it with the same rejected payload.
process.env.SystemRoot = unsafeRoot;
process.env.WINDIR = unsafeRoot;
spawnMock.mockClear();
const result = await runCommandWithTimeout(["pnpm", "--version"], { timeoutMs: 1000 });
expect(result.code).toBe(0);
const captured = requireSpawnCall(0);
expectCmdWrappedInvocation({
captured,
expectedComSpec: path.win32.join("C:\\Windows", "System32", "cmd.exe"),
});
}
});
} finally {
if (previousSystemRoot === undefined) {
delete process.env.SystemRoot;
} else {
process.env.SystemRoot = previousSystemRoot;
}
if (previousWindir === undefined) {
delete process.env.WINDIR;
} else {
process.env.WINDIR = previousWindir;
}
}
});
it("wraps corepack.cmd via cmd.exe in runCommandWithTimeout", async () => {
const expectedComSpec = expectedTrustedCmdExe();
spawnMock.mockImplementation(
(_command: string, _args: string[], _options: Record<string, unknown>) => createMockChild(),
);
await withMockedWindowsPlatform(async () => {
const result = await runCommandWithTimeout(["corepack", "--version"], { timeoutMs: 1000 });
expect(result.code).toBe(0);
const captured = requireSpawnCall(0);
expect(captured[0]).toBe(expectedComSpec);
expect(captured[1].slice(0, 3)).toEqual(["/d", "/s", "/c"]);
expect(captured[1][3]).toContain("corepack.cmd --version");
expect(captured[2].windowsHide).toBe(true);
expect(captured[2].windowsVerbatimArguments).toBe(true);
});
});
it("escapes caret arguments in Windows command wrappers", async () => {
spawnMock.mockImplementation(
(_command: string, _args: string[], _options: Record<string, unknown>) => createMockChild(),
);
await withMockedWindowsPlatform(async () => {
const result = await runCommandWithTimeout(
["pnpm", "exec", "vitest", "-t", "@scope/pkg@^1.2.3"],
{ timeoutMs: 1000 },
);
expect(result.code).toBe(0);
const captured = requireSpawnCall(0);
expect(captured[1].slice(0, 3)).toEqual(["/d", "/s", "/c"]);
expect(captured[1][3]).toBe("pnpm.cmd exec vitest -t @scope/pkg@^^1.2.3");
expect(captured[2].windowsVerbatimArguments).toBe(true);
});
});
it("keeps child exitCode when close reports null on Windows npm shims", async () => {
const child = createMockChild({ closeCode: null, exitCode: 0 });
spawnMock.mockImplementation(() => child);
await withMockedWindowsPlatform(async () => {
const result = await runCommandWithTimeout(["npm", "--version"], { timeoutMs: 1000 });
expect(result.code).toBe(0);
});
});
it("spawns node + npm-cli.js for npm argv to avoid direct .cmd execution", async () => {
const existsSpy = vi.spyOn(fs, "existsSync").mockReturnValue(true);
const child = createMockChild({ closeCode: 0, exitCode: 0 });
spawnMock.mockImplementation(() => child);
await withRestoredMocks([existsSpy], async () => {
await withMockedWindowsPlatform(async () => {
const result = await runCommandWithTimeout(["npm", "--version"], { timeoutMs: 1000 });
expect(result.code).toBe(0);
const captured = requireSpawnCall(0);
expect(captured[0]).toBe(process.execPath);
expect(captured[1][0]).toBe(
path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"),
);
expect(captured[1][1]).toBe("--version");
expect(captured[2].windowsHide).toBe(true);
expect(captured[2].windowsVerbatimArguments).toBeUndefined();
expect(captured[2].stdio).toEqual(["inherit", "pipe", "pipe"]);
void spawnCommand(["custom-shim", "--version"], {
baseEnv: {
ComSpec: "C:\\workspace\\evil\\cmd.exe",
PATH: binDir,
PATHEXT: ".CMD",
},
});
const call = requireExecaCall(0);
expect(call[0]).toBe(expectedTrustedCmdExe());
expect(call[1][3]).toContain(`${shimPath} --version`);
});
});
});
it("falls back to npm.cmd when npm-cli.js is unavailable", async () => {
const existsSpy = vi.spyOn(fs, "existsSync").mockReturnValue(false);
const expectedComSpec = expectedTrustedCmdExe();
it("rejects unresolved commands before Execa can consult ambient ComSpec", async () => {
resolveExecutableFromPathEnvMock.mockReturnValueOnce(undefined);
spawnMock.mockImplementation(
(_command: string, _args: string[], _options: Record<string, unknown>) => createMockChild(),
);
await withMockedWindowsPlatform(async () => {
expect(() =>
spawnCommand(["missing\r\ncalc.exe"], {
baseEnv: {
ComSpec: "C:\\workspace\\evil\\cmd.exe",
PATH: "C:\\openclaw-test-bin",
PATHEXT: ".EXE;.CMD;.BAT;.COM",
},
}),
).toThrow("ENOENT");
expect(execaMock).not.toHaveBeenCalled();
});
});
await withRestoredMocks([existsSpy], async () => {
await withMockedWindowsPlatform(async () => {
const result = await runCommandWithTimeout(["npm", "--version"], { timeoutMs: 1000 });
expect(result.code).toBe(0);
const captured = requireSpawnCall(0);
expect(captured[0]).toBe(expectedComSpec);
expect(captured[1].slice(0, 3)).toEqual(["/d", "/s", "/c"]);
expect(captured[1][3]).toContain("npm.cmd --version");
expect(captured[2].windowsHide).toBe(true);
expect(captured[2].windowsVerbatimArguments).toBe(true);
expect(captured[2].stdio).toEqual(["inherit", "pipe", "pipe"]);
it("rejects unsupported Windows command types before Execa", async () => {
resolveExecutableFromPathEnvMock.mockReturnValueOnce("C:\\tools\\script.ps1");
await withMockedWindowsPlatform(async () => {
expect(() => spawnCommand(["script.ps1"])).toThrow("Unsupported Windows command extension");
expect(execaMock).not.toHaveBeenCalled();
});
});
it("escapes command arguments inside the trusted cmd.exe wrapper", async () => {
await withMockedWindowsPlatform(async () => {
await runCommandWithTimeout(["pnpm", "run", "value^with^carets"], { timeoutMs: 1_000 });
const commandLine = String(requireExecaCall(0)[1][3]);
expect(commandLine).toContain("value^^with^^carets");
});
});
it("spawns node plus npm-cli.js instead of npm.cmd when available", async () => {
vi.spyOn(fs, "existsSync").mockReturnValue(true);
await withMockedWindowsPlatform(async () => {
void spawnCommand(["npm", "--version"]);
const [command, args, options] = requireExecaCall(0);
expect(path.win32.basename(command).toLowerCase()).toBe("node.exe");
expect(args[0]).toContain(path.join("node_modules", "npm", "bin", "npm-cli.js"));
expect(args[1]).toBe("--version");
expect(options.shell).toBe(false);
});
});
it("falls back to a trusted npm.cmd wrapper when npm-cli.js is unavailable", async () => {
vi.spyOn(fs, "existsSync").mockReturnValue(false);
await withMockedWindowsPlatform(async () => {
void spawnCommand(["npm", "--version"]);
expectCmdWrappedInvocation(requireExecaCall(0), "npm.cmd");
});
});
it("sets windowsHide and disables shell on direct commands", async () => {
await withMockedWindowsPlatform(async () => {
void spawnCommand(["node", "script.js"]);
const [command, , options] = requireExecaCall(0);
expect(path.win32.basename(command).toLowerCase()).toBe("node.exe");
expect(options).toMatchObject({ shell: false, windowsHide: true });
});
});
it("infers success when a spawned Windows shim has no exit state", async () => {
vi.useFakeTimers();
const command = createMockSubprocess({ autoFinish: false });
execaMock.mockReturnValueOnce(command);
await withMockedWindowsPlatform(async () => {
const resultPromise = runCommandWithTimeout(["pnpm", "--version"], {
timeoutMs: 1_000,
});
command.finish({ exitCode: undefined, failed: true });
await vi.advanceTimersByTimeAsync(251);
await expect(resultPromise).resolves.toMatchObject({ code: 0, termination: "exit" });
});
});
it("waits for Windows exitCode settlement after close reports null", async () => {
const child = createMockChild({
closeCode: null,
exitCode: null,
exitCodeAfterClose: 0,
exitCodeAfterCloseDelayMs: 50,
});
spawnMock.mockImplementation(() => child);
it("preserves a delayed nonzero exit code from a Windows shim", async () => {
vi.useFakeTimers();
const command = createMockSubprocess({ autoFinish: false });
execaMock.mockReturnValueOnce(command);
await withMockedWindowsPlatform(async () => {
const result = await runCommandWithTimeout(["npm", "--version"], { timeoutMs: 1000 });
expect(result.code).toBe(0);
const resultPromise = runCommandWithTimeout(["pnpm", "--version"], {
timeoutMs: 1_000,
});
command.finish({ exitCode: undefined, failed: true });
setTimeout(() => {
command.exitCode = 7;
}, 20);
await vi.advanceTimersByTimeAsync(30);
await expect(resultPromise).resolves.toMatchObject({ code: 7, termination: "exit" });
});
});
it("treats shimmed Windows commands without a reported exit code as success when they close cleanly", async () => {
await expectShimmedWindowsCommandWithoutExitCodeSucceeds();
});
it("treats shimmed Windows commands without a reported exit code as success even when child.killed is true", async () => {
await expectShimmedWindowsCommandWithoutExitCodeSucceeds({ killed: true });
});
it("uses cmd.exe wrapper with windowsVerbatimArguments in runExec for .cmd shims", async () => {
const expectedComSpec = expectedTrustedCmdExe();
execFileMock.mockImplementation(
(
_command: string,
_args: string[],
_options: Record<string, unknown>,
cb: (err: Error | null, stdout: string, stderr: string) => void,
) => {
cb(null, "ok", "");
},
);
it("still rejects a Windows shim launch error without an exit state", async () => {
const command = createMockSubprocess({ autoFinish: false });
execaMock.mockReturnValueOnce(command);
await withMockedWindowsPlatform(async () => {
await runExec("pnpm", ["--version"], 1000);
const captured = requireExecFileCall(0);
expectCmdWrappedInvocation({ captured, expectedComSpec });
const resultPromise = runCommandWithTimeout(["pnpm", "--version"], {
timeoutMs: 1_000,
});
command.finish({
cause: new Error("spawn pnpm ENOENT"),
code: "ENOENT",
exitCode: undefined,
failed: true,
});
await expect(resultPromise).rejects.toThrow("Failed to launch command: pnpm");
});
});
it("wraps spaced .cmd command paths in an outer cmd.exe command line", async () => {
const expectedComSpec = expectedTrustedCmdExe();
execFileMock.mockImplementation(
(
_command: string,
_args: string[],
_options: Record<string, unknown>,
cb: (err: Error | null, stdout: string, stderr: string) => void,
) => {
cb(null, "ok", "");
},
);
it("gracefully then force-kills a Windows process tree", async () => {
vi.useFakeTimers();
const command = createMockSubprocess({ autoFinish: false });
execaMock
.mockImplementationOnce(() => command)
.mockImplementation(() => createMockSubprocess());
await withMockedWindowsPlatform(async () => {
await runExec("C:\\Program Files\\pnpm\\pnpm.cmd", ["--version"], 1000);
const captured = requireExecFileCall(0);
expect(captured[0]).toBe(expectedComSpec);
expect(captured[1]).toEqual([
"/d",
"/s",
"/c",
'""C:\\Program Files\\pnpm\\pnpm.cmd" --version"',
const resultPromise = runCommandWithTimeout(["node", "idle.js"], {
killProcessTree: true,
timeoutMs: 80,
});
await vi.advanceTimersByTimeAsync(81);
expect(requireExecaCall(1).slice(0, 2)).toEqual([
getWindowsSystem32ExePath("taskkill.exe"),
["/PID", "1234", "/T"],
]);
expect(captured[2].windowsHide).toBe(true);
expect(captured[2].windowsVerbatimArguments).toBe(true);
expect(command.kill).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(300);
expect(requireExecaCall(2)[1]).toEqual(["/PID", "1234", "/T", "/F"]);
command.finish({ signal: "SIGKILL" });
await expect(resultPromise).resolves.toMatchObject({ code: 124, termination: "timeout" });
});
});
it("sets windowsHide on direct runExec invocations too", async () => {
execFileMock.mockImplementation(
(
_command: string,
_args: string[],
_options: Record<string, unknown>,
cb: (err: Error | null, stdout: string, stderr: string) => void,
) => {
cb(null, "ok", "");
},
it("decodes GBK stdout and stderr from runExec", async () => {
execaMock.mockImplementationOnce(() =>
createMockSubprocess({
stderr: Buffer.from([0xa3, 0xbb]),
stdout: Buffer.from([0xb2, 0xe2, 0xca, 0xd4]),
}),
);
await withMockedWindowsPlatform(async () => {
await runExec("node", ["--version"], 1000);
const captured = requireExecFileCall(0);
expect(captured[0]).toBe("node");
expect(captured[1]).toEqual(["--version"]);
expect(captured[2].windowsHide).toBe(true);
await expect(runExec("node", ["gbk-output.js"], 1_000)).resolves.toEqual({
stdout: "测试",
stderr: "",
});
expect(requireExecaCall(0)[2].encoding).toBe("buffer");
});
});
it("sets windowsHide on direct runCommandWithTimeout invocations too", async () => {
spawnMock.mockImplementation(
(_command: string, _args: string[], _options: Record<string, unknown>) => createMockChild(),
it("prefers valid UTF-8 output from runExec", async () => {
execaMock.mockImplementationOnce(() =>
createMockSubprocess({ stdout: Buffer.from("测试", "utf8") }),
);
await withMockedWindowsPlatform(async () => {
const result = await runCommandWithTimeout(["node", "--version"], { timeoutMs: 1000 });
expect(result.code).toBe(0);
const captured = requireSpawnCall(0);
expect(captured[0]).toBe("node");
expect(captured[1]).toEqual(["--version"]);
expect(captured[2].windowsHide).toBe(true);
expect(captured[2].windowsVerbatimArguments).toBeUndefined();
});
});
it("kills the Windows process tree when the overall timeout elapses", async () => {
vi.useFakeTimers();
const child = createMockChild({ autoClose: false });
const taskkillChild = createMockChild();
spawnMock.mockImplementationOnce(() => child).mockImplementationOnce(() => taskkillChild);
try {
await withMockedWindowsPlatform(async () => {
const resultPromise = runCommandWithTimeout(["node", "idle.js"], { timeoutMs: 80 });
await vi.advanceTimersByTimeAsync(81);
expect(child.kill).not.toHaveBeenCalled();
expect(spawnMock).toHaveBeenCalledTimes(2);
const taskkillCall = requireSpawnCall(1);
expect(taskkillCall[0]).toBe(getWindowsSystem32ExePath("taskkill.exe"));
expect(taskkillCall[1]).toEqual(["/PID", "1234", "/T", "/F"]);
expect(taskkillCall[2]).toEqual({
stdio: "ignore",
windowsHide: true,
});
child.emit("close", null, "SIGKILL");
const result = await resultPromise;
expect(result.termination).toBe("timeout");
expect(result.code).toBe(124);
});
} finally {
vi.useRealTimers();
}
});
it("gracefully then force-kills the Windows process tree when requested", async () => {
vi.useFakeTimers();
const child = createMockChild({ autoClose: false });
child.exitCode = null;
const gracefulTaskkillChild = createMockChild();
const forcedTaskkillChild = createMockChild();
spawnMock
.mockImplementationOnce(() => child)
.mockImplementationOnce(() => gracefulTaskkillChild)
.mockImplementationOnce(() => forcedTaskkillChild);
try {
await withMockedWindowsPlatform(async () => {
const resultPromise = runCommandWithTimeout(["node", "idle.js"], {
killProcessTree: true,
timeoutMs: 80,
});
await vi.advanceTimersByTimeAsync(81);
expect(requireSpawnCall(1)[1]).toEqual(["/PID", "1234", "/T"]);
await vi.advanceTimersByTimeAsync(300);
expect(requireSpawnCall(2)[1]).toEqual(["/PID", "1234", "/T", "/F"]);
expect(child.kill).not.toHaveBeenCalled();
child.emit("close", null, "SIGKILL");
const result = await resultPromise;
expect(result.termination).toBe("timeout");
expect(result.code).toBe(124);
});
} finally {
vi.useRealTimers();
}
});
it("falls back to direct child kill when forced Windows taskkill emits a spawn error", async () => {
vi.useFakeTimers();
const child = createMockChild({ autoClose: false });
child.exitCode = null;
const taskkillChild = createMockChild({ autoClose: false });
spawnMock.mockImplementationOnce(() => child).mockImplementationOnce(() => taskkillChild);
try {
await withMockedWindowsPlatform(async () => {
const resultPromise = runCommandWithTimeout(["node", "idle.js"], { timeoutMs: 80 });
await vi.advanceTimersByTimeAsync(81);
taskkillChild.emit("error", Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }));
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
child.emit("close", null, "SIGKILL");
const result = await resultPromise;
expect(result.termination).toBe("timeout");
expect(result.code).toBe(124);
});
} finally {
vi.useRealTimers();
}
});
it("falls back to direct child kill when graceful Windows taskkill emits a spawn error", async () => {
vi.useFakeTimers();
const child = createMockChild({ autoClose: false });
child.exitCode = null;
const taskkillChild = createMockChild({ autoClose: false });
spawnMock.mockImplementationOnce(() => child).mockImplementationOnce(() => taskkillChild);
try {
await withMockedWindowsPlatform(async () => {
const resultPromise = runCommandWithTimeout(["node", "idle.js"], {
killProcessTree: true,
timeoutMs: 80,
});
await vi.advanceTimersByTimeAsync(81);
taskkillChild.emit("error", Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }));
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
await vi.advanceTimersByTimeAsync(300);
expect(spawnMock).toHaveBeenCalledTimes(2);
child.emit("close", null, "SIGKILL");
const result = await resultPromise;
expect(result.termination).toBe("timeout");
expect(result.code).toBe(124);
});
} finally {
vi.useRealTimers();
}
});
it("decodes GBK stdout and stderr from runExec on Windows", async () => {
const stdout = Buffer.from([0xb2, 0xe2, 0xca, 0xd4]);
const stderr = Buffer.from([0xa3, 0xbb]);
execFileMock.mockImplementation(
(
_command: string,
_args: string[],
_options: Record<string, unknown>,
cb: (err: Error | null, stdout: Buffer, stderr: Buffer) => void,
) => {
cb(null, stdout, stderr);
},
);
await withMockedWindowsPlatform(async () => {
const result = await runExec("node", ["gbk-output.js"], 1000);
expect(result.stdout).toBe("测试");
expect(result.stderr).toBe("");
const captured = requireExecFileCall(0);
expect(captured[2].encoding).toBe("buffer");
});
});
it("prefers valid UTF-8 stdout from runExec on Windows", async () => {
execFileMock.mockImplementation(
(
_command: string,
_args: string[],
_options: Record<string, unknown>,
cb: (err: Error | null, stdout: Buffer, stderr: Buffer) => void,
) => {
cb(null, Buffer.from("测试", "utf8"), Buffer.alloc(0));
},
);
await withMockedWindowsPlatform(async () => {
await expect(runExec("node", ["utf8-output.js"], 1000)).resolves.toEqual({
await expect(runExec("node", ["utf8-output.js"], 1_000)).resolves.toEqual({
stdout: "测试",
stderr: "",
});
});
});
it("decodes spawn stdout once so GBK characters split across chunks survive", async () => {
const child = createMockChild({ autoClose: false });
spawnMock.mockImplementation(() => {
queueMicrotask(() => {
child.stdout.emit("data", Buffer.from([0xb2]));
child.stdout.emit("data", Buffer.from([0xe2, 0xca]));
child.stdout.emit("data", Buffer.from([0xd4]));
child.emit("close", 0, null);
});
return child;
});
it("decodes split GBK chunks after complete output capture", async () => {
execaMock.mockImplementationOnce(() =>
createMockSubprocess({
stdoutChunks: [Buffer.from([0xb2]), Buffer.from([0xe2, 0xca]), Buffer.from([0xd4])],
}),
);
await withMockedWindowsPlatform(async () => {
await expect(
runCommandWithTimeout(["node", "gbk-output.js"], { timeoutMs: 1000 }),
).resolves.toEqual({
pid: 1234,
stdout: "测试",
stderr: "",
code: 0,
signal: null,
killed: false,
termination: "exit",
noOutputTimedOut: false,
});
runCommandWithTimeout(["node", "gbk-output.js"], { timeoutMs: 1_000 }),
).resolves.toMatchObject({ code: 0, stdout: "测试", termination: "exit" });
});
});
});
+134 -2
View File
@@ -1,8 +1,12 @@
// Windows command tests cover command quoting and shell resolution on Windows.
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveWindowsCommandShim } from "./windows-command.js";
import { withTempDir } from "../test-utils/temp-dir.js";
import { withMockedWindowsPlatform } from "../test-utils/vitest-spies.js";
import { resolveSafeChildProcessInvocation, resolveWindowsCommandShim } from "./windows-command.js";
describe("resolveWindowsCommandShim", () => {
describe("Windows command helpers", () => {
it("leaves commands unchanged outside Windows", () => {
expect(
resolveWindowsCommandShim({
@@ -42,4 +46,132 @@ describe("resolveWindowsCommandShim", () => {
}),
).toBe("npm.cmd");
});
it("resolves relative executables against the child cwd", async () => {
await withTempDir("openclaw-windows-command-cwd-", async (cwd) => {
const binDir = path.join(cwd, "bin");
const executable = path.join(binDir, "tool.exe");
await mkdir(binDir);
await writeFile(executable, "");
await withMockedWindowsPlatform(async () => {
expect(
resolveSafeChildProcessInvocation({
argv: ["./bin/tool"],
cwd,
env: { PATHEXT: ".EXE" },
}).command,
).toBe(executable);
});
});
});
it("resolves bare executables from PATH without allowing child-cwd shadowing", async () => {
await withTempDir("openclaw-windows-command-bare-path-", async (base) => {
const cwd = path.join(base, "cwd");
const binDir = path.join(base, "bin");
const cwdExecutable = path.join(cwd, "tool.exe");
const pathExecutable = path.join(binDir, "tool.exe");
await mkdir(cwd);
await mkdir(binDir);
await writeFile(cwdExecutable, "");
await writeFile(pathExecutable, "");
await withMockedWindowsPlatform(async () => {
expect(
resolveSafeChildProcessInvocation({
argv: ["tool.exe"],
cwd,
env: { PATH: binDir, PATHEXT: ".EXE" },
}).command,
).toBe(pathExecutable);
});
});
});
it("requires an explicit relative path for executables in the child cwd", async () => {
await withTempDir("openclaw-windows-command-bare-cwd-", async (cwd) => {
await writeFile(path.join(cwd, "tool.exe"), "");
await withMockedWindowsPlatform(async () => {
expect(() =>
resolveSafeChildProcessInvocation({
argv: ["tool.exe"],
cwd,
env: { PATH: "", PATHEXT: ".EXE" },
}),
).toThrow(/ENOENT/);
});
});
});
it("accepts explicit executable paths independently of PATHEXT", async () => {
await withTempDir("openclaw-windows-command-explicit-", async (cwd) => {
const executable = path.join(cwd, "tool.exe");
await writeFile(executable, "");
await withMockedWindowsPlatform(async () => {
expect(
resolveSafeChildProcessInvocation({
argv: [executable],
cwd,
env: { PATH: "", PATHEXT: ".CMD;.BAT" },
}).command,
).toBe(executable);
});
});
});
it("resolves PATH and PATHEXT keys case-insensitively", async () => {
await withTempDir("openclaw-windows-command-env-case-", async (binDir) => {
const executable = path.join(binDir, "tool.exe");
await writeFile(executable, "");
await withMockedWindowsPlatform(async () => {
expect(
resolveSafeChildProcessInvocation({
argv: ["tool"],
env: { path: binDir, pathext: ".EXE" },
}).command,
).toBe(executable);
});
});
});
it("accepts PATH executables with explicit extensions independently of PATHEXT", async () => {
await withTempDir("openclaw-windows-command-path-extension-", async (binDir) => {
const executable = path.join(binDir, "tool.exe");
await writeFile(executable, "");
await withMockedWindowsPlatform(async () => {
expect(
resolveSafeChildProcessInvocation({
argv: ["tool.exe"],
env: { PATH: binDir, PATHEXT: ".CMD;.BAT" },
}).command,
).toBe(executable);
});
});
});
it("honors PATHEXT precedence before package-manager shim fallback", async () => {
await withTempDir("openclaw-windows-command-pathext-", async (binDir) => {
const exePath = path.join(binDir, "pnpm.exe");
await writeFile(exePath, "");
await writeFile(path.join(binDir, "pnpm.cmd"), "");
await withMockedWindowsPlatform(async () => {
expect(
resolveSafeChildProcessInvocation({
argv: ["pnpm", "--version"],
env: { PATH: binDir, PATHEXT: ".EXE;.CMD" },
}),
).toMatchObject({
args: ["--version"],
command: exePath,
usesWindowsExitCodeShim: false,
});
});
});
});
});
+159
View File
@@ -1,11 +1,170 @@
// Windows command helpers resolve executable and shell invocation details.
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { expectDefined } from "@openclaw/normalization-core";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import {
isRegularFile,
resolveExecutableFromPathEnv,
resolveExecutablePathCandidate,
} from "../infra/executable-path.js";
import { getWindowsCmdExePath } from "../infra/windows-install-roots.js";
const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>%\r\n]/;
function resolveNpmArgvForWindows(argv: string[]): string[] | null {
if (process.platform !== "win32" || argv.length === 0) {
return null;
}
const basename = normalizeLowercaseStringOrEmpty(
path.basename(expectDefined(argv[0], "argv entry at 0")),
).replace(/\.(cmd|exe|bat)$/, "");
const cliName = basename === "npx" ? "npx-cli.js" : basename === "npm" ? "npm-cli.js" : null;
if (!cliName) {
return null;
}
const cliPath = path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", cliName);
if (fs.existsSync(cliPath)) {
return [process.execPath, cliPath, ...argv.slice(1)];
}
// Bun-based runs don't ship npm-cli.js next to process.execPath. The .cmd
// fallback remains inside the trusted cmd.exe wrapper below.
const command = argv[0] ?? "";
const extension = normalizeLowercaseStringOrEmpty(path.extname(command));
return [extension ? command : `${command}.cmd`, ...argv.slice(1)];
}
function createWindowsCommandNotFoundError(command: string): NodeJS.ErrnoException {
const error = new Error(`spawn ${command} ENOENT`) as NodeJS.ErrnoException;
error.code = "ENOENT";
error.path = command;
error.syscall = `spawn ${command}`;
return error;
}
function resolveWindowsEnvironmentValue(env: NodeJS.ProcessEnv, name: string): string | undefined {
const normalizedName = name.toLowerCase();
return Object.entries(env).find(([key]) => key.toLowerCase() === normalizedName)?.[1];
}
function resolveWindowsCommandFromCwdOrPath(params: {
command: string;
cwd?: string;
env: NodeJS.ProcessEnv;
}): string | undefined {
const hasPathSeparator = params.command.includes("/") || params.command.includes("\\");
if (hasPathSeparator) {
const candidate = resolveExecutablePathCandidate(params.command, {
cwd: params.cwd,
env: params.env,
});
if (!candidate) {
return undefined;
}
// PATHEXT controls suffix probing, not whether an explicit path is executable.
// The supported-extension check below still rejects shell/script formats.
if (path.extname(candidate)) {
return isRegularFile(candidate) ? candidate : undefined;
}
return resolveExecutableFromPathEnv(
path.basename(candidate),
path.dirname(candidate),
params.env,
{ includeExtensionless: false },
);
}
const cwd = params.cwd?.trim() || process.cwd();
const pathValue =
resolveWindowsEnvironmentValue(params.env, "PATH") ??
resolveWindowsEnvironmentValue(process.env, "PATH") ??
"";
const pathEntries = pathValue
.split(";")
.map((entry) => entry.replace(/^"(.*)"$/, "$1").trim())
.filter(Boolean)
.map((entry) => (path.isAbsolute(entry) ? entry : path.resolve(cwd, entry)));
// Bare names search PATH only. Requiring an explicit relative path prevents
// an untrusted child cwd from shadowing installed tools such as git or node.
return resolveExecutableFromPathEnv(params.command, pathEntries.join(";"), params.env, {
includeExtensionless: false,
});
}
function resolveSupportedWindowsCommand(params: {
command: string;
cwd?: string;
env: NodeJS.ProcessEnv;
}): string {
if (process.platform !== "win32") {
return params.command;
}
let resolved = resolveWindowsCommandFromCwdOrPath(params);
const shimmedCommand = resolveWindowsCommandShim({
command: params.command,
cmdCommands: ["corepack", "pnpm", "yarn"],
});
if (!resolved && shimmedCommand !== params.command) {
resolved = resolveWindowsCommandFromCwdOrPath({ ...params, command: shimmedCommand });
}
if (!resolved) {
// Execa delegates Windows parsing to cross-spawn, which otherwise falls
// through to ambient ComSpec even with shell:false.
throw createWindowsCommandNotFoundError(params.command);
}
const extension = normalizeLowercaseStringOrEmpty(path.extname(resolved));
if ([".exe", ".com", ".cmd", ".bat"].includes(extension)) {
return resolved;
}
throw new Error(
`Unsupported Windows command extension ${JSON.stringify(extension || "<none>")} for ${JSON.stringify(params.command)}; use an explicit executable or shell wrapper.`,
);
}
type SafeChildProcessInvocation = {
args: string[];
command: string;
usesWindowsExitCodeShim: boolean;
windowsHide: true;
windowsVerbatimArguments?: boolean;
};
/** Resolve one shell-free invocation before Execa can apply Windows fallbacks. */
export function resolveSafeChildProcessInvocation(params: {
argv: string[];
cwd?: string | URL;
env: NodeJS.ProcessEnv;
windowsVerbatimArguments?: boolean;
}): SafeChildProcessInvocation {
const finalArgv = resolveNpmArgvForWindows(params.argv) ?? params.argv;
const cwd = params.cwd instanceof URL ? fileURLToPath(params.cwd) : params.cwd;
const resolvedCommand = resolveSupportedWindowsCommand({
command: finalArgv[0] ?? "",
cwd,
env: params.env,
});
const useCmdWrapper = isWindowsBatchCommand(resolvedCommand);
const command = useCmdWrapper
? resolveSupportedWindowsCommand({
command: resolveTrustedWindowsCmdExe(),
cwd,
env: params.env,
})
: resolvedCommand;
return {
command,
args: useCmdWrapper
? ["/d", "/s", "/c", buildWindowsCmdExeCommandLine(resolvedCommand, finalArgv.slice(1))]
: finalArgv.slice(1),
usesWindowsExitCodeShim:
process.platform === "win32" && (useCmdWrapper || finalArgv !== params.argv),
windowsHide: true,
windowsVerbatimArguments: useCmdWrapper ? true : params.windowsVerbatimArguments,
};
}
export function isWindowsBatchCommand(
resolvedCommand: string,
platform: NodeJS.Platform = process.platform,
+2 -1
View File
@@ -60,7 +60,8 @@ function objectFieldEquals(entry: FixtureLogEntry, field: string, value: unknown
}
async function writeTuiPtyFixtureScript(dir: string) {
const scriptPath = path.join(dir, "run-tui-pty-fixture.ts");
// Temp files sit outside the repo package scope; .mts preserves the ESM contract under tsx.
const scriptPath = path.join(dir, "run-tui-pty-fixture.mts");
const tuiModuleUrl = pathToFileURL(path.join(process.cwd(), "src/tui/tui.ts")).href;
const payloadsModuleUrl = pathToFileURL(
path.join(process.cwd(), "src/agents/embedded-agent-runner/run/payloads.ts"),