diff --git a/.dev/config.toml b/.dev/config.toml new file mode 100644 index 0000000..d71b576 --- /dev/null +++ b/.dev/config.toml @@ -0,0 +1,27 @@ +default_language = "rust" + +[tasks.rust_fmt] +commands = [["cargo", "fmt"]] + +[tasks.rust_fmt_check] +commands = [["cargo", "fmt", "--", "--check"]] + +[tasks.rust_lint] +commands = [["cargo", "clippy", "--all-targets", "--", "-D", "warnings"]] + +[tasks.rust_test] +commands = [["cargo", "test", "--all-targets"]] + +[tasks.rust_ci] +commands = ["rust_fmt_check", "rust_lint", "rust_test"] + +[languages.rust] +install = [] + +[languages.rust.pipelines] +fmt = ["rust_fmt"] +lint = ["rust_lint"] +test = ["rust_test"] +fix = ["rust_fmt"] +check = ["rust_ci"] +ci = ["rust_ci"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 699d103..5240c23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,9 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-python@v5 + with: + python-version: '3.x' - name: Format run: cargo fmt --check - name: Clippy diff --git a/CHANGELOG.md b/CHANGELOG.md index 71971f5..8541eb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.1.5 - 2026-10-10 + +- Add native OpenRouter-compatible Chat Completions profiles with environment or command-based API keys. +- Give each Linux capture a dedicated subreaper, preserving host process ownership while cleaning detached descendants and zombies. +- Assign Windows workers to Job Objects before execution and prevent detached supervisors from holding caller output pipes open. +- Preserve failed foreground reports and exit codes, bound external summaries and status tails, and prevent detached job ID collisions. +- Retain discovery, Core compatibility, and durable error capture; validate provider execution against a live compatible endpoint. + ## 0.1.4 - 2026-09-17 - Reap successfully exited adopted descendants throughout active Linux agent runs instead of retaining them as zombies until the direct agent exits. diff --git a/Cargo.lock b/Cargo.lock index b7e63fd..f23a4ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,9 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "agentctl" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "assert_cmd", @@ -18,7 +24,8 @@ dependencies = [ "signal-hook", "tempfile", "toml", - "windows-sys", + "ureq", + "windows-sys 0.61.2", ] [[package]] @@ -57,7 +64,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -68,7 +75,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -92,6 +99,12 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.13.0" @@ -118,6 +131,16 @@ dependencies = [ "serde", ] +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -155,7 +178,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -179,6 +202,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -205,6 +237,17 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -218,7 +261,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -227,12 +270,37 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -288,12 +356,116 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -336,6 +508,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "log" version = "0.4.32" @@ -348,6 +526,16 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -360,6 +548,21 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "predicates" version = "3.1.4" @@ -394,7 +597,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -427,6 +630,20 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -437,7 +654,42 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -473,7 +725,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -509,6 +761,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook" version = "0.3.18" @@ -529,12 +787,36 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -546,6 +828,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -556,7 +860,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -565,6 +869,16 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "toml" version = "0.8.23" @@ -624,6 +938,48 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -703,12 +1059,39 @@ dependencies = [ "semver", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -718,6 +1101,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -763,7 +1210,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -779,7 +1226,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -821,6 +1268,95 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 0f9ccca..b7a1c45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agentctl" -version = "0.1.4" +version = "0.1.5" edition = "2024" license = "MIT" description = "Compact, bounded command and agent reports for LLM tool loops" @@ -19,18 +19,14 @@ serde_json = "1.0.140" semver = "1.0.26" sha2 = "0.10.9" toml = "0.8.23" +ureq = { version = "2.12.1", features = ["json"] } [target.'cfg(unix)'.dependencies] libc = "0.2.186" signal-hook = "0.3.18" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61.2", features = [ - "Win32_Foundation", - "Win32_Security", - "Win32_System_JobObjects", - "Win32_System_Threading", -] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Threading", "Win32_System_Console", "Win32_System_JobObjects", "Win32_System_Diagnostics_ToolHelp"] } [dev-dependencies] assert_cmd = "2.0.17" diff --git a/README.md b/README.md index 0bec6b5..a0dc4f7 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ agentctl config check `agentctl` reads `./agentctl.toml` first, then falls back to `~/.agentctl/config.toml`. Raw command logs and detached job records live under `~/.agentctl/jobs/`. +On Windows, `USERPROFILE` is used when `HOME` is unset. Inspect the current platform, home-directory resolution, LDGR harness selection, configured agents, and commands actually available on `PATH`: @@ -89,16 +90,20 @@ handling still works. A failing `cargo test` returns a compact JSON summary: The full stdout and stderr remain in `raw_log_path` for later inspection. Use `--raw` when you deliberately want the untouched command output streamed back. -Output is streamed to the raw log while only the configured head/tail window is -retained in memory. Agent commands run in an isolated process group. Interrupting +Output is streamed to capture files while only the configured head/tail window is +retained in memory; the complete raw log is assembled when the command finishes. +On Unix, agent commands run in an isolated process group. Interrupting agentctl forwards the signal to the complete command tree, waits for it, and terminates descendants left behind by a command that exits early. On Linux, -agentctl also adopts and promptly reaps orphaned descendants while the direct agent -is still running, so repeated tool loops do not accumulate zombie or live background -agent processes. Native Windows commands -are assigned to kill-on-close Job Objects, detached supervisors use a detached -process group, and job status uses native process handles rather than Unix -utilities. +agentctl also adopts and reaps orphaned descendants so repeated tool loops do not +accumulate zombie or live background agent processes. Cleanup is scoped to each +capture, preserving unrelated child processes and concurrent captures in library +hosts. Each Linux capture has a dedicated subreaper supervisor, so descendants +remain owned even after changing session, clearing their environment, or exiting +before cleanup. The embedding application is never made a subreaper. This is +lifecycle management, not a security sandbox. +On Windows, commands start suspended and join a Job Object before running, so +remaining descendants are terminated when the command finishes or its host exits. ## Run a detached agent task @@ -141,6 +146,24 @@ prompt_stdin = true command = ["ollama", "run", "llama3"] prompt_stdin = true +# Native OpenRouter Chat Completions provider. The command returns only the key +# on stdout. agentctl never writes the key to its config or job logs. +[providers.openrouter] +kind = "openrouter" +base_url = "https://openrouter.ai/api/v1" +api_key_command = ["op", "read", "op://vault/OPENROUTER_API_KEY/password"] +model = "openrouter/auto" + +# Use the native provider through the normal run command. +[agents.openrouter-api] +provider = "openrouter" +prompt_stdin = true + +# Run Pi with its OpenRouter provider instead. +[agents.openrouter] +command = ["pi", "--provider", "openrouter", "--model", "openrouter/auto", "--print", "--no-session"] +prompt_stdin = false + [agents.openclaw] command = ["openclaw", "run"] prompt_stdin = false @@ -150,6 +173,16 @@ command = ["opencode", "run"] prompt_stdin = false ``` +Use either OpenRouter profile: + +```sh +agentctl run openrouter-api --prompt "Summarize the current design." +agentctl run openrouter --prompt "Inspect the repository and fix the failing test." +``` + +`api_key_env = "OPENROUTER_API_KEY"` is also supported. Set exactly one of +`api_key_env` and `api_key_command` for each provider. + Start a long task without keeping the caller attached: ```sh @@ -204,9 +237,12 @@ agentctl list --json `logs` is intentionally the raw-output command. `status` and `list` stay bounded and machine-readable for agent loops. +Status reads at most the final 16 KiB of a log, even when a line is very long. +Raw logs are streamed without requiring valid UTF-8. -Detached supervisors also run in an isolated process group and terminate the -remaining group after recording their exit status. +Detached supervisors use isolated process groups on Unix. On Windows they start +without a console or inherited caller handles, so the caller's output pipes close +immediately after the launch report. ## JSON contract @@ -236,6 +272,9 @@ Foreground `run --json` returns an array of iteration reports: ] ``` +If an iteration fails, its report and preceding successful reports are returned, +further iterations stop, and the CLI exits with the failed command's exit code. + Detached `run --json` and `list --json` use job records: ```json @@ -272,10 +311,22 @@ These shapes are semver-relevant. The crate also exposes the capture, summary, config, and job modules used by the CLI. The public API is intentionally small so other local tooling can build the same bounded reports without shelling out. +Embedding applications can call `jobs::launch_detached_with_executable` with the +path to an installed agentctl binary. `launch_detached` re-executes the current +binary and is intended for callers that implement agentctl's supervisor command. + +## Development + +Run `dev fix` and `dev ci` using the repository's Rust-only `.dev/config.toml`, +or use Cargo directly. CLI integration tests require Python on `PATH`. +CI is configured for Windows, Linux, and macOS. Linux-only subreaper tests are +separate from the portable command, provider, and detached-job tests. + +## Provider support -## Provider boundary +`agentctl` includes a native OpenRouter Chat Completions provider. Native +provider profiles use the same bounded capture, summaries, detached jobs, and +iteration handling as command profiles. -`agentctl` is intentionally a wrapper, not a provider SDK. Codex, Claude Code, -OpenAI REST, OpenAI websocket/realtime, Ollama, or other provider-specific -clients live in their own CLIs/scripts. `agentctl` only decides how to pass the -prompt, capture output, summarize logs, and track detached jobs. +Other provider-specific clients remain external commands. Configure those +clients under `agents`, as shown for OpenAI and Ollama above. diff --git a/docs/audit-2026-10-10.md b/docs/audit-2026-10-10.md new file mode 100644 index 0000000..81d6242 --- /dev/null +++ b/docs/audit-2026-10-10.md @@ -0,0 +1,88 @@ +# Library audit — 2026-10-10 + +Scope: current library and CLI, including the pre-existing uncommitted native +OpenRouter provider and sandbox configuration changes. The operator authorized +ownership, repairs, validation, and a commit. No paid provider requests were made. + +## Findings and repairs + +| Priority | Finding | Repair and evidence | +| --- | --- | --- | +| P1 | Linux subreaper cleanup signalled all children of the host and used `waitpid(-1)`, interfering with unrelated work and concurrent captures. | Scope cleanup by process group and an inherited capture marker; reap only owned PIDs; register every active process group for signal forwarding. Regression preserves an unrelated child and a second capture while the first exits. Existing escaped-session and signal-forwarding tests pass. | +| P1 | Windows had no process-tree ownership or descendant cleanup. | Start commands suspended, assign them to a kill-on-close Job Object, then resume them. Regression verifies a grandchild is terminated after its parent exits. | +| P1 | Windows detached supervisors inherited caller output handles, keeping a tool caller waiting until the job finished. | Create the supervisor without inherited handles or a visible console; initialize its log inside the supervisor. A live-job regression verifies the launch returns while the job is still running. | +| P2 | Job liveness invoked Unix `kill`, causing live Windows jobs to be labelled stale. | Use native process-handle liveness checks on Windows and `kill(pid, 0)` on Unix. | +| P2 | Failed foreground iterations discarded all reports, emitted no JSON, and returned generic exit code 1. | Preserve prior reports and the failed report, stop iterations, and return the actual failed exit code. Regression fails iteration two with code 7 and checks both reports and the raw-log path. | +| P2 | External summarizers collected arbitrary output and ignored preview-line limits. | Use managed bounded capture and enforce the configured preview-line count. Regression emits 100,000 summary lines. | +| P2 | Status loaded entire logs to return a tail; raw-log viewing repeatedly loaded whole files and rejected non-UTF-8 output. | Read at most a 16 KiB tail for status; stream raw logs by byte position. Regression exercises a 1 MiB line followed by invalid UTF-8. | +| P2 | Detached job IDs collided for repeated calls in one process within the same second. | Include nanoseconds and a process-local counter; regression checks 1,000 generated IDs. | +| P2 | Native Windows usage required HOME, and two CLI tests required an unavailable Unix shell. | Fall back to USERPROFILE and use Python test commands on both platforms. | + +Embedders now have `launch_detached_with_executable` so they can explicitly supply +the agentctl supervisor binary instead of re-executing their own application. +The existing self-reexecution API is documented. Existing provider support is +included in this commit, with local HTTP fixture tests of request authentication, +model/prompt serialization, returned content, and the CLI integration. + +## Validation + +- Windows: `dev fix`; `dev ci` (format, Clippy with warnings denied, all-target tests). +- Linux/WSL: Cargo format check, all-target tests, and all-target Clippy with warnings denied. +- Windows: 34 tests. Linux: 36 tests. +- Added a repository-local Rust-only dev configuration; the inherited parent + configuration incorrectly included TypeScript checks for this Rust repository. +- Expanded GitHub Actions to Windows and Linux with Python available for CLI tests. +- `git diff --check` checks the final changes for whitespace errors. + +## Verification limits + +The provider was also tested against an operator-provided live, OpenAI-compatible +Chat Completions endpoint using `qwen3.6:latest` on 2026-10-10: + +- Foreground `run --json` returned the exact requested text, exit code 0, and a + valid raw log in 12.714 seconds. +- Detached launch returned `running` in 0.021 seconds. Status subsequently reported + `succeeded`, exit code 0, and the exact requested response; `logs` agreed. +- Credentials were supplied through an environment variable. All six generated + config, job-record, and log files were checked and contained no API key. +- This validates live compatibility through the native provider path, including + Windows capture and detached supervision. It does not validate OpenRouter's + hosted authentication, billing, or routing. The private endpoint and credential + are intentionally omitted from committed evidence. + +## Follow-up repairs + +The Linux attribution gap is now removed. Each capture launches a dedicated +subreaper supervisor before executing the command. The kernel assigns orphaned +descendants to that supervisor regardless of session or environment changes. +Only that supervisor uses `waitpid(-1)`; the embedding application neither becomes +a subreaper nor reaps arbitrary children. The supervisor preserves command exit +status and exec errors, escalates cleanup to SIGKILL after a grace period, and +watches the host process through a pidfd so cleanup also runs if the host is killed. + +Regression coverage includes detached descendants with empty environments, +SIGTERM-resistant descendants, early-exit zombies, unrelated host children, +concurrent captures, unchanged host subreaper state, exec failure propagation, +cleanup after the host receives SIGKILL, and captures transferred between host threads. + +The GitHub Actions matrix now includes macOS as well as Windows and Linux. +Tests that require Linux subreaper semantics are correctly Linux-gated rather +than assuming every Unix platform provides `setsid` and subreaper adoption. + +Local follow-up validation: Windows 34 tests; Linux 40 tests; formatting and +Clippy with warnings denied. `dev fix` and `dev ci` pass on Windows. + +## Remaining external verification + +Release 0.1.5 incorporates the newer discovery, compatibility, and durable error +capture work from main. The integrated Windows suite passes 55 tests locally. +The branch is published in [PR #3](https://github.com/hydra-dynamix/agentctl/pull/3), +whose checks track hosted Linux, Windows, and macOS validation of the current +commit. The test counts above describe the pre-integration audit snapshots. +OpenRouter-hosted authentication and billing remain untested; the operator-provided +compatible endpoint was verified above. + +This process manager remains lifecycle management rather than a security sandbox. +Linux supervision requires pidfd support (Linux 5.3+) and the procfs child inventory and refuses to launch if +that inventory is unavailable. Commands that deliberately attack their supervisor +or change operating-system privileges need a separate containment boundary. diff --git a/docs/spec.md b/docs/spec.md index cd22bcf..2589945 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -91,8 +91,26 @@ prompt_stdin = true [agents.claude] command = ["claude", "-p"] prompt_stdin = false # prompt appended as final arg + +[providers.openrouter] +kind = "openrouter" +base_url = "https://openrouter.ai/api/v1" +api_key_env = "OPENROUTER_API_KEY" +model = "openrouter/auto" + +[agents.openrouter-api] +provider = "openrouter" # native HTTP provider +prompt_stdin = true + +[agents.openrouter] +command = ["pi", "--provider", "openrouter", "--model", "openrouter/auto", "--print", "--no-session"] +prompt_stdin = false ``` +A provider can resolve its key from `api_key_env` or `api_key_command`. The key +is never stored in job records or logs. OpenRouter uses its Chat Completions +endpoint. Other provider clients remain external commands. + Job records and raw logs live under `~/.agentctl/jobs//` (`job.toml`, `output.log`, `exit-code`). @@ -150,5 +168,6 @@ above verified. ## Out of scope - Publishing (user-triggered). +- Native provider APIs other than OpenRouter. - MCP server mode, daemon mode, TUI — maybe later; do not scaffold for them. - devkit integration (devkit removes its agent/summary commands separately). diff --git a/src/config.rs b/src/config.rs index 572676d..9a85609 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,6 +16,8 @@ pub struct Config { #[serde(default)] pub summary: SummaryConfig, #[serde(default)] + pub providers: BTreeMap, + #[serde(default)] pub agents: BTreeMap, } @@ -30,10 +32,35 @@ pub struct SummaryConfig { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AgentConfig { + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub command: Vec, + #[serde(default)] pub prompt_stdin: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderConfig { + #[serde(default)] + pub kind: ProviderKind, + pub base_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_key_env: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_key_command: Option>, + pub model: String, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProviderKind { + #[default] + Openrouter, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -44,6 +71,25 @@ pub struct LoadedConfig { impl Default for Config { fn default() -> Self { + let mut providers = BTreeMap::new(); + providers.insert( + "openrouter".to_owned(), + ProviderConfig { + kind: ProviderKind::Openrouter, + base_url: "https://openrouter.ai/api/v1".to_owned(), + api_key_env: Some("OPENROUTER_API_KEY".to_owned()), + api_key_command: None, + model: "openrouter/auto".to_owned(), + }, + ); + + let command_agent = |command: Vec, prompt_stdin| AgentConfig { + command, + prompt_stdin, + cwd: None, + provider: None, + model: None, + }; let mut agents = BTreeMap::new(); agents.insert( "pi".to_owned(), @@ -51,52 +97,37 @@ impl Default for Config { command: vec!["pi".to_owned(), "-p".to_owned()], prompt_stdin: false, cwd: None, + provider: None, + model: None, }, ); agents.insert( "codex".to_owned(), - AgentConfig { - command: vec![ + command_agent( + vec![ "codex".to_owned(), "exec".to_owned(), "--sandbox".to_owned(), "danger-full-access".to_owned(), ], - prompt_stdin: true, - cwd: None, - }, + true, + ), ); agents.insert( "claude-code".to_owned(), - AgentConfig { - command: vec!["claude".to_owned(), "-p".to_owned()], - prompt_stdin: false, - cwd: None, - }, + command_agent(vec!["claude".to_owned(), "-p".to_owned()], false), ); agents.insert( "claude".to_owned(), - AgentConfig { - command: vec!["claude".to_owned(), "-p".to_owned()], - prompt_stdin: false, - cwd: None, - }, + command_agent(vec!["claude".to_owned(), "-p".to_owned()], false), ); agents.insert( "openai-rest".to_owned(), - AgentConfig { - command: vec!["openai-rest-agent".to_owned()], - prompt_stdin: true, - cwd: None, - }, + command_agent(vec!["openai-rest-agent".to_owned()], true), ); agents.insert( "openai-websocket".to_owned(), - AgentConfig { - command: vec!["openai-websocket-agent".to_owned()], - prompt_stdin: true, - cwd: None, - }, + command_agent(vec!["openai-websocket-agent".to_owned()], true), ); agents.insert( "openclaw".to_owned(), @@ -104,6 +135,8 @@ impl Default for Config { command: vec!["openclaw".to_owned(), "run".to_owned()], prompt_stdin: false, cwd: None, + provider: None, + model: None, }, ); agents.insert( @@ -112,19 +145,46 @@ impl Default for Config { command: vec!["opencode".to_owned(), "run".to_owned()], prompt_stdin: false, cwd: None, + provider: None, + model: None, }, ); agents.insert( "ollama".to_owned(), + command_agent( + vec!["ollama".to_owned(), "run".to_owned(), "llama3".to_owned()], + true, + ), + ); + agents.insert( + "openrouter-api".to_owned(), AgentConfig { - command: vec!["ollama".to_owned(), "run".to_owned(), "llama3".to_owned()], + command: Vec::new(), prompt_stdin: true, cwd: None, + provider: Some("openrouter".to_owned()), + model: None, }, ); + agents.insert( + "openrouter".to_owned(), + command_agent( + vec![ + "pi".to_owned(), + "--provider".to_owned(), + "openrouter".to_owned(), + "--model".to_owned(), + "openrouter/auto".to_owned(), + "--print".to_owned(), + "--no-session".to_owned(), + ], + false, + ), + ); Self { summary: SummaryConfig::default(), + providers, agents, } } @@ -216,13 +276,50 @@ pub fn validate(config: &Config) -> Result<()> { bail!("summary.summarizer_command must not be empty when set"); } + for (name, provider) in &config.providers { + if provider.base_url.trim().is_empty() { + bail!("providers.{name}.base_url must not be empty"); + } + if provider.model.trim().is_empty() { + bail!("providers.{name}.model must not be empty"); + } + match (&provider.api_key_env, &provider.api_key_command) { + (Some(environment), None) if environment.trim().is_empty() => { + bail!("providers.{name}.api_key_env must not be empty") + } + (None, Some(command)) if command.is_empty() => { + bail!("providers.{name}.api_key_command must not be empty") + } + (None, Some(command)) if command.iter().any(|part| part.trim().is_empty()) => { + bail!("providers.{name}.api_key_command must not contain empty arguments") + } + (Some(_), Some(_)) => { + bail!("providers.{name} must set only one of api_key_env or api_key_command") + } + (None, None) => { + bail!("providers.{name} must set one of api_key_env or api_key_command") + } + _ => {} + } + } + for (name, agent) in &config.agents { - if agent.command.is_empty() { - bail!("agents.{name}.command must not be empty"); + match (agent.command.is_empty(), agent.provider.as_deref()) { + (true, None) => bail!("agents.{name} must set command or provider"), + (false, Some(_)) => bail!("agents.{name} must not set both command and provider"), + _ => {} } if agent.command.iter().any(|part| part.trim().is_empty()) { bail!("agents.{name}.command must not contain empty arguments"); } + if let Some(provider) = &agent.provider + && !config.providers.contains_key(provider) + { + bail!("agents.{name}.provider references unknown provider `{provider}`"); + } + if agent.provider.is_none() && agent.model.is_some() { + bail!("agents.{name}.model requires agents.{name}.provider"); + } } Ok(()) @@ -296,6 +393,7 @@ mod tests { max_preview_lines: 12, summarizer_command: None, }, + providers: Config::default().providers, agents: BTreeMap::new(), }; diff --git a/src/discovery.rs b/src/discovery.rs index af4a2cb..8d5bf6f 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -356,7 +356,7 @@ fn agent_statuses(config: &Config) -> Vec { AgentStatus { name: name.clone(), command: agent.command.clone(), - available: executable.is_some(), + available: agent.provider.is_some() || executable.is_some(), executable, } }) @@ -451,11 +451,12 @@ fn agent_available(agent: &AgentConfig, lookup: &F) -> bool where F: Fn(&str) -> Option, { - agent - .command - .first() - .and_then(|command| lookup(command)) - .is_some() + agent.provider.is_some() + || agent + .command + .first() + .and_then(|command| lookup(command)) + .is_some() } fn unavailable_agent_warning(name: &str, agent: &AgentConfig) -> String { @@ -598,6 +599,8 @@ mod tests { command: vec![(*command).to_owned()], prompt_stdin: true, cwd: None, + provider: None, + model: None, }, ) }) diff --git a/src/exec.rs b/src/exec.rs index b48c140..3b8f94b 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -106,6 +106,11 @@ pub(crate) fn run_captured_with( stdin .write_all(input) .context("writing managed child stdin")?; + stdin.flush().context("flushing managed child stdin")?; + // Close the pipe before waiting so agents that read until EOF (for + // example `cat` or `codex exec`) can finish promptly and do not fall + // back to interpreting inherited input in platform-specific ways. + drop(stdin); } let status = child diff --git a/src/jobs.rs b/src/jobs.rs index 92b9c93..4b6250a 100644 --- a/src/jobs.rs +++ b/src/jobs.rs @@ -1,7 +1,9 @@ use std::fs; -use std::io::{Read, Write}; +use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; -use std::process::{Command as ProcessCommand, ExitStatus, Stdio}; +use std::process::ExitStatus; +#[cfg(not(windows))] +use std::process::{Command as ProcessCommand, Stdio}; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -12,6 +14,7 @@ use crate::capture::{ExecutionAttempt, FailureKind}; use crate::config::{AgentConfig, CONFIG_DIR, SummaryConfig}; use crate::exec::{CaptureOptions, CapturedRun, ProcessOptions, format_command, run_captured_with}; use crate::platform; +#[cfg(not(windows))] use crate::process::spawn_detached; use crate::summary::{Summary, summarize, summarize_with_command}; @@ -124,20 +127,27 @@ pub fn run_foreground( options .attempt .record_error(failure_from_status(run.exit_status))?; - bail!( - "agent iteration {iteration}/{} exited with code {exit_code}", - options.iterations - ); + break; } } options.attempt.complete(); Ok(reports) } +/// Launch using the current executable's `__supervise` command. Embedders should +/// use `launch_detached_with_executable` with the path to the agentctl binary. pub fn launch_detached(options: &AgentRunOptions) -> Result { + let executable = std::env::current_exe().context("locating agentctl executable")?; + launch_detached_with_executable(options, &executable) +} + +pub fn launch_detached_with_executable( + options: &AgentRunOptions, + executable: &Path, +) -> Result { validate_options(options)?; let started_at = unix_timestamp()?; - let job_id = job_id(started_at, &options.agent_name); + let job_id = job_id(&options.agent_name); let job_dir = jobs_dir()?.join(&job_id); fs::create_dir_all(&job_dir).with_context(|| format!("creating {}", job_dir.display()))?; let log_path = job_dir.join("output.log"); @@ -146,6 +156,7 @@ pub fn launch_detached(options: &AgentRunOptions) -> Result { let supervisor_path = job_dir.join("supervisor.toml"); let stdout = fs::File::create(&log_path) .with_context(|| format!("creating agent log {}", log_path.display()))?; + #[cfg(not(windows))] let stderr = stdout .try_clone() .with_context(|| format!("cloning agent log {}", log_path.display()))?; @@ -164,28 +175,54 @@ pub fn launch_detached(options: &AgentRunOptions) -> Result { fs::write(&supervisor_path, toml::to_string_pretty(&supervisor)?) .with_context(|| format!("writing {}", supervisor_path.display()))?; - let executable = std::env::current_exe().context("locating agentctl executable")?; - let mut command = ProcessCommand::new(executable); - command.arg("__supervise").arg(&supervisor_path); - command.current_dir(&options.cwd); - command.stdin(Stdio::null()); - command.stdout(Stdio::from(stdout)); - command.stderr(Stdio::from(stderr)); - platform::configure_child_home(&mut command)?; - - let child = match spawn_detached(&mut command) { - Ok(child) => child, - Err(error) => { - options.attempt.record_error(FailureKind::Spawn)?; - return Err(error) - .with_context(|| format!("launching detached agent `{}`", format_command(&argv))); + #[cfg(windows)] + let pid = { + drop(stdout); + match crate::windows_process::spawn_supervisor( + executable, + &supervisor_path, + &options.cwd, + &log_path, + ) { + Ok(pid) => pid, + Err(error) => { + options.attempt.record_error(FailureKind::Spawn)?; + return Err(error).context("launching detached supervisor"); + } } }; + #[cfg(not(windows))] + let pid = { + let mut command = ProcessCommand::new(executable); + command.arg("__supervise").arg(&supervisor_path); + command.current_dir(&options.cwd); + command.stdin(Stdio::null()); + command.stdout(Stdio::from(stdout)); + command.stderr(Stdio::from(stderr)); + platform::configure_child_home(&mut command)?; + + let child = match spawn_detached(&mut command) { + Ok(child) => child, + Err(error) => { + options.attempt.record_error(FailureKind::Spawn)?; + return Err(error).with_context(|| { + format!("launching detached agent `{}`", format_command(&argv)) + }); + } + }; + let pid = child.id(); + // Reap the supervisor when a long-lived library caller outlives it. + thread::spawn(move || { + let mut child = child; + let _ = child.wait(); + }); + pid + }; let stored = StoredJobRecord { id: job_id, agent: options.agent_name.clone(), - pid: child.id(), + pid, started_at, command: format_command(&argv), cwd: options.cwd.clone(), @@ -288,14 +325,15 @@ pub fn logs(job_id: &str, follow: bool) -> Result<()> { loop { let mut file = fs::File::open(&stored.log_path) .with_context(|| format!("opening {}", stored.log_path.display()))?; - let mut contents = String::new(); - file.read_to_string(&mut contents) - .with_context(|| format!("reading {}", stored.log_path.display()))?; - if position < contents.len() { - print!("{}", &contents[position..]); - std::io::stdout().flush().context("flushing logs")?; - position = contents.len(); + let length = file.metadata()?.len(); + if length < position { + position = 0; } + file.seek(SeekFrom::Start(position))?; + let mut output = std::io::stdout().lock(); + position += std::io::copy(&mut file.take(length - position), &mut output)?; + output.flush().context("flushing logs")?; + drop(output); if !follow || !matches!(record_from_stored(stored.clone())?.state, JobState::Running) { break; } @@ -474,7 +512,18 @@ fn read_job_path(path: &Path) -> Result { } fn read_tail_lines(path: &Path, tail: usize) -> Result> { - let raw = fs::read_to_string(path).unwrap_or_default(); + // Bound both IO and the response, even for a log containing one huge line. + const MAX_TAIL_BYTES: u64 = 16_384; + let mut file = match fs::File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error).context("opening job log tail"), + }; + let length = file.metadata()?.len(); + file.seek(SeekFrom::Start(length.saturating_sub(MAX_TAIL_BYTES)))?; + let mut bytes = Vec::new(); + file.take(MAX_TAIL_BYTES).read_to_end(&mut bytes)?; + let raw = String::from_utf8_lossy(&bytes); let mut lines = raw .lines() .rev() @@ -489,11 +538,17 @@ fn jobs_dir() -> Result { Ok(platform::home_dir()?.join(CONFIG_DIR).join("jobs")) } -fn job_id(started_at: u64, agent_name: &str) -> String { +fn job_id(agent_name: &str) -> String { + static NEXT_JOB: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); format!( - "{}-{}-{}", - started_at, + "{}-{}-{}-{}", + timestamp, std::process::id(), + NEXT_JOB.fetch_add(1, std::sync::atomic::Ordering::Relaxed), safe_name(agent_name) ) } @@ -523,30 +578,25 @@ fn process_is_running(pid: u32) -> bool { if pid == 0 { return false; } - let result = unsafe { libc::kill(pid as i32, 0) }; - result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) + (unsafe { libc::kill(pid as i32, 0) == 0 }) + || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) } #[cfg(windows)] fn process_is_running(pid: u32) -> bool { - use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE}; + use windows_sys::Win32::Foundation::{CloseHandle, WAIT_TIMEOUT}; use windows_sys::Win32::System::Threading::{ - GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject, }; - - if pid == 0 { - return false; - } - let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; - if handle.is_null() { - return false; - } - let mut exit_code = 0; - let success = unsafe { GetExitCodeProcess(handle, &mut exit_code) } != 0; unsafe { + let handle = OpenProcess(PROCESS_SYNCHRONIZE, 0, pid); + if handle.is_null() { + return false; + } + let running = WaitForSingleObject(handle, 0) == WAIT_TIMEOUT; CloseHandle(handle); + running } - success && exit_code == STILL_ACTIVE as u32 } #[cfg(not(any(unix, windows)))] @@ -558,6 +608,25 @@ fn process_is_running(_pid: u32) -> bool { mod tests { use super::*; + #[test] + fn log_tail_bounds_single_lines_and_accepts_non_utf8() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("output.log"); + let mut bytes = vec![b'x'; 1024 * 1024]; + bytes.extend_from_slice(b"\nlast\xffline\n"); + fs::write(&path, bytes).unwrap(); + let tail = read_tail_lines(&path, 2).unwrap(); + assert_eq!(tail.len(), 2); + assert!(tail[1].contains("last")); + assert!(tail.iter().map(String::len).sum::() <= 16_386); + } + + #[test] + fn detached_job_ids_are_unique_within_one_process() { + let ids: std::collections::BTreeSet<_> = (0..1000).map(|_| job_id("same-agent")).collect(); + assert_eq!(ids.len(), 1000); + } + #[test] fn stale_job_reports_stale_when_pid_is_dead_and_exit_code_missing() { let temp_dir = tempfile::tempdir().expect("tempdir"); diff --git a/src/lib.rs b/src/lib.rs index a838bde..1382957 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,4 +13,10 @@ pub mod jobs; pub mod output; pub mod platform; mod process; +pub mod provider; pub mod summary; +#[cfg(windows)] +pub mod windows_process; + +#[cfg(target_os = "linux")] +mod linux_process; diff --git a/src/linux_process.rs b/src/linux_process.rs new file mode 100644 index 0000000..04d1243 --- /dev/null +++ b/src/linux_process.rs @@ -0,0 +1,193 @@ +//! Per-capture Linux subreaper. The host never adopts or reaps command descendants. +//! +//! This runs in Command's pre_exec child, then forks the actual worker. The +//! worker returns to Rust's exec path; the supervisor never returns. All code +//! after fork uses stack storage and async-signal-safe libc operations only. +use std::io; +use std::os::unix::process::CommandExt; +use std::process::Command; +use std::sync::atomic::{AtomicI32, Ordering}; + +static SIGNAL: AtomicI32 = AtomicI32::new(0); + +extern "C" fn record_signal(signal: libc::c_int) { + SIGNAL.store(signal, Ordering::Relaxed); +} + +pub(crate) fn configure_supervisor(command: &mut Command) -> io::Result<()> { + // Fail before launching a worker if the kernel child inventory is unavailable. + std::fs::read("/proc/thread-self/children")?; + let parent = std::process::id() as libc::pid_t; + // SAFETY: the closure and supervisor use no heap allocation, Rust locks, + // formatting, destructors, or non-async-signal-safe library operations. + unsafe { + command.pre_exec(move || { + if libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) != 0 { + return Err(io::Error::last_os_error()); + } + // Watch the host process, not its spawning thread. PDEATHSIG is + // thread-scoped and would kill captures moved between host threads. + let parent_fd = libc::syscall(libc::SYS_pidfd_open, parent, 0) as i32; + if parent_fd < 0 { + return Err(io::Error::last_os_error()); + } + SIGNAL.store(0, Ordering::Relaxed); + let signals = [libc::SIGTERM, libc::SIGINT, libc::SIGHUP, libc::SIGQUIT]; + let mut originals: [libc::sigaction; 4] = std::mem::zeroed(); + let mut action: libc::sigaction = std::mem::zeroed(); + action.sa_sigaction = record_signal as *const () as usize; + libc::sigemptyset(&mut action.sa_mask); + for (index, signal) in signals.iter().enumerate() { + if libc::sigaction(*signal, &action, &mut originals[index]) != 0 { + return Err(io::Error::last_os_error()); + } + } + if libc::getppid() != parent { + libc::_exit(125); + } + // Raw fork-equivalent clone avoids invoking host pthread_atfork + // callbacks a second time inside an already-forked process. + let worker = libc::syscall(libc::SYS_clone, libc::SIGCHLD, 0, 0, 0, 0) as libc::pid_t; + if worker < 0 { + return Err(io::Error::last_os_error()); + } + if worker == 0 { + libc::close(parent_fd); + for (index, signal) in signals.iter().enumerate() { + if libc::sigaction(*signal, &originals[index], std::ptr::null_mut()) != 0 { + return Err(io::Error::last_os_error()); + } + } + return Ok(()); + } + if libc::dup2(parent_fd, 0) < 0 { + libc::_exit(125); + } + supervise(worker) + }); + } + Ok(()) +} + +unsafe fn supervise(worker: libc::pid_t) -> ! { + // The worker retains the exec-error pipe until exec succeeds or reports an + // error. Closing our copy preserves Command::spawn's ordinary error result. + unsafe { + // FD 0 is the host pidfd; all other descriptors belong to the worker. + if libc::syscall(libc::SYS_close_range, 1_u32, u32::MAX, 0_u32) != 0 { + let mut limit: libc::rlimit = std::mem::zeroed(); + if libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) != 0 { + libc::_exit(125); + } + for fd in 1..limit.rlim_cur.min(i32::MAX as u64) { + libc::close(fd as i32); + } + } + let mut worker_status = None; + let mut stopping_at = None; + loop { + let mut status = 0; + let pid = libc::waitpid(-1, &mut status, libc::WNOHANG); + if pid > 0 { + if pid == worker { + worker_status = Some(status); + } + continue; + } + if pid < 0 && *libc::__errno_location() == libc::ECHILD { + finish(worker_status.unwrap_or(125 << 8)); + } + let mut host = libc::pollfd { + fd: 0, + events: libc::POLLIN, + revents: 0, + }; + if libc::poll(&mut host, 1, 0) > 0 { + SIGNAL.store(libc::SIGTERM, Ordering::Relaxed); + } + let signal = SIGNAL.load(Ordering::Relaxed); + if worker_status.is_some() || signal != 0 { + let start = *stopping_at.get_or_insert_with(monotonic_ms); + let signal = if monotonic_ms().saturating_sub(start) >= 750 { + libc::SIGKILL + } else if signal != 0 { + signal + } else { + libc::SIGTERM + }; + signal_children(signal); + } + let delay = libc::timespec { + tv_sec: 0, + tv_nsec: 10_000_000, + }; + libc::nanosleep(&delay, std::ptr::null_mut()); + } + } +} + +fn monotonic_ms() -> u64 { + unsafe { + let mut now: libc::timespec = std::mem::zeroed(); + libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut now); + (now.tv_sec as u64).saturating_mul(1000) + now.tv_nsec as u64 / 1_000_000 + } +} + +unsafe fn signal_children(signal: i32) { + unsafe { + // Every child belongs to this capture, including adopted zombies and + // descendants that call setsid or exec with a completely empty env. + let fd = libc::open(c"/proc/thread-self/children".as_ptr(), libc::O_RDONLY); + if fd < 0 { + return; + } + let mut buffer = [0_u8; 4096]; + let mut pid: i32 = 0; + loop { + let count = libc::read(fd, buffer.as_mut_ptr().cast(), buffer.len()); + if count < 0 { + if *libc::__errno_location() == libc::EINTR { + continue; + } + // Never signal a partially parsed PID after a failed read. + pid = 0; + break; + } + if count == 0 { + break; + } + for byte in &buffer[..count as usize] { + if byte.is_ascii_digit() { + pid = pid.saturating_mul(10).saturating_add((byte - b'0') as i32); + } else if pid > 0 { + libc::kill(pid, signal); + pid = 0; + } + } + } + if pid > 0 { + libc::kill(pid, signal); + } + libc::close(fd); + } +} + +unsafe fn finish(status: i32) -> ! { + unsafe { + if libc::WIFEXITED(status) { + libc::_exit(libc::WEXITSTATUS(status)); + } + if libc::WIFSIGNALED(status) { + let signal = libc::WTERMSIG(status); + libc::signal(signal, libc::SIG_DFL); + let mut mask: libc::sigset_t = std::mem::zeroed(); + libc::sigemptyset(&mut mask); + libc::sigaddset(&mut mask, signal); + libc::sigprocmask(libc::SIG_UNBLOCK, &mask, std::ptr::null_mut()); + libc::kill(libc::getpid(), signal); + libc::_exit(128 + signal); + } + libc::_exit(125); + } +} diff --git a/src/main.rs b/src/main.rs index 37112ff..4d3265e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -63,7 +63,19 @@ enum Command { follow: bool, }, #[command(name = "__supervise", hide = true)] - Supervise { spec: PathBuf }, + Supervise { + spec: PathBuf, + #[cfg(windows)] + #[arg(long)] + log: Option, + }, + #[command(name = "__provider", hide = true)] + Provider { + config: PathBuf, + provider: String, + #[arg(long)] + model: Option, + }, Config { #[command(subcommand)] command: ConfigCommand, @@ -153,6 +165,7 @@ fn main() -> Result<()> { ) })?; } + resolved.agent = materialize_provider_agent(resolved.agent, &loaded.path)?; if sandbox { restrict_codex_worker(&mut resolved.agent.command)?; } @@ -215,6 +228,7 @@ fn main() -> Result<()> { print_summary(&report.summary); } } + std::process::exit(reports.last().map_or(0, |report| report.summary.exit_code)); } } Command::Status { job_id, tail, json } => { @@ -250,10 +264,40 @@ fn main() -> Result<()> { Command::Logs { job_id, follow } => { agentctl::jobs::logs(&job_id, follow)?; } - Command::Supervise { spec } => { + Command::Supervise { + spec, + #[cfg(windows)] + log, + } => { + #[cfg(windows)] + let _log = log + .as_deref() + .map(agentctl::windows_process::redirect_log) + .transpose()?; let exit_code = agentctl::jobs::supervise(&spec)?; std::process::exit(exit_code); } + Command::Provider { + config, + provider, + model, + } => { + let mut prompt = String::new(); + std::io::stdin() + .read_to_string(&mut prompt) + .context("reading provider prompt from stdin")?; + let config = if config.exists() { + agentctl::config::check_path(&config)? + } else { + agentctl::config::Config::default() + }; + let provider_config = config + .providers + .get(&provider) + .with_context(|| format!("unknown provider `{provider}`"))?; + let answer = agentctl::provider::complete(provider_config, model.as_deref(), &prompt)?; + println!("{answer}"); + } Command::Config { command } => match command { ConfigCommand::Generate => { let path = agentctl::config::generate()?; @@ -352,6 +396,32 @@ fn read_prompt(prompt: Option<&str>, prompt_file: Option<&Path>) -> Result Result { + let Some(provider) = agent.provider.take() else { + return Ok(agent); + }; + + let mut command = vec![ + std::env::current_exe() + .context("locating agentctl executable")? + .to_string_lossy() + .into_owned(), + "__provider".to_owned(), + config_path.to_string_lossy().into_owned(), + provider, + ]; + if let Some(model) = agent.model.take() { + command.push("--model".to_owned()); + command.push(model); + } + agent.command = command; + agent.prompt_stdin = true; + Ok(agent) +} + fn resolve_cwd(configured: Option<&Path>, override_cwd: Option<&Path>) -> Result { let cwd = if let Some(path) = override_cwd { path.to_path_buf() diff --git a/src/process.rs b/src/process.rs index 7fa8d0f..3afdb05 100644 --- a/src/process.rs +++ b/src/process.rs @@ -1,39 +1,25 @@ use std::io; use std::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus}; +#[cfg(all(unix, not(target_os = "linux")))] +use std::time::{Duration, Instant}; #[cfg(unix)] -use std::sync::OnceLock; +use std::collections::BTreeSet; #[cfg(unix)] -use std::sync::atomic::{AtomicI32, Ordering}; +use std::sync::{Mutex, OnceLock}; #[cfg(unix)] -use signal_hook::consts::signal::{SIGCHLD, SIGHUP, SIGINT, SIGQUIT, SIGTERM}; +use signal_hook::consts::signal::{SIGHUP, SIGINT, SIGQUIT, SIGTERM}; #[cfg(unix)] use signal_hook::iterator::Signals; #[cfg(unix)] use std::os::unix::process::CommandExt; -#[cfg(windows)] -use std::os::windows::io::AsRawHandle; -#[cfg(windows)] -use std::os::windows::process::CommandExt; -#[cfg(unix)] -use std::time::{Duration, Instant}; -#[cfg(windows)] -use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; -#[cfg(windows)] -use windows_sys::Win32::System::JobObjects::{ - AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, - JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, - SetInformationJobObject, TerminateJobObject, -}; -#[cfg(windows)] -use windows_sys::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, DETACHED_PROCESS}; -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "linux")))] const TERMINATION_GRACE: Duration = Duration::from_millis(750); #[cfg(unix)] -static ACTIVE_PROCESS_GROUP: AtomicI32 = AtomicI32::new(0); +static ACTIVE_PROCESS_GROUPS: Mutex> = Mutex::new(BTreeSet::new()); #[cfg(unix)] static SIGNAL_FORWARDER: OnceLock<()> = OnceLock::new(); @@ -46,40 +32,38 @@ pub(crate) struct ManagedChild { #[cfg(unix)] process_group: i32, #[cfg(windows)] - job: HANDLE, + job: crate::windows_process::Job, reaped: bool, } /// Spawn a detached supervisor as its own process-group leader. The supervisor /// is responsible for recording completion and terminating its group. +#[cfg(not(windows))] pub(crate) fn spawn_detached(command: &mut Command) -> io::Result { - #[cfg(windows)] - command.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS); - #[cfg(not(windows))] configure_process_group(command); command.spawn() } impl ManagedChild { pub(crate) fn spawn(command: &mut Command) -> io::Result { - enable_subreaper()?; configure_process_group(command); install_signal_forwarder()?; + #[cfg(target_os = "linux")] + crate::linux_process::configure_supervisor(command)?; + // Serialize spawn/registration against signal forwarding. #[cfg(unix)] - ACTIVE_PROCESS_GROUP.store(-1, Ordering::SeqCst); - let child = match command.spawn() { - Ok(child) => child, - Err(error) => { - #[cfg(unix)] - ACTIVE_PROCESS_GROUP.store(0, Ordering::SeqCst); - return Err(error); - } - }; + let mut active = ACTIVE_PROCESS_GROUPS + .lock() + .unwrap_or_else(|error| error.into_inner()); + #[cfg(not(windows))] + let child = command.spawn()?; + #[cfg(windows)] + let (child, job) = crate::windows_process::Job::spawn(command)?; #[cfg(unix)] { let process_group = child.id() as i32; - ACTIVE_PROCESS_GROUP.store(process_group, Ordering::SeqCst); + active.insert(process_group); Ok(Self { child, process_group, @@ -87,27 +71,11 @@ impl ManagedChild { }) } - #[cfg(windows)] - { - let mut child = child; - let job = match create_kill_on_close_job(&child) { - Ok(job) => job, - Err(error) => { - let _ = child.kill(); - let _ = child.wait(); - return Err(error); - } - }; - Ok(Self { - child, - job, - reaped: false, - }) - } - - #[cfg(not(any(unix, windows)))] + #[cfg(not(unix))] Ok(Self { child, + #[cfg(windows)] + job, reaped: false, }) } @@ -132,28 +100,20 @@ impl ManagedChild { Ok(status) } - fn clean_remaining_descendants(&mut self) { - #[cfg(unix)] + fn clean_remaining_descendants(&self) { + #[cfg(all(unix, not(target_os = "linux")))] terminate_process_tree(self.process_group); #[cfg(windows)] - if !self.job.is_null() { - unsafe { - TerminateJobObject(self.job, 1); - CloseHandle(self.job); - } - self.job = std::ptr::null_mut(); - } + self.job.terminate(); } fn clear_active_group(&self) { #[cfg(unix)] { - let _ = ACTIVE_PROCESS_GROUP.compare_exchange( - self.process_group, - 0, - Ordering::SeqCst, - Ordering::SeqCst, - ); + ACTIVE_PROCESS_GROUPS + .lock() + .unwrap_or_else(|error| error.into_inner()) + .remove(&self.process_group); } } } @@ -164,7 +124,14 @@ impl Drop for ManagedChild { return; } - self.clean_remaining_descendants(); + #[cfg(target_os = "linux")] + unsafe { + libc::kill(self.process_group, SIGTERM); + } + #[cfg(all(unix, not(target_os = "linux")))] + terminate_process_tree(self.process_group); + #[cfg(windows)] + self.job.terminate(); #[cfg(not(any(unix, windows)))] let _ = self.child.kill(); @@ -180,48 +147,7 @@ fn configure_process_group(command: &mut Command) { } #[cfg(not(unix))] -fn configure_process_group(command: &mut Command) { - #[cfg(windows)] - command.creation_flags(CREATE_NEW_PROCESS_GROUP); - #[cfg(not(windows))] - let _ = command; -} - -#[cfg(windows)] -fn create_kill_on_close_job(child: &Child) -> io::Result { - let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; - if job.is_null() { - return Err(io::Error::last_os_error()); - } - - let mut information = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); - information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - let configured = unsafe { - SetInformationJobObject( - job, - JobObjectExtendedLimitInformation, - (&information as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), - std::mem::size_of::() as u32, - ) - }; - if configured == 0 { - let error = io::Error::last_os_error(); - unsafe { - CloseHandle(job); - } - return Err(error); - } - - let process = child.as_raw_handle() as HANDLE; - if unsafe { AssignProcessToJobObject(job, process) } == 0 { - let error = io::Error::last_os_error(); - unsafe { - CloseHandle(job); - } - return Err(error); - } - Ok(job) -} +fn configure_process_group(_command: &mut Command) {} #[cfg(unix)] fn install_signal_forwarder() -> io::Result<()> { @@ -229,21 +155,19 @@ fn install_signal_forwarder() -> io::Result<()> { return Ok(()); } - let mut signals = Signals::new([SIGINT, SIGTERM, SIGHUP, SIGQUIT, SIGCHLD])?; + let mut signals = Signals::new([SIGINT, SIGTERM, SIGHUP, SIGQUIT])?; SIGNAL_FORWARDER.get_or_init(|| { std::thread::spawn(move || { for signal in signals.forever() { - let process_group = ACTIVE_PROCESS_GROUP.load(Ordering::SeqCst); - if signal == SIGCHLD { - if process_group > 0 { - reap_adopted_children_except(process_group); - } - continue; - } - if process_group > 0 { - // Negative PIDs address the complete process group. - unsafe { - libc::kill(-process_group, signal); + let active = ACTIVE_PROCESS_GROUPS + .lock() + .unwrap_or_else(|error| error.into_inner()); + if !active.is_empty() { + for &process_group in active.iter() { + // Negative PIDs address the complete process group. + unsafe { + libc::kill(-process_group, signal); + } } } else { // Do not turn agentctl into a process that ignores Ctrl-C @@ -256,145 +180,179 @@ fn install_signal_forwarder() -> io::Result<()> { Ok(()) } -#[cfg(target_os = "linux")] -fn enable_subreaper() -> io::Result<()> { - let result = unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) }; - if result == 0 { - Ok(()) - } else { - Err(io::Error::last_os_error()) - } -} - -#[cfg(not(target_os = "linux"))] -fn enable_subreaper() -> io::Result<()> { - Ok(()) -} - #[cfg(not(unix))] fn install_signal_forwarder() -> io::Result<()> { Ok(()) } -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "linux")))] fn terminate_process_tree(process_group: i32) { - if process_group <= 0 { - return; - } - - unsafe { - libc::kill(-process_group, SIGTERM); - } - signal_adopted_descendants(SIGTERM); - let deadline = Instant::now() + TERMINATION_GRACE; - while Instant::now() < deadline { - reap_adopted_children(); - signal_adopted_descendants(SIGTERM); - if !process_tree_exists(process_group) { - return; + for signal in [SIGTERM, libc::SIGKILL] { + unsafe { + libc::kill(-process_group, signal); } - std::thread::sleep(Duration::from_millis(20)); - } - - unsafe { - libc::kill(-process_group, libc::SIGKILL); - } - signal_adopted_descendants(libc::SIGKILL); - let kill_deadline = Instant::now() + TERMINATION_GRACE; - while Instant::now() < kill_deadline { - reap_adopted_children(); - signal_adopted_descendants(libc::SIGKILL); - if !process_tree_exists(process_group) { - return; + let deadline = Instant::now() + TERMINATION_GRACE; + while Instant::now() < deadline { + if unsafe { libc::kill(-process_group, 0) } != 0 { + return; + } + std::thread::sleep(Duration::from_millis(20)); } - std::thread::sleep(Duration::from_millis(20)); } - reap_adopted_children(); } -#[cfg(unix)] -fn process_tree_exists(process_group: i32) -> bool { - process_group_exists(process_group) || adopted_children_exist() -} +#[cfg(test)] +mod tests { + #[cfg(target_os = "linux")] + use super::*; + #[cfg(target_os = "linux")] + use std::process::Stdio; -#[cfg(unix)] -fn process_group_exists(process_group: i32) -> bool { - let result = unsafe { libc::kill(-process_group, 0) }; - if result == 0 { - return true; + #[test] + #[cfg(target_os = "linux")] + fn capture_survives_spawning_thread_exit() { + let mut managed = std::thread::spawn(|| { + let mut command = Command::new("sleep"); + command.arg("0.1"); + ManagedChild::spawn(&mut command).unwrap() + }) + .join() + .unwrap(); + assert!(managed.wait().unwrap().success()); } - io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) -} -#[cfg(target_os = "linux")] -fn reap_adopted_children() { - loop { - let mut status = 0; - let result = unsafe { libc::waitpid(-1, &mut status, libc::WNOHANG) }; - if result <= 0 { - break; + #[test] + #[cfg(target_os = "linux")] + fn supervisor_reaps_escaped_children_without_environment_markers() { + for zombie in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let pid_file = temp.path().join("child.pid"); + let escaped = if zombie { + "import os,sys; os.setsid(); open(sys.argv[1], 'w').write(str(os.getpid()))" + } else { + "import os,sys,signal,time; os.setsid(); signal.signal(signal.SIGTERM, signal.SIG_IGN); open(sys.argv[1], 'w').write(str(os.getpid())); time.sleep(30)" + }; + let script = "import os,sys,time; pid=os.fork(); (os.execve(sys.executable,[sys.executable,'-c',sys.argv[2],sys.argv[1]],{}) if pid==0 else None); time.sleep(0.3)"; + let mut command = Command::new("python"); + command + .args(["-c", script]) + .arg(&pid_file) + .arg(escaped) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut managed = ManagedChild::spawn(&mut command).unwrap(); + assert!(managed.wait().unwrap().success()); + let pid: i32 = std::fs::read_to_string(pid_file).unwrap().parse().unwrap(); + let survived = unsafe { libc::kill(pid, 0) } == 0; + if survived { + unsafe { + libc::kill(pid, libc::SIGKILL); + } + } + assert!(!survived, "escaped child remained (zombie={zombie})"); } } -} -#[cfg(target_os = "linux")] -fn reap_adopted_children_except(excluded_pid: i32) { - for pid in linux_child_pids(std::process::id() as i32) { - if pid == excluded_pid { - continue; + #[test] + #[cfg(target_os = "linux")] + fn supervisor_preserves_exec_errors_and_host_subreaper_setting() { + let mut before: libc::c_int = 0; + unsafe { + assert_eq!(libc::prctl(libc::PR_GET_CHILD_SUBREAPER, &mut before), 0); } - let mut status = 0; + let error = ManagedChild::spawn(&mut Command::new("/agentctl-nonexistent-executable")); + assert!(matches!(error, Err(ref error) if error.kind() == io::ErrorKind::NotFound)); + let mut after: libc::c_int = 0; unsafe { - libc::waitpid(pid, &mut status, libc::WNOHANG); + assert_eq!(libc::prctl(libc::PR_GET_CHILD_SUBREAPER, &mut after), 0); } + assert_eq!(before, after, "library changed the host subreaper setting"); } -} -#[cfg(all(unix, not(target_os = "linux")))] -fn reap_adopted_children() {} + #[test] + #[cfg(windows)] + fn windows_job_terminates_descendants_after_leader_exits() { + use super::*; + let temp = tempfile::tempdir().unwrap(); + let pid_path = temp.path().join("descendant.pid"); + let mut command = Command::new("python"); + command.args(["-c", "import subprocess,sys; p=subprocess.Popen([sys.executable,'-c','import time; time.sleep(30)']); open(sys.argv[1],'w').write(str(p.pid))"]) + .arg(&pid_path); + let mut child = ManagedChild::spawn(&mut command).unwrap(); + assert!(child.wait().unwrap().success()); + let pid: u32 = std::fs::read_to_string(pid_path).unwrap().parse().unwrap(); + use windows_sys::Win32::Foundation::{CloseHandle, WAIT_OBJECT_0}; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject, + }; + unsafe { + let handle = OpenProcess(PROCESS_SYNCHRONIZE, 0, pid); + if !handle.is_null() { + let state = WaitForSingleObject(handle, 5000); + CloseHandle(handle); + assert_eq!(state, WAIT_OBJECT_0, "descendant survived its job"); + } + } + } -#[cfg(all(unix, not(target_os = "linux")))] -fn reap_adopted_children_except(_excluded_pid: i32) {} + #[test] + #[cfg(target_os = "linux")] + fn cleanup_preserves_unrelated_children_and_other_captures() { + let mut unrelated = Command::new("sleep") + .arg("30") + .spawn() + .expect("unrelated child"); + let mut long_command = Command::new("sleep"); + long_command.arg("30"); + let mut other = ManagedChild::spawn(&mut long_command).expect("other capture"); + let mut short = ManagedChild::spawn(&mut Command::new("true")).expect("short capture"); + assert!(short.wait().expect("wait short").success()); + let unrelated_alive = unrelated.try_wait().expect("poll unrelated").is_none(); + let other_alive = other.child.try_wait().expect("poll other").is_none(); + let _ = unrelated.kill(); + let _ = unrelated.wait(); + drop(other); + assert!( + unrelated_alive, + "capture cleanup killed an unrelated host child" + ); + assert!(other_alive, "capture cleanup killed a concurrent capture"); + } -#[cfg(target_os = "linux")] -fn adopted_children_exist() -> bool { - !linux_child_pids(std::process::id() as i32).is_empty() -} + #[test] + #[cfg(target_os = "linux")] + fn wait_terminates_descendants_left_by_the_direct_child() { + let temp = tempfile::tempdir().expect("tempdir"); + let pid_path = temp.path().join("descendant.pid"); + let script = format!( + "setsid sleep 30 & printf '%s' $! > '{}'; exit 0", + pid_path.display() + ); + let mut command = Command::new("bash"); + command + .args(["-c", &script]) + .stdout(Stdio::null()) + .stderr(Stdio::null()); -#[cfg(all(unix, not(target_os = "linux")))] -fn adopted_children_exist() -> bool { - false -} + let mut child = ManagedChild::spawn(&mut command).expect("spawn managed child"); + let status = child.wait().expect("wait managed child"); + assert!(status.success()); -#[cfg(target_os = "linux")] -fn signal_adopted_descendants(signal: i32) { - let mut pending = linux_child_pids(std::process::id() as i32); - while let Some(pid) = pending.pop() { - pending.extend(linux_child_pids(pid)); - unsafe { - libc::kill(pid, signal); - } + let pid = std::fs::read_to_string(pid_path) + .expect("descendant pid") + .parse::() + .expect("numeric pid"); + let running = unsafe { libc::kill(pid, 0) } == 0; + assert!(!running, "descendant {pid} survived its agent command"); } } -#[cfg(all(unix, not(target_os = "linux")))] -fn signal_adopted_descendants(_signal: i32) {} - -#[cfg(target_os = "linux")] -fn linux_child_pids(pid: i32) -> Vec { - let path = format!("/proc/{pid}/task/{pid}/children"); - std::fs::read_to_string(path) - .unwrap_or_default() - .split_whitespace() - .filter_map(|value| value.parse().ok()) - .collect() -} - -#[cfg(all(test, unix))] -mod tests { +#[cfg(all(test, target_os = "linux"))] +mod upstream_tests { use super::*; use std::process::Stdio; use std::sync::Mutex; + use std::time::{Duration, Instant}; static PROCESS_TEST_LOCK: Mutex<()> = Mutex::new(()); diff --git a/src/provider.rs b/src/provider.rs new file mode 100644 index 0000000..d89a113 --- /dev/null +++ b/src/provider.rs @@ -0,0 +1,231 @@ +use std::io::Read; +use std::process::{Command, Stdio}; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow, bail}; +use serde::{Deserialize, Serialize}; + +use crate::config::{ProviderConfig, ProviderKind}; + +const MAX_ERROR_BODY_BYTES: u64 = 8_192; + +#[derive(Debug, Serialize)] +struct ChatRequest<'a> { + model: &'a str, + messages: [ChatMessage<'a>; 1], + stream: bool, +} + +#[derive(Debug, Serialize)] +struct ChatMessage<'a> { + role: &'static str, + content: &'a str, +} + +#[derive(Debug, Deserialize)] +struct ChatResponse { + choices: Vec, +} + +#[derive(Debug, Deserialize)] +struct ChatChoice { + message: AssistantMessage, +} + +#[derive(Debug, Deserialize)] +struct AssistantMessage { + content: Option, +} + +pub fn complete(config: &ProviderConfig, model: Option<&str>, prompt: &str) -> Result { + if prompt.trim().is_empty() { + bail!("provider prompt cannot be empty"); + } + + let api_key = resolve_api_key(config)?; + let model = model.unwrap_or(&config.model); + match config.kind { + ProviderKind::Openrouter => complete_openrouter(config, model, prompt, &api_key), + } +} + +fn resolve_api_key(config: &ProviderConfig) -> Result { + let key = match (&config.api_key_env, &config.api_key_command) { + (Some(name), None) => std::env::var(name).with_context(|| { + format!("reading provider API key from environment variable {name}") + })?, + (None, Some(argv)) => { + let (program, args) = argv + .split_first() + .ok_or_else(|| anyhow!("provider API key command is empty"))?; + let output = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .with_context(|| format!("running provider API key command `{program}`"))?; + if !output.status.success() { + bail!( + "provider API key command `{program}` exited with {}", + output.status.code().unwrap_or(1) + ); + } + String::from_utf8(output.stdout) + .context("provider API key command returned non-UTF-8 output")? + } + _ => bail!("provider API key source is invalid"), + }; + let key = key.trim().to_owned(); + if key.is_empty() { + bail!("provider API key source returned an empty value"); + } + Ok(key) +} + +fn complete_openrouter( + config: &ProviderConfig, + model: &str, + prompt: &str, + api_key: &str, +) -> Result { + let url = format!("{}/chat/completions", config.base_url.trim_end_matches('/')); + let request = ChatRequest { + model, + messages: [ChatMessage { + role: "user", + content: prompt, + }], + stream: false, + }; + let agent = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(120)) + .build(); + let response = agent + .post(&url) + .set("Authorization", &format!("Bearer {api_key}")) + .set("Content-Type", "application/json") + .set("HTTP-Referer", "https://github.com/hydra-dynamix/agentctl") + .set("X-Title", "agentctl") + .send_json(serde_json::to_value(request).context("serializing OpenRouter request")?); + + let response = match response { + Ok(response) => response, + Err(ureq::Error::Status(status, response)) => { + let mut body = String::new(); + response + .into_reader() + .take(MAX_ERROR_BODY_BYTES) + .read_to_string(&mut body) + .context("reading OpenRouter error response")?; + let detail = openrouter_error_message(&body); + bail!("OpenRouter returned HTTP {status}: {detail}"); + } + Err(ureq::Error::Transport(error)) => { + return Err(anyhow!(error)).context("sending request to OpenRouter"); + } + }; + + let response: ChatResponse = response + .into_json() + .context("parsing OpenRouter response")?; + response + .choices + .into_iter() + .next() + .and_then(|choice| choice.message.content) + .filter(|content| !content.is_empty()) + .ok_or_else(|| anyhow!("OpenRouter response did not contain assistant text")) +} + +fn openrouter_error_message(body: &str) -> String { + serde_json::from_str::(body) + .ok() + .and_then(|value| value.pointer("/error/message")?.as_str().map(str::to_owned)) + .unwrap_or_else(|| { + let compact = body.split_whitespace().collect::>().join(" "); + if compact.is_empty() { + "empty response body".to_owned() + } else { + compact + } + }) +} + +#[cfg(test)] +mod tests { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::thread; + + use super::*; + + #[test] + fn openrouter_completion_sends_auth_model_and_prompt() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server"); + let address = listener.local_addr().expect("mock address"); + let server = thread::spawn(move || { + let (mut socket, _) = listener.accept().expect("accept request"); + let mut request = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + let read = socket.read(&mut buffer).expect("read request"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + let text = String::from_utf8_lossy(&request); + let content_length = text + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .and_then(|value| value.trim().parse::().ok()) + }) + .unwrap_or(0); + let header_end = request + .windows(4) + .position(|window| window == b"\r\n\r\n") + .expect("header end") + + 4; + if request.len() >= header_end + content_length { + break; + } + } + } + let body = br#"{"choices":[{"message":{"content":"mock answer"}}]}"#; + write!( + socket, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .expect("write response headers"); + socket.write_all(body).expect("write response body"); + String::from_utf8(request).expect("request UTF-8") + }); + + let config = ProviderConfig { + kind: ProviderKind::Openrouter, + base_url: format!("http://{address}"), + api_key_env: None, + api_key_command: None, + model: "default-model".to_owned(), + }; + let answer = complete_openrouter(&config, "test/model", "hello", "test-key") + .expect("OpenRouter completion"); + let request = server.join().expect("mock server"); + + assert_eq!(answer, "mock answer"); + assert!(request.contains("Authorization: Bearer test-key")); + assert!(request.contains("\"model\":\"test/model\"")); + assert!(request.contains("\"content\":\"hello\"")); + } + + #[test] + fn extracts_structured_openrouter_error() { + assert_eq!( + openrouter_error_message(r#"{"error":{"message":"bad model"}}"#), + "bad model" + ); + } +} diff --git a/src/summary.rs b/src/summary.rs index 6da64b2..a7e4697 100644 --- a/src/summary.rs +++ b/src/summary.rs @@ -1,12 +1,10 @@ -use std::io::Write; use std::path::PathBuf; -use std::process::{Command as ProcessCommand, Stdio}; -use anyhow::{Context, Result, bail}; +use anyhow::{Result, bail}; use serde::Serialize; use crate::config::SummaryConfig; -use crate::exec::{CapturedRun, format_command}; +use crate::exec::{CaptureOptions, CapturedRun, ProcessOptions, format_command, run_captured_with}; use crate::output::{bounded_output, combine_output}; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -67,38 +65,28 @@ pub fn summarize_with_command( bounded ); - let mut child = ProcessCommand::new(&summarizer_argv[0]) - .args(&summarizer_argv[1..]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .with_context(|| { - format!( - "starting summary command `{}`", - format_command(summarizer_argv) - ) - })?; - - if let Some(stdin) = child.stdin.as_mut() { - stdin - .write_all(prompt.as_bytes()) - .context("writing prompt to summary command")?; - } - - let output = child - .wait_with_output() - .context("waiting for summary command")?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); + let output = run_captured_with( + summarizer_argv, + &CaptureOptions { + max_output_bytes: config.max_output_bytes, + tail_bytes: config.tail_bytes, + label: Some("summarizer".to_owned()), + stream_raw: false, + }, + &ProcessOptions { + stdin: Some(prompt.into_bytes()), + ..ProcessOptions::default() + }, + )?; + if !output.exit_status.success() { bail!( "summary command exited with {}: {}", - output.status.code().unwrap_or(1), - stderr.trim() + output.exit_status.code().unwrap_or(1), + output.stderr.trim() ); } - let summary_text = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + let summary_text = output.stdout.trim(); if summary_text.is_empty() { return Ok(summarize(run, config)); } @@ -107,7 +95,11 @@ pub fn summarize_with_command( command: format_command(&run.command), exit_code: exit_code(run), duration_ms: run.elapsed.as_millis(), - preview_lines: summary_text.lines().map(str::to_owned).collect(), + preview_lines: summary_text + .lines() + .take(config.max_preview_lines) + .map(str::to_owned) + .collect(), raw_log_path: run.raw_log_path.clone(), }) } diff --git a/src/windows_process.rs b/src/windows_process.rs new file mode 100644 index 0000000..07df53b --- /dev/null +++ b/src/windows_process.rs @@ -0,0 +1,186 @@ +use std::ffi::OsStr; +use std::fs::{File, OpenOptions}; +use std::io; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::io::AsRawHandle; +use std::path::Path; +use windows_sys::Win32::Foundation::CloseHandle; +use windows_sys::Win32::System::Console::{STD_ERROR_HANDLE, STD_OUTPUT_HANDLE, SetStdHandle}; +use windows_sys::Win32::System::Threading::{ + CREATE_NO_WINDOW, CreateProcessW, PROCESS_INFORMATION, STARTUPINFOW, +}; + +fn wide(value: &OsStr) -> io::Result> { + let mut value: Vec = value.encode_wide().collect(); + if value.contains(&0) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "NUL in process path", + )); + } + value.push(0); + Ok(value) +} + +/// Launch without inheriting any caller handles. Rust's ordinary spawn can +/// inherit the caller's original stdout pipe even when child stdout is replaced, +/// making a detached launch appear to block until the supervisor exits. +pub(crate) fn spawn_supervisor( + executable: &Path, + spec: &Path, + cwd: &Path, + log: &Path, +) -> io::Result { + // All arguments are file paths ending in a filename, so quoted arguments + // cannot end in a backslash. Quotes are invalid in Windows file names. + let mut command = std::ffi::OsString::from("\""); + command.push(executable); + command.push("\" __supervise \""); + command.push(spec); + command.push("\" --log \""); + command.push(log); + command.push("\""); + let mut command = wide(&command)?; + let executable = wide(executable.as_os_str())?; + let cwd = wide(cwd.as_os_str())?; + let startup = STARTUPINFOW { + cb: std::mem::size_of::() as u32, + ..Default::default() + }; + let mut process = PROCESS_INFORMATION::default(); + unsafe { + if CreateProcessW( + executable.as_ptr(), + command.as_mut_ptr(), + std::ptr::null(), + std::ptr::null(), + 0, + CREATE_NO_WINDOW, + std::ptr::null(), + cwd.as_ptr(), + &startup, + &mut process, + ) == 0 + { + return Err(io::Error::last_os_error()); + } + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + } + Ok(process.dwProcessId) +} + +/// Initialize a newly launched supervisor's output before it writes anything. +/// The caller must retain the returned file while using stdout/stderr. +#[doc(hidden)] +pub fn redirect_log(path: &Path) -> io::Result { + let file = OpenOptions::new().append(true).open(path)?; + unsafe { + if SetStdHandle(STD_OUTPUT_HANDLE, file.as_raw_handle()) == 0 + || SetStdHandle(STD_ERROR_HANDLE, file.as_raw_handle()) == 0 + { + return Err(io::Error::last_os_error()); + } + } + Ok(file) +} + +use std::os::windows::io::{FromRawHandle, OwnedHandle}; +use std::os::windows::process::CommandExt; +use std::process::{Child, Command}; +use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; +use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next, +}; +use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, TerminateJobObject, +}; +use windows_sys::Win32::System::Threading::{ + CREATE_SUSPENDED, OpenThread, ResumeThread, THREAD_SUSPEND_RESUME, +}; + +pub(crate) struct Job(OwnedHandle); + +impl Job { + pub(crate) fn spawn(command: &mut Command) -> io::Result<(Child, Self)> { + let job = unsafe { + let handle = CreateJobObjectW(std::ptr::null(), std::ptr::null()); + if handle.is_null() { + return Err(io::Error::last_os_error()); + } + Self(OwnedHandle::from_raw_handle(handle)) + }; + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + unsafe { + if SetInformationJobObject( + job.0.as_raw_handle(), + JobObjectExtendedLimitInformation, + &limits as *const _ as *const _, + std::mem::size_of_val(&limits) as u32, + ) == 0 + { + return Err(io::Error::last_os_error()); + } + } + // Assign before the first instruction, so even a fast child cannot + // spawn descendants outside the job between spawn and assignment. + command.creation_flags(CREATE_SUSPENDED | CREATE_NO_WINDOW); + let mut child = command.spawn()?; + let result = (|| { + unsafe { + if AssignProcessToJobObject(job.0.as_raw_handle(), child.as_raw_handle()) == 0 { + return Err(io::Error::last_os_error()); + } + } + resume_primary_thread(child.id()) + })(); + if let Err(error) = result { + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + } + Ok((child, job)) + } + + pub(crate) fn terminate(&self) { + unsafe { + TerminateJobObject(self.0.as_raw_handle(), 1); + } + } +} + +fn resume_primary_thread(pid: u32) -> io::Result<()> { + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0); + if snapshot == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + let snapshot = OwnedHandle::from_raw_handle(snapshot); + let mut entry = THREADENTRY32 { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let mut found = Thread32First(snapshot.as_raw_handle(), &mut entry); + while found != 0 { + if entry.th32OwnerProcessID == pid { + let thread = OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID); + if thread.is_null() { + return Err(io::Error::last_os_error()); + } + let thread = OwnedHandle::from_raw_handle(thread); + if ResumeThread(thread.as_raw_handle()) == u32::MAX { + return Err(io::Error::last_os_error()); + } + return Ok(()); + } + found = Thread32Next(snapshot.as_raw_handle(), &mut entry); + } + } + Err(io::Error::new( + io::ErrorKind::NotFound, + "suspended child thread not found", + )) +} diff --git a/tests/agent_jobs_cli.rs b/tests/agent_jobs_cli.rs index cf73951..a29d3c6 100644 --- a/tests/agent_jobs_cli.rs +++ b/tests/agent_jobs_cli.rs @@ -18,11 +18,11 @@ tail_bytes = 4096 max_preview_lines = 12 [agents.trivial] -command = ["bash", "-c", "echo done"] +command = ["python", "-c", "print('done')"] prompt_stdin = false [agents.stdin_loop] -command = ["bash", "-c", "prompt=$(cat); echo prompt=$prompt; echo iteration=$AGENTCTL_AGENT_ITERATION"] +command = ["python", "-c", "import os, sys; prompt=sys.stdin.read(); print('prompt='+prompt); print('iteration='+os.environ.get('AGENTCTL_AGENT_ITERATION',''))"] prompt_stdin = true "# } @@ -54,7 +54,7 @@ tail_bytes = 4096 max_preview_lines = 12 [agents.codex] -command = ["bash", "-c", "prompt=$(cat); echo default-codex=$prompt"] +command = ["python", "-c", "import sys; print('default-codex='+sys.stdin.read())"] prompt_stdin = true "# } @@ -146,6 +146,107 @@ fn foreground_agent_reports_each_iteration_as_summary_json() { ); } +#[test] +fn native_openrouter_provider_runs_through_agent_profile() { + use std::io::{Read, Write}; + use std::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock OpenRouter"); + let address = listener.local_addr().expect("mock address"); + let server = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().expect("accept OpenRouter request"); + let mut request = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + let read = socket.read(&mut buffer).expect("read request"); + request.extend_from_slice(&buffer[..read]); + let Some(header_end) = request + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|position| position + 4) + else { + continue; + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .and_then(|value| value.trim().parse::().ok()) + }) + .unwrap_or(0); + if request.len() >= header_end + content_length { + break; + } + } + let body = br#"{"choices":[{"message":{"content":"provider-profile-ok"}}]}"#; + write!( + socket, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .expect("write response headers"); + socket.write_all(body).expect("write response body"); + String::from_utf8(request).expect("request UTF-8") + }); + + let home_dir = tempfile::tempdir().expect("home tempdir"); + let project_dir = tempfile::tempdir().expect("project tempdir"); + std::fs::write( + project_dir.path().join("agentctl.toml"), + format!( + r#" +[summary] +max_output_bytes = 16384 +tail_bytes = 4096 +max_preview_lines = 12 + +[providers.openrouter] +kind = "openrouter" +base_url = "http://{address}" +api_key_env = "TEST_OPENROUTER_API_KEY" +model = "test/model" + +[agents.openrouter-api] +provider = "openrouter" +prompt_stdin = true +"# + ), + ) + .expect("write provider config"); + + let output = Command::cargo_bin("agentctl") + .expect("agentctl binary") + .current_dir(project_dir.path()) + .env("HOME", home_dir.path()) + .env("TEST_OPENROUTER_API_KEY", "test-key") + .args([ + "run", + "openrouter-api", + "--prompt", + "hello provider", + "--json", + ]) + .output() + .expect("run native provider"); + + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let json: Value = serde_json::from_slice(&output.stdout).expect("run JSON"); + assert!( + json[0]["summary"]["preview_lines"] + .to_string() + .contains("provider-profile-ok") + ); + let request = server.join().expect("mock server"); + assert!(request.contains("Authorization: Bearer test-key")); + assert!(request.contains("\"content\":\"hello provider\"")); +} + #[test] fn detached_trivial_agent_round_trips_run_status_logs() { let home_dir = tempfile::tempdir().expect("home tempdir"); @@ -216,7 +317,7 @@ fn detached_trivial_agent_round_trips_run_status_logs() { } #[test] -#[cfg(unix)] +#[cfg(target_os = "linux")] fn detached_supervisor_reaps_descendants_that_create_a_new_session() { let home_dir = tempfile::tempdir().expect("home tempdir"); let project_dir = tempfile::tempdir().expect("project tempdir"); @@ -318,10 +419,10 @@ agent = "trivial" pid = 0 started_at = 1 command = "bash -c true" -cwd = "{}" +cwd = '{}' iterations = 1 -log_path = "{}" -exit_code_path = "{}" +log_path = '{}' +exit_code_path = '{}' "#, toml_path(project_dir.path()), toml_path(&log_path), @@ -386,7 +487,7 @@ fn foreground_agent_accepts_prompt_file_and_cwd_override() { } #[test] -#[cfg(unix)] +#[cfg(target_os = "linux")] fn terminating_foreground_agentctl_terminates_and_reaps_the_agent_tree() { use std::os::unix::process::ExitStatusExt; @@ -536,3 +637,117 @@ fn assert_key_set(mut actual: Vec<&str>, expected: &[&str]) { expected.sort_unstable(); assert_eq!(actual, expected); } + +#[test] +fn failed_foreground_run_preserves_reports_and_exit_status() { + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + std::fs::write(project.path().join("agentctl.toml"), r#" +[agents.fail] +command = ["python", "-c", "import os,sys; i=int(os.environ['AGENTCTL_AGENT_ITERATION']); print('error: deliberate failure' if i==2 else 'ok'); sys.exit(7 if i==2 else 0)"] +prompt_stdin = false +"#).unwrap(); + let output = Command::cargo_bin("agentctl") + .unwrap() + .current_dir(project.path()) + .env("HOME", home.path()) + .args([ + "run", + "fail", + "--prompt", + "test", + "--iterations", + "3", + "--json", + ]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(7)); + let reports: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(reports.as_array().unwrap().len(), 2); + assert_eq!(reports[1]["summary"]["exit_code"], 7); + assert!( + reports[1]["summary"]["preview_lines"] + .to_string() + .contains("deliberate failure") + ); + assert!(std::path::Path::new(reports[1]["summary"]["raw_log_path"].as_str().unwrap()).exists()); +} + +#[test] +fn detached_live_job_reports_running_until_completion() { + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + std::fs::write( + project.path().join("agentctl.toml"), + r#" +[agents.slow] +command = ["python", "-c", "import time; time.sleep(2); print('done')"] +prompt_stdin = false +"#, + ) + .unwrap(); + let output = Command::cargo_bin("agentctl") + .unwrap() + .current_dir(project.path()) + .env("HOME", home.path()) + .args(["run", "slow", "--prompt", "test", "--detach", "--json"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let record: Value = serde_json::from_slice(&output.stdout).unwrap(); + let initial_state = record["state"].clone(); + let id = record["id"].as_str().unwrap(); + let output = Command::cargo_bin("agentctl") + .unwrap() + .current_dir(project.path()) + .env("HOME", home.path()) + .args(["status", id, "--json"]) + .output() + .unwrap(); + let status: Value = serde_json::from_slice(&output.stdout).unwrap(); + // Wait for our worker before deleting its temporary home. + std::thread::sleep(std::time::Duration::from_secs(3)); + assert_eq!(initial_state, "running"); + assert_eq!(status["record"]["state"], "running"); +} + +#[test] +#[cfg(target_os = "linux")] +fn killed_host_still_cleans_escaped_descendants() { + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + let pid_file = project.path().join("escaped.pid"); + let mut host = std::process::Command::new(assert_cmd::cargo::cargo_bin!("agentctl")) + .current_dir(project.path()).env("HOME", home.path()) + .args(["exec", "--", "python", "-c", "import os,sys,time; os.setsid(); open(sys.argv[1],'w').write(str(os.getpid())); time.sleep(30)"]) + .arg(&pid_file).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()) + .spawn().unwrap(); + for _ in 0..200 { + if pid_file.exists() && std::fs::metadata(&pid_file).unwrap().len() > 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let pid: i32 = std::fs::read_to_string(&pid_file).unwrap().parse().unwrap(); + host.kill().unwrap(); + host.wait().unwrap(); + let mut survived = true; + for _ in 0..300 { + if unsafe { libc::kill(pid, 0) } != 0 { + survived = false; + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + if survived { + unsafe { + libc::kill(pid, libc::SIGKILL); + } + } + assert!(!survived, "worker survived the host's SIGKILL"); +} diff --git a/tests/config_cli.rs b/tests/config_cli.rs index 82868f0..fa98cdf 100644 --- a/tests/config_cli.rs +++ b/tests/config_cli.rs @@ -42,6 +42,9 @@ fn config_generate_and_check_round_trips_through_cli() { "[agents.openai-rest]", "[agents.openai-websocket]", "[agents.ollama]", + "[agents.openrouter]", + "[agents.openrouter-api]", + "[providers.openrouter]", ] { assert!( show.contains(profile), @@ -65,8 +68,8 @@ fn config_check_uses_builtin_defaults_when_no_file_exists() { assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); - assert!(stdout.contains(".agentctl")); - assert!(stdout.contains("config.toml")); + let normalized = stdout.replace('\\', "/"); + assert!(normalized.contains(".agentctl/config.toml")); } #[test] @@ -124,3 +127,27 @@ fn list_json_schema_is_empty_array_when_no_jobs_exist() { let json: Value = serde_json::from_slice(&output.stdout).expect("list json"); assert_eq!(json.as_array().expect("array").len(), 0); } + +#[test] +#[cfg(windows)] +fn windows_uses_userprofile_when_home_is_missing() { + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + Command::cargo_bin("agentctl") + .unwrap() + .current_dir(project.path()) + .env_remove("HOME") + .env("USERPROFILE", home.path()) + .args(["config", "generate"]) + .assert() + .success(); + assert!(home.path().join(".agentctl/config.toml").exists()); + Command::cargo_bin("agentctl") + .unwrap() + .current_dir(project.path()) + .env_remove("HOME") + .env("USERPROFILE", home.path()) + .args(["exec", "--json", "--", "python", "-c", "print('ok')"]) + .assert() + .success(); +} diff --git a/tests/exec_cli.rs b/tests/exec_cli.rs index c8b2c26..4ae86d3 100644 --- a/tests/exec_cli.rs +++ b/tests/exec_cli.rs @@ -150,3 +150,39 @@ fn assert_key_set(mut actual: Vec<&str>, expected: &[&str]) { expected.sort_unstable(); assert_eq!(actual, expected); } + +#[test] +fn external_summarizer_respects_output_limits() { + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + std::fs::write(project.path().join("agentctl.toml"), r#" +[summary] +max_output_bytes = 256 +tail_bytes = 64 +max_preview_lines = 2 +summarizer_command = ["python", "-c", "import sys; sys.stdin.read(); print('summary-line\\n'*100000)"] +"#).unwrap(); + let output = Command::cargo_bin("agentctl") + .unwrap() + .current_dir(project.path()) + .env("HOME", home.path()) + .args(["exec", "--json", "--", "python", "-c", "print('hello')"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let summary: Value = serde_json::from_slice(&output.stdout).unwrap(); + let lines = summary["preview_lines"].as_array().unwrap(); + assert_eq!(lines[0], "summary-line"); + assert!(lines.len() <= 2); + assert!( + lines + .iter() + .map(|line| line.as_str().unwrap().len()) + .sum::() + <= 256 + ); +}