From 711c9ef8d6a159329a8bfe6b28639790f675742a Mon Sep 17 00:00:00 2001 From: bakobiibizo Date: Sat, 10 Oct 2026 10:50:07 -0700 Subject: [PATCH 1/5] Add native provider support and harden cross-platform job execution --- .dev/config.toml | 27 ++ .github/workflows/ci.yml | 8 +- Cargo.lock | 578 ++++++++++++++++++++++++++++++++++++++- Cargo.toml | 6 +- README.md | 92 ++++++- docs/audit-2026-10-10.md | 46 ++++ docs/spec.md | 25 +- src/config.rs | 161 ++++++++--- src/exec.rs | 8 +- src/jobs.rs | 163 ++++++++--- src/lib.rs | 3 + src/main.rs | 174 +++++++++++- src/process.rs | 260 ++++++++++++------ src/provider.rs | 231 ++++++++++++++++ src/summary.rs | 56 ++-- src/windows_process.rs | 186 +++++++++++++ tests/agent_jobs_cli.rs | 191 ++++++++++++- tests/config_cli.rs | 32 ++- tests/exec_cli.rs | 44 ++- 19 files changed, 2054 insertions(+), 237 deletions(-) create mode 100644 .dev/config.toml create mode 100644 docs/audit-2026-10-10.md create mode 100644 src/provider.rs create mode 100644 src/windows_process.rs 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 f8d0504..9abfeaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,10 +6,16 @@ on: jobs: verify: - runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} 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/Cargo.lock b/Cargo.lock index 705fe39..b870653 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.1" +version = "0.1.3" dependencies = [ "anyhow", "assert_cmd", @@ -15,6 +21,8 @@ dependencies = [ "signal-hook", "tempfile", "toml", + "ureq", + "windows-sys 0.61.2", ] [[package]] @@ -53,7 +61,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]] @@ -64,7 +72,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -88,6 +96,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" @@ -105,6 +119,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" @@ -142,7 +166,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -157,12 +181,32 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + [[package]] name = "difflib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[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" @@ -176,7 +220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -185,12 +229,48 @@ 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 = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -225,12 +305,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" @@ -273,6 +457,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" @@ -285,6 +475,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" @@ -297,6 +497,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" @@ -331,7 +546,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -364,6 +579,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" @@ -374,7 +603,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]] @@ -410,7 +674,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -435,6 +699,12 @@ dependencies = [ "serde", ] +[[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" @@ -455,12 +725,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" @@ -472,6 +766,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" @@ -479,10 +795,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -491,6 +807,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" @@ -544,6 +870,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" @@ -559,6 +927,12 @@ dependencies = [ "libc", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" @@ -611,12 +985,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" @@ -626,6 +1027,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" @@ -671,7 +1136,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -687,7 +1152,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -729,6 +1194,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 bd424f0..4eb4002 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agentctl" -version = "0.1.1" +version = "0.1.3" edition = "2024" license = "MIT" description = "Compact, bounded command and agent reports for LLM tool loops" @@ -16,11 +16,15 @@ clap = { version = "4.5.40", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" 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", 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" tempfile = "3.20.0" diff --git a/README.md b/README.md index 36b7cee..1b71668 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. ## Wrap a noisy command @@ -52,12 +53,18 @@ 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 reaps orphaned descendants so repeated tool loops do not -accumulate zombie or live background agent processes. +accumulate zombie or live background agent processes. Cleanup is scoped to each +capture, preserving unrelated child processes and concurrent captures in library +hosts. Linux descendants that leave their process group retain an inherited +ownership marker for cleanup; 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 @@ -70,7 +77,7 @@ tail_bytes = 4096 max_preview_lines = 12 [agents.codex] -command = ["codex", "exec", "--sandbox", "workspace-write"] +command = ["codex", "exec", "--sandbox", "danger-full-access"] prompt_stdin = true [agents.claude-code] @@ -95,8 +102,36 @@ prompt_stdin = true [agents.ollama] 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 +``` + +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 @@ -110,7 +145,19 @@ agentctl run codex \ printf 'Run cargo test and summarize the result.' | agentctl run --json ``` -That returns a job record: +Codex profiles now default to `--sandbox danger-full-access`, allowing Git staging, +commits, and branch creation. To opt into the restricted workspace sandbox, use +`agentctl run --sandbox --prompt "Inspect the project"`. This also works with +`--detach` and `--iterations`; `.git` remains read-only in that mode. The flag +requires a profile whose command starts with `codex exec` (including an absolute +path to Codex). Other runtimes retain their own permission settings. + +Existing config files retain their explicit commands. To migrate an older Codex +profile, change its `workspace-write` argument to `danger-full-access` in +`agentctl.toml` or `~/.agentctl/config.toml`. Workers still inherit any restrictions +imposed by the process that launches agentctl. + +The detached command returns a job record: ```json { @@ -121,7 +168,7 @@ That returns a job record: "started_at": 1780000000, "finished_at": null, "exit_code": null, - "command": "codex exec --sandbox workspace-write", + "command": "codex exec --sandbox danger-full-access", "cwd": "/path/to/project", "iterations": 1, "log_path": "~/.agentctl/jobs/1780000000-1234-codex/output.log" @@ -138,9 +185,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 @@ -170,6 +220,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 @@ -181,7 +234,7 @@ Detached `run --json` and `list --json` use job records: "started_at": 1780000000, "finished_at": null, "exit_code": null, - "command": "codex exec --sandbox workspace-write", + "command": "codex exec --sandbox danger-full-access", "cwd": "/path/to/project", "iterations": 1, "log_path": "/path/to/output.log" @@ -206,10 +259,21 @@ 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 covers Windows and Linux. + +## 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..1a96fe7 --- /dev/null +++ b/docs/audit-2026-10-10.md @@ -0,0 +1,46 @@ +# 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 + +Provider tests use a local server; live OpenRouter credentials, billing, and remote +model availability were not exercised. Hosted GitHub Actions has not run locally. +macOS was not tested. Linux cleanup remains cooperative lifecycle management: +a descendant that both abandons its process group and removes its ownership +environment marker cannot reliably be attributed to its originating capture. +The same attribution limit applies to escaped descendants that exit before +cleanup can observe their environment; their zombie records lack that marker. +This process manager is not a containment boundary for hostile commands. diff --git a/docs/spec.md b/docs/spec.md index 2c603e2..efefb8e 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -43,9 +43,11 @@ agentctl exec [--json] [--raw] [--label ] -- Raw capture stored under the job directory either way. agentctl run [--prompt | --prompt-file ] - [--detach] [--iterations ] [--cwd ] [--json] + [--detach] [--sandbox] [--iterations ] [--cwd ] [--json] Launch a configured agent. Foreground: summarized report per iteration. --detach: returns a job id immediately. + --sandbox: opt into workspace-write for a codex exec profile; Git metadata + remains read-only. Codex defaults to danger-full-access without this flag. agentctl status [--tail ] [--json] agentctl list [--json] @@ -69,15 +71,33 @@ max_preview_lines = 12 # notable-line preview length # summarizer_command = ["claude", "-p", "Summarize this build output"] [agents.codex] -command = ["codex", "exec", "--sandbox", "workspace-write"] +command = ["codex", "exec", "--sandbox", "danger-full-access"] prompt_stdin = true # cwd = "/path/to/project" [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`). @@ -135,5 +155,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 19a6a13..cd423da 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,8 @@ pub struct Config { #[serde(default)] pub summary: SummaryConfig, #[serde(default)] + pub providers: BTreeMap, + #[serde(default)] pub agents: BTreeMap, } @@ -28,10 +30,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)] @@ -42,63 +69,90 @@ 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( "codex".to_owned(), - AgentConfig { - command: vec![ + command_agent( + vec![ "codex".to_owned(), "exec".to_owned(), "--sandbox".to_owned(), - "workspace-write".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( "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, } } @@ -185,21 +239,65 @@ 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(()) } pub fn home_config_path() -> Result { - let home = std::env::var_os("HOME").ok_or_else(|| anyhow!("HOME is not set"))?; - Ok(PathBuf::from(home).join(CONFIG_DIR).join(CONFIG_FILE)) + Ok(home_dir()?.join(CONFIG_DIR).join(CONFIG_FILE)) +} + +pub(crate) fn home_dir() -> Result { + let home = std::env::var_os("HOME").filter(|home| !home.is_empty()); + #[cfg(windows)] + let home = home.or_else(|| std::env::var_os("USERPROFILE").filter(|home| !home.is_empty())); + home.map(PathBuf::from) + .ok_or_else(|| anyhow!("home directory is not set")) } fn load_path(path: &Path) -> Result { @@ -266,6 +364,7 @@ mod tests { max_preview_lines: 12, summarizer_command: None, }, + providers: Config::default().providers, agents: BTreeMap::new(), }; diff --git a/src/exec.rs b/src/exec.rs index a5b5426..fec3136 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -104,6 +104,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 @@ -261,8 +266,7 @@ fn create_raw_log_path(label: Option<&str>) -> Result { } fn home_jobs_dir() -> Result { - let home = std::env::var_os("HOME").context("HOME is not set")?; - Ok(PathBuf::from(home).join(CONFIG_DIR).join("jobs")) + Ok(crate::config::home_dir()?.join(CONFIG_DIR).join("jobs")) } fn job_id(label: Option<&str>) -> String { diff --git a/src/jobs.rs b/src/jobs.rs index 938d034..846efd0 100644 --- a/src/jobs.rs +++ b/src/jobs.rs @@ -1,15 +1,18 @@ 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}; -use anyhow::{Context, Result, anyhow, bail}; +use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; use crate::config::{AgentConfig, CONFIG_DIR, SummaryConfig}; use crate::exec::{CaptureOptions, CapturedRun, ProcessOptions, format_command, run_captured_with}; +#[cfg(not(windows))] use crate::process::spawn_detached; use crate::summary::{Summary, summarize, summarize_with_command}; @@ -109,19 +112,26 @@ pub fn run_foreground( summary, }); if exit_code != 0 { - bail!( - "agent iteration {iteration}/{} exited with code {exit_code}", - options.iterations - ); + break; } } 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"); @@ -130,6 +140,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()))?; @@ -147,21 +158,40 @@ 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)); - - let child = spawn_detached(&mut command) - .with_context(|| format!("launching detached agent `{}`", format_command(&argv)))?; + #[cfg(windows)] + let pid = { + drop(stdout); + crate::windows_process::spawn_supervisor( + executable, + &supervisor_path, + &options.cwd, + &log_path, + )? + }; + #[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)); + + let child = spawn_detached(&mut command) + .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(), @@ -257,14 +287,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; } @@ -419,7 +450,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() @@ -431,15 +473,20 @@ fn read_tail_lines(path: &Path, tail: usize) -> Result> { } fn jobs_dir() -> Result { - let home = std::env::var_os("HOME").ok_or_else(|| anyhow!("HOME is not set"))?; - Ok(PathBuf::from(home).join(CONFIG_DIR).join("jobs")) + Ok(crate::config::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) ) } @@ -464,24 +511,60 @@ fn unix_timestamp() -> Result { .as_secs()) } +#[cfg(unix)] fn process_is_running(pid: u32) -> bool { if pid == 0 { return false; } - ProcessCommand::new("kill") - .arg("-0") - .arg(pid.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|status| status.success()) - .unwrap_or(false) + (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, WAIT_TIMEOUT}; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject, + }; + unsafe { + let handle = OpenProcess(PROCESS_SYNCHRONIZE, 0, pid); + if handle.is_null() { + return false; + } + let running = WaitForSingleObject(handle, 0) == WAIT_TIMEOUT; + CloseHandle(handle); + running + } +} + +#[cfg(not(any(unix, windows)))] +fn process_is_running(_pid: u32) -> bool { + false } #[cfg(test)] 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 0d0c698..943c82b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,4 +9,7 @@ pub mod exec; pub mod jobs; pub mod output; mod process; +pub mod provider; pub mod summary; +#[cfg(windows)] +pub mod windows_process; diff --git a/src/main.rs b/src/main.rs index f6749c8..230f3ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,6 +33,9 @@ enum Command { prompt_file: Option, #[arg(long)] detach: bool, + /// Restrict a Codex worker to workspace writes (Git metadata stays read-only). + #[arg(long)] + sandbox: bool, #[arg(long, default_value_t = 1)] iterations: u32, #[arg(long)] @@ -57,7 +60,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, @@ -118,6 +133,7 @@ fn main() -> Result<()> { prompt, prompt_file, detach, + sandbox, iterations, cwd, json, @@ -127,6 +143,10 @@ fn main() -> Result<()> { loaded.config.agents.get(&agent).cloned().with_context(|| { format!("unknown agent `{agent}` in {}", loaded.path.display()) })?; + let mut agent_config = materialize_provider_agent(agent_config, &loaded.path)?; + if sandbox { + restrict_codex_worker(&mut agent_config.command)?; + } let prompt = read_prompt(prompt.as_deref(), prompt_file.as_deref())?; let cwd = resolve_cwd(agent_config.cwd.as_deref(), cwd.as_deref())?; let options = agentctl::jobs::AgentRunOptions { @@ -155,6 +175,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 } => { @@ -190,10 +211,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()?; @@ -212,6 +263,51 @@ fn main() -> Result<()> { Ok(()) } +fn restrict_codex_worker(command: &mut Vec) -> Result<()> { + let executable = command + .first() + .and_then(|value| Path::new(value).file_stem()) + .and_then(|name| name.to_str()); + if !executable.is_some_and(|name| name.eq_ignore_ascii_case("codex")) + || command.get(1).map(String::as_str) != Some("exec") + { + bail!("--sandbox requires a profile whose command starts with `codex exec`"); + } + + let mut restricted = command[..2].to_vec(); + let mut args = command[2..].iter(); + while let Some(arg) = args.next() { + match arg.as_str() { + "--" => { + restricted.extend(["--sandbox".to_owned(), "workspace-write".to_owned()]); + restricted.push(arg.clone()); + restricted.extend(args.cloned()); + *command = restricted; + return Ok(()); + } + "--sandbox" | "-s" => { + args.next() + .context("configured sandbox flag is missing its value")?; + } + "--dangerously-bypass-approvals-and-sandbox" | "--yolo" | "--full-auto" => {} + value if value.starts_with("--sandbox=") || value.starts_with("-s=") => {} + // Preserve values verbatim: they may happen to look like flags. + "--config" | "-c" | "--model" | "-m" | "--profile" | "-p" => { + restricted.push(arg.clone()); + restricted.push( + args.next() + .context("configured flag is missing its value")? + .clone(), + ); + } + _ => restricted.push(arg.clone()), + } + } + restricted.extend(["--sandbox".to_owned(), "workspace-write".to_owned()]); + *command = restricted; + Ok(()) +} + fn read_prompt(prompt: Option<&str>, prompt_file: Option<&Path>) -> Result { match (prompt, prompt_file) { (Some(_), Some(_)) => bail!("use either --prompt or --prompt-file, not both"), @@ -239,6 +335,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() @@ -300,3 +422,51 @@ fn print_job_status(status: &agentctl::jobs::JobStatus) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sandbox_replaces_full_access_and_preserves_worker_options() { + for flags in [ + vec!["--sandbox", "danger-full-access"], + vec!["-s", "danger-full-access"], + vec!["--sandbox=danger-full-access"], + vec!["--dangerously-bypass-approvals-and-sandbox"], + vec!["--yolo"], + vec!["--full-auto"], + ] { + let mut command: Vec = [ + vec!["codex", "exec", "--model", "test-model"], + flags, + vec!["--", "-"], + ] + .concat() + .into_iter() + .map(str::to_owned) + .collect(); + restrict_codex_worker(&mut command).unwrap(); + assert_eq!( + command, + [ + "codex", + "exec", + "--model", + "test-model", + "--sandbox", + "workspace-write", + "--", + "-" + ] + ); + } + } + + #[test] + fn sandbox_rejects_unsupported_runtimes() { + let mut command = vec!["claude".to_owned(), "-p".to_owned()]; + assert!(restrict_codex_worker(&mut command).is_err()); + assert_eq!(command, ["claude", "-p"]); + } +} diff --git a/src/process.rs b/src/process.rs index 7619dc2..794c059 100644 --- a/src/process.rs +++ b/src/process.rs @@ -1,11 +1,14 @@ use std::io; use std::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus}; +#[cfg(unix)] use std::time::{Duration, Instant}; #[cfg(unix)] -use std::sync::OnceLock; +use std::collections::BTreeSet; +#[cfg(unix)] +use std::sync::atomic::{AtomicU64, Ordering}; #[cfg(unix)] -use std::sync::atomic::{AtomicI32, Ordering}; +use std::sync::{Mutex, OnceLock}; #[cfg(unix)] use signal_hook::consts::signal::{SIGHUP, SIGINT, SIGQUIT, SIGTERM}; @@ -14,10 +17,15 @@ use signal_hook::iterator::Signals; #[cfg(unix)] use std::os::unix::process::CommandExt; +#[cfg(unix)] 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 NEXT_OWNER: AtomicU64 = AtomicU64::new(0); +#[cfg(unix)] +const OWNER_ENV: &str = "AGENTCTL_PROCESS_OWNER"; #[cfg(unix)] static SIGNAL_FORWARDER: OnceLock<()> = OnceLock::new(); @@ -29,11 +37,16 @@ pub(crate) struct ManagedChild { child: Child, #[cfg(unix)] process_group: i32, + #[cfg(unix)] + owner: String, + #[cfg(windows)] + 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 { configure_process_group(command); command.spawn() @@ -44,15 +57,32 @@ impl ManagedChild { enable_subreaper()?; configure_process_group(command); install_signal_forwarder()?; + #[cfg(unix)] + let owner = format!( + "{}-{}", + std::process::id(), + NEXT_OWNER.fetch_add(1, Ordering::Relaxed) + ); + #[cfg(unix)] + command.env(OWNER_ENV, &owner); + // Serialize spawn/registration against signal forwarding. + #[cfg(unix)] + 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, + owner, reaped: false, }) } @@ -60,6 +90,8 @@ impl ManagedChild { #[cfg(not(unix))] Ok(Self { child, + #[cfg(windows)] + job, reaped: false, }) } @@ -86,18 +118,18 @@ impl ManagedChild { fn clean_remaining_descendants(&self) { #[cfg(unix)] - terminate_process_tree(self.process_group); + terminate_process_tree(self.process_group, &self.owner); + #[cfg(windows)] + 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); } } } @@ -109,8 +141,10 @@ impl Drop for ManagedChild { } #[cfg(unix)] - terminate_process_tree(self.process_group); - #[cfg(not(unix))] + terminate_process_tree(self.process_group, &self.owner); + #[cfg(windows)] + self.job.terminate(); + #[cfg(not(any(unix, windows)))] let _ = self.child.kill(); let _ = self.child.wait(); @@ -137,11 +171,15 @@ fn install_signal_forwarder() -> io::Result<()> { SIGNAL_FORWARDER.get_or_init(|| { std::thread::spawn(move || { for signal in signals.forever() { - let process_group = ACTIVE_PROCESS_GROUP.load(Ordering::SeqCst); - 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 @@ -175,108 +213,152 @@ fn install_signal_forwarder() -> io::Result<()> { } #[cfg(unix)] -fn terminate_process_tree(process_group: i32) { +fn terminate_process_tree(process_group: i32, owner: &str) { 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; - } - 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 mut descendants = BTreeSet::new(); + for signal in [SIGTERM, libc::SIGKILL] { + let deadline = Instant::now() + TERMINATION_GRACE; + loop { + collect_owned_descendants(process_group, owner, &mut descendants); + unsafe { + libc::kill(-process_group, signal); + } + for &pid in &descendants { + unsafe { + libc::kill(pid, signal); + } + } + // Do not consume the direct child's ExitStatus or any unrelated + // child's wait status. Retain known PIDs until reaped, since a + // zombie no longer exposes its environment ownership marker. + descendants.retain(|&pid| { + let mut status = 0; + let result = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) }; + result != pid && (unsafe { libc::kill(pid, 0) } == 0) + }); + if !process_group_exists(process_group) && descendants.is_empty() { + return; + } + if Instant::now() >= deadline { + break; + } + 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(unix)] fn process_group_exists(process_group: i32) -> bool { - let result = unsafe { libc::kill(-process_group, 0) }; - if result == 0 { + if unsafe { libc::kill(-process_group, 0) } == 0 { return true; } io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) } +// A subreaper can also adopt unrelated host children. Restrict cleanup to +// this process group or its inherited ownership marker, which survives setsid. #[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; - } - } -} - -#[cfg(all(unix, not(target_os = "linux")))] -fn reap_adopted_children() {} - -#[cfg(target_os = "linux")] -fn adopted_children_exist() -> bool { - !linux_child_pids(std::process::id() as i32).is_empty() -} - -#[cfg(all(unix, not(target_os = "linux")))] -fn adopted_children_exist() -> bool { - false -} - -#[cfg(target_os = "linux")] -fn signal_adopted_descendants(signal: i32) { - let mut pending = linux_child_pids(std::process::id() as i32); +fn collect_owned_descendants(group: i32, owner: &str, descendants: &mut BTreeSet) { + let marker = format!("{OWNER_ENV}={owner}"); + let mut pending: Vec = linux_child_pids(std::process::id() as i32) + .into_iter() + .filter(|&pid| { + if pid == group { + return false; + } + let same_group = unsafe { libc::getpgid(pid) } == group; + same_group + || std::fs::read(format!("/proc/{pid}/environ")).is_ok_and(|env| { + env.split(|byte| *byte == 0) + .any(|entry| entry == marker.as_bytes()) + }) + }) + .collect(); while let Some(pid) = pending.pop() { - pending.extend(linux_child_pids(pid)); - unsafe { - libc::kill(pid, signal); + if descendants.insert(pid) { + pending.extend(linux_child_pids(pid)); } } } #[cfg(all(unix, not(target_os = "linux")))] -fn signal_adopted_descendants(_signal: i32) {} +fn collect_owned_descendants(_group: i32, _owner: &str, _descendants: &mut BTreeSet) {} #[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() + let mut children = BTreeSet::new(); + if let Ok(tasks) = std::fs::read_dir(format!("/proc/{pid}/task")) { + for task in tasks.flatten() { + if let Ok(raw) = std::fs::read_to_string(task.path().join("children")) { + children.extend( + raw.split_whitespace() + .filter_map(|value| value.parse::().ok()), + ); + } + } + } + children.into_iter().collect() } #[cfg(test)] mod tests { + #[cfg(unix)] use super::*; + #[cfg(unix)] use std::process::Stdio; + #[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"); + } + } + } + + #[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"); + } + #[test] #[cfg(unix)] fn wait_terminates_descendants_left_by_the_direct_child() { 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 8202082..0304470 100644 --- a/tests/agent_jobs_cli.rs +++ b/tests/agent_jobs_cli.rs @@ -11,11 +11,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 "#, ) @@ -35,7 +35,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 "#, ) @@ -104,6 +104,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"); @@ -276,10 +377,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 = '{}' "#, project_dir.path().display(), log_path.display(), @@ -483,3 +584,81 @@ 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"); +} diff --git a/tests/config_cli.rs b/tests/config_cli.rs index 544bdbb..4248b9d 100644 --- a/tests/config_cli.rs +++ b/tests/config_cli.rs @@ -33,6 +33,8 @@ fn config_generate_and_check_round_trips_through_cli() { assert!(show_output.status.success()); let show = String::from_utf8(show_output.stdout).expect("show utf8"); assert!(show.contains("[summary]")); + assert!(show.contains("danger-full-access")); + assert!(!show.contains("workspace-write")); for profile in [ "[agents.codex]", "[agents.claude-code]", @@ -40,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), @@ -63,7 +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/config.toml")); + let normalized = stdout.replace('\\', "/"); + assert!(normalized.contains(".agentctl/config.toml")); } #[test] @@ -106,3 +112,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 fd56ca7..e43cea2 100644 --- a/tests/exec_cli.rs +++ b/tests/exec_cli.rs @@ -60,9 +60,9 @@ fn exec_passthrough_failing_exit_code_and_summary() { "exec", "--json", "--", - "sh", + "python", "-c", - "echo warning: careful >&2; exit 7", + "import sys; print('warning: careful', file=sys.stderr); sys.exit(7)", ]) .output() .expect("run failing agentctl exec"); @@ -92,9 +92,9 @@ fn exec_raw_streams_child_output_without_summary_json() { "--raw", "--json", "--", - "sh", + "python", "-c", - "printf raw-out; printf raw-err >&2", + "import sys; sys.stdout.write('raw-out'); sys.stderr.write('raw-err')", ]) .output() .expect("run raw agentctl exec"); @@ -133,3 +133,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 + ); +} From a732ea38b45dc0e5cec14c92c975a154284d1db9 Mon Sep 17 00:00:00 2001 From: bakobiibizo Date: Sat, 10 Oct 2026 10:56:04 -0700 Subject: [PATCH 2/5] Record live chat completion provider validation --- docs/audit-2026-10-10.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/audit-2026-10-10.md b/docs/audit-2026-10-10.md index 1a96fe7..5a07586 100644 --- a/docs/audit-2026-10-10.md +++ b/docs/audit-2026-10-10.md @@ -36,8 +36,21 @@ model/prompt serialization, returned content, and the CLI integration. ## Verification limits -Provider tests use a local server; live OpenRouter credentials, billing, and remote -model availability were not exercised. Hosted GitHub Actions has not run locally. +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. + +Hosted GitHub Actions has not run locally. macOS was not tested. Linux cleanup remains cooperative lifecycle management: a descendant that both abandons its process group and removes its ownership environment marker cannot reliably be attributed to its originating capture. From 77501d1816e4d1dcd140c1470aaa827c6190dfd1 Mon Sep 17 00:00:00 2001 From: bakobiibizo Date: Sat, 10 Oct 2026 11:25:52 -0700 Subject: [PATCH 3/5] Isolate Linux captures with dedicated subreaper supervisors --- .github/workflows/ci.yml | 2 +- README.md | 9 +- docs/audit-2026-10-10.md | 41 ++++++-- src/lib.rs | 3 + src/linux_process.rs | 193 ++++++++++++++++++++++++++++++++++++++ src/process.rs | 198 ++++++++++++++++----------------------- tests/agent_jobs_cli.rs | 40 +++++++- 7 files changed, 354 insertions(+), 132 deletions(-) create mode 100644 src/linux_process.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9abfeaf..99c895f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ jobs: verify: strategy: matrix: - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 1b71668..ec3099d 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,10 @@ terminates descendants left behind by a command that exits early. On Linux, 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. Linux descendants that leave their process group retain an inherited -ownership marker for cleanup; this is lifecycle management, not a security sandbox. +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. @@ -267,7 +269,8 @@ binary and is intended for callers that implement agentctl's supervisor command. 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 covers Windows and Linux. +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 diff --git a/docs/audit-2026-10-10.md b/docs/audit-2026-10-10.md index 5a07586..551d6b2 100644 --- a/docs/audit-2026-10-10.md +++ b/docs/audit-2026-10-10.md @@ -50,10 +50,37 @@ Chat Completions endpoint using `qwen3.6:latest` on 2026-10-10: hosted authentication, billing, or routing. The private endpoint and credential are intentionally omitted from committed evidence. -Hosted GitHub Actions has not run locally. -macOS was not tested. Linux cleanup remains cooperative lifecycle management: -a descendant that both abandons its process group and removes its ownership -environment marker cannot reliably be attributed to its originating capture. -The same attribution limit applies to escaped descendants that exit before -cleanup can observe their environment; their zombie records lack that marker. -This process manager is not a containment boundary for hostile commands. +## 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 + +Hosted CI for these local commits, including macOS, requires publishing a branch +to the repository. The latest existing hosted run is successful but covers an +older commit. No macOS machine is available in this workspace. 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/src/lib.rs b/src/lib.rs index 943c82b..52ca44b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,3 +13,6 @@ 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/process.rs b/src/process.rs index 794c059..1185629 100644 --- a/src/process.rs +++ b/src/process.rs @@ -1,13 +1,11 @@ use std::io; use std::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus}; -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "linux")))] use std::time::{Duration, Instant}; #[cfg(unix)] use std::collections::BTreeSet; #[cfg(unix)] -use std::sync::atomic::{AtomicU64, Ordering}; -#[cfg(unix)] use std::sync::{Mutex, OnceLock}; #[cfg(unix)] @@ -17,16 +15,12 @@ use signal_hook::iterator::Signals; #[cfg(unix)] use std::os::unix::process::CommandExt; -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "linux")))] const TERMINATION_GRACE: Duration = Duration::from_millis(750); #[cfg(unix)] static ACTIVE_PROCESS_GROUPS: Mutex> = Mutex::new(BTreeSet::new()); #[cfg(unix)] -static NEXT_OWNER: AtomicU64 = AtomicU64::new(0); -#[cfg(unix)] -const OWNER_ENV: &str = "AGENTCTL_PROCESS_OWNER"; -#[cfg(unix)] static SIGNAL_FORWARDER: OnceLock<()> = OnceLock::new(); /// A child that owns an isolated process group. Waiting for the command also @@ -37,8 +31,6 @@ pub(crate) struct ManagedChild { child: Child, #[cfg(unix)] process_group: i32, - #[cfg(unix)] - owner: String, #[cfg(windows)] job: crate::windows_process::Job, reaped: bool, @@ -54,17 +46,10 @@ pub(crate) fn spawn_detached(command: &mut Command) -> io::Result { impl ManagedChild { pub(crate) fn spawn(command: &mut Command) -> io::Result { - enable_subreaper()?; configure_process_group(command); install_signal_forwarder()?; - #[cfg(unix)] - let owner = format!( - "{}-{}", - std::process::id(), - NEXT_OWNER.fetch_add(1, Ordering::Relaxed) - ); - #[cfg(unix)] - command.env(OWNER_ENV, &owner); + #[cfg(target_os = "linux")] + crate::linux_process::configure_supervisor(command)?; // Serialize spawn/registration against signal forwarding. #[cfg(unix)] let mut active = ACTIVE_PROCESS_GROUPS @@ -82,7 +67,6 @@ impl ManagedChild { Ok(Self { child, process_group, - owner, reaped: false, }) } @@ -117,8 +101,8 @@ impl ManagedChild { } fn clean_remaining_descendants(&self) { - #[cfg(unix)] - terminate_process_tree(self.process_group, &self.owner); + #[cfg(all(unix, not(target_os = "linux")))] + terminate_process_tree(self.process_group); #[cfg(windows)] self.job.terminate(); } @@ -140,8 +124,12 @@ impl Drop for ManagedChild { return; } - #[cfg(unix)] - terminate_process_tree(self.process_group, &self.owner); + #[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)))] @@ -192,122 +180,94 @@ 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)] -fn terminate_process_tree(process_group: i32, owner: &str) { - if process_group <= 0 { - return; - } - let mut descendants = BTreeSet::new(); +#[cfg(all(unix, not(target_os = "linux")))] +fn terminate_process_tree(process_group: i32) { for signal in [SIGTERM, libc::SIGKILL] { + unsafe { + libc::kill(-process_group, signal); + } let deadline = Instant::now() + TERMINATION_GRACE; - loop { - collect_owned_descendants(process_group, owner, &mut descendants); - unsafe { - libc::kill(-process_group, signal); - } - for &pid in &descendants { - unsafe { - libc::kill(pid, signal); - } - } - // Do not consume the direct child's ExitStatus or any unrelated - // child's wait status. Retain known PIDs until reaped, since a - // zombie no longer exposes its environment ownership marker. - descendants.retain(|&pid| { - let mut status = 0; - let result = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) }; - result != pid && (unsafe { libc::kill(pid, 0) } == 0) - }); - if !process_group_exists(process_group) && descendants.is_empty() { + while Instant::now() < deadline { + if unsafe { libc::kill(-process_group, 0) } != 0 { return; } - if Instant::now() >= deadline { - break; - } std::thread::sleep(Duration::from_millis(20)); } } } -#[cfg(unix)] -fn process_group_exists(process_group: i32) -> bool { - if unsafe { libc::kill(-process_group, 0) } == 0 { - return true; - } - io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) -} +#[cfg(test)] +mod tests { + #[cfg(target_os = "linux")] + use super::*; + #[cfg(target_os = "linux")] + use std::process::Stdio; -// A subreaper can also adopt unrelated host children. Restrict cleanup to -// this process group or its inherited ownership marker, which survives setsid. -#[cfg(target_os = "linux")] -fn collect_owned_descendants(group: i32, owner: &str, descendants: &mut BTreeSet) { - let marker = format!("{OWNER_ENV}={owner}"); - let mut pending: Vec = linux_child_pids(std::process::id() as i32) - .into_iter() - .filter(|&pid| { - if pid == group { - return false; - } - let same_group = unsafe { libc::getpgid(pid) } == group; - same_group - || std::fs::read(format!("/proc/{pid}/environ")).is_ok_and(|env| { - env.split(|byte| *byte == 0) - .any(|entry| entry == marker.as_bytes()) - }) + #[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() }) - .collect(); - while let Some(pid) = pending.pop() { - if descendants.insert(pid) { - pending.extend(linux_child_pids(pid)); - } + .join() + .unwrap(); + assert!(managed.wait().unwrap().success()); } -} -#[cfg(all(unix, not(target_os = "linux")))] -fn collect_owned_descendants(_group: i32, _owner: &str, _descendants: &mut BTreeSet) {} - -#[cfg(target_os = "linux")] -fn linux_child_pids(pid: i32) -> Vec { - let mut children = BTreeSet::new(); - if let Ok(tasks) = std::fs::read_dir(format!("/proc/{pid}/task")) { - for task in tasks.flatten() { - if let Ok(raw) = std::fs::read_to_string(task.path().join("children")) { - children.extend( - raw.split_whitespace() - .filter_map(|value| value.parse::().ok()), - ); + #[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})"); } } - children.into_iter().collect() -} -#[cfg(test)] -mod tests { - #[cfg(unix)] - use super::*; - #[cfg(unix)] - use std::process::Stdio; + #[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 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 { + assert_eq!(libc::prctl(libc::PR_GET_CHILD_SUBREAPER, &mut after), 0); + } + assert_eq!(before, after, "library changed the host subreaper setting"); + } #[test] #[cfg(windows)] @@ -360,7 +320,7 @@ mod tests { } #[test] - #[cfg(unix)] + #[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"); diff --git a/tests/agent_jobs_cli.rs b/tests/agent_jobs_cli.rs index 0304470..2d5fba0 100644 --- a/tests/agent_jobs_cli.rs +++ b/tests/agent_jobs_cli.rs @@ -275,7 +275,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"); @@ -441,7 +441,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; @@ -662,3 +662,39 @@ prompt_stdin = false 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"); +} From 927cc6359d11909769b50bcb62d59f845e45de7b Mon Sep 17 00:00:00 2001 From: bakobiibizo Date: Sat, 10 Oct 2026 11:27:06 -0700 Subject: [PATCH 4/5] Bump agentctl to 0.1.4 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b870653..ae20576 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "agentctl" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 4eb4002..9d55575 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agentctl" -version = "0.1.3" +version = "0.1.4" edition = "2024" license = "MIT" description = "Compact, bounded command and agent reports for LLM tool loops" From 615bc3d3b41c69b965cd69ab163b962e507032cd Mon Sep 17 00:00:00 2001 From: bakobiibizo Date: Sat, 10 Oct 2026 11:30:03 -0700 Subject: [PATCH 5/5] Link audit evidence to release PR and hosted validation --- docs/audit-2026-10-10.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/audit-2026-10-10.md b/docs/audit-2026-10-10.md index 551d6b2..81d6242 100644 --- a/docs/audit-2026-10-10.md +++ b/docs/audit-2026-10-10.md @@ -74,11 +74,13 @@ Clippy with warnings denied. `dev fix` and `dev ci` pass on Windows. ## Remaining external verification -Hosted CI for these local commits, including macOS, requires publishing a branch -to the repository. The latest existing hosted run is successful but covers an -older commit. No macOS machine is available in this workspace. OpenRouter-hosted -authentication and billing remain untested; the operator-provided compatible -endpoint was verified above. +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