mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
Generated
+201
-4
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+92
-6
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user