From 27bc5a717df32188e794b63ed29de25173b4f53d Mon Sep 17 00:00:00 2001 From: Ompragash Date: Wed, 22 Jul 2026 15:36:51 +0530 Subject: [PATCH 01/18] feat: replace production runtime with Rust agentctl --- .cargo/config.toml | 2 + .dockerignore | 16 + .github/workflows/ci.yml | 83 + .github/workflows/release-prep.yml | 30 + .gitignore | 22 +- Cargo.lock | 2726 ++++++++++++++ Cargo.toml | 60 + Containerfile | 20 + LICENSE | 13 + archive/TYPESCRIPT_REFERENCE.md | 7 + artifacts/.gitkeep | 1 + crates/agentctl-cli/Cargo.toml | 36 + crates/agentctl-cli/src/main.rs | 1689 +++++++++ crates/agentctl-core/Cargo.toml | 31 + crates/agentctl-core/src/compiler.rs | 1092 ++++++ crates/agentctl-core/src/diagnostic.rs | 76 + crates/agentctl-core/src/dsl.rs | 964 +++++ crates/agentctl-core/src/effect.rs | 143 + crates/agentctl-core/src/lib.rs | 23 + crates/agentctl-core/src/pack.rs | 137 + crates/agentctl-core/src/policy.rs | 398 ++ crates/agentctl-core/src/provider.rs | 133 + crates/agentctl-core/src/state.rs | 177 + crates/agentctl-core/src/template.rs | 299 ++ crates/agentctl-core/src/tool.rs | 140 + crates/agentctl-core/tests/compatibility.rs | 44 + crates/agentctl-observability/Cargo.toml | 18 + crates/agentctl-observability/src/lib.rs | 181 + crates/agentctl-protocols/Cargo.toml | 27 + crates/agentctl-protocols/src/lib.rs | 1075 ++++++ crates/agentctl-providers/Cargo.toml | 26 + crates/agentctl-providers/src/lib.rs | 1389 +++++++ crates/agentctl-runtime/Cargo.toml | 30 + crates/agentctl-runtime/src/lib.rs | 3198 +++++++++++++++++ crates/agentctl-store/Cargo.toml | 25 + crates/agentctl-store/src/lib.rs | 1778 +++++++++ deny.toml | 27 + .../acceptance/mock-tool/artifacts/.gitkeep | 1 + .../acceptance/mock-tool/fixture/service.txt | 3 + examples/acceptance/mock-tool/workflow.yaml | 78 + examples/memory-flow/state/long-term.db | Bin 12288 -> 0 bytes examples/openai-live/artifacts/.gitkeep | 1 + examples/openai-live/fixture/service.txt | 3 + examples/openai-live/workflow.yaml | 90 + examples/v1/README.md | 40 + examples/v1/a2a.yaml | 23 + examples/v1/anthropic-live.yaml | 19 + examples/v1/approval.yaml | 18 + examples/v1/artifacts/.gitkeep | 1 + examples/v1/capability-failure.yaml | 18 + examples/v1/check-diff.yaml | 18 + examples/v1/condition.yaml | 19 + examples/v1/crash-resume.yaml | 22 + examples/v1/dataflow.yaml | 27 + examples/v1/example.pack.yaml | 9 + examples/v1/fake-provider.yaml | 21 + examples/v1/google-live.yaml | 19 + examples/v1/hello.yaml | 18 + examples/v1/long-term-memory.yaml | 25 + examples/v1/mcp.yaml | 23 + examples/v1/openai-live.yaml | 25 + examples/v1/policy-denial.yaml | 17 + examples/v1/reusable-pack.yaml | 15 + examples/v1/secret-reference.yaml | 23 + examples/v1/working-memory.yaml | 21 + fixture/service.txt | 3 + fixtures/compat/v0/assign.expected.json | 11 + fixtures/compat/v0/assign.playbook.yaml | 12 + fuzz/Cargo.lock | 2302 ++++++++++++ fuzz/Cargo.toml | 58 + fuzz/fuzz_targets/persisted_state.rs | 11 + fuzz/fuzz_targets/protocol_response.rs | 9 + fuzz/fuzz_targets/provider_response.rs | 8 + fuzz/fuzz_targets/template.rs | 12 + fuzz/fuzz_targets/tool_schema_input.rs | 14 + fuzz/fuzz_targets/workflow_yaml.rs | 10 + package-lock.json | 7 +- package.json | 21 +- rust-toolchain.toml | 4 + rustfmt.toml | 3 + schemas/workflow.schema.json | 913 +++++ workflow.yaml | 78 + xtask/Cargo.toml | 18 + xtask/src/acceptance.rs | 1878 ++++++++++ xtask/src/main.rs | 598 +++ 85 files changed, 22677 insertions(+), 26 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 .dockerignore create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release-prep.yml create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Containerfile create mode 100644 LICENSE create mode 100644 archive/TYPESCRIPT_REFERENCE.md create mode 100644 artifacts/.gitkeep create mode 100644 crates/agentctl-cli/Cargo.toml create mode 100644 crates/agentctl-cli/src/main.rs create mode 100644 crates/agentctl-core/Cargo.toml create mode 100644 crates/agentctl-core/src/compiler.rs create mode 100644 crates/agentctl-core/src/diagnostic.rs create mode 100644 crates/agentctl-core/src/dsl.rs create mode 100644 crates/agentctl-core/src/effect.rs create mode 100644 crates/agentctl-core/src/lib.rs create mode 100644 crates/agentctl-core/src/pack.rs create mode 100644 crates/agentctl-core/src/policy.rs create mode 100644 crates/agentctl-core/src/provider.rs create mode 100644 crates/agentctl-core/src/state.rs create mode 100644 crates/agentctl-core/src/template.rs create mode 100644 crates/agentctl-core/src/tool.rs create mode 100644 crates/agentctl-core/tests/compatibility.rs create mode 100644 crates/agentctl-observability/Cargo.toml create mode 100644 crates/agentctl-observability/src/lib.rs create mode 100644 crates/agentctl-protocols/Cargo.toml create mode 100644 crates/agentctl-protocols/src/lib.rs create mode 100644 crates/agentctl-providers/Cargo.toml create mode 100644 crates/agentctl-providers/src/lib.rs create mode 100644 crates/agentctl-runtime/Cargo.toml create mode 100644 crates/agentctl-runtime/src/lib.rs create mode 100644 crates/agentctl-store/Cargo.toml create mode 100644 crates/agentctl-store/src/lib.rs create mode 100644 deny.toml create mode 100644 examples/acceptance/mock-tool/artifacts/.gitkeep create mode 100644 examples/acceptance/mock-tool/fixture/service.txt create mode 100644 examples/acceptance/mock-tool/workflow.yaml delete mode 100644 examples/memory-flow/state/long-term.db create mode 100644 examples/openai-live/artifacts/.gitkeep create mode 100644 examples/openai-live/fixture/service.txt create mode 100644 examples/openai-live/workflow.yaml create mode 100644 examples/v1/README.md create mode 100644 examples/v1/a2a.yaml create mode 100644 examples/v1/anthropic-live.yaml create mode 100644 examples/v1/approval.yaml create mode 100644 examples/v1/artifacts/.gitkeep create mode 100644 examples/v1/capability-failure.yaml create mode 100644 examples/v1/check-diff.yaml create mode 100644 examples/v1/condition.yaml create mode 100644 examples/v1/crash-resume.yaml create mode 100644 examples/v1/dataflow.yaml create mode 100644 examples/v1/example.pack.yaml create mode 100644 examples/v1/fake-provider.yaml create mode 100644 examples/v1/google-live.yaml create mode 100644 examples/v1/hello.yaml create mode 100644 examples/v1/long-term-memory.yaml create mode 100644 examples/v1/mcp.yaml create mode 100644 examples/v1/openai-live.yaml create mode 100644 examples/v1/policy-denial.yaml create mode 100644 examples/v1/reusable-pack.yaml create mode 100644 examples/v1/secret-reference.yaml create mode 100644 examples/v1/working-memory.yaml create mode 100644 fixture/service.txt create mode 100644 fixtures/compat/v0/assign.expected.json create mode 100644 fixtures/compat/v0/assign.playbook.yaml create mode 100644 fuzz/Cargo.lock create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/fuzz_targets/persisted_state.rs create mode 100644 fuzz/fuzz_targets/protocol_response.rs create mode 100644 fuzz/fuzz_targets/provider_response.rs create mode 100644 fuzz/fuzz_targets/template.rs create mode 100644 fuzz/fuzz_targets/tool_schema_input.rs create mode 100644 fuzz/fuzz_targets/workflow_yaml.rs create mode 100644 rust-toolchain.toml create mode 100644 rustfmt.toml create mode 100644 schemas/workflow.schema.json create mode 100644 workflow.yaml create mode 100644 xtask/Cargo.toml create mode 100644 xtask/src/acceptance.rs create mode 100644 xtask/src/main.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..35049cb --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --package xtask --" diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bbea975 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.agentctl +.runtime +archive +artifacts +dist +docs +examples +fixture +fixtures +fuzz +node_modules +schemas +target +*.db +*.log diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..eb68893 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,83 @@ +name: ci + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + verify: + env: + RUSTUP_TOOLCHAIN: ${{ matrix.rust }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + rust: [stable, "1.88.0"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.rust }} + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@cargo-deny + - run: cargo xtask verify + + acceptance: + env: + RUSTUP_TOOLCHAIN: "1.88.0" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.88.0" + - uses: Swatinem/rust-cache@v2 + - run: cargo xtask acceptance + + container: + env: + RUSTUP_TOOLCHAIN: "1.88.0" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.88.0" + - uses: Swatinem/rust-cache@v2 + - run: cargo xtask acceptance-container + - name: Reject fixed critical/high image vulnerabilities + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: agentctl-acceptance:local + format: table + severity: CRITICAL,HIGH + ignore-unfixed: true + exit-code: "1" + - name: Generate CycloneDX image SBOM + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: agentctl-acceptance:local + format: cyclonedx + output: agentctl-image.cdx.json + exit-code: "0" + - uses: actions/upload-artifact@v4 + with: + name: agentctl-image-sbom + path: agentctl-image.cdx.json + if-no-files-found: error + + supply-chain: + env: + RUSTUP_TOOLCHAIN: stable + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: taiki-e/install-action@cargo-deny + - run: cargo deny check + - run: cargo xtask verify diff --git a/.github/workflows/release-prep.yml b/.github/workflows/release-prep.yml new file mode 100644 index 0000000..c5e2cb8 --- /dev/null +++ b/.github/workflows/release-prep.yml @@ -0,0 +1,30 @@ +name: release-prep + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + package: + env: + RUSTUP_TOOLCHAIN: "1.88.0" + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.88.0" + components: rustfmt, clippy + - uses: taiki-e/install-action@cargo-deny + - run: cargo xtask verify + - run: cargo xtask package + - uses: actions/upload-artifact@v4 + with: + name: agentctl-${{ runner.os }} + path: dist/ + if-no-files-found: error diff --git a/.gitignore b/.gitignore index f110609..79797d6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,18 @@ -node_modules -dist -.env -.runtime +/target/ +/fuzz/target/ +/.agentctl/ +/.runtime/ +/dist/ +/node_modules/ +*.db +*.db-shm +*.db-wal +*.log +examples/v1/artifacts/* +!examples/v1/artifacts/.gitkeep +artifacts/* +!artifacts/.gitkeep +examples/acceptance/mock-tool/artifacts/* +!examples/acceptance/mock-tool/artifacts/.gitkeep +examples/openai-live/artifacts/* +!examples/openai-live/artifacts/.gitkeep diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2582174 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2726 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "agentctl" +version = "0.2.0" +dependencies = [ + "agentctl-core", + "agentctl-observability", + "agentctl-protocols", + "agentctl-providers", + "agentctl-runtime", + "agentctl-store", + "anyhow", + "chrono", + "clap", + "clap_complete", + "serde", + "serde_json", + "serde_yaml_ng", + "tempfile", + "tokio", + "tokio-util", + "url", +] + +[[package]] +name = "agentctl-core" +version = "0.2.0" +dependencies = [ + "async-trait", + "chrono", + "hex", + "jsonschema", + "proptest", + "schemars", + "semver", + "serde", + "serde_json", + "serde_path_to_error", + "serde_yaml_ng", + "sha2", + "tempfile", + "thiserror", + "tokio-util", + "url", +] + +[[package]] +name = "agentctl-observability" +version = "0.2.0" +dependencies = [ + "agentctl-core", + "chrono", + "opentelemetry", + "serde", + "serde_json", +] + +[[package]] +name = "agentctl-protocols" +version = "0.2.0" +dependencies = [ + "agentctl-core", + "agentctl-runtime", + "async-trait", + "futures-util", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "url", + "wiremock", +] + +[[package]] +name = "agentctl-providers" +version = "0.2.0" +dependencies = [ + "agentctl-core", + "async-trait", + "futures-util", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "url", + "wiremock", +] + +[[package]] +name = "agentctl-runtime" +version = "0.2.0" +dependencies = [ + "agentctl-core", + "agentctl-observability", + "agentctl-store", + "async-trait", + "chrono", + "hex", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror", + "tokio", + "tokio-util", + "url", + "uuid", +] + +[[package]] +name = "agentctl-store" +version = "0.2.0" +dependencies = [ + "agentctl-core", + "chrono", + "hex", + "parking_lot", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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 = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonschema" +version = "0.37.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73c9ffb2b5c56d58030e1b532d8e8389da94590515f118cf35b5cb68e4764a7e" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", +] + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.5", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "referencing" +version = "0.37.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4283168a506f0dcbdce31c9f9cce3129c924da4c6bca46e46707fcb746d2d70c" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[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 = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" +dependencies = [ + "bitflags", + "chrono", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[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.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[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.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[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", + "serde_derive", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[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.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[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-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +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 = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xtask" +version = "0.2.0" +dependencies = [ + "anyhow", + "hex", + "serde_json", + "sha2", + "tempfile", +] + +[[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.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.119", + "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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7465cf9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,60 @@ +[workspace] +members = [ + "crates/agentctl-core", + "crates/agentctl-store", + "crates/agentctl-runtime", + "crates/agentctl-providers", + "crates/agentctl-protocols", + "crates/agentctl-observability", + "crates/agentctl-cli", + "xtask", +] +resolver = "2" +exclude = ["fuzz"] + +[workspace.package] +version = "0.2.0" +edition = "2024" +rust-version = "1.88" +license = "Apache-2.0" +repository = "https://github.com/ompragash/agentctl" + +[workspace.dependencies] +anyhow = "1.0.100" +async-trait = "0.1.89" +bytes = "1.11.0" +chrono = { version = "0.4.42", default-features = false, features = ["clock", "serde"] } +clap = { version = "4.5.53", features = ["derive", "env", "string"] } +clap_complete = "4.5.61" +futures-util = "0.3.31" +hex = "0.4.3" +http = "1.4.0" +jsonschema = { version = "0.37.1", default-features = false } +opentelemetry = "0.31.0" +parking_lot = "0.12.5" +proptest = "1.9.0" +reqwest = { version = "0.12.24", default-features = false, features = ["json", "rustls-tls", "stream"] } +rusqlite = { version = "0.38.0", features = ["bundled", "chrono"] } +schemars = { version = "1.2.1", features = ["preserve_order"] } +semver = { version = "1.0.27", features = ["serde"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = { version = "1.0.145", features = ["preserve_order"] } +serde_path_to_error = "0.1.20" +serde_yaml_ng = "0.10.0" +sha2 = "0.10.9" +tempfile = "3.23.0" +thiserror = "2.0.17" +tokio = { version = "1.48.0", features = ["macros", "process", "rt-multi-thread", "signal", "time", "fs", "io-util", "sync"] } +tokio-util = "0.7.17" +url = { version = "2.5.7", features = ["serde"] } +uuid = { version = "1.18.1", features = ["serde", "v7"] } +wiremock = "0.6.5" + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +all = { level = "deny", priority = -1 } +module_name_repetitions = "allow" +too_many_lines = "allow" +struct_excessive_bools = "allow" diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..269e961 --- /dev/null +++ b/Containerfile @@ -0,0 +1,20 @@ +# syntax=docker/dockerfile:1.7 +FROM rust:1.88.0-bookworm AS build +WORKDIR /source + +COPY Cargo.toml Cargo.lock rust-toolchain.toml rustfmt.toml ./ +COPY crates ./crates +COPY xtask ./xtask +RUN cargo build --release --locked -p agentctl + +FROM gcr.io/distroless/cc-debian12:nonroot +ARG AGENTCTL_VERSION=0.2.0 +LABEL org.opencontainers.image.title="agentctl" \ + org.opencontainers.image.description="Deterministic control plane for policy-constrained agentic automation" \ + org.opencontainers.image.version="${AGENTCTL_VERSION}" \ + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.source="https://github.com/ompragash/agentctl" +COPY --from=build --chown=nonroot:nonroot /source/target/release/agentctl /usr/local/bin/agentctl +USER nonroot:nonroot +WORKDIR /workspace +ENTRYPOINT ["/usr/local/bin/agentctl"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4f09f8d --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2026 agentctl contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/archive/TYPESCRIPT_REFERENCE.md b/archive/TYPESCRIPT_REFERENCE.md new file mode 100644 index 0000000..3f5ae21 --- /dev/null +++ b/archive/TYPESCRIPT_REFERENCE.md @@ -0,0 +1,7 @@ +# Archived TypeScript reference + +The root `src/`, `test/`, legacy examples, `package.json`, and `package-lock.json` are retained only as the behavioral oracle used during the Rust migration. They have no production binary entry point, are excluded from `cargo xtask verify`, and are not part of installation, packaging, CI, or runtime support. Superseded prototype documentation was removed to avoid presenting two product contracts. + +The final passing prototype baseline was Node.js 26.0.0 with `NODE_OPTIONS=--no-deprecation`: 16 test files and 139 tests passed. Compatibility-derived behavior lives in `fixtures/compat`; intentional changes are recorded in `docs/COMPATIBILITY.md`. + +New behavior changes must target the Rust workspace. Do not add product features to the TypeScript reference. diff --git a/artifacts/.gitkeep b/artifacts/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/artifacts/.gitkeep @@ -0,0 +1 @@ + diff --git a/crates/agentctl-cli/Cargo.toml b/crates/agentctl-cli/Cargo.toml new file mode 100644 index 0000000..1ab138a --- /dev/null +++ b/crates/agentctl-cli/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "agentctl" +description = "Deterministic declarative control plane for safe agentic automation" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "agentctl" +path = "src/main.rs" + +[dependencies] +agentctl-core = { version = "0.2.0", path = "../agentctl-core" } +agentctl-observability = { version = "0.2.0", path = "../agentctl-observability" } +agentctl-protocols = { version = "0.2.0", path = "../agentctl-protocols" } +agentctl-providers = { version = "0.2.0", path = "../agentctl-providers" } +agentctl-runtime = { version = "0.2.0", path = "../agentctl-runtime" } +agentctl-store = { version = "0.2.0", path = "../agentctl-store" } +anyhow.workspace = true +clap.workspace = true +clap_complete.workspace = true +chrono.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_yaml_ng.workspace = true +tokio.workspace = true +tokio-util.workspace = true +url.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/agentctl-cli/src/main.rs b/crates/agentctl-cli/src/main.rs new file mode 100644 index 0000000..ce7f4d0 --- /dev/null +++ b/crates/agentctl-cli/src/main.rs @@ -0,0 +1,1689 @@ +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::io::{self, IsTerminal}; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use agentctl_core::compiler::provider_capabilities; +use agentctl_core::diagnostic::Diagnostic; +use agentctl_core::dsl::{ProviderKind, SecretReference, Workflow, parse_workflow, schema_json}; +use agentctl_core::pack::{PackManifest, verify_pack}; +use agentctl_core::policy::PolicyEngine; +use agentctl_core::provider::{ContentBlock, Message, ModelProvider, ProviderRequest}; +use agentctl_core::{MACHINE_OUTPUT_VERSION, compile}; +use agentctl_protocols::{A2aClient, McpClient, ProtocolActionHandler, ProtocolHttpConfig}; +use agentctl_providers::{ + AnthropicProvider, FakeProvider, GoogleProvider, HttpProviderConfig, OpenAiProvider, +}; +use agentctl_runtime::{BuiltinToolExecutor, RunOptions, Runtime, RuntimeRegistry}; +use agentctl_store::{ApprovalResolution, SqliteStore, StoreError}; +use chrono::{Duration as ChronoDuration, Utc}; +use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; +use clap_complete::Shell; +use serde::Serialize; +use serde_json::Value; +use tokio_util::sync::CancellationToken; +use url::Url; + +const EXIT_OK: u8 = 0; +const EXIT_VALIDATION: u8 = 2; +const EXIT_POLICY: u8 = 3; +const EXIT_RUN_FAILED: u8 = 4; +const EXIT_PERSISTENCE: u8 = 5; +const EXIT_REMOTE: u8 = 6; +const EXIT_CANCELLED: u8 = 130; +static VERBOSE_OUTPUT: AtomicBool = AtomicBool::new(false); +static COLOR_OUTPUT: AtomicBool = AtomicBool::new(false); + +#[derive(Debug, Parser)] +#[command( + name = "agentctl", + version, + about = "Deterministic, declarative control plane for policy-constrained agentic automation", + disable_help_subcommand = true +)] +struct Cli { + #[arg(long, global = true, value_enum, default_value_t = OutputFormat::Human)] + output: OutputFormat, + #[arg(long, global = true, value_enum, default_value_t = ColorMode::Auto)] + color: ColorMode, + #[arg(long, global = true)] + verbose: bool, + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +enum OutputFormat { + Human, + Json, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum ColorMode { + Auto, + Always, + Never, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Validate syntax, schema, references, capabilities, policy, and templates. + Check(WorkflowFile), + /// Print the deterministic compiled plan. + Plan(WorkflowFile), + /// Execute a workflow, or predict it with --check. + Run(RunArgs), + /// Continue an interrupted or approval-paused run. + Resume(ResumeArgs), + /// Reconstruct a terminal run only from recorded state and results. + Replay(RunIdArgs), + /// Create a new run from a prior workflow with fresh effects. + Fork(ForkArgs), + /// Durably request cancellation. + Cancel(RunIdArgs), + /// Inspect durable run, task, and audit state. + Inspect(RunIdArgs), + /// List or resolve durable approval requests. + Approvals(ApprovalArgs), + /// Inspect provider capabilities or run the opt-in OpenAI smoke. + Providers(ProviderArgs), + /// Check configured secret references without revealing values. + Auth(AuthArgs), + /// Print or write the generated workflow JSON Schema. + Schema(SchemaArgs), + /// Translate an unversioned TypeScript-era workflow into v1alpha1. + Migrate(MigrateArgs), + /// Inspect and verify a local reusable pack. + Packs(PackArgs), + /// Inspect the runtime database. + Db(DbArgs), + /// Read or write namespaced long-term memory. + Memory(MemoryArgs), + /// Garbage-collect expired memory and old terminal runs. + Gc(GcArgs), + /// Generate completion for a supported shell. + Completion(CompletionArgs), + /// Print the exact build version. + Version, + /// Explain safe update options without modifying the installation. + Update, +} + +#[derive(Debug, Args)] +struct WorkflowFile { + file: PathBuf, +} + +#[derive(Debug, Args)] +struct RunArgs { + file: PathBuf, + #[arg(long, default_value = ".agentctl/runtime.db")] + db: PathBuf, + #[arg(long)] + inputs: Option, + #[arg(long, conflicts_with = "inputs")] + inputs_file: Option, + #[arg(long = "input", value_name = "KEY=VALUE")] + input: Vec, + #[arg(long)] + workspace: Option, + #[arg(long)] + timeout_seconds: Option, + #[arg(long)] + check: bool, + #[arg(long)] + diff: bool, + #[arg(long)] + interactive: bool, +} + +#[derive(Debug, Args)] +struct ResumeArgs { + run_id: String, + #[arg(long, default_value = ".agentctl/runtime.db")] + db: PathBuf, + #[arg(long)] + diff: bool, + #[arg(long)] + interactive: bool, + #[arg(long)] + workspace: Option, + #[arg(long)] + timeout_seconds: Option, +} + +#[derive(Debug, Args)] +struct RunIdArgs { + run_id: String, + #[arg(long, default_value = ".agentctl/runtime.db")] + db: PathBuf, +} + +#[derive(Debug, Args)] +struct ForkArgs { + run_id: String, + #[arg(long, default_value = ".agentctl/runtime.db")] + db: PathBuf, + #[arg(long)] + interactive: bool, + #[arg(long)] + diff: bool, + #[arg(long)] + workspace: Option, + #[arg(long)] + timeout_seconds: Option, +} + +#[derive(Debug, Args)] +struct ApprovalArgs { + #[arg(long, default_value = ".agentctl/runtime.db")] + db: PathBuf, + #[command(subcommand)] + command: ApprovalCommand, +} + +#[derive(Debug, Subcommand)] +enum ApprovalCommand { + List { run_id: String }, + Approve(ResolutionArgs), + Reject(ResolutionArgs), +} + +#[derive(Debug, Args)] +struct ResolutionArgs { + approval_id: String, + #[arg(long, default_value = "cli-user")] + actor: String, + #[arg(long)] + reason: String, +} + +#[derive(Debug, Args)] +struct ProviderArgs { + #[command(subcommand)] + command: ProviderCommand, +} + +#[derive(Debug, Subcommand)] +enum ProviderCommand { + Inspect(WorkflowFile), + SmokeOpenai { + /// Required acknowledgement that this performs one bounded live request. + #[arg(long, required = true)] + live: bool, + #[arg(long, default_value = "gpt-5.6")] + model: String, + }, +} + +#[derive(Debug, Args)] +struct AuthArgs { + #[command(subcommand)] + command: AuthCommand, +} + +#[derive(Debug, Subcommand)] +enum AuthCommand { + Check(WorkflowFile), +} + +#[derive(Debug, Args)] +struct SchemaArgs { + #[arg(long)] + write: Option, +} + +#[derive(Debug, Args)] +struct MigrateArgs { + file: PathBuf, + #[arg(long)] + write: Option, +} + +#[derive(Debug, Args)] +struct PackArgs { + #[command(subcommand)] + command: PackCommand, +} + +#[derive(Debug, Subcommand)] +enum PackCommand { + Inspect { + manifest: PathBuf, + }, + Verify { + manifest: PathBuf, + #[arg(long)] + integrity: String, + }, +} + +#[derive(Debug, Args)] +struct DbArgs { + #[arg(long, default_value = ".agentctl/runtime.db")] + db: PathBuf, + #[command(subcommand)] + command: DbCommand, +} + +#[derive(Debug, Subcommand)] +enum DbCommand { + Stats, + Migrate, +} + +#[derive(Debug, Args)] +struct MemoryArgs { + #[arg(long, default_value = ".agentctl/runtime.db")] + db: PathBuf, + #[command(subcommand)] + command: MemoryCommand, +} + +#[derive(Debug, Subcommand)] +enum MemoryCommand { + Get { + namespace: String, + key: String, + }, + Put { + namespace: String, + key: String, + value: String, + #[arg(long)] + retention_days: Option, + }, +} + +#[derive(Debug, Args)] +struct GcArgs { + #[arg(long, default_value = ".agentctl/runtime.db")] + db: PathBuf, + #[arg(long, default_value_t = 30)] + older_than_days: i64, +} + +#[derive(Debug, Args)] +struct CompletionArgs { + #[arg(value_enum)] + shell: Shell, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Envelope { + api_version: &'static str, + kind: &'static str, + ok: bool, + data: T, + diagnostics: Vec, +} + +#[derive(Debug)] +struct CliError { + code: u8, + message: String, + diagnostics: Vec, + run_id: Option, + trace_id: Option, +} + +impl CliError { + fn validation(message: impl Into) -> Self { + Self { + code: EXIT_VALIDATION, + message: message.into(), + diagnostics: Vec::new(), + run_id: None, + trace_id: None, + } + } + + fn persistence(error: impl ToString) -> Self { + Self { + code: EXIT_PERSISTENCE, + message: error.to_string(), + diagnostics: Vec::new(), + run_id: None, + trace_id: None, + } + } +} + +#[tokio::main] +async fn main() { + let args = std::env::args_os().collect::>(); + let requested_output = requested_output(&args); + let cli = match Cli::try_parse_from(&args) { + Ok(cli) => cli, + Err(error) + if requested_output == OutputFormat::Json + && !matches!( + error.kind(), + clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion + ) => + { + let error = CliError::validation(error.to_string()); + render_error(OutputFormat::Json, &error); + std::process::exit(i32::from(error.code)); + } + Err(error) => error.exit(), + }; + let output = cli.output; + VERBOSE_OUTPUT.store(cli.verbose, Ordering::Relaxed); + COLOR_OUTPUT.store( + color_enabled(cli.color, io::stdout().is_terminal()), + Ordering::Relaxed, + ); + let result = execute(cli).await; + match result { + Ok(code) => { + if code != EXIT_OK { + std::process::exit(i32::from(code)); + } + } + Err(error) => { + render_error(output, &error); + std::process::exit(i32::from(error.code)); + } + } +} + +fn requested_output(args: &[OsString]) -> OutputFormat { + args.windows(2) + .find_map(|pair| (pair[0] == "--output").then(|| pair[1].to_str()).flatten()) + .or_else(|| { + args.iter() + .find_map(|arg| arg.to_str().and_then(|arg| arg.strip_prefix("--output="))) + }) + .filter(|value| *value == "json") + .map_or(OutputFormat::Human, |_| OutputFormat::Json) +} + +async fn execute(cli: Cli) -> Result { + let output = cli.output; + match cli.command { + Command::Check(args) => { + let (workflow, plan, diagnostics) = load_and_compile(&args.file)?; + print_value( + output, + "CheckResult", + &serde_json::json!({ + "valid": true, + "workflow": workflow.metadata.name, + "workflowDigest": plan.workflow_digest, + "planDigest": plan.plan_digest, + "tasks": plan.order.len(), + }), + diagnostics, + format!( + "valid: {} ({} tasks)", + workflow.metadata.name, + plan.order.len() + ), + )?; + Ok(EXIT_OK) + } + Command::Plan(args) => { + let (_, plan, diagnostics) = load_and_compile(&args.file)?; + print_value( + output, + "Plan", + &plan, + diagnostics, + format!( + "plan {}\norder: {}\npredictability: {:?}\nproviders: {}\ntools: {}\neffects: {}", + plan.plan_digest, + plan.order.join(" -> "), + plan.predictability, + plan.requirements + .providers + .iter() + .map(|provider| provider.name.as_str()) + .collect::>() + .join(", "), + plan.requirements + .tools + .iter() + .map(|tool| format!("{}:{}", tool.name, tool.capability)) + .collect::>() + .join(", "), + plan.requirements.effects.len(), + ), + )?; + Ok(EXIT_OK) + } + Command::Run(args) => run_workflow(output, args).await, + Command::Resume(args) => resume_run(output, args).await, + Command::Replay(args) => { + let store = open_store(&args.db)?; + let runtime = Runtime::new(store, current_dir()?); + let outcome = runtime + .replay(&args.run_id) + .await + .map_err(map_runtime_error)?; + print_value( + output, + "RunOutcome", + &outcome, + Vec::new(), + format!( + "{} {:?} (recorded replay; no effects)", + outcome.run_id, outcome.state + ), + )?; + Ok(EXIT_OK) + } + Command::Fork(args) => { + validate_interactive(args.interactive)?; + let store = open_store(&args.db)?; + let source = store + .load_run(&args.run_id) + .map_err(CliError::persistence)?; + let workflow: Workflow = serde_json::from_value(source.workflow.clone()) + .map_err(|error| CliError::persistence(error.to_string()))?; + let base = resolve_base_path( + args.workspace + .as_deref() + .or_else(|| source.base_path.as_deref().map(Path::new)), + )?; + let registry = build_registry(&workflow, &base)?; + let runtime = Runtime::new(store, &base).with_registry(registry); + let cancellation = cancellation_token(args.timeout_seconds); + let outcome = runtime + .fork( + &args.run_id, + RunOptions { + check: false, + diff: args.diff, + interactive: args.interactive, + }, + &cancellation, + ) + .await + .map_err(map_runtime_error)?; + print_outcome(output, &outcome) + } + Command::Cancel(args) => { + let store = open_store(&args.db)?; + store + .request_cancellation(&args.run_id, Utc::now(), "cli-cancel") + .map_err(CliError::persistence)?; + print_value( + output, + "Cancellation", + &serde_json::json!({"runId": args.run_id, "requested": true}), + Vec::new(), + "cancellation requested".to_owned(), + )?; + Ok(EXIT_OK) + } + Command::Inspect(args) => { + let store = open_store(&args.db)?; + let run = store + .load_run(&args.run_id) + .map_err(CliError::persistence)?; + let tasks = store + .list_tasks(&args.run_id) + .map_err(CliError::persistence)?; + let audit = store + .audit_events(&args.run_id) + .map_err(CliError::persistence)?; + let effects = store + .list_effects(&args.run_id) + .map_err(CliError::persistence)?; + let approvals = store + .pending_approvals(&args.run_id) + .map_err(CliError::persistence)?; + let checkpoints = store + .checkpoints(&args.run_id) + .map_err(CliError::persistence)?; + let provider_sessions = store + .provider_sessions(&args.run_id) + .map_err(CliError::persistence)?; + let tool_calls = store + .tool_calls(&args.run_id) + .map_err(CliError::persistence)?; + let traces = store + .trace_events(&args.run_id) + .map_err(CliError::persistence)?; + let human = format!( + "{} {:?}; {} tasks; {} effects; {} checkpoints; {} audit events; {} traces", + args.run_id, + run.state, + tasks.len(), + effects.len(), + checkpoints.len(), + audit.len(), + traces.len(), + ); + let value = serde_json::json!({ + "run": run, + "tasks": tasks, + "effects": effects, + "approvals": approvals, + "checkpoints": checkpoints, + "providerSessions": provider_sessions, + "toolCalls": tool_calls, + "audit": audit, + "traces": traces, + }); + print_value(output, "RunInspection", &value, Vec::new(), human)?; + Ok(EXIT_OK) + } + Command::Approvals(args) => approval_command(output, args), + Command::Providers(args) => provider_command(output, args).await, + Command::Auth(args) => auth_command(output, args), + Command::Schema(args) => schema_command(output, args), + Command::Migrate(args) => migrate_command(output, args), + Command::Packs(args) => pack_command(output, args), + Command::Db(args) => db_command(output, args), + Command::Memory(args) => memory_command(output, args), + Command::Gc(args) => gc_command(output, args), + Command::Completion(args) => { + let mut command = Cli::command(); + clap_complete::generate(args.shell, &mut command, "agentctl", &mut io::stdout()); + Ok(EXIT_OK) + } + Command::Version => { + print_value( + output, + "Version", + &serde_json::json!({ + "version": env!("CARGO_PKG_VERSION"), + "rust": true, + "machineOutput": MACHINE_OUTPUT_VERSION, + }), + Vec::new(), + format!("agentctl {}", env!("CARGO_PKG_VERSION")), + )?; + Ok(EXIT_OK) + } + Command::Update => { + print_value( + output, + "UpdateInfo", + &serde_json::json!({ + "currentVersion": env!("CARGO_PKG_VERSION"), + "automaticUpdate": false, + "command": "cargo install --locked agentctl" + }), + Vec::new(), + "automatic update is disabled; reinstall from a reviewed release artifact" + .to_owned(), + )?; + Ok(EXIT_OK) + } + } +} + +async fn run_workflow(output: OutputFormat, args: RunArgs) -> Result { + validate_interactive(args.interactive)?; + let (workflow, plan, diagnostics) = load_and_compile(&args.file)?; + let mut inputs = workflow.spec.inputs.clone(); + let supplied = if let Some(path) = &args.inputs_file { + parse_inputs(&read_text(path)?, "--inputs-file")? + } else if let Some(raw) = &args.inputs { + parse_inputs(raw, "--inputs")? + } else { + serde_json::Map::new() + }; + inputs.extend(supplied); + for pair in &args.input { + let (key, raw) = pair + .split_once('=') + .ok_or_else(|| CliError::validation("--input must use KEY=VALUE syntax"))?; + if key.is_empty() { + return Err(CliError::validation("--input key cannot be empty")); + } + let value = serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_owned())); + inputs.insert(key.to_owned(), value); + } + let default_base = args + .file + .parent() + .filter(|path| !path.as_os_str().is_empty()); + let base = resolve_base_path(args.workspace.as_deref().or(default_base))?; + let registry = build_registry(&workflow, &base)?; + let store = open_store(&args.db)?; + let runtime = Runtime::new(store, &base).with_registry(registry); + let cancellation = cancellation_token(args.timeout_seconds); + let outcome = runtime + .start( + &workflow, + &plan, + serde_json::to_value(inputs) + .map_err(|error| CliError::validation(error.to_string()))?, + RunOptions { + check: args.check, + diff: args.diff, + interactive: args.interactive, + }, + &cancellation, + ) + .await + .map_err(map_runtime_error)?; + if !diagnostics.is_empty() && output == OutputFormat::Human { + for diagnostic in diagnostics { + eprintln!("warning: {}", diagnostic.message); + } + } + print_outcome(output, &outcome) +} + +async fn resume_run(output: OutputFormat, args: ResumeArgs) -> Result { + validate_interactive(args.interactive)?; + let store = open_store(&args.db)?; + let run = store + .load_run(&args.run_id) + .map_err(CliError::persistence)?; + let workflow: Workflow = serde_json::from_value(run.workflow.clone()) + .map_err(|error| CliError::persistence(error.to_string()))?; + let base = resolve_base_path( + args.workspace + .as_deref() + .or_else(|| run.base_path.as_deref().map(Path::new)), + )?; + let registry = build_registry(&workflow, &base)?; + let runtime = Runtime::new(store, &base).with_registry(registry); + let cancellation = cancellation_token(args.timeout_seconds); + let outcome = runtime + .resume( + &args.run_id, + RunOptions { + check: false, + diff: args.diff, + interactive: args.interactive, + }, + &cancellation, + ) + .await + .map_err(map_runtime_error)?; + print_outcome(output, &outcome) +} + +fn print_outcome( + output: OutputFormat, + outcome: &agentctl_runtime::RunOutcome, +) -> Result { + print_value( + output, + "RunOutcome", + outcome, + Vec::new(), + format!( + "{} {:?} trace={}", + outcome.run_id, outcome.state, outcome.trace_id + ), + )?; + Ok(match outcome.state { + agentctl_core::state::RunState::Succeeded => EXIT_OK, + agentctl_core::state::RunState::Paused => EXIT_POLICY, + agentctl_core::state::RunState::Cancelled => EXIT_CANCELLED, + agentctl_core::state::RunState::Failed => EXIT_RUN_FAILED, + agentctl_core::state::RunState::Running => EXIT_RUN_FAILED, + }) +} + +fn approval_command(output: OutputFormat, args: ApprovalArgs) -> Result { + let store = open_store(&args.db)?; + match args.command { + ApprovalCommand::List { run_id } => { + let approvals = store + .pending_approvals(&run_id) + .map_err(CliError::persistence)?; + let count = approvals.len(); + print_value( + output, + "ApprovalList", + &approvals, + Vec::new(), + format!("{count} pending approval(s)"), + )?; + } + ApprovalCommand::Approve(resolution) => { + store + .resolve_approval( + &resolution.approval_id, + ApprovalResolution::Approved, + &resolution.actor, + &resolution.reason, + Utc::now(), + ) + .map_err(CliError::persistence)?; + print_value( + output, + "ApprovalResolution", + &serde_json::json!({"approvalId": resolution.approval_id, "status": "approved"}), + Vec::new(), + "approval recorded".to_owned(), + )?; + } + ApprovalCommand::Reject(resolution) => { + store + .resolve_approval( + &resolution.approval_id, + ApprovalResolution::Rejected, + &resolution.actor, + &resolution.reason, + Utc::now(), + ) + .map_err(CliError::persistence)?; + print_value( + output, + "ApprovalResolution", + &serde_json::json!({"approvalId": resolution.approval_id, "status": "rejected"}), + Vec::new(), + "rejection recorded".to_owned(), + )?; + } + } + Ok(EXIT_OK) +} + +async fn provider_command(output: OutputFormat, args: ProviderArgs) -> Result { + match args.command { + ProviderCommand::Inspect(args) => { + let (workflow, _, diagnostics) = load_and_compile(&args.file)?; + let data = workflow + .spec + .providers + .iter() + .map(|(name, definition)| { + serde_json::json!({ + "name": name, + "kind": definition.kind, + "capabilities": provider_capabilities(definition.kind.clone()) + .into_iter() + .map(agentctl_core::compiler::ProviderCapability::as_str) + .collect::>(), + "credentialConfigured": definition.credential.as_ref().is_some_and(|secret| std::env::var_os(&secret.env).is_some()), + }) + }) + .collect::>(); + print_value( + output, + "ProviderCapabilities", + &data, + diagnostics, + format!("{} provider(s)", data.len()), + )?; + Ok(EXIT_OK) + } + ProviderCommand::SmokeOpenai { live, model } => { + if !live { + return Err(CliError::validation("--live acknowledgement is required")); + } + if std::env::var_os("OPENAI_API_KEY").is_none() { + return Err(CliError { + code: EXIT_REMOTE, + message: "OPENAI_API_KEY is not configured".to_owned(), + diagnostics: Vec::new(), + run_id: None, + trace_id: None, + }); + } + let provider = OpenAiProvider::new(HttpProviderConfig::openai("OPENAI_API_KEY")) + .map_err(|error| CliError { + code: EXIT_REMOTE, + message: error.to_string(), + diagnostics: Vec::new(), + run_id: None, + trace_id: None, + })?; + let request = ProviderRequest { + model, + instructions: "Reply with exactly: ok".to_owned(), + messages: vec![Message::User(vec![ContentBlock::Text { + text: "health check".to_owned(), + }])], + tools: Vec::new(), + max_output_tokens: 16, + reasoning: None, + structured_output: None, + continuation: None, + prompt_cache_key: None, + safety_identifier: None, + provider_options: BTreeMap::new(), + }; + let response = tokio::time::timeout( + Duration::from_secs(30), + provider.complete(&request, &CancellationToken::new()), + ) + .await + .map_err(|_| CliError { + code: EXIT_REMOTE, + message: "OpenAI smoke timed out".to_owned(), + diagnostics: Vec::new(), + run_id: None, + trace_id: None, + })? + .map_err(|error| CliError { + code: EXIT_REMOTE, + message: error.to_string(), + diagnostics: Vec::new(), + run_id: None, + trace_id: None, + })?; + if response.text.trim().is_empty() { + return Err(CliError { + code: EXIT_REMOTE, + message: "OpenAI smoke returned no text content".to_owned(), + diagnostics: Vec::new(), + run_id: None, + trace_id: None, + }); + } + print_value( + output, + "LiveProviderSmoke", + &serde_json::json!({ + "provider": "openai", + "passed": true, + "responseIdPresent": response.response_id.is_some(), + "usage": response.usage, + }), + Vec::new(), + "OpenAI live smoke passed (response content redacted)".to_owned(), + )?; + Ok(EXIT_OK) + } + } +} + +fn auth_command(output: OutputFormat, args: AuthArgs) -> Result { + let AuthCommand::Check(args) = args.command; + let (workflow, _, diagnostics) = load_and_compile(&args.file)?; + let status = workflow + .spec + .providers + .iter() + .map(|(name, definition)| { + let env = definition + .credential + .as_ref() + .map(|secret| secret.env.clone()) + .unwrap_or_else(|| default_credential_env(definition.kind.clone()).to_owned()); + serde_json::json!({"provider": name, "environment": env, "present": std::env::var_os(&env).is_some()}) + }) + .collect::>(); + print_value( + output, + "AuthStatus", + &status, + diagnostics, + format!( + "checked {} credential reference(s); values were not read", + status.len() + ), + )?; + Ok(EXIT_OK) +} + +fn schema_command(output: OutputFormat, args: SchemaArgs) -> Result { + let schema = schema_json(); + if let Some(path) = args.write { + let content = serde_json::to_string_pretty(&schema) + .map_err(|error| CliError::validation(error.to_string()))?; + write_text(&path, &(content + "\n"))?; + print_value( + output, + "SchemaWrite", + &serde_json::json!({"path": path, "written": true}), + Vec::new(), + format!("wrote {}", path.display()), + )?; + } else { + print_value( + output, + "WorkflowSchema", + &schema, + Vec::new(), + "workflow schema".to_owned(), + )?; + } + Ok(EXIT_OK) +} + +fn migrate_command(output: OutputFormat, args: MigrateArgs) -> Result { + let source = read_text(&args.file)?; + let outcome = + parse_workflow(&source, &args.file.display().to_string()).map_err(diagnostics_error)?; + let yaml = serde_yaml_ng::to_string(&outcome.workflow) + .map_err(|error| CliError::validation(error.to_string()))?; + if let Some(path) = args.write { + write_text(&path, &yaml)?; + print_value( + output, + "Migration", + &serde_json::json!({"source": args.file, "destination": path, "migratedLegacy": outcome.migrated_legacy}), + outcome.diagnostics, + format!("wrote migrated workflow to {}", path.display()), + )?; + } else if output == OutputFormat::Human { + print!("{yaml}"); + } else { + print_value( + output, + "Migration", + &serde_json::json!({"workflow": outcome.workflow, "migratedLegacy": outcome.migrated_legacy}), + outcome.diagnostics, + String::new(), + )?; + } + Ok(EXIT_OK) +} + +fn pack_command(output: OutputFormat, args: PackArgs) -> Result { + match args.command { + PackCommand::Inspect { manifest } => { + let source = read_text(&manifest)?; + let pack: PackManifest = serde_yaml_ng::from_str(&source).map_err(|error| { + CliError::validation(format!("{}: {error}", manifest.display())) + })?; + pack.validate() + .map_err(|error| CliError::validation(error.to_string()))?; + print_value( + output, + "PackInspection", + &pack, + Vec::new(), + format!("pack {} {}", pack.name, pack.version), + )?; + } + PackCommand::Verify { + manifest, + integrity, + } => { + let actual = verify_pack(&manifest, &integrity) + .map_err(|error| CliError::validation(error.to_string()))?; + print_value( + output, + "PackVerification", + &serde_json::json!({"path": manifest, "integrity": actual, "valid": true}), + Vec::new(), + "pack integrity verified".to_owned(), + )?; + } + } + Ok(EXIT_OK) +} + +fn cancellation_token(timeout_seconds: Option) -> CancellationToken { + let token = CancellationToken::new(); + let signal = token.clone(); + tokio::spawn(async move { + #[cfg(unix)] + { + if let Ok(mut terminate) = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + { + tokio::select! { + result = tokio::signal::ctrl_c() => { + if result.is_ok() { + signal.cancel(); + } + } + _ = terminate.recv() => signal.cancel(), + } + } + } + #[cfg(not(unix))] + if tokio::signal::ctrl_c().await.is_ok() { + signal.cancel(); + } + }); + if let Some(seconds) = timeout_seconds { + let timeout = token.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(seconds)).await; + timeout.cancel(); + }); + } + token +} + +fn db_command(output: OutputFormat, args: DbArgs) -> Result { + let store = open_store(&args.db)?; + match args.command { + DbCommand::Stats => { + let stats = store.stats().map_err(CliError::persistence)?; + print_value( + output, + "DatabaseStats", + &stats, + Vec::new(), + format!( + "schema {}: {} runs, {} effects", + stats.schema_version, stats.runs, stats.effects + ), + )?; + } + DbCommand::Migrate => { + print_value( + output, + "DatabaseMigration", + &serde_json::json!({"schemaVersion": store.schema_version(), "migrated": true}), + Vec::new(), + format!("database schema is at version {}", store.schema_version()), + )?; + } + } + Ok(EXIT_OK) +} + +fn memory_command(output: OutputFormat, args: MemoryArgs) -> Result { + let store = open_store(&args.db)?; + match args.command { + MemoryCommand::Get { namespace, key } => { + let value = store + .get_long_term_memory(&namespace, &key, Utc::now()) + .map_err(CliError::persistence)?; + print_value( + output, + "MemoryValue", + &serde_json::json!({"namespace": namespace, "key": key, "value": value}), + Vec::new(), + if value.is_some() { + "memory found" + } else { + "memory not found" + } + .to_owned(), + )?; + } + MemoryCommand::Put { + namespace, + key, + value, + retention_days, + } => { + let value: Value = serde_json::from_str(&value) + .map_err(|error| CliError::validation(format!("value must be JSON: {error}")))?; + let expires = retention_days.map(|days| Utc::now() + ChronoDuration::days(days)); + store + .put_long_term_memory(&namespace, &key, &value, expires, Utc::now()) + .map_err(CliError::persistence)?; + print_value( + output, + "MemoryWrite", + &serde_json::json!({"namespace": namespace, "key": key, "written": true, "expiresAt": expires}), + Vec::new(), + "memory written".to_owned(), + )?; + } + } + Ok(EXIT_OK) +} + +fn gc_command(output: OutputFormat, args: GcArgs) -> Result { + if args.older_than_days < 0 { + return Err(CliError::validation( + "--older-than-days must be non-negative", + )); + } + let store = open_store(&args.db)?; + let before = Utc::now() - ChronoDuration::days(args.older_than_days); + let removed = store + .garbage_collect(before) + .map_err(CliError::persistence)?; + print_value( + output, + "GarbageCollection", + &serde_json::json!({"removed": removed, "before": before}), + Vec::new(), + format!("removed {removed} record(s)"), + )?; + Ok(EXIT_OK) +} + +fn load_and_compile( + path: &Path, +) -> Result<(Workflow, agentctl_core::CompiledPlan, Vec), CliError> { + let source = read_text(path)?; + let parsed = parse_workflow(&source, &path.display().to_string()).map_err(diagnostics_error)?; + let mut workflow = parsed.workflow; + load_packs(&mut workflow, path)?; + let plan = compile(&workflow, &path.display().to_string()).map_err(diagnostics_error)?; + Ok((workflow, plan, parsed.diagnostics)) +} + +fn load_packs(workflow: &mut Workflow, workflow_path: &Path) -> Result<(), CliError> { + let base = workflow_path + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let canonical_base = std::fs::canonicalize(base) + .map_err(|error| CliError::validation(format!("{}: {error}", base.display())))?; + for reference in workflow.spec.packs.clone() { + let relative = Path::new(&reference.path); + if relative.is_absolute() + || relative + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err(CliError::validation(format!( + "pack `{}` path must remain under the workflow directory", + reference.name + ))); + } + let path = base.join(relative); + let canonical = std::fs::canonicalize(&path) + .map_err(|error| CliError::validation(format!("{}: {error}", path.display())))?; + if !canonical.starts_with(&canonical_base) { + return Err(CliError::validation(format!( + "pack `{}` resolves outside the workflow directory", + reference.name + ))); + } + verify_pack(&canonical, &reference.integrity) + .map_err(|error| CliError::validation(error.to_string()))?; + let source = read_text(&canonical)?; + let mut pack: PackManifest = serde_yaml_ng::from_str(&source) + .map_err(|error| CliError::validation(format!("{}: {error}", canonical.display())))?; + pack.validate() + .map_err(|error| CliError::validation(error.to_string()))?; + if pack.name != reference.name || pack.version != reference.version { + return Err(CliError::validation(format!( + "pack reference `{}@{}` does not match manifest `{}@{}`", + reference.name, reference.version, pack.name, pack.version + ))); + } + let qualify = |name: &str| format!("{}.{}", pack.name, name); + for agent in pack.agents.values_mut() { + agent.tools = agent.tools.iter().map(|name| qualify(name)).collect(); + } + for (name, action) in pack.actions { + insert_pack_item( + &mut workflow.spec.actions, + qualify(&name), + action, + &pack.name, + )?; + } + for (name, tool) in pack.tools { + insert_pack_item(&mut workflow.spec.tools, qualify(&name), tool, &pack.name)?; + } + for (name, agent) in pack.agents { + insert_pack_item(&mut workflow.spec.agents, qualify(&name), agent, &pack.name)?; + } + } + Ok(()) +} + +fn insert_pack_item( + target: &mut BTreeMap, + name: String, + value: T, + pack: &str, +) -> Result<(), CliError> { + if target.insert(name.clone(), value).is_some() { + Err(CliError::validation(format!( + "pack `{pack}` collides at `{name}`" + ))) + } else { + Ok(()) + } +} + +fn build_registry(workflow: &Workflow, base: &Path) -> Result { + let mut registry = RuntimeRegistry::default(); + for (name, definition) in &workflow.spec.providers { + let credential = definition + .credential + .clone() + .unwrap_or_else(|| SecretReference { + env: default_credential_env(definition.kind.clone()).to_owned(), + }); + if definition.kind != ProviderKind::Fake && std::env::var_os(&credential.env).is_none() { + return Err(CliError { + code: EXIT_REMOTE, + message: format!( + "provider `{name}` requires environment variable `{}`; configure it or run `agentctl auth check`", + credential.env + ), + diagnostics: Vec::new(), + run_id: None, + trace_id: None, + }); + } + match definition.kind { + ProviderKind::Fake => { + registry = registry.with_provider(name, Arc::new(FakeProvider::default())); + } + ProviderKind::Openai => { + let mut config = HttpProviderConfig::openai(credential.env); + config.headers = resolve_protocol_headers(&definition.headers, workflow)?; + if let Some(endpoint) = &definition.endpoint { + config.endpoint = endpoint.clone(); + } + registry = registry.with_provider( + name, + Arc::new(OpenAiProvider::new(config).map_err(remote_error)?), + ); + } + ProviderKind::Anthropic => { + let mut config = HttpProviderConfig::anthropic(credential.env); + config.headers = resolve_protocol_headers(&definition.headers, workflow)?; + if let Some(endpoint) = &definition.endpoint { + config.endpoint = endpoint.clone(); + } + registry = registry.with_provider( + name, + Arc::new(AnthropicProvider::new(config).map_err(remote_error)?), + ); + } + ProviderKind::Google => { + let mut config = HttpProviderConfig::google(credential.env); + config.headers = resolve_protocol_headers(&definition.headers, workflow)?; + if let Some(endpoint) = &definition.endpoint { + config.endpoint = endpoint.clone(); + } + registry = registry.with_provider( + name, + Arc::new(GoogleProvider::new(config).map_err(remote_error)?), + ); + } + ProviderKind::AzureOpenai => { + let endpoint = definition.endpoint.clone().ok_or_else(|| { + CliError::validation(format!( + "Azure OpenAI provider `{name}` requires endpoint" + )) + })?; + let config = HttpProviderConfig { + endpoint, + credential, + organization: None, + project: None, + api_version: definition + .api_version + .clone() + .or_else(|| Some("v1".to_owned())), + headers: resolve_protocol_headers(&definition.headers, workflow)?, + }; + registry = registry.with_provider( + name, + Arc::new(OpenAiProvider::azure(config).map_err(remote_error)?), + ); + } + } + } + + let tool_policy = + PolicyEngine::new(workflow.spec.policy.clone(), base).map_err(|error| CliError { + code: EXIT_POLICY, + message: error.to_string(), + diagnostics: Vec::new(), + run_id: None, + trace_id: None, + })?; + for (name, definition) in &workflow.spec.tools { + registry = registry.with_tool( + name, + Arc::new(BuiltinToolExecutor::new( + name, + definition, + tool_policy.clone(), + )), + ); + } + + let mut mcp = BTreeMap::new(); + for (name, definition) in &workflow.spec.mcp_servers { + let headers = resolve_protocol_headers(&definition.headers, workflow)?; + let client = McpClient::new(ProtocolHttpConfig { + url: Url::parse(&definition.url).map_err(|error| { + CliError::validation(format!("MCP server `{name}` URL: {error}")) + })?, + headers, + timeout: Duration::from_secs(definition.timeout_seconds), + }) + .map_err(remote_error)?; + mcp.insert(name.clone(), Arc::new(client)); + } + let mut a2a = BTreeMap::new(); + for (name, definition) in &workflow.spec.a2a_peers { + let headers = resolve_protocol_headers(&definition.headers, workflow)?; + let client = A2aClient::new(ProtocolHttpConfig { + url: Url::parse(&definition.card_url).map_err(|error| { + CliError::validation(format!("A2A peer `{name}` card URL: {error}")) + })?, + headers, + timeout: Duration::from_secs(definition.timeout_seconds), + }) + .map_err(remote_error)?; + a2a.insert(name.clone(), Arc::new(client)); + } + if !mcp.is_empty() || !a2a.is_empty() { + registry = registry.with_external_actions(Arc::new(ProtocolActionHandler::new(mcp, a2a))); + } + Ok(registry) +} + +fn resolve_protocol_headers( + headers: &BTreeMap, + workflow: &Workflow, +) -> Result, CliError> { + headers + .iter() + .map(|(name, reference)| { + if !workflow.spec.policy.environment_allowlist.contains(&reference.env) { + return Err(CliError { + code: EXIT_POLICY, + message: format!( + "header `{name}` secret environment `{}` is not in policy.environmentAllowlist", + reference.env + ), + diagnostics: Vec::new(), + run_id: None, + trace_id: None, + }); + } + let value = std::env::var(&reference.env).map_err(|_| CliError { + code: EXIT_POLICY, + message: format!("required environment variable `{}` is unavailable", reference.env), + diagnostics: Vec::new(), + run_id: None, + trace_id: None, + })?; + Ok((name.clone(), value)) + }) + .collect() +} + +fn default_credential_env(kind: ProviderKind) -> &'static str { + match kind { + ProviderKind::Fake => "AGENTCTL_FAKE_PROVIDER", + ProviderKind::Openai => "OPENAI_API_KEY", + ProviderKind::Anthropic => "ANTHROPIC_API_KEY", + ProviderKind::Google => "GEMINI_API_KEY", + ProviderKind::AzureOpenai => "AZURE_OPENAI_API_KEY", + } +} + +fn validate_interactive(interactive: bool) -> Result<(), CliError> { + if interactive && !(io::stdin().is_terminal() && io::stdout().is_terminal()) { + Err(CliError { + code: EXIT_POLICY, + message: "--interactive requires terminal stdin and stdout".to_owned(), + diagnostics: Vec::new(), + run_id: None, + trace_id: None, + }) + } else { + Ok(()) + } +} + +fn open_store(path: &Path) -> Result { + SqliteStore::open(path).map_err(CliError::persistence) +} + +fn current_dir() -> Result { + std::env::current_dir().map_err(|error| CliError::validation(error.to_string())) +} + +fn resolve_base_path(path: Option<&Path>) -> Result { + let path = path.map_or_else(current_dir, |value| Ok(value.to_path_buf()))?; + std::fs::canonicalize(&path) + .map_err(|error| CliError::validation(format!("workspace {}: {error}", path.display()))) +} + +fn parse_inputs(raw: &str, source: &str) -> Result, CliError> { + let value: Value = serde_json::from_str(raw) + .map_err(|error| CliError::validation(format!("{source} must be JSON: {error}")))?; + value + .as_object() + .cloned() + .ok_or_else(|| CliError::validation(format!("{source} must contain a JSON object"))) +} + +fn read_text(path: &Path) -> Result { + std::fs::read_to_string(path) + .map_err(|error| CliError::validation(format!("{}: {error}", path.display()))) +} + +fn write_text(path: &Path, content: &str) -> Result<(), CliError> { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent) + .map_err(|error| CliError::validation(format!("{}: {error}", parent.display())))?; + } + std::fs::write(path, content) + .map_err(|error| CliError::validation(format!("{}: {error}", path.display()))) +} + +fn print_value( + output: OutputFormat, + kind: &'static str, + data: &T, + diagnostics: Vec, + human: String, +) -> Result<(), CliError> { + match output { + OutputFormat::Human => { + if COLOR_OUTPUT.load(Ordering::Relaxed) { + println!("\u{1b}[32m{human}\u{1b}[0m"); + } else { + println!("{human}"); + } + if VERBOSE_OUTPUT.load(Ordering::Relaxed) { + println!( + "{}", + serde_json::to_string_pretty(data) + .map_err(|error| CliError::validation(error.to_string()))? + ); + } + } + OutputFormat::Json => println!( + "{}", + serde_json::to_string(&Envelope { + api_version: MACHINE_OUTPUT_VERSION, + kind, + ok: true, + data, + diagnostics, + }) + .map_err(|error| CliError::validation(error.to_string()))? + ), + } + Ok(()) +} + +fn render_error(output: OutputFormat, error: &CliError) { + match output { + OutputFormat::Human => { + if COLOR_OUTPUT.load(Ordering::Relaxed) { + eprintln!("\u{1b}[31merror: {}\u{1b}[0m", error.message); + } else { + eprintln!("error: {}", error.message); + } + for diagnostic in &error.diagnostics { + let location = match (diagnostic.line, diagnostic.column) { + (Some(line), Some(column)) => format!("{}:{line}:{column}", diagnostic.file), + _ => diagnostic.file.clone(), + }; + eprintln!(" {location}: {}", diagnostic.message); + if let Some(help) = &diagnostic.help { + eprintln!(" help: {help}"); + } + } + if let Some(run_id) = &error.run_id { + eprintln!(" run: {run_id}"); + } + if let Some(trace_id) = &error.trace_id { + eprintln!(" trace: {trace_id}"); + } + } + OutputFormat::Json => { + let value = serde_json::json!({ + "apiVersion": MACHINE_OUTPUT_VERSION, + "kind": "Error", + "ok": false, + "error": { + "message": error.message, + "exitCode": error.code, + "runId": error.run_id, + "traceId": error.trace_id, + }, + "diagnostics": error.diagnostics, + }); + eprintln!("{value}"); + } + } +} + +const fn color_enabled(mode: ColorMode, terminal: bool) -> bool { + match mode { + ColorMode::Auto => terminal, + ColorMode::Always => true, + ColorMode::Never => false, + } +} + +fn diagnostics_error(diagnostics: Vec) -> CliError { + CliError { + code: EXIT_VALIDATION, + message: "workflow validation failed".to_owned(), + diagnostics, + run_id: None, + trace_id: None, + } +} + +fn remote_error(error: impl ToString) -> CliError { + CliError { + code: EXIT_REMOTE, + message: error.to_string(), + diagnostics: Vec::new(), + run_id: None, + trace_id: None, + } +} + +fn map_runtime_error(error: agentctl_runtime::RuntimeError) -> CliError { + let (run_id, trace_id) = match &error { + agentctl_runtime::RuntimeError::RunFailed { + run_id, trace_id, .. + } => (Some(run_id.clone()), Some(trace_id.clone())), + agentctl_runtime::RuntimeError::UncertainEffect { + run_id, trace_id, .. + } => (Some(run_id.clone()), Some(trace_id.clone())), + _ => (None, None), + }; + let code = match &error { + agentctl_runtime::RuntimeError::Store(StoreError::UnknownSchema { .. }) + | agentctl_runtime::RuntimeError::Store(StoreError::Corrupt(_)) + | agentctl_runtime::RuntimeError::Store(StoreError::Incompatible(_)) => EXIT_PERSISTENCE, + agentctl_runtime::RuntimeError::Policy(_) => EXIT_POLICY, + agentctl_runtime::RuntimeError::Provider(_) => EXIT_REMOTE, + agentctl_runtime::RuntimeError::Cancelled => EXIT_CANCELLED, + agentctl_runtime::RuntimeError::UncertainEffect { .. } => EXIT_POLICY, + _ => EXIT_RUN_FAILED, + }; + CliError { + code, + message: error.to_string(), + diagnostics: Vec::new(), + run_id, + trace_id, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::error::ErrorKind; + + #[test] + fn cli_has_all_stable_commands() { + let mut command = Cli::command(); + command.build(); + let names = command + .get_subcommands() + .map(|command| command.get_name()) + .collect::>(); + for expected in [ + "check", + "plan", + "run", + "resume", + "replay", + "fork", + "cancel", + "inspect", + "approvals", + "providers", + "auth", + "schema", + "migrate", + "packs", + "db", + "memory", + "gc", + "completion", + "version", + "update", + ] { + assert!(names.contains(&expected), "missing {expected}"); + } + } + + #[test] + fn malformed_command_is_usage_exit_two() { + let error = Cli::try_parse_from(["agentctl", "run"]).expect_err("missing file"); + assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument); + assert_eq!(error.exit_code(), i32::from(EXIT_VALIDATION)); + } + + #[test] + fn requested_json_output_is_detected_before_clap_parsing() { + assert_eq!( + requested_output(&[ + OsString::from("agentctl"), + OsString::from("--output"), + OsString::from("json"), + OsString::from("unknown"), + ]), + OutputFormat::Json + ); + assert_eq!( + requested_output(&[ + OsString::from("agentctl"), + OsString::from("unknown"), + OsString::from("--output=json"), + ]), + OutputFormat::Json + ); + } + + #[test] + fn machine_envelope_is_versioned() { + let envelope = Envelope { + api_version: MACHINE_OUTPUT_VERSION, + kind: "Fixture", + ok: true, + data: serde_json::json!({"value": 1}), + diagnostics: Vec::new(), + }; + let value = serde_json::to_value(envelope).expect("serialize"); + assert_eq!(value["apiVersion"], MACHINE_OUTPUT_VERSION); + } + + #[test] + fn color_modes_are_tty_aware_and_json_is_selected_explicitly() { + assert!(color_enabled(ColorMode::Always, false)); + assert!(color_enabled(ColorMode::Auto, true)); + assert!(!color_enabled(ColorMode::Auto, false)); + assert!(!color_enabled(ColorMode::Never, true)); + let cli = Cli::try_parse_from([ + "agentctl", "--output", "json", "--color", "never", "version", + ]) + .expect("valid flags"); + assert_eq!(cli.output, OutputFormat::Json); + } +} diff --git a/crates/agentctl-core/Cargo.toml b/crates/agentctl-core/Cargo.toml new file mode 100644 index 0000000..927b88d --- /dev/null +++ b/crates/agentctl-core/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "agentctl-core" +description = "Deterministic domain model, DSL, compiler, policy, and effect contracts for agentctl" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +async-trait.workspace = true +chrono.workspace = true +hex.workspace = true +jsonschema.workspace = true +schemars.workspace = true +semver.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_path_to_error.workspace = true +serde_yaml_ng.workspace = true +sha2.workspace = true +thiserror.workspace = true +tokio-util.workspace = true +url.workspace = true + +[dev-dependencies] +proptest.workspace = true +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/agentctl-core/src/compiler.rs b/crates/agentctl-core/src/compiler.rs new file mode 100644 index 0000000..422f8fd --- /dev/null +++ b/crates/agentctl-core/src/compiler.rs @@ -0,0 +1,1092 @@ +use std::collections::{BTreeMap, BTreeSet, VecDeque}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::PLAN_FORMAT_VERSION; +use crate::diagnostic::{Diagnostic, DiagnosticCode}; +use crate::dsl::{ + ActionKind, EffectClass, Idempotency, JsonMap, ProviderKind, RetryDefinition, ToolKind, + Workflow, +}; +use crate::template::{TemplateError, referenced_tasks, validate_expression}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PlanPredictability { + FullyPredictable, + PartiallyPredictable, + RequiresExecution, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompiledTask { + pub id: String, + pub uses: TaskUse, + pub needs: Vec, + pub when: Option, + pub vars: JsonMap, + pub input: JsonMap, + pub retry: RetryDefinition, + pub timeout_seconds: u64, + pub failure: crate::dsl::FailureBehavior, + pub predictability: PlanPredictability, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "kind", content = "name")] +pub enum TaskUse { + Action(String), + Agent(String), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompiledPlan { + pub format_version: u32, + pub workflow_name: String, + pub workflow_digest: String, + pub plan_digest: String, + pub order: Vec, + pub tasks: BTreeMap, + pub predictability: PlanPredictability, + pub requirements: PlanRequirements, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlanRequirements { + pub providers: Vec, + pub tools: Vec, + pub effects: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderRequirement { + pub name: String, + pub kind: ProviderKind, + pub agents: Vec, + pub capabilities: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolRequirement { + pub name: String, + pub capability: String, + pub effect_class: EffectClass, + pub risk: crate::dsl::Risk, + pub approval: crate::dsl::ApprovalRequirement, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EffectRequirement { + pub task: String, + pub operation: String, + pub effect_class: EffectClass, + pub approval_possible: bool, + pub predictability: PlanPredictability, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ProviderCapability { + Text, + FunctionTools, + StructuredOutput, + ReasoningEffort, + ReasoningMode, + Continuation, + Usage, + CostMetadata, + Cancellation, + PersistedReasoning, + PromptCaching, + MultipleFunctionCalls, + ResponseStorage, +} + +impl ProviderCapability { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Text => "text", + Self::FunctionTools => "function_tools", + Self::StructuredOutput => "structured_output", + Self::ReasoningEffort => "reasoning_effort", + Self::ReasoningMode => "reasoning_mode", + Self::Continuation => "continuation", + Self::Usage => "usage", + Self::CostMetadata => "cost_metadata", + Self::Cancellation => "cancellation", + Self::PersistedReasoning => "persisted_reasoning", + Self::PromptCaching => "prompt_caching", + Self::MultipleFunctionCalls => "multiple_function_calls", + Self::ResponseStorage => "response_storage", + } + } +} + +pub fn compile(workflow: &Workflow, file: &str) -> Result> { + let mut diagnostics = Vec::new(); + let mut tasks = BTreeMap::new(); + let mut declaration_order = Vec::new(); + + for (position, task) in workflow.spec.tasks.iter().enumerate() { + if tasks.contains_key(&task.id) { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::DuplicateTask, + file, + format!("duplicate task id `{}`", task.id), + ) + .with_path(format!("spec.tasks[{position}].id")), + ); + continue; + } + if !valid_identifier(&task.id) { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + format!("invalid task id `{}`", task.id), + ) + .with_path(format!("spec.tasks[{position}].id")), + ); + } + let task_use = match parse_use(&task.uses) { + Some(TaskUse::Action(name)) if workflow.spec.actions.contains_key(&name) => { + TaskUse::Action(name) + } + Some(TaskUse::Agent(name)) if workflow.spec.agents.contains_key(&name) => { + TaskUse::Agent(name) + } + _ => { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::MissingReference, + file, + format!("task `{}` refers to unknown `{}`", task.id, task.uses), + ) + .with_path(format!("spec.tasks[{position}].uses")), + ); + continue; + } + }; + let mut input = match &task_use { + TaskUse::Action(name) => workflow + .spec + .actions + .get(name) + .map(|action| action.defaults.clone()) + .unwrap_or_default(), + TaskUse::Agent(_) => JsonMap::new(), + }; + input.extend(task.input.clone()); + let mut vars = match &task_use { + TaskUse::Agent(name) => workflow + .spec + .agents + .get(name) + .map(|agent| agent.vars.clone()) + .unwrap_or_default(), + TaskUse::Action(_) => JsonMap::new(), + }; + vars.extend(task.vars.clone()); + declaration_order.push(task.id.clone()); + tasks.insert( + task.id.clone(), + CompiledTask { + id: task.id.clone(), + uses: task_use, + needs: task.needs.clone(), + when: task.when.clone(), + vars, + input, + retry: task.retry.clone(), + timeout_seconds: task + .timeout_seconds + .unwrap_or(workflow.spec.runtime.default_timeout_seconds), + failure: task.failure, + predictability: PlanPredictability::FullyPredictable, + }, + ); + } + + for (position, id) in declaration_order.iter().enumerate() { + let Some(task) = tasks.get(id) else { + continue; + }; + for dependency in &task.needs { + if !tasks.contains_key(dependency) { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::MissingReference, + file, + format!("task `{id}` needs unknown task `{dependency}`"), + ) + .with_path(format!("spec.tasks[{position}].needs")), + ); + } + } + validate_task_templates(task, &tasks, file, position, &mut diagnostics); + } + + validate_tools(workflow, file, &mut diagnostics); + validate_agents(workflow, file, &mut diagnostics); + if !diagnostics.is_empty() { + return Err(diagnostics); + } + + let order = stable_topological_order(&declaration_order, &tasks).map_err(|cycle| { + vec![Diagnostic::error( + DiagnosticCode::DependencyCycle, + file, + format!("task dependency cycle: {}", cycle.join(" -> ")), + )] + })?; + + for task in tasks.values_mut() { + task.predictability = match &task.uses { + TaskUse::Agent(_) => PlanPredictability::RequiresExecution, + TaskUse::Action(name) => workflow + .spec + .actions + .get(name) + .map_or(PlanPredictability::RequiresExecution, |action| { + action_predictability(action.kind) + }), + }; + } + let predictability = tasks + .values() + .map(|task| task.predictability) + .max_by_key(|value| match value { + PlanPredictability::FullyPredictable => 0, + PlanPredictability::PartiallyPredictable => 1, + PlanPredictability::RequiresExecution => 2, + }) + .unwrap_or(PlanPredictability::FullyPredictable); + let workflow_json = serde_json::to_vec(workflow).map_err(|error| { + vec![Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + error.to_string(), + )] + })?; + let workflow_digest = sha256(&workflow_json); + let requirements = plan_requirements(workflow, &tasks); + let plan_seed = serde_json::to_vec(&( + &workflow.metadata.name, + &workflow_digest, + &order, + &tasks, + &requirements, + )) + .map_err(|error| { + vec![Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + error.to_string(), + )] + })?; + let plan_digest = sha256(&plan_seed); + + Ok(CompiledPlan { + format_version: PLAN_FORMAT_VERSION, + workflow_name: workflow.metadata.name.clone(), + workflow_digest, + plan_digest, + order, + tasks, + predictability, + requirements, + }) +} + +fn plan_requirements( + workflow: &Workflow, + tasks: &BTreeMap, +) -> PlanRequirements { + let providers = workflow + .spec + .providers + .iter() + .filter_map(|(name, definition)| { + let agents = workflow + .spec + .agents + .iter() + .filter(|(_, agent)| agent.provider == *name) + .map(|(agent, _)| agent.clone()) + .collect::>(); + (!agents.is_empty()).then(|| ProviderRequirement { + name: name.clone(), + kind: definition.kind.clone(), + agents, + capabilities: provider_capabilities(definition.kind.clone()) + .into_iter() + .map(|capability| capability.as_str().to_owned()) + .collect(), + }) + }) + .collect(); + let tools = workflow + .spec + .tools + .iter() + .map(|(name, tool)| ToolRequirement { + name: name.clone(), + capability: tool.capability.clone(), + effect_class: tool.effect_class, + risk: tool.risk, + approval: tool.approval, + }) + .collect(); + let effects = tasks + .values() + .flat_map(|task| match &task.uses { + TaskUse::Agent(agent_name) => { + let mut effects = vec![EffectRequirement { + task: task.id.clone(), + operation: format!("agent:{agent_name}"), + effect_class: EffectClass::Model, + approval_possible: workflow.spec.policy.approval + != crate::dsl::ApprovalMode::Never, + predictability: task.predictability, + }]; + if let Some(agent) = workflow.spec.agents.get(agent_name) { + effects.extend(agent.tools.iter().filter_map(|name| { + workflow.spec.tools.get(name).map(|tool| EffectRequirement { + task: task.id.clone(), + operation: format!("tool:{name}"), + effect_class: tool.effect_class, + approval_possible: tool.approval + != crate::dsl::ApprovalRequirement::Never + || workflow.spec.policy.approval != crate::dsl::ApprovalMode::Never, + predictability: PlanPredictability::RequiresExecution, + }) + })); + } + effects + } + TaskUse::Action(name) => { + let effect_class = workflow + .spec + .actions + .get(name) + .map_or(EffectClass::ExternalMutate, |action| { + action_effect_class(action.kind) + }); + vec![EffectRequirement { + task: task.id.clone(), + operation: format!("action:{name}"), + effect_class, + approval_possible: workflow.spec.policy.approval + != crate::dsl::ApprovalMode::Never, + predictability: task.predictability, + }] + } + }) + .collect(); + PlanRequirements { + providers, + tools, + effects, + } +} + +const fn action_effect_class(kind: ActionKind) -> EffectClass { + match kind { + ActionKind::Assign | ActionKind::Assert => EffectClass::Pure, + ActionKind::Read => EffectClass::Observe, + ActionKind::Write => EffectClass::WorkspaceMutate, + ActionKind::ShellExec => EffectClass::ProcessExecution, + ActionKind::MemoryRead | ActionKind::MemoryWrite => EffectClass::InternalState, + ActionKind::LongTermMemoryRead => EffectClass::Observe, + ActionKind::LongTermMemoryWrite => EffectClass::ExternalMutate, + ActionKind::McpCall => EffectClass::Network, + ActionKind::A2aDelegate => EffectClass::RemoteAgent, + } +} + +fn validate_task_templates( + task: &CompiledTask, + tasks: &BTreeMap, + file: &str, + position: usize, + diagnostics: &mut Vec, +) { + let mut values: Vec<&Value> = task.input.values().chain(task.vars.values()).collect(); + if let Some(condition) = &task.when { + if let Err(error) = validate_expression(condition) { + push_template_error(error, task, file, position, "when", diagnostics); + } + } + while let Some(value) = values.pop() { + match value { + Value::String(template) => { + if let Err(error) = validate_expression(template) { + push_template_error(error, task, file, position, "with", diagnostics); + } + for reference in referenced_tasks(template) { + if !tasks.contains_key(&reference) { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::MissingReference, + file, + format!( + "task `{}` template refers to unknown task `{reference}`", + task.id + ), + ) + .with_path(format!("spec.tasks[{position}].with")), + ); + } else if !task.needs.contains(&reference) { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::InvalidTemplate, + file, + format!( + "task `{}` must declare `{reference}` in needs before reading its output", + task.id + ), + ) + .with_path(format!("spec.tasks[{position}].with")), + ); + } + } + } + Value::Array(items) => values.extend(items), + Value::Object(map) => values.extend(map.values()), + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } + } +} + +fn push_template_error( + error: TemplateError, + task: &CompiledTask, + file: &str, + position: usize, + field: &str, + diagnostics: &mut Vec, +) { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::InvalidTemplate, + file, + format!("task `{}`: {error}", task.id), + ) + .with_path(format!("spec.tasks[{position}].{field}")), + ); +} + +fn validate_agents(workflow: &Workflow, file: &str, diagnostics: &mut Vec) { + for (name, agent) in &workflow.spec.agents { + let Some(provider) = workflow.spec.providers.get(&agent.provider) else { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::MissingReference, + file, + format!( + "agent `{name}` refers to unknown provider `{}`", + agent.provider + ), + ) + .with_path(format!("spec.agents.{name}.provider")), + ); + continue; + }; + let capabilities = provider_capabilities(provider.kind.clone()); + validate_provider_options( + name, + provider.kind.clone(), + &agent.provider_options, + file, + diagnostics, + ); + if matches!( + provider.kind, + ProviderKind::Openai | ProviderKind::AzureOpenai + ) && !agent.tools.is_empty() + && agent.provider_options.get("store") == Some(&Value::Bool(false)) + { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::UnsupportedCapability, + file, + format!( + "provider option `store: false` is unsupported for tool-using agent `{name}` because stateless continuation replay is not implemented" + ), + ) + .with_path(format!("spec.agents.{name}.providerOptions.store")), + ); + } + let mut required = BTreeSet::from([ + ProviderCapability::Text, + ProviderCapability::Usage, + ProviderCapability::Cancellation, + ]); + if !agent.tools.is_empty() { + required.insert(ProviderCapability::FunctionTools); + required.insert(ProviderCapability::Continuation); + } + if agent.structured_output.is_some() { + required.insert(ProviderCapability::StructuredOutput); + } + if let Some(reasoning) = &agent.reasoning { + required.insert(ProviderCapability::ReasoningEffort); + if reasoning.mode.is_some() { + required.insert(ProviderCapability::ReasoningMode); + } + } + if agent + .usage_limit + .as_ref() + .is_some_and(|limit| limit.max_cost_usd.is_some()) + { + required.insert(ProviderCapability::CostMetadata); + } + if agent.provider_options.contains_key("reasoningContext") { + required.insert(ProviderCapability::PersistedReasoning); + } + if agent.provider_options.contains_key("promptCacheMode") + || agent.provider_options.contains_key("promptCacheTtl") + { + required.insert(ProviderCapability::PromptCaching); + } + if agent.provider_options.contains_key("parallelToolCalls") { + required.insert(ProviderCapability::MultipleFunctionCalls); + } + if agent.provider_options.contains_key("store") { + required.insert(ProviderCapability::ResponseStorage); + } + for tool in &agent.tools { + if !workflow.spec.tools.contains_key(tool) { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::MissingReference, + file, + format!("agent `{name}` refers to unknown tool `{tool}`"), + ) + .with_path(format!("spec.agents.{name}.tools")), + ); + } + } + if let Some(missing) = required.difference(&capabilities).next() { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::UnsupportedCapability, + file, + format!( + "provider `{}` does not support requested capability `{}` for agent `{name}`", + agent.provider, + missing.as_str() + ), + ) + .with_path(format!("spec.agents.{name}")), + ); + } + } +} + +fn validate_tools(workflow: &Workflow, file: &str, diagnostics: &mut Vec) { + for (name, tool) in &workflow.spec.tools { + if !valid_identifier(name) { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + format!("tool name `{name}` must start with a letter and contain only letters, digits, `_`, or `-`"), + ) + .with_path(format!("spec.tools.{name}")), + ); + } + for (direction, schema) in [ + ("inputSchema", &tool.input_schema), + ("outputSchema", &tool.output_schema), + ] { + if let Err(error) = jsonschema::validator_for(schema) { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + format!("tool `{name}` has invalid {direction}: {error}"), + ) + .with_path(format!("spec.tools.{name}.{direction}")), + ); + } + } + if tool.input_schema.get("type").and_then(Value::as_str) != Some("object") + || tool + .input_schema + .get("additionalProperties") + .and_then(Value::as_bool) + != Some(false) + { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + format!("tool `{name}` inputSchema must be a strict object with additionalProperties: false"), + ) + .with_path(format!("spec.tools.{name}.inputSchema")), + ); + } + if let Some(properties) = tool + .input_schema + .get("properties") + .and_then(Value::as_object) + { + let required = tool + .input_schema + .get("required") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect::>(); + if properties + .keys() + .any(|key| !required.contains(key.as_str())) + { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + format!("tool `{name}` strict inputSchema must require every property"), + ) + .with_path(format!("spec.tools.{name}.inputSchema.required")), + ); + } + } + let semantic_error = match tool.kind { + ToolKind::WorkspaceRead + if tool.effect_class != EffectClass::Observe + || tool.idempotency != Idempotency::Idempotent + || tool.capability != "filesystem.read" => + { + Some( + "builtin.workspace.read requires capability filesystem.read, effectClass observe, and idempotency idempotent", + ) + } + ToolKind::WorkspaceWrite + if tool.effect_class != EffectClass::WorkspaceMutate + || !matches!( + tool.idempotency, + Idempotency::Idempotent | Idempotency::Keyed + ) + || tool.capability != "filesystem.write" => + { + Some( + "builtin.workspace.write requires capability filesystem.write, effectClass workspace_mutate, and idempotency idempotent or keyed", + ) + } + ToolKind::Echo + if tool.effect_class != EffectClass::Pure + || tool.idempotency != Idempotency::Pure + || tool.capability != "internal" => + { + Some( + "builtin.echo requires capability internal, effectClass pure, and idempotency pure", + ) + } + _ => None, + }; + if let Some(message) = semantic_error { + diagnostics.push( + Diagnostic::error(DiagnosticCode::SchemaViolation, file, message) + .with_path(format!("spec.tools.{name}")), + ); + } + if !tool.secrets.is_empty() || !tool.network.is_empty() { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::UnsupportedCapability, + file, + format!("built-in tool `{name}` cannot declare secret or network requirements"), + ) + .with_path(format!("spec.tools.{name}")), + ); + } + } +} + +fn validate_provider_options( + agent_name: &str, + kind: ProviderKind, + options: &JsonMap, + file: &str, + diagnostics: &mut Vec, +) { + let allowed: &[&str] = match kind { + ProviderKind::Fake => &["toolInput", "finalText", "delayMs", "failFirst"], + ProviderKind::Openai | ProviderKind::AzureOpenai => &[ + "store", + "reasoningContext", + "promptCacheMode", + "promptCacheTtl", + "parallelToolCalls", + "safetyIdentifier", + ], + ProviderKind::Anthropic | ProviderKind::Google => &[], + }; + for key in options.keys() { + if !allowed.contains(&key.as_str()) { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::UnsupportedCapability, + file, + format!("provider option `{key}` is unsupported for agent `{agent_name}`"), + ) + .with_path(format!("spec.agents.{agent_name}.providerOptions.{key}")), + ); + } + } + let path = |key: &str| format!("spec.agents.{agent_name}.providerOptions.{key}"); + if let Some(value) = options.get("reasoningContext") + && !matches!(value.as_str(), Some("auto" | "current_turn" | "all_turns")) + { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + "reasoningContext must be auto, current_turn, or all_turns", + ) + .with_path(path("reasoningContext")), + ); + } + if let Some(value) = options.get("promptCacheMode") + && !matches!(value.as_str(), Some("implicit" | "explicit")) + { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + "promptCacheMode must be implicit or explicit", + ) + .with_path(path("promptCacheMode")), + ); + } + if let Some(value) = options.get("promptCacheTtl") + && value.as_str() != Some("30m") + { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + "promptCacheTtl currently supports only 30m", + ) + .with_path(path("promptCacheTtl")), + ); + } + for key in ["store", "parallelToolCalls"] { + if options.get(key).is_some_and(|value| !value.is_boolean()) { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + format!("{key} must be a boolean"), + ) + .with_path(path(key)), + ); + } + } + for key in ["delayMs", "failFirst"] { + if options.get(key).is_some_and(|value| !value.is_u64()) { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + format!("{key} must be a non-negative integer"), + ) + .with_path(path(key)), + ); + } + } + for key in ["finalText", "safetyIdentifier"] { + if options.get(key).is_some_and(|value| !value.is_string()) { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + format!("{key} must be a string"), + ) + .with_path(path(key)), + ); + } + } + if options + .get("toolInput") + .is_some_and(|value| !value.is_object()) + { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + "toolInput must be a JSON object", + ) + .with_path(path("toolInput")), + ); + } +} + +#[must_use] +pub fn provider_capabilities(kind: ProviderKind) -> BTreeSet { + use ProviderCapability as C; + let mut values = BTreeSet::from([C::Text, C::Usage, C::Cancellation]); + match kind { + ProviderKind::Fake => { + values.extend([C::FunctionTools, C::StructuredOutput, C::Continuation]); + } + ProviderKind::Openai | ProviderKind::AzureOpenai => { + values.extend([ + C::FunctionTools, + C::StructuredOutput, + C::ReasoningEffort, + C::ReasoningMode, + C::Continuation, + C::PersistedReasoning, + C::PromptCaching, + C::MultipleFunctionCalls, + C::ResponseStorage, + ]); + } + ProviderKind::Anthropic => { + values.extend([ + C::FunctionTools, + C::StructuredOutput, + C::ReasoningEffort, + C::Continuation, + ]); + } + ProviderKind::Google => { + values.extend([ + C::FunctionTools, + C::StructuredOutput, + C::ReasoningEffort, + C::Continuation, + ]); + } + } + values +} + +fn parse_use(value: &str) -> Option { + value + .strip_prefix("action:") + .map(|name| TaskUse::Action(name.to_owned())) + .or_else(|| { + value + .strip_prefix("agent:") + .map(|name| TaskUse::Agent(name.to_owned())) + }) +} + +fn valid_identifier(value: &str) -> bool { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first.is_ascii_alphabetic()) + && chars + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-')) +} + +fn stable_topological_order( + declaration_order: &[String], + tasks: &BTreeMap, +) -> Result, Vec> { + let position: BTreeMap<&str, usize> = declaration_order + .iter() + .enumerate() + .map(|(index, id)| (id.as_str(), index)) + .collect(); + let mut incoming: BTreeMap<&str, usize> = tasks + .iter() + .map(|(id, task)| (id.as_str(), task.needs.len())) + .collect(); + let mut dependents: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + for (id, task) in tasks { + for dependency in &task.needs { + dependents.entry(dependency).or_default().push(id); + } + } + for items in dependents.values_mut() { + items.sort_by_key(|id| position.get(id).copied().unwrap_or(usize::MAX)); + } + let mut ready: VecDeque<&str> = declaration_order + .iter() + .filter(|id| incoming.get(id.as_str()).copied() == Some(0)) + .map(String::as_str) + .collect(); + let mut result = Vec::with_capacity(tasks.len()); + while let Some(id) = ready.pop_front() { + result.push(id.to_owned()); + if let Some(children) = dependents.get(id) { + for child in children { + if let Some(count) = incoming.get_mut(child) { + *count -= 1; + if *count == 0 { + let child_position = position.get(child).copied().unwrap_or(usize::MAX); + let insertion = ready + .iter() + .position(|queued| { + position.get(queued).copied().unwrap_or(usize::MAX) > child_position + }) + .unwrap_or(ready.len()); + ready.insert(insertion, child); + } + } + } + } + } + if result.len() == tasks.len() { + Ok(result) + } else { + Err(declaration_order + .iter() + .filter(|id| !result.contains(id)) + .cloned() + .collect()) + } +} + +const fn action_predictability(kind: ActionKind) -> PlanPredictability { + match kind { + ActionKind::Assign + | ActionKind::Assert + | ActionKind::MemoryRead + | ActionKind::MemoryWrite => PlanPredictability::FullyPredictable, + ActionKind::Read | ActionKind::Write => PlanPredictability::PartiallyPredictable, + ActionKind::ShellExec + | ActionKind::LongTermMemoryRead + | ActionKind::LongTermMemoryWrite + | ActionKind::McpCall + | ActionKind::A2aDelegate => PlanPredictability::RequiresExecution, + } +} + +fn sha256(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dsl::parse_workflow; + use proptest::prelude::*; + + fn parse(source: &str) -> Workflow { + parse_workflow(source, "fixture.yaml") + .expect("fixture parses") + .workflow + } + + #[test] + fn stable_order_uses_declaration_order_for_ready_tasks() { + let workflow = parse( + r#" +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: ordering } +spec: + actions: + assign: { kind: builtin.assign } + tasks: + - { id: b, uses: "action:assign" } + - { id: a, uses: "action:assign" } + - { id: c, uses: "action:assign", needs: [a, b] } +"#, + ); + let plan = compile(&workflow, "fixture.yaml").expect("compiles"); + assert_eq!(plan.order, ["b", "a", "c"]); + } + + #[test] + fn rejects_cycle() { + let workflow = parse( + r#" +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: cycle } +spec: + actions: + assign: { kind: builtin.assign } + tasks: + - { id: a, uses: "action:assign", needs: [b] } + - { id: b, uses: "action:assign", needs: [a] } +"#, + ); + let diagnostics = compile(&workflow, "fixture.yaml").expect_err("cycle rejected"); + assert_eq!(diagnostics[0].code, DiagnosticCode::DependencyCycle); + } + + #[test] + fn rejects_stateless_openai_continuation_for_tool_using_agents() { + let workflow = parse( + r#" +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: stateless-tools } +spec: + providers: { openai: { kind: openai } } + tools: + echo: + kind: builtin.echo + description: echo + inputSchema: { type: object } + outputSchema: { type: object } + capability: internal + risk: low + effectClass: pure + idempotency: pure + retrySafe: true + timeoutSeconds: 5 + approval: never + agents: + worker: + provider: openai + model: gpt-5.6 + instructions: use echo + tools: [echo] + providerOptions: { store: false } + tasks: [{ id: work, uses: "agent:worker" }] +"#, + ); + let diagnostics = compile(&workflow, "fixture.yaml").expect_err("must reject"); + assert!(diagnostics.iter().any(|diagnostic| { + diagnostic.code == DiagnosticCode::UnsupportedCapability + && diagnostic.message.contains("stateless continuation replay") + })); + } + + proptest! { + #[test] + fn chain_plans_are_stable(length in 1_usize..32) { + let tasks = (0..length) + .map(|index| { + let dependency = if index == 0 { + String::new() + } else { + format!("\n needs: [task-{}]", index - 1) + }; + format!("\n - id: task-{index}\n uses: action:assign{dependency}") + }) + .collect::(); + let source = format!( + "apiVersion: agentctl.dev/v1alpha1\nkind: Workflow\nmetadata: {{ name: property }}\nspec:\n actions:\n assign: {{ kind: builtin.assign }}\n tasks:{tasks}\n" + ); + let workflow = parse(&source); + let first = compile(&workflow, "property.yaml").expect("compile"); + let second = compile(&workflow, "property.yaml").expect("compile again"); + prop_assert_eq!(&first.order, &second.order); + prop_assert_eq!(&first.plan_digest, &second.plan_digest); + prop_assert_eq!(first.order.len(), length); + } + } +} diff --git a/crates/agentctl-core/src/diagnostic.rs b/crates/agentctl-core/src/diagnostic.rs new file mode 100644 index 0000000..98cc8c8 --- /dev/null +++ b/crates/agentctl-core/src/diagnostic.rs @@ -0,0 +1,76 @@ +use serde::{Deserialize, Serialize}; + +/// Stable category for a user-facing diagnostic. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DiagnosticCode { + YamlSyntax, + SchemaViolation, + UnsupportedVersion, + MigrationRequired, + DuplicateTask, + MissingReference, + DependencyCycle, + InvalidTemplate, + UnsupportedCapability, + InvalidSecretReference, + PolicyDenied, + IncompatibleState, +} + +/// Diagnostic importance. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Severity { + Error, + Warning, +} + +/// Source-aware, stable diagnostic shape used by both humans and machines. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Diagnostic { + pub code: DiagnosticCode, + pub severity: Severity, + pub message: String, + pub file: String, + pub line: Option, + pub column: Option, + pub path: Option, + pub help: Option, +} + +impl Diagnostic { + #[must_use] + pub fn error(code: DiagnosticCode, file: &str, message: impl Into) -> Self { + Self { + code, + severity: Severity::Error, + message: message.into(), + file: file.to_owned(), + line: None, + column: None, + path: None, + help: None, + } + } + + #[must_use] + pub fn with_location(mut self, line: usize, column: usize) -> Self { + self.line = Some(line); + self.column = Some(column); + self + } + + #[must_use] + pub fn with_path(mut self, path: impl Into) -> Self { + self.path = Some(path.into()); + self + } + + #[must_use] + pub fn with_help(mut self, help: impl Into) -> Self { + self.help = Some(help.into()); + self + } +} diff --git a/crates/agentctl-core/src/dsl.rs b/crates/agentctl-core/src/dsl.rs new file mode 100644 index 0000000..ec4f38f --- /dev/null +++ b/crates/agentctl-core/src/dsl.rs @@ -0,0 +1,964 @@ +use std::collections::BTreeMap; + +use schemars::{JsonSchema, schema_for}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::diagnostic::{Diagnostic, DiagnosticCode, Severity}; + +pub const API_VERSION: &str = "agentctl.dev/v1alpha1"; + +pub type JsonMap = BTreeMap; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Workflow { + pub api_version: String, + pub kind: WorkflowKind, + pub metadata: Metadata, + pub spec: WorkflowSpec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum WorkflowKind { + Workflow, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Metadata { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub labels: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WorkflowSpec { + #[serde(default)] + pub inputs: JsonMap, + #[serde(default)] + pub outputs: JsonMap, + #[serde(default)] + pub providers: BTreeMap, + #[serde(default)] + pub agents: BTreeMap, + #[serde(default)] + pub actions: BTreeMap, + #[serde(default)] + pub tools: BTreeMap, + pub tasks: Vec, + #[serde(default)] + pub policy: PolicyDefinition, + #[serde(default)] + pub memory: MemoryDefinition, + #[serde(default)] + pub mcp_servers: BTreeMap, + #[serde(default)] + pub a2a_peers: BTreeMap, + #[serde(default)] + pub packs: Vec, + #[serde(default)] + pub runtime: RuntimeDefinition, + #[serde(default)] + pub output: OutputDefinition, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ProviderKind { + Fake, + Openai, + Anthropic, + Google, + AzureOpenai, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProviderDefinition { + pub kind: ProviderKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credential: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_version: Option, + #[serde(default)] + pub headers: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SecretReference { + pub env: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgentDefinition { + pub provider: String, + pub model: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instructions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instructions_file: Option, + #[serde(default)] + pub vars: JsonMap, + #[serde(default)] + pub tools: Vec, + #[serde(default = "default_max_turns")] + pub max_turns: u16, + #[serde(default = "default_max_tool_calls")] + pub max_tool_calls: u16, + #[serde(default = "default_max_output_tokens")] + pub max_output_tokens: u32, + #[serde(default = "default_timeout_seconds")] + pub timeout_seconds: u64, + #[serde(default)] + pub retry: RetryDefinition, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub structured_output: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage_limit: Option, + #[serde(default)] + pub provider_options: JsonMap, +} + +const fn default_max_turns() -> u16 { + 8 +} +const fn default_max_tool_calls() -> u16 { + 16 +} +const fn default_max_output_tokens() -> u32 { + 2_048 +} +const fn default_timeout_seconds() -> u64 { + 120 +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ReasoningEffort { + None, + Low, + Medium, + High, + Xhigh, + Max, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReasoningDefinition { + pub effort: ReasoningEffort, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UsageLimitDefinition { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_input_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_cost_usd: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ActionDefinition { + pub kind: ActionKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, rename = "with")] + pub defaults: JsonMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, + #[serde(default)] + pub args: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(default)] + pub env: BTreeMap, + #[serde(default)] + pub timeout_seconds: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum ActionKind { + #[serde(rename = "builtin.assign")] + Assign, + #[serde(rename = "builtin.assert")] + Assert, + #[serde(rename = "builtin.read")] + Read, + #[serde(rename = "builtin.write")] + Write, + #[serde(rename = "builtin.shell.exec")] + ShellExec, + #[serde(rename = "builtin.memory.read")] + MemoryRead, + #[serde(rename = "builtin.memory.write")] + MemoryWrite, + #[serde(rename = "builtin.long_term_memory.read")] + LongTermMemoryRead, + #[serde(rename = "builtin.long_term_memory.write")] + LongTermMemoryWrite, + #[serde(rename = "mcp.call")] + McpCall, + #[serde(rename = "a2a.delegate")] + A2aDelegate, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ToolDefinition { + pub kind: ToolKind, + pub description: String, + pub input_schema: Value, + pub output_schema: Value, + pub capability: String, + pub risk: Risk, + pub effect_class: EffectClass, + pub idempotency: Idempotency, + pub retry_safe: bool, + pub timeout_seconds: u64, + #[serde(default)] + pub secrets: Vec, + #[serde(default)] + pub network: Vec, + #[serde(default)] + pub approval: ApprovalRequirement, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compensation: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum ToolKind { + #[serde(rename = "builtin.workspace.read")] + WorkspaceRead, + #[serde(rename = "builtin.workspace.write")] + WorkspaceWrite, + #[serde(rename = "builtin.echo")] + Echo, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum Risk { + Low, + Medium, + High, + Critical, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum EffectClass { + Pure, + InternalState, + Observe, + WorkspaceMutate, + ExternalMutate, + ProcessExecution, + Network, + Model, + RemoteAgent, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum Idempotency { + Pure, + Idempotent, + Keyed, + AtMostOnce, + Unknown, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalRequirement { + #[default] + Policy, + Never, + Always, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TaskDefinition { + pub id: String, + pub uses: String, + #[serde(default)] + pub needs: Vec, + #[serde(default)] + pub when: Option, + #[serde(default)] + pub vars: JsonMap, + #[serde(default, rename = "with")] + pub input: JsonMap, + #[serde(default)] + pub retry: RetryDefinition, + #[serde(default)] + pub timeout_seconds: Option, + #[serde(default)] + pub failure: FailureBehavior, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RetryDefinition { + #[serde(default = "one")] + pub max_attempts: u16, + #[serde(default)] + pub backoff_ms: u64, +} + +impl Default for RetryDefinition { + fn default() -> Self { + Self { + max_attempts: 1, + backoff_ms: 0, + } + } +} + +const fn one() -> u16 { + 1 +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum FailureBehavior { + #[default] + Stop, + Continue, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PolicyDefinition { + #[serde(default = "default_workspace_root")] + pub workspace_root: String, + #[serde(default)] + pub writable_roots: Vec, + #[serde(default)] + pub environment_allowlist: Vec, + #[serde(default)] + pub network_allowlist: Vec, + #[serde(default)] + pub process_allowlist: Vec, + #[serde(default)] + pub providers: Vec, + #[serde(default)] + pub tools_allow: Vec, + #[serde(default)] + pub tools_deny: Vec, + #[serde(default)] + pub approval: ApprovalMode, + #[serde(default)] + pub non_interactive: NonInteractiveMode, +} + +impl Default for PolicyDefinition { + fn default() -> Self { + Self { + workspace_root: default_workspace_root(), + writable_roots: Vec::new(), + environment_allowlist: Vec::new(), + network_allowlist: Vec::new(), + process_allowlist: Vec::new(), + providers: Vec::new(), + tools_allow: Vec::new(), + tools_deny: Vec::new(), + approval: ApprovalMode::default(), + non_interactive: NonInteractiveMode::default(), + } + } +} + +fn default_workspace_root() -> String { + ".".to_owned() +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalMode { + Never, + #[default] + Mutations, + HighRisk, + Always, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum NonInteractiveMode { + #[default] + Pause, + DenyApproval, + Fail, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct MemoryDefinition { + #[serde(default)] + pub working: JsonMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub long_term: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LongTermMemoryDefinition { + #[serde(default = "default_sqlite")] + pub provider: String, + #[serde(default = "default_memory_namespace")] + pub namespace: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retention_days: Option, +} + +fn default_sqlite() -> String { + "sqlite".to_owned() +} +fn default_memory_namespace() -> String { + "default".to_owned() +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct McpServerDefinition { + pub url: String, + #[serde(default)] + pub headers: BTreeMap, + #[serde(default = "default_timeout_seconds")] + pub timeout_seconds: u64, + #[serde(default = "default_mcp_version")] + pub protocol_version: String, +} + +fn default_mcp_version() -> String { + "2025-11-25".to_owned() +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct A2aPeerDefinition { + pub card_url: String, + #[serde(default)] + pub headers: BTreeMap, + #[serde(default = "default_timeout_seconds")] + pub timeout_seconds: u64, + #[serde(default = "default_a2a_version")] + pub protocol_version: String, +} + +fn default_a2a_version() -> String { + "1.0".to_owned() +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PackReference { + pub name: String, + pub version: String, + pub path: String, + pub integrity: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimeDefinition { + #[serde(default = "one_usize")] + pub max_concurrency: usize, + #[serde(default = "default_timeout_seconds")] + pub default_timeout_seconds: u64, +} + +impl Default for RuntimeDefinition { + fn default() -> Self { + Self { + max_concurrency: 1, + default_timeout_seconds: default_timeout_seconds(), + } + } +} + +const fn one_usize() -> usize { + 1 +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OutputDefinition { + #[serde(default)] + pub verbose: bool, + #[serde(default)] + pub show_diff: bool, +} + +impl Default for OutputDefinition { + fn default() -> Self { + Self { + verbose: false, + show_diff: true, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ParseOutcome { + pub workflow: Workflow, + pub diagnostics: Vec, + pub migrated_legacy: bool, +} + +/// Parse a strict v1 workflow or translate the prototype's unversioned envelope. +pub fn parse_workflow(source: &str, file: &str) -> Result> { + if source.len() > 1_048_576 { + return Err(vec![Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + "workflow exceeds the 1 MiB parser limit", + )]); + } + let raw: serde_yaml_ng::Value = serde_yaml_ng::from_str(source).map_err(|error| { + let mut diagnostic = Diagnostic::error(DiagnosticCode::YamlSyntax, file, error.to_string()); + if let Some(location) = error.location() { + diagnostic = diagnostic.with_location(location.line(), location.column()); + } + vec![diagnostic] + })?; + + let is_legacy = raw.get("apiVersion").is_none(); + let normalized = if is_legacy { + translate_legacy(raw, file)? + } else { + raw + }; + + let normalized_source; + let parse_source = if is_legacy { + normalized_source = serde_yaml_ng::to_string(&normalized).map_err(|error| { + vec![Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + error.to_string(), + )] + })?; + normalized_source.as_str() + } else { + source + }; + let deserializer = serde_yaml_ng::Deserializer::from_str(parse_source); + let workflow: Workflow = serde_path_to_error::deserialize(deserializer).map_err(|error| { + let path = error.path().to_string(); + let inner = error.into_inner(); + let mut diagnostic = + Diagnostic::error(DiagnosticCode::SchemaViolation, file, inner.to_string()) + .with_path(path); + if let Some(location) = inner.location() { + diagnostic = diagnostic.with_location(location.line(), location.column()); + } + vec![diagnostic] + })?; + + let mut diagnostics = validate_document(&workflow, file); + if diagnostics + .iter() + .any(|item| item.severity == Severity::Error) + { + return Err(diagnostics); + } + if is_legacy { + diagnostics.push(Diagnostic { + code: DiagnosticCode::MigrationRequired, + severity: Severity::Warning, + message: "translated an unversioned TypeScript-era workflow".to_owned(), + file: file.to_owned(), + line: Some(1), + column: Some(1), + path: None, + help: Some("run `agentctl migrate ` and commit the versioned form".to_owned()), + }); + } + Ok(ParseOutcome { + workflow, + diagnostics, + migrated_legacy: is_legacy, + }) +} + +fn translate_legacy( + raw: serde_yaml_ng::Value, + file: &str, +) -> Result> { + let mut json: Value = serde_json::to_value(raw).map_err(|error| { + vec![Diagnostic::error( + DiagnosticCode::MigrationRequired, + file, + error.to_string(), + )] + })?; + let Some(root) = json.as_object_mut() else { + return Err(vec![Diagnostic::error( + DiagnosticCode::MigrationRequired, + file, + "workflow root must be a mapping", + )]); + }; + let name = root + .remove("playbook") + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .ok_or_else(|| { + vec![Diagnostic::error( + DiagnosticCode::MigrationRequired, + file, + "legacy workflow requires `playbook`", + )] + })?; + root.remove("version"); + let description = root.remove("description"); + if let Some(modules) = root.remove("modules") { + root.insert("actions".to_owned(), modules); + } + if let Some(tasks) = root.get_mut("tasks").and_then(Value::as_array_mut) { + for task in tasks { + if let Some(uses) = task.get_mut("uses") + && let Some(text) = uses.as_str() + && let Some(reference) = text.strip_prefix("module:") + { + *uses = Value::String(format!("action:{reference}")); + } + } + } + if let Some(agents) = root.get_mut("agents").and_then(Value::as_object_mut) { + for agent in agents.values_mut() { + let Some(object) = agent.as_object_mut() else { + continue; + }; + let kind = object + .remove("kind") + .and_then(|value| value.as_str().map(ToOwned::to_owned)); + let provider = object + .remove("provider") + .and_then(|value| value.as_str().map(ToOwned::to_owned)); + let resolved = match (kind.as_deref(), provider) { + (Some("builtin.heuristic"), _) => "fake".to_owned(), + (_, Some(provider)) => provider, + _ => "openai".to_owned(), + }; + object.insert("provider".to_owned(), Value::String(resolved)); + object + .entry("model") + .or_insert_with(|| Value::String("scripted".to_owned())); + object.remove("profile"); + object.remove("promptCache"); + object.remove("baseUrl"); + object.remove("organization"); + object.remove("project"); + object.remove("endpoint"); + object.remove("apiVersion"); + object.remove("deployment"); + object.remove("temperature"); + object.remove("reasoningEffort"); + } + } + if !root.contains_key("providers") { + let providers = serde_json::json!({ + "fake": { "kind": "fake" }, + "openai": { "kind": "openai", "credential": { "env": "OPENAI_API_KEY" } } + }); + root.insert("providers".to_owned(), providers); + } + root.remove("defaults"); + root.remove("promptCache"); + if let Some(memory) = root.get_mut("memory").and_then(Value::as_object_mut) { + if let Some(working) = memory.remove("working") { + let initial = working + .get("initial") + .cloned() + .unwrap_or_else(|| Value::Object(Default::default())); + root.insert("memory".to_owned(), serde_json::json!({"working": initial})); + } else { + root.remove("memory"); + } + } + root.remove("mcpServers"); + root.remove("a2aAgents"); + root.remove("packs"); + if let Some(policy) = root.get_mut("policy").and_then(Value::as_object_mut) { + if let Some(mode) = policy.remove("approvalMode") { + let mapped = match mode.as_str() { + Some("never") => "never", + Some("always") => "always", + _ => "mutations", + }; + policy.insert("approval".to_owned(), Value::String(mapped.to_owned())); + } + } + let spec = Value::Object(std::mem::take(root)); + let mut metadata = serde_json::Map::new(); + metadata.insert("name".to_owned(), Value::String(name)); + if let Some(description) = description { + metadata.insert("description".to_owned(), description); + } + json = serde_json::json!({ + "apiVersion": API_VERSION, + "kind": "Workflow", + "metadata": metadata, + "spec": spec + }); + serde_json::from_value(json).map_err(|error| { + vec![Diagnostic::error( + DiagnosticCode::MigrationRequired, + file, + error.to_string(), + )] + }) +} + +fn validate_document(workflow: &Workflow, file: &str) -> Vec { + let mut diagnostics = Vec::new(); + if workflow.api_version != API_VERSION { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::UnsupportedVersion, + file, + format!("unsupported apiVersion `{}`", workflow.api_version), + ) + .with_path("apiVersion") + .with_help(format!("use `{API_VERSION}`")), + ); + } + if workflow.metadata.name.is_empty() { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + "metadata.name must not be empty", + ) + .with_path("metadata.name"), + ); + } + if workflow.spec.tasks.is_empty() { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + "spec.tasks must contain at least one task", + ) + .with_path("spec.tasks"), + ); + } + if workflow.spec.runtime.max_concurrency != 1 { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + "v1alpha1 requires runtime.maxConcurrency: 1", + ) + .with_path("spec.runtime.maxConcurrency") + .with_help( + "parallel scheduling is deferred until deterministic merge semantics are versioned", + ), + ); + } + for (name, provider) in &workflow.spec.providers { + if let Some(secret) = &provider.credential + && !valid_env_name(&secret.env) + { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::InvalidSecretReference, + file, + format!("provider `{name}` uses invalid environment variable name"), + ) + .with_path(format!("spec.providers.{name}.credential.env")), + ); + } + for (header, secret) in &provider.headers { + if !valid_env_name(&secret.env) { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::InvalidSecretReference, + file, + format!( + "provider `{name}` header `{header}` uses an invalid secret reference" + ), + ) + .with_path(format!("spec.providers.{name}.headers.{header}.env")), + ); + } + } + } + for (name, agent) in &workflow.spec.agents { + if agent.instructions.is_some() == agent.instructions_file.is_some() { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + format!( + "agent `{name}` must define exactly one of instructions or instructionsFile" + ), + ) + .with_path(format!("spec.agents.{name}")), + ); + } + if agent.max_turns == 0 || agent.max_turns > 64 { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + format!("agent `{name}` maxTurns must be between 1 and 64"), + ) + .with_path(format!("spec.agents.{name}.maxTurns")), + ); + } + if agent.max_tool_calls > 256 || agent.max_output_tokens == 0 || agent.timeout_seconds == 0 + { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + format!("agent `{name}` has invalid execution bounds"), + ) + .with_path(format!("spec.agents.{name}")), + ); + } + } + for (position, task) in workflow.spec.tasks.iter().enumerate() { + if task.retry.max_attempts == 0 || task.retry.max_attempts > 20 { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + "retry.maxAttempts must be between 1 and 20", + ) + .with_path(format!("spec.tasks[{position}].retry.maxAttempts")), + ); + } + if task.retry.backoff_ms > 60_000 { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + "retry.backoffMs must not exceed 60000", + ) + .with_path(format!("spec.tasks[{position}].retry.backoffMs")), + ); + } + } + for (name, server) in &workflow.spec.mcp_servers { + for (header, secret) in &server.headers { + if !valid_env_name(&secret.env) { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::InvalidSecretReference, + file, + format!( + "MCP server `{name}` header `{header}` has an invalid secret reference" + ), + ) + .with_path(format!("spec.mcpServers.{name}.headers.{header}.env")), + ); + } + } + } + for (name, peer) in &workflow.spec.a2a_peers { + for (header, secret) in &peer.headers { + if !valid_env_name(&secret.env) { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::InvalidSecretReference, + file, + format!( + "A2A peer `{name}` header `{header}` has an invalid secret reference" + ), + ) + .with_path(format!("spec.a2aPeers.{name}.headers.{header}.env")), + ); + } + } + } + diagnostics +} + +fn valid_env_name(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first.is_ascii_alphabetic()) + && chars.all(|character| character == '_' || character.is_ascii_alphanumeric()) +} + +#[must_use] +pub fn schema_json() -> Value { + serde_json::to_value(schema_for!(Workflow)).unwrap_or_else(|_| Value::Null) +} + +#[cfg(test)] +mod tests { + use super::*; + + const MINIMAL: &str = r#" +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: + name: hello +spec: + actions: + greet: + kind: builtin.assign + tasks: + - id: greet + uses: action:greet + with: + message: hello +"#; + + #[test] + fn parses_versioned_workflow() { + let outcome = parse_workflow(MINIMAL, "hello.yaml").expect("valid fixture"); + assert_eq!(outcome.workflow.metadata.name, "hello"); + assert!(!outcome.migrated_legacy); + } + + #[test] + fn rejects_unknown_fields_with_location_and_path() { + let source = MINIMAL.replace(" name: hello", " name: hello\n surprise: true"); + let diagnostics = parse_workflow(&source, "bad.yaml").expect_err("unknown field"); + assert_eq!(diagnostics[0].code, DiagnosticCode::SchemaViolation); + assert!(diagnostics[0].line.is_some()); + assert!( + diagnostics[0] + .path + .as_deref() + .is_some_and(|path| path.contains("metadata")) + ); + } + + #[test] + fn validates_secret_reference_name() { + let source = MINIMAL.replace( + " actions:", + " providers:\n openai:\n kind: openai\n credential:\n env: bad-key\n actions:", + ); + let diagnostics = parse_workflow(&source, "bad.yaml").expect_err("bad env name"); + assert_eq!(diagnostics[0].code, DiagnosticCode::InvalidSecretReference); + } +} diff --git a/crates/agentctl-core/src/effect.rs b/crates/agentctl-core/src/effect.rs new file mode 100644 index 0000000..05e603a --- /dev/null +++ b/crates/agentctl-core/src/effect.rs @@ -0,0 +1,143 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::EFFECT_FORMAT_VERSION; +use crate::dsl::{EffectClass, Idempotency, Risk}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EffectStatus { + Requested, + WaitingForApproval, + Started, + Succeeded, + Failed, + Cancelled, + Uncertain, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EffectRequest { + pub format_version: u32, + pub id: String, + pub run_id: String, + pub task_id: String, + pub attempt: u16, + pub ordinal: u16, + pub operation: String, + pub effect_class: EffectClass, + pub risk: Risk, + pub idempotency: Idempotency, + pub idempotency_key: String, + pub input_digest: String, + pub input: Value, + pub expected_effect: String, + pub trace_id: String, +} + +impl EffectRequest { + #[must_use] + #[allow(clippy::too_many_arguments)] + pub fn new( + run_id: &str, + task_id: &str, + attempt: u16, + ordinal: u16, + operation: &str, + effect_class: EffectClass, + risk: Risk, + idempotency: Idempotency, + input: Value, + expected_effect: &str, + trace_id: &str, + ) -> Self { + let serialized = serde_json::to_vec(&input).unwrap_or_default(); + let input_digest = hex::encode(Sha256::digest(serialized)); + let identity = + format!("{run_id}\0{task_id}\0{attempt}\0{ordinal}\0{operation}\0{input_digest}"); + let id = hex::encode(Sha256::digest(identity.as_bytes())); + Self { + format_version: EFFECT_FORMAT_VERSION, + idempotency_key: id.clone(), + id, + run_id: run_id.to_owned(), + task_id: task_id.to_owned(), + attempt, + ordinal, + operation: operation.to_owned(), + effect_class, + risk, + idempotency, + input_digest, + input, + expected_effect: expected_effect.to_owned(), + trace_id: trace_id.to_owned(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EffectRecord { + pub request: EffectRequest, + pub status: EffectStatus, + pub attempt_number: u16, + pub requested_at: DateTime, + pub started_at: Option>, + pub completed_at: Option>, + pub result: Option, + pub error: Option, + pub confirmed: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActionResult { + pub status: ChangeStatus, + pub changed: bool, + pub before: Option, + pub after: Option, + pub diff: Option, + pub output: Value, + pub predictability: crate::compiler::PlanPredictability, +} + +impl ActionResult { + #[must_use] + pub fn unchanged(output: Value) -> Self { + Self { + status: ChangeStatus::Unchanged, + changed: false, + before: None, + after: None, + diff: None, + output, + predictability: crate::compiler::PlanPredictability::FullyPredictable, + } + } + + #[must_use] + pub fn changed(output: Value) -> Self { + Self { + status: ChangeStatus::Changed, + changed: true, + before: None, + after: None, + diff: None, + output, + predictability: crate::compiler::PlanPredictability::FullyPredictable, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ChangeStatus { + Changed, + Unchanged, + Skipped, + Failed, +} diff --git a/crates/agentctl-core/src/lib.rs b/crates/agentctl-core/src/lib.rs new file mode 100644 index 0000000..1f2f798 --- /dev/null +++ b/crates/agentctl-core/src/lib.rs @@ -0,0 +1,23 @@ +//! Deterministic, provider-independent contracts for `agentctl`. + +pub mod compiler; +pub mod diagnostic; +pub mod dsl; +pub mod effect; +pub mod pack; +pub mod policy; +pub mod provider; +pub mod state; +pub mod template; +pub mod tool; + +pub use compiler::{CompiledPlan, CompiledTask, PlanPredictability, compile}; +pub use diagnostic::{Diagnostic, DiagnosticCode, Severity}; +pub use dsl::{ParseOutcome, Workflow, parse_workflow, schema_json}; + +/// Version of every machine-readable CLI envelope emitted by this release. +pub const MACHINE_OUTPUT_VERSION: &str = "agentctl.dev/cli/v1"; +/// Version of the durable compiled-plan representation. +pub const PLAN_FORMAT_VERSION: u32 = 1; +/// Version of the durable effect representation. +pub const EFFECT_FORMAT_VERSION: u32 = 1; diff --git a/crates/agentctl-core/src/pack.rs b/crates/agentctl-core/src/pack.rs new file mode 100644 index 0000000..1d3101f --- /dev/null +++ b/crates/agentctl-core/src/pack.rs @@ -0,0 +1,137 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use schemars::JsonSchema; +use semver::{Version, VersionReq}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::dsl::{ActionDefinition, AgentDefinition, PolicyDefinition, ToolDefinition}; + +pub const PACK_API_VERSION: &str = "agentctl.dev/pack/v1alpha1"; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PackManifest { + pub api_version: String, + pub name: String, + pub version: String, + pub agentctl: String, + #[serde(default)] + pub actions: BTreeMap, + #[serde(default)] + pub agents: BTreeMap, + #[serde(default)] + pub tools: BTreeMap, + #[serde(default)] + pub capabilities: Vec, + #[serde(default)] + pub providers: Vec, + #[serde(default)] + pub policy_defaults: Option, +} + +impl PackManifest { + pub fn validate(&self) -> Result<(), PackError> { + if self.api_version != PACK_API_VERSION { + return Err(PackError::Invalid(format!( + "unsupported apiVersion `{}`; expected `{PACK_API_VERSION}`", + self.api_version + ))); + } + if !self.name.contains('.') || self.name.split('.').any(str::is_empty) { + return Err(PackError::Invalid( + "name must be a fully qualified dotted name".to_owned(), + )); + } + Version::parse(&self.version) + .map_err(|error| PackError::Invalid(format!("version is not semver: {error}")))?; + let requirement = VersionReq::parse(&self.agentctl).map_err(|error| { + PackError::Invalid(format!("agentctl constraint is not valid semver: {error}")) + })?; + let current = Version::parse(env!("CARGO_PKG_VERSION")).map_err(|error| { + PackError::Invalid(format!("agentctl build version is invalid: {error}")) + })?; + if !requirement.matches(¤t) { + return Err(PackError::Invalid(format!( + "agentctl {current} does not satisfy `{requirement}`" + ))); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PackLockEntry { + pub name: String, + pub version: String, + pub source: PathBuf, + pub integrity: String, +} + +#[derive(Debug, Error)] +pub enum PackError { + #[error("pack manifest is invalid: {0}")] + Invalid(String), + #[error("pack integrity mismatch for {path}: expected {expected}, got {actual}")] + Integrity { + path: PathBuf, + expected: String, + actual: String, + }, + #[error("pack input/output error: {0}")] + Io(#[from] std::io::Error), +} + +pub fn verify_pack(path: &Path, expected: &str) -> Result { + let bytes = fs::read(path)?; + let actual = format!("sha256:{}", hex::encode(Sha256::digest(bytes))); + if actual == expected { + Ok(actual) + } else { + Err(PackError::Integrity { + path: path.to_path_buf(), + expected: expected.to_owned(), + actual, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn detects_pack_tampering() { + let mut file = tempfile::NamedTempFile::new().expect("temp file"); + file.write_all(b"trusted").expect("write"); + let integrity = verify_pack( + file.path(), + "sha256:a9a089195c68d2adeee23beaa2c3a93b1d4cdf09046e7a9e520b3b166dff3e6a", + ) + .expect("matches"); + assert!(integrity.starts_with("sha256:")); + file.write_all(b"tampered").expect("tamper"); + assert!(matches!( + verify_pack(file.path(), &integrity), + Err(PackError::Integrity { .. }) + )); + } + + #[test] + fn validates_manifest_identity_and_compatibility() { + let manifest: PackManifest = serde_yaml_ng::from_str( + "apiVersion: agentctl.dev/pack/v1alpha1\nname: example.utility\nversion: 1.0.0\nagentctl: '>=0.2.0, <1.0.0'\n", + ) + .expect("manifest"); + manifest.validate().expect("valid manifest"); + + let mut invalid = manifest; + invalid.name = "local".to_owned(); + assert!(matches!(invalid.validate(), Err(PackError::Invalid(_)))); + } +} diff --git a/crates/agentctl-core/src/policy.rs b/crates/agentctl-core/src/policy.rs new file mode 100644 index 0000000..1e5a2fa --- /dev/null +++ b/crates/agentctl-core/src/policy.rs @@ -0,0 +1,398 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; +use url::Url; + +use crate::dsl::{ApprovalMode, EffectClass, NonInteractiveMode, PolicyDefinition, Risk}; + +#[derive(Debug, Clone)] +pub struct PolicyEngine { + policy: PolicyDefinition, + workspace_root: PathBuf, + writable_roots: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PolicyContext { + pub run_id: String, + pub trace_id: String, + pub task_id: String, + pub agent: Option, + pub tool: String, + pub capability: String, + pub effect_class: EffectClass, + pub risk: Risk, + pub resource: Option, + pub provider: Option, + pub input: Value, + pub interactive: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PolicyDecision { + Allow { reason: String }, + RequireApproval { reason: String }, + Deny { reason: String }, +} + +#[derive(Debug, Error)] +pub enum PolicyError { + #[error("workspace root is invalid: {0}")] + Workspace(String), + #[error("resource path escapes the authorized root: {0}")] + PathEscape(String), + #[error("network destination is not authorized: {0}")] + NetworkDenied(String), + #[error("environment variable is not authorized: {0}")] + EnvironmentDenied(String), + #[error("process is not authorized: {0}")] + ProcessDenied(String), +} + +impl PolicyEngine { + pub fn new(policy: PolicyDefinition, base: &Path) -> Result { + let workspace_candidate = if Path::new(&policy.workspace_root).is_absolute() { + PathBuf::from(&policy.workspace_root) + } else { + base.join(&policy.workspace_root) + }; + let workspace_root = canonicalize_existing_or_parent(&workspace_candidate)?; + let writable_roots = policy + .writable_roots + .iter() + .map(|root| { + let candidate = if Path::new(root).is_absolute() { + PathBuf::from(root) + } else { + workspace_root.join(root) + }; + canonicalize_existing_or_parent(&candidate) + }) + .collect::, _>>()?; + Ok(Self { + policy, + workspace_root, + writable_roots, + }) + } + + #[must_use] + pub fn decide(&self, context: &PolicyContext) -> PolicyDecision { + if self + .policy + .tools_deny + .iter() + .any(|tool| tool == &context.tool) + { + return PolicyDecision::Deny { + reason: "tool is explicitly denied".to_owned(), + }; + } + if !self.policy.tools_allow.is_empty() + && !self + .policy + .tools_allow + .iter() + .any(|tool| tool == &context.tool) + { + return PolicyDecision::Deny { + reason: "tool is not in the allowlist".to_owned(), + }; + } + if let Some(provider) = &context.provider + && !self.policy.providers.is_empty() + && !self.policy.providers.contains(provider) + { + return PolicyDecision::Deny { + reason: "provider is not in the allowlist".to_owned(), + }; + } + let approval = match self.policy.approval { + ApprovalMode::Never => false, + ApprovalMode::Always => true, + ApprovalMode::HighRisk => matches!(context.risk, Risk::High | Risk::Critical), + ApprovalMode::Mutations => matches!( + context.effect_class, + EffectClass::WorkspaceMutate + | EffectClass::ExternalMutate + | EffectClass::ProcessExecution + | EffectClass::RemoteAgent + ), + }; + if approval && !context.interactive { + match self.policy.non_interactive { + NonInteractiveMode::Pause => PolicyDecision::RequireApproval { + reason: "approval is required; the non-interactive run will pause durably" + .to_owned(), + }, + NonInteractiveMode::DenyApproval => PolicyDecision::Deny { + reason: "approval is required and non-interactive policy denies approval" + .to_owned(), + }, + NonInteractiveMode::Fail => PolicyDecision::Deny { + reason: "approval is required and non-interactive policy is fail".to_owned(), + }, + } + } else if approval { + PolicyDecision::RequireApproval { + reason: format!( + "policy requires approval for {:?} / {:?}", + context.effect_class, context.risk + ), + } + } else { + PolicyDecision::Allow { + reason: "request satisfies policy".to_owned(), + } + } + } + + pub fn resolve_read_path(&self, requested: &str) -> Result { + let candidate = self.join_workspace(requested)?; + let canonical = fs::canonicalize(&candidate) + .map_err(|error| PolicyError::PathEscape(format!("{requested}: {error}")))?; + if canonical.starts_with(&self.workspace_root) { + Ok(canonical) + } else { + Err(PolicyError::PathEscape(requested.to_owned())) + } + } + + pub fn resolve_write_path(&self, requested: &str) -> Result { + let candidate = self.join_workspace(requested)?; + let canonical = canonicalize_existing_or_parent(&candidate)?; + if self + .writable_roots + .iter() + .any(|root| canonical.starts_with(root)) + { + Ok(candidate) + } else { + Err(PolicyError::PathEscape(requested.to_owned())) + } + } + + pub fn authorize_network(&self, target: &Url) -> Result<(), PolicyError> { + if target.scheme() != "https" && target.scheme() != "http" { + return Err(PolicyError::NetworkDenied(target.to_string())); + } + let host = target + .host_str() + .ok_or_else(|| PolicyError::NetworkDenied(target.to_string()))?; + if self + .policy + .network_allowlist + .iter() + .any(|rule| host_matches(host, rule)) + { + Ok(()) + } else { + Err(PolicyError::NetworkDenied(host.to_owned())) + } + } + + pub fn authorize_redirect(&self, from: &Url, to: &Url) -> Result<(), PolicyError> { + self.authorize_network(from)?; + self.authorize_network(to) + } + + pub fn authorize_environment(&self, name: &str) -> Result<(), PolicyError> { + if self + .policy + .environment_allowlist + .iter() + .any(|allowed| allowed == name) + { + Ok(()) + } else { + Err(PolicyError::EnvironmentDenied(name.to_owned())) + } + } + + pub fn authorize_process(&self, command: &str) -> Result<(), PolicyError> { + let basename = Path::new(command) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(command); + if self + .policy + .process_allowlist + .iter() + .any(|allowed| allowed == basename) + { + Ok(()) + } else { + Err(PolicyError::ProcessDenied(command.to_owned())) + } + } + + #[must_use] + pub fn filtered_environment( + &self, + source: &BTreeMap, + ) -> BTreeMap { + source + .iter() + .filter(|(name, _)| self.policy.environment_allowlist.contains(name)) + .map(|(name, value)| (name.clone(), value.clone())) + .collect() + } + + fn join_workspace(&self, requested: &str) -> Result { + let path = Path::new(requested); + if path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err(PolicyError::PathEscape(requested.to_owned())); + } + Ok(if path.is_absolute() { + path.to_path_buf() + } else { + self.workspace_root.join(path) + }) + } +} + +fn canonicalize_existing_or_parent(path: &Path) -> Result { + if path.exists() { + return fs::canonicalize(path) + .map_err(|error| PolicyError::Workspace(format!("{}: {error}", path.display()))); + } + let parent = path + .parent() + .ok_or_else(|| PolicyError::Workspace(path.display().to_string()))?; + let canonical_parent = fs::canonicalize(parent) + .map_err(|error| PolicyError::Workspace(format!("{}: {error}", parent.display())))?; + let name = path + .file_name() + .ok_or_else(|| PolicyError::Workspace(path.display().to_string()))?; + Ok(canonical_parent.join(name)) +} + +fn host_matches(host: &str, rule: &str) -> bool { + rule.strip_prefix("*.").map_or(host == rule, |suffix| { + host != suffix && host.ends_with(&format!(".{suffix}")) + }) +} + +/// Replace sensitive values before audit, persistence, or tracing. +#[must_use] +pub fn redact(value: &Value, secret_values: &[String]) -> Value { + match value { + Value::String(text) => { + let mut redacted = text.clone(); + for secret in secret_values.iter().filter(|secret| !secret.is_empty()) { + redacted = redacted.replace(secret, "[REDACTED]"); + } + Value::String(redacted) + } + Value::Array(items) => Value::Array( + items + .iter() + .map(|item| redact(item, secret_values)) + .collect(), + ), + Value::Object(map) => Value::Object( + map.iter() + .map(|(key, item)| { + let sensitive_key = matches!( + key.to_ascii_lowercase().as_str(), + "authorization" | "api_key" | "apikey" | "token" | "secret" | "password" + ); + ( + key.clone(), + if sensitive_key { + Value::String("[REDACTED]".to_owned()) + } else { + redact(item, secret_values) + }, + ) + }) + .collect(), + ), + primitive => primitive.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn policy(root: &Path) -> PolicyEngine { + let policy = PolicyDefinition { + workspace_root: root.display().to_string(), + writable_roots: vec!["safe".to_owned()], + network_allowlist: vec!["api.example.com".to_owned(), "*.tools.example".to_owned()], + environment_allowlist: vec!["SAFE_VALUE".to_owned()], + process_allowlist: vec!["git".to_owned()], + ..PolicyDefinition::default() + }; + PolicyEngine::new(policy, root).expect("valid policy") + } + + #[test] + fn rejects_parent_traversal() { + let root = tempdir().expect("temp dir"); + fs::create_dir(root.path().join("safe")).expect("safe dir"); + assert!(matches!( + policy(root.path()).resolve_write_path("safe/../../escape"), + Err(PolicyError::PathEscape(_)) + )); + } + + #[cfg(unix)] + #[test] + fn rejects_symlink_escape() { + use std::os::unix::fs::symlink; + let root = tempdir().expect("temp dir"); + let outside = tempdir().expect("outside"); + fs::create_dir(root.path().join("safe")).expect("safe dir"); + symlink(outside.path(), root.path().join("safe/link")).expect("symlink"); + assert!( + policy(root.path()) + .resolve_write_path("safe/link/secret") + .is_err() + ); + } + + #[test] + fn network_wildcard_does_not_match_apex_or_suffix_attack() { + let root = tempdir().expect("temp dir"); + fs::create_dir(root.path().join("safe")).expect("safe dir"); + let engine = policy(root.path()); + assert!( + engine + .authorize_network(&Url::parse("https://x.tools.example/a").expect("url")) + .is_ok() + ); + assert!( + engine + .authorize_network(&Url::parse("https://tools.example/a").expect("url")) + .is_err() + ); + assert!( + engine + .authorize_network(&Url::parse("https://api.example.com.evil/a").expect("url")) + .is_err() + ); + } + + #[test] + fn redacts_keys_and_embedded_secret_values() { + let value = serde_json::json!({ + "authorization": "Bearer secret-value", + "message": "found secret-value in output" + }); + let redacted = redact(&value, &["secret-value".to_owned()]); + let text = serde_json::to_string(&redacted).expect("json"); + assert!(!text.contains("secret-value")); + assert!(text.contains("[REDACTED]")); + } +} diff --git a/crates/agentctl-core/src/provider.rs b/crates/agentctl-core/src/provider.rs new file mode 100644 index 0000000..2d92ecc --- /dev/null +++ b/crates/agentctl-core/src/provider.rs @@ -0,0 +1,133 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; +use thiserror::Error; +use tokio_util::sync::CancellationToken; + +use crate::dsl::ReasoningDefinition; +use crate::tool::ToolContract; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderRequest { + pub model: String, + pub instructions: String, + pub messages: Vec, + pub tools: Vec, + pub max_output_tokens: u32, + pub reasoning: Option, + pub structured_output: Option, + pub continuation: Option, + pub prompt_cache_key: Option, + pub safety_identifier: Option, + #[serde(default)] + pub provider_options: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "role", content = "content")] +pub enum Message { + User(Vec), + Assistant(Vec), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "type")] +pub enum ContentBlock { + Text { + text: String, + }, + ToolCall { + id: String, + name: String, + input: Value, + }, + ToolResult { + id: String, + output: Value, + is_error: bool, + }, + OpaqueReasoning { + value: Value, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolCall { + pub id: String, + pub name: String, + pub input: Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderResponse { + pub response_id: Option, + pub text: String, + pub tool_calls: Vec, + pub assistant_content: Vec, + pub continuation: Option, + pub usage: Usage, + pub finish_reason: FinishReason, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "kind", content = "value")] +pub enum ContinuationState { + OpenaiPreviousResponse(String), + Conversation(Vec), +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Usage { + pub input_tokens: u64, + pub output_tokens: u64, + pub reasoning_tokens: u64, + pub cache_read_tokens: u64, + pub cache_write_tokens: u64, + pub cost_microusd: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FinishReason { + Complete, + ToolCalls, + MaxTokens, + Refusal, + Cancelled, +} + +#[derive(Debug, Error)] +pub enum ProviderError { + #[error("provider authentication is unavailable: {0}")] + Authentication(String), + #[error("provider capability is unsupported: {0}")] + Unsupported(String), + #[error("provider request timed out")] + Timeout, + #[error("provider request was cancelled")] + Cancelled, + #[error("provider returned HTTP {status}: {message} (request id: {request_id})")] + Http { + status: u16, + message: String, + request_id: String, + retryable: bool, + }, + #[error("provider response was malformed: {0}")] + Malformed(String), +} + +#[async_trait] +pub trait ModelProvider: Send + Sync { + fn name(&self) -> &'static str; + async fn complete( + &self, + request: &ProviderRequest, + cancellation: &CancellationToken, + ) -> Result; +} diff --git a/crates/agentctl-core/src/state.rs b/crates/agentctl-core/src/state.rs new file mode 100644 index 0000000..7589c8d --- /dev/null +++ b/crates/agentctl-core/src/state.rs @@ -0,0 +1,177 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RunState { + Running, + Paused, + Succeeded, + Failed, + Cancelled, +} + +impl RunState { + #[must_use] + pub const fn is_terminal(self) -> bool { + matches!(self, Self::Succeeded | Self::Failed | Self::Cancelled) + } + + pub fn transition(self, next: Self) -> Result { + let valid = matches!( + (self, next), + ( + Self::Running, + Self::Paused | Self::Succeeded | Self::Failed | Self::Cancelled + ) | (Self::Paused, Self::Running | Self::Failed | Self::Cancelled) + ); + if valid { + Ok(next) + } else { + Err(InvalidRunTransition { + from: self, + to: next, + }) + } + } +} + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[error("invalid run transition from {from:?} to {to:?}")] +pub struct InvalidRunTransition { + pub from: RunState, + pub to: RunState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskState { + Pending, + Ready, + Running, + WaitingForApproval, + WaitingForEffect, + RetryScheduled, + Succeeded, + Failed, + Skipped, + Cancelled, +} + +impl TaskState { + #[must_use] + pub const fn is_terminal(self) -> bool { + matches!( + self, + Self::Succeeded | Self::Failed | Self::Skipped | Self::Cancelled + ) + } + + pub fn transition(self, next: Self) -> Result { + let valid = matches!( + (self, next), + (Self::Pending, Self::Ready | Self::Skipped | Self::Cancelled) + | (Self::Ready, Self::Running | Self::Cancelled) + | ( + Self::Running, + Self::WaitingForApproval + | Self::WaitingForEffect + | Self::Succeeded + | Self::Failed + | Self::RetryScheduled + | Self::Cancelled + ) + | ( + Self::WaitingForApproval, + Self::Running | Self::Failed | Self::Cancelled + ) + | ( + Self::WaitingForEffect, + Self::Running | Self::Failed | Self::Cancelled + ) + | (Self::RetryScheduled, Self::Ready | Self::Cancelled) + ); + if valid { + Ok(next) + } else { + Err(InvalidTransition { + from: self, + to: next, + }) + } + } +} + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[error("invalid task transition from {from:?} to {to:?}")] +pub struct InvalidTransition { + pub from: TaskState, + pub to: TaskState, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_terminal_state_rejects_every_transition() { + let states = [ + TaskState::Pending, + TaskState::Ready, + TaskState::Running, + TaskState::WaitingForApproval, + TaskState::WaitingForEffect, + TaskState::RetryScheduled, + TaskState::Succeeded, + TaskState::Failed, + TaskState::Skipped, + TaskState::Cancelled, + ]; + for terminal in [ + TaskState::Succeeded, + TaskState::Failed, + TaskState::Skipped, + TaskState::Cancelled, + ] { + for candidate in states { + assert!(terminal.transition(candidate).is_err()); + } + } + } + + #[test] + fn happy_path_is_explicit() { + let state = TaskState::Pending + .transition(TaskState::Ready) + .and_then(|state| state.transition(TaskState::Running)) + .and_then(|state| state.transition(TaskState::WaitingForEffect)) + .and_then(|state| state.transition(TaskState::Running)) + .and_then(|state| state.transition(TaskState::Succeeded)) + .expect("valid path"); + assert_eq!(state, TaskState::Succeeded); + } + + #[test] + fn run_transitions_are_explicit_and_terminal_runs_are_immutable() { + assert_eq!( + RunState::Running.transition(RunState::Paused), + Ok(RunState::Paused) + ); + assert_eq!( + RunState::Paused.transition(RunState::Running), + Ok(RunState::Running) + ); + for terminal in [RunState::Succeeded, RunState::Failed, RunState::Cancelled] { + assert!(terminal.is_terminal()); + for candidate in [ + RunState::Running, + RunState::Paused, + RunState::Succeeded, + RunState::Failed, + RunState::Cancelled, + ] { + assert!(terminal.transition(candidate).is_err()); + } + } + } +} diff --git a/crates/agentctl-core/src/template.rs b/crates/agentctl-core/src/template.rs new file mode 100644 index 0000000..6be7a39 --- /dev/null +++ b/crates/agentctl-core/src/template.rs @@ -0,0 +1,299 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::Value; +use thiserror::Error; + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum TemplateError { + #[error("unclosed template expression")] + Unclosed, + #[error("empty template expression")] + Empty, + #[error("unsupported expression `{0}`")] + Unsupported(String), + #[error("undefined value `{0}`")] + Undefined(String), + #[error("embedded object or array `{0}` cannot be rendered into text")] + NonScalar(String), +} + +#[derive(Debug, Default, Clone)] +pub struct EvalContext { + pub inputs: BTreeMap, + pub vars: BTreeMap, + pub memory: BTreeMap, + pub tasks: BTreeMap, +} + +pub fn validate_expression(template: &str) -> Result<(), TemplateError> { + for expression in expressions(template)? { + validate_path_or_comparison(expression)?; + } + Ok(()) +} + +#[must_use] +pub fn referenced_tasks(template: &str) -> BTreeSet { + expressions(template) + .unwrap_or_default() + .into_iter() + .filter_map(|expression| { + let expression = expression + .trim() + .strip_prefix("not ") + .unwrap_or(expression.trim()); + let path = expression.split("==").next().unwrap_or(expression).trim(); + let mut parts = path.split('.'); + (parts.next() == Some("tasks")) + .then(|| parts.next().map(ToOwned::to_owned)) + .flatten() + }) + .collect() +} + +pub fn render(value: &Value, context: &EvalContext) -> Result { + match value { + Value::String(text) => render_string(text, context), + Value::Array(items) => items + .iter() + .map(|item| render(item, context)) + .collect::, _>>() + .map(Value::Array), + Value::Object(map) => map + .iter() + .map(|(key, item)| render(item, context).map(|value| (key.clone(), value))) + .collect::, _>>() + .map(Value::Object), + primitive => Ok(primitive.clone()), + } +} + +pub fn evaluate_when(expression: &str, context: &EvalContext) -> Result { + let trimmed = expression.trim(); + let inner = trimmed + .strip_prefix("${{") + .and_then(|value| value.strip_suffix("}}")) + .map(str::trim) + .unwrap_or(trimmed); + let negated = inner.starts_with("not "); + let candidate = inner.strip_prefix("not ").unwrap_or(inner).trim(); + let result = if let Some((left, right)) = candidate.split_once("==") { + let left_value = resolve_path(left.trim(), context)?; + let right_value: Value = serde_json::from_str(right.trim()) + .unwrap_or_else(|_| Value::String(right.trim().trim_matches(['\'', '"']).to_owned())); + left_value == &right_value + } else { + truthy(resolve_path(candidate, context)?) + }; + Ok(if negated { !result } else { result }) +} + +fn render_string(text: &str, context: &EvalContext) -> Result { + let found = expressions_with_ranges(text)?; + if found.is_empty() { + return Ok(Value::String(text.to_owned())); + } + if found.len() == 1 && found[0].0 == 0 && found[0].1 == text.len() { + if found[0].2.contains("==") || found[0].2.trim_start().starts_with("not ") { + return evaluate_when(found[0].2, context).map(Value::Bool); + } + return Ok(resolve_path(found[0].2, context)?.clone()); + } + let mut output = String::new(); + let mut cursor = 0; + for (start, end, expression) in found { + output.push_str(&text[cursor..start]); + let value = resolve_path(expression, context)?; + match value { + Value::Null => output.push_str("null"), + Value::Bool(value) => output.push_str(if *value { "true" } else { "false" }), + Value::Number(value) => output.push_str(&value.to_string()), + Value::String(value) => output.push_str(value), + Value::Array(_) | Value::Object(_) => { + return Err(TemplateError::NonScalar(expression.to_owned())); + } + } + cursor = end; + } + output.push_str(&text[cursor..]); + Ok(Value::String(output)) +} + +fn expressions(template: &str) -> Result, TemplateError> { + expressions_with_ranges(template).map(|items| { + items + .into_iter() + .map(|(_, _, expression)| expression) + .collect() + }) +} + +fn expressions_with_ranges(template: &str) -> Result, TemplateError> { + let mut output = Vec::new(); + let mut cursor = 0; + while let Some(relative_start) = template[cursor..].find("${{") { + let start = cursor + relative_start; + let inner_start = start + 3; + let Some(relative_end) = template[inner_start..].find("}}") else { + return Err(TemplateError::Unclosed); + }; + let end_marker = inner_start + relative_end; + let expression = template[inner_start..end_marker].trim(); + if expression.is_empty() { + return Err(TemplateError::Empty); + } + output.push((start, end_marker + 2, expression)); + cursor = end_marker + 2; + } + Ok(output) +} + +fn validate_path_or_comparison(expression: &str) -> Result<(), TemplateError> { + let candidate = expression + .trim() + .strip_prefix("not ") + .unwrap_or(expression.trim()); + let path = candidate + .split_once("==") + .map_or(candidate, |(left, _)| left) + .trim(); + let mut parts = path.split('.'); + match parts.next() { + Some("inputs" | "vars" | "memory") if parts.next().is_some() => {} + Some("tasks") if parts.next().is_some() && parts.next() == Some("output") => {} + _ => return Err(TemplateError::Unsupported(expression.to_owned())), + } + if path.split('.').any(|part| { + part.is_empty() + || !part + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')) + }) { + return Err(TemplateError::Unsupported(expression.to_owned())); + } + Ok(()) +} + +fn resolve_path<'a>(path: &str, context: &'a EvalContext) -> Result<&'a Value, TemplateError> { + validate_path_or_comparison(path)?; + let parts: Vec<&str> = path.trim().split('.').collect(); + let (root, remainder): (&BTreeMap, &[&str]) = match parts.as_slice() { + ["inputs", rest @ ..] => (&context.inputs, rest), + ["vars", rest @ ..] => (&context.vars, rest), + ["memory", rest @ ..] => (&context.memory, rest), + ["tasks", task, "output", rest @ ..] => { + let value = context + .tasks + .get(*task) + .ok_or_else(|| TemplateError::Undefined(path.to_owned()))?; + return descend(value, rest, path); + } + _ => return Err(TemplateError::Unsupported(path.to_owned())), + }; + let first = remainder + .first() + .ok_or_else(|| TemplateError::Unsupported(path.to_owned()))?; + let value = root + .get(*first) + .ok_or_else(|| TemplateError::Undefined(path.to_owned()))?; + descend(value, &remainder[1..], path) +} + +fn descend<'a>( + mut value: &'a Value, + parts: &[&str], + path: &str, +) -> Result<&'a Value, TemplateError> { + for part in parts { + value = value + .as_object() + .and_then(|map| map.get(*part)) + .ok_or_else(|| TemplateError::Undefined(path.to_owned()))?; + } + Ok(value) +} + +fn truthy(value: &Value) -> bool { + match value { + Value::Null => false, + Value::Bool(value) => *value, + Value::Number(value) => value.as_f64().is_some_and(|number| number != 0.0), + Value::String(value) => !value.is_empty(), + Value::Array(value) => !value.is_empty(), + Value::Object(value) => !value.is_empty(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + #[test] + fn exact_template_preserves_object_type() { + let inputs = BTreeMap::from([("config".to_owned(), serde_json::json!({"safe": true}))]); + let context = EvalContext { + inputs, + ..EvalContext::default() + }; + let rendered = render(&Value::String("${{ inputs.config }}".to_owned()), &context) + .expect("template renders"); + assert_eq!(rendered, serde_json::json!({"safe": true})); + } + + #[test] + fn embedded_object_is_rejected() { + let inputs = BTreeMap::from([("config".to_owned(), serde_json::json!({"safe": true}))]); + let context = EvalContext { + inputs, + ..EvalContext::default() + }; + assert!(matches!( + render( + &Value::String("config=${{ inputs.config }}".to_owned()), + &context + ), + Err(TemplateError::NonScalar(_)) + )); + } + + #[test] + fn condition_supports_safe_equality_only() { + let inputs = BTreeMap::from([("deploy".to_owned(), Value::Bool(true))]); + let context = EvalContext { + inputs, + ..EvalContext::default() + }; + assert!(evaluate_when("${{ inputs.deploy == true }}", &context).expect("valid")); + assert_eq!( + render( + &Value::String("${{ inputs.deploy == true }}".to_owned()), + &context + ), + Ok(Value::Bool(true)) + ); + assert!(validate_expression("${{ inputs.x + 1 }}").is_err()); + } + + proptest! { + #[test] + fn arbitrary_templates_never_panic(template in ".{0,4096}") { + let validation = validate_expression(&template); + if validation.is_ok() { + let _ = render(&Value::String(template), &EvalContext::default()); + } + } + + #[test] + fn scalar_exact_templates_preserve_json(value in any::()) { + let context = EvalContext { + inputs: BTreeMap::from([("value".to_owned(), Value::from(value))]), + ..EvalContext::default() + }; + prop_assert_eq!( + render(&Value::String("${{ inputs.value }}".to_owned()), &context), + Ok(Value::from(value)) + ); + } + } +} diff --git a/crates/agentctl-core/src/tool.rs b/crates/agentctl-core/src/tool.rs new file mode 100644 index 0000000..117dc96 --- /dev/null +++ b/crates/agentctl-core/src/tool.rs @@ -0,0 +1,140 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; +use tokio_util::sync::CancellationToken; + +use crate::dsl::{ApprovalRequirement, EffectClass, Idempotency, Risk, SecretReference}; +use crate::effect::ActionResult; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolContract { + pub id: String, + pub description: String, + pub input_schema: Value, + pub output_schema: Value, + pub capability: String, + pub risk: Risk, + pub effect_class: EffectClass, + pub idempotency: Idempotency, + pub retry_safe: bool, + pub timeout_seconds: u64, + pub secret_requirements: Vec, + pub network_requirements: Vec, + pub approval: ApprovalRequirement, + pub observability: Value, + pub compensation: Option, +} + +impl ToolContract { + pub fn validate_input(&self, input: &Value) -> Result<(), ToolContractError> { + validate_schema(&self.input_schema, input, "input") + } + + pub fn validate_output(&self, output: &Value) -> Result<(), ToolContractError> { + validate_schema(&self.output_schema, output, "output") + } +} + +fn validate_schema( + schema: &Value, + instance: &Value, + direction: &str, +) -> Result<(), ToolContractError> { + let validator = jsonschema::validator_for(schema) + .map_err(|error| ToolContractError::InvalidSchema(error.to_string()))?; + let errors: Vec = validator + .iter_errors(instance) + .map(|error| error.to_string()) + .collect(); + if errors.is_empty() { + Ok(()) + } else { + Err(ToolContractError::Validation { + direction: direction.to_owned(), + errors, + }) + } +} + +#[derive(Debug, Error)] +pub enum ToolContractError { + #[error("invalid JSON Schema: {0}")] + InvalidSchema(String), + #[error("tool {direction} failed schema validation: {errors:?}")] + Validation { + direction: String, + errors: Vec, + }, + #[error("tool execution failed: {0}")] + Execution(String), + #[error("tool execution was cancelled")] + Cancelled, +} + +#[async_trait] +pub trait ToolExecutor: Send + Sync { + fn contract(&self) -> &ToolContract; + async fn execute( + &self, + input: Value, + cancellation: &CancellationToken, + ) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dsl::{ApprovalRequirement, EffectClass, Idempotency, Risk}; + + fn contract() -> ToolContract { + ToolContract { + id: "example.echo".to_owned(), + description: "Echo text".to_owned(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + "additionalProperties": false + }), + output_schema: serde_json::json!({"type": "object"}), + capability: "observe".to_owned(), + risk: Risk::Low, + effect_class: EffectClass::Pure, + idempotency: Idempotency::Pure, + retry_safe: true, + timeout_seconds: 5, + secret_requirements: Vec::new(), + network_requirements: Vec::new(), + approval: ApprovalRequirement::Never, + observability: Value::Null, + compensation: None, + } + } + + #[test] + fn rejects_malformed_input_and_output() { + let contract = contract(); + assert!( + contract + .validate_input(&serde_json::json!({"text": "ok"})) + .is_ok() + ); + assert!( + contract + .validate_input(&serde_json::json!({"text": 4})) + .is_err() + ); + assert!( + contract + .validate_input(&serde_json::json!({"text": "ok", "extra": true})) + .is_err() + ); + assert!( + contract + .validate_output(&Value::String("wrong".to_owned())) + .is_err() + ); + } +} diff --git a/crates/agentctl-core/tests/compatibility.rs b/crates/agentctl-core/tests/compatibility.rs new file mode 100644 index 0000000..dfd2319 --- /dev/null +++ b/crates/agentctl-core/tests/compatibility.rs @@ -0,0 +1,44 @@ +use agentctl_core::compiler::TaskUse; +use agentctl_core::{compile, parse_workflow}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Expected { + migrated_legacy: bool, + workflow_name: String, + order: Vec, + task: ExpectedTask, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ExpectedTask { + id: String, + use_kind: String, + reference: String, + needs: Vec, +} + +#[test] +fn typescript_assign_fixture_translates_to_the_language_neutral_contract() { + let source = include_str!("../../../fixtures/compat/v0/assign.playbook.yaml"); + let expected: Expected = serde_json::from_str(include_str!( + "../../../fixtures/compat/v0/assign.expected.json" + )) + .expect("expected fixture"); + let parsed = parse_workflow(source, "assign.playbook.yaml").expect("legacy parse"); + let plan = compile(&parsed.workflow, "assign.playbook.yaml").expect("legacy compile"); + assert_eq!(parsed.migrated_legacy, expected.migrated_legacy); + assert_eq!(plan.workflow_name, expected.workflow_name); + assert_eq!(plan.order, expected.order); + let task = plan.tasks.get(&expected.task.id).expect("task"); + assert_eq!(task.needs, expected.task.needs); + match &task.uses { + TaskUse::Action(reference) => { + assert_eq!(expected.task.use_kind, "action"); + assert_eq!(reference, &expected.task.reference); + } + TaskUse::Agent(_) => panic!("expected action task"), + } +} diff --git a/crates/agentctl-observability/Cargo.toml b/crates/agentctl-observability/Cargo.toml new file mode 100644 index 0000000..80335be --- /dev/null +++ b/crates/agentctl-observability/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "agentctl-observability" +description = "Redacted audit and OpenTelemetry-compatible events for agentctl" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +agentctl-core = { version = "0.2.0", path = "../agentctl-core" } +chrono.workspace = true +opentelemetry.workspace = true +serde.workspace = true +serde_json.workspace = true + +[lints] +workspace = true diff --git a/crates/agentctl-observability/src/lib.rs b/crates/agentctl-observability/src/lib.rs new file mode 100644 index 0000000..4125dbb --- /dev/null +++ b/crates/agentctl-observability/src/lib.rs @@ -0,0 +1,181 @@ +//! Optional, redacted observability contracts for agentctl. + +use std::sync::Mutex; + +use agentctl_core::policy::redact; +use chrono::{DateTime, Utc}; +use opentelemetry::KeyValue; +use opentelemetry::trace::{Span, Tracer}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const TRACE_EVENT_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SpanKind { + Run, + Task, + Attempt, + AgentTurn, + ProviderRequest, + ModelResponse, + ToolCall, + Effect, + Approval, + McpRequest, + A2aDelegation, + Retry, + Checkpoint, + StateTransition, + Database, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TracePhase { + Started, + Completed, + Failed, + Waiting, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TraceEvent { + pub version: u32, + pub kind: SpanKind, + pub phase: TracePhase, + pub name: String, + pub trace_id: String, + pub run_id: String, + pub task_id: Option, + pub effect_id: Option, + pub attributes: Value, + pub timestamp: DateTime, +} + +impl TraceEvent { + #[must_use] + pub fn new( + kind: SpanKind, + phase: TracePhase, + name: impl Into, + trace_id: impl Into, + run_id: impl Into, + timestamp: DateTime, + ) -> Self { + Self { + version: TRACE_EVENT_VERSION, + kind, + phase, + name: name.into(), + trace_id: trace_id.into(), + run_id: run_id.into(), + task_id: None, + effect_id: None, + attributes: Value::Null, + timestamp, + } + } + + #[must_use] + pub fn task(mut self, task_id: impl Into) -> Self { + self.task_id = Some(task_id.into()); + self + } + + #[must_use] + pub fn effect(mut self, effect_id: impl Into) -> Self { + self.effect_id = Some(effect_id.into()); + self + } + + #[must_use] + pub fn attributes(mut self, attributes: Value, secrets: &[String]) -> Self { + self.attributes = redact(&attributes, secrets); + self + } +} + +pub trait TraceSink: Send + Sync { + fn record(&self, event: &TraceEvent); +} + +#[derive(Debug, Default)] +pub struct NoopTraceSink; + +impl TraceSink for NoopTraceSink { + fn record(&self, _event: &TraceEvent) {} +} + +/// Emits OpenTelemetry spans through the process-global tracer provider. +#[derive(Debug, Default)] +pub struct OpenTelemetrySink; + +impl TraceSink for OpenTelemetrySink { + fn record(&self, event: &TraceEvent) { + let tracer = opentelemetry::global::tracer("agentctl"); + let mut span = tracer.start(event.name.clone()); + span.set_attribute(KeyValue::new("agentctl.trace_id", event.trace_id.clone())); + span.set_attribute(KeyValue::new("agentctl.run_id", event.run_id.clone())); + span.set_attribute(KeyValue::new("agentctl.kind", format!("{:?}", event.kind))); + span.set_attribute(KeyValue::new( + "agentctl.phase", + format!("{:?}", event.phase), + )); + if let Some(task_id) = &event.task_id { + span.set_attribute(KeyValue::new("agentctl.task_id", task_id.clone())); + } + if let Some(effect_id) = &event.effect_id { + span.set_attribute(KeyValue::new("agentctl.effect_id", effect_id.clone())); + } + span.end(); + } +} + +#[derive(Debug, Default)] +pub struct BufferedTraceSink { + events: Mutex>, +} + +impl BufferedTraceSink { + #[must_use] + pub fn events(&self) -> Vec { + self.events + .lock() + .map_or_else(|_| Vec::new(), |events| events.clone()) + } +} + +impl TraceSink for BufferedTraceSink { + fn record(&self, event: &TraceEvent) { + if let Ok(mut events) = self.events.lock() { + events.push(event.clone()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trace_attributes_are_redacted_before_reaching_sink() { + let event = TraceEvent::new( + SpanKind::ProviderRequest, + TracePhase::Started, + "provider.request", + "trace", + "run", + Utc::now(), + ) + .attributes( + serde_json::json!({"authorization": "Bearer key", "text": "contains key"}), + &["key".to_owned()], + ); + let serialized = serde_json::to_string(&event).expect("serialize"); + assert!(!serialized.contains("key")); + assert!(serialized.contains("[REDACTED]")); + } +} diff --git a/crates/agentctl-protocols/Cargo.toml b/crates/agentctl-protocols/Cargo.toml new file mode 100644 index 0000000..5afb666 --- /dev/null +++ b/crates/agentctl-protocols/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "agentctl-protocols" +description = "MCP and A2A clients for agentctl" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +agentctl-core = { version = "0.2.0", path = "../agentctl-core" } +agentctl-runtime = { version = "0.2.0", path = "../agentctl-runtime" } +async-trait.workspace = true +futures-util.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +tokio-util.workspace = true +url.workspace = true + +[dev-dependencies] +wiremock.workspace = true + +[lints] +workspace = true diff --git a/crates/agentctl-protocols/src/lib.rs b/crates/agentctl-protocols/src/lib.rs new file mode 100644 index 0000000..e99a6a8 --- /dev/null +++ b/crates/agentctl-protocols/src/lib.rs @@ -0,0 +1,1075 @@ +//! Stable MCP and A2A protocol clients for agentctl. + +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use agentctl_core::dsl::ActionKind; +use agentctl_runtime::{ExternalActionHandler, RuntimeError}; +use async_trait::async_trait; +use futures_util::StreamExt; +use reqwest::{Client, Response, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; +use tokio_util::sync::CancellationToken; +use url::Url; + +pub const MCP_PROTOCOL_VERSION: &str = "2025-11-25"; +pub const A2A_PROTOCOL_VERSION: &str = "1.0"; +const MAX_PROTOCOL_RESPONSE_BYTES: usize = 4 * 1024 * 1024; + +#[derive(Debug, Error)] +pub enum ProtocolError { + #[error("protocol request was cancelled")] + Cancelled, + #[error("protocol request timed out after {0:?}")] + Timeout(Duration), + #[error("protocol transport failed: {0}")] + Transport(String), + #[error("protocol returned HTTP {status}: {message}")] + Http { status: u16, message: String }, + #[error("protocol response is malformed: {0}")] + Malformed(String), + #[error("protocol version `{found}` is unsupported; expected `{expected}`")] + Version { + found: String, + expected: &'static str, + }, + #[error("remote session expired")] + SessionExpired, + #[error("remote operation failed ({code}): {message}")] + Remote { code: i64, message: String }, + #[error("remote operation is unsupported: {0}")] + Unsupported(String), + #[error("remote task `{0}` did not finish before the polling bound")] + PollLimit(String), +} + +#[derive(Debug, Clone)] +pub struct ProtocolHttpConfig { + pub url: Url, + pub headers: BTreeMap, + pub timeout: Duration, +} + +fn client() -> Result { + Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .user_agent(concat!("agentctl/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|error| ProtocolError::Transport(error.to_string())) +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpTool { + pub name: String, + pub description: Option, + pub input_schema: Value, + pub output_schema: Option, + pub annotations: Option, +} + +pub struct McpClient { + client: Client, + config: ProtocolHttpConfig, + session_id: Mutex>, + initialized: AtomicBool, + next_id: AtomicU64, +} + +impl McpClient { + pub fn new(config: ProtocolHttpConfig) -> Result { + Ok(Self { + client: client()?, + config, + session_id: Mutex::new(None), + initialized: AtomicBool::new(false), + next_id: AtomicU64::new(1), + }) + } + + pub async fn initialize(&self, cancellation: &CancellationToken) -> Result<(), ProtocolError> { + let result = self + .rpc( + "initialize", + serde_json::json!({ + "protocolVersion": MCP_PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": {"name": "agentctl", "version": env!("CARGO_PKG_VERSION")} + }), + false, + cancellation, + ) + .await?; + let version = result + .get("protocolVersion") + .and_then(Value::as_str) + .ok_or_else(|| { + ProtocolError::Malformed("initialize result omits protocolVersion".to_owned()) + })?; + if version != MCP_PROTOCOL_VERSION { + return Err(ProtocolError::Version { + found: version.to_owned(), + expected: MCP_PROTOCOL_VERSION, + }); + } + self.notification( + "notifications/initialized", + serde_json::json!({}), + cancellation, + ) + .await?; + self.initialized.store(true, Ordering::Release); + Ok(()) + } + + pub async fn list_tools( + &self, + cancellation: &CancellationToken, + ) -> Result, ProtocolError> { + let result = self + .rpc("tools/list", serde_json::json!({}), true, cancellation) + .await?; + let tools = result + .get("tools") + .and_then(Value::as_array) + .ok_or_else(|| ProtocolError::Malformed("tools/list result omits tools".to_owned()))?; + tools + .iter() + .map(|tool| { + Ok(McpTool { + name: string_field(tool, "name")?, + description: tool + .get("description") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + input_schema: tool.get("inputSchema").cloned().ok_or_else(|| { + ProtocolError::Malformed("MCP tool omits inputSchema".to_owned()) + })?, + output_schema: tool.get("outputSchema").cloned(), + annotations: tool.get("annotations").cloned(), + }) + }) + .collect() + } + + pub async fn call_tool( + &self, + name: &str, + arguments: Value, + cancellation: &CancellationToken, + ) -> Result { + if !self.initialized.load(Ordering::Acquire) { + self.initialize(cancellation).await?; + } + let result = self + .rpc( + "tools/call", + serde_json::json!({"name": name, "arguments": arguments}), + true, + cancellation, + ) + .await?; + if result.get("isError").and_then(Value::as_bool) == Some(true) { + return Err(ProtocolError::Remote { + code: -1, + message: summarize_remote(&result), + }); + } + if let Some(structured) = result.get("structuredContent") { + return Ok(structured.clone()); + } + Ok(result.get("content").cloned().unwrap_or(Value::Null)) + } + + async fn rpc( + &self, + method: &str, + params: Value, + require_session: bool, + cancellation: &CancellationToken, + ) -> Result { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let body = + serde_json::json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); + let mut request = self.request().json(&body); + if require_session { + let session = self + .session_id + .lock() + .map_err(|_| ProtocolError::Transport("MCP session lock was poisoned".to_owned()))? + .clone(); + request = request.header("MCP-Protocol-Version", MCP_PROTOCOL_VERSION); + if let Some(session) = session { + request = request.header("Mcp-Session-Id", session); + } + } + let response = + execute_request(request, self.config.timeout, cancellation, Some((id, self))).await?; + if method == "initialize" + && let Some(session) = response + .headers() + .get("Mcp-Session-Id") + .and_then(|value| value.to_str().ok()) + { + *self.session_id.lock().map_err(|_| { + ProtocolError::Transport("MCP session lock was poisoned".to_owned()) + })? = Some(session.to_owned()); + } + let value = response_value(response, self.config.timeout, cancellation).await?; + json_rpc_result(value) + } + + async fn notification( + &self, + method: &str, + params: Value, + cancellation: &CancellationToken, + ) -> Result<(), ProtocolError> { + let body = serde_json::json!({"jsonrpc": "2.0", "method": method, "params": params}); + let mut request = self + .request() + .json(&body) + .header("MCP-Protocol-Version", MCP_PROTOCOL_VERSION); + if let Some(session) = self + .session_id + .lock() + .map_err(|_| ProtocolError::Transport("MCP session lock was poisoned".to_owned()))? + .clone() + { + request = request.header("Mcp-Session-Id", session); + } + let response = execute_request(request, self.config.timeout, cancellation, None).await?; + if response.status().is_success() { + Ok(()) + } else { + Err(http_error(response).await) + } + } + + fn request(&self) -> reqwest::RequestBuilder { + self.config.headers.iter().fold( + self.client + .post(self.config.url.clone()) + .header("Accept", "application/json, text/event-stream") + .header("Origin", "agentctl://local"), + |request, (name, value)| request.header(name, value), + ) + } + + async fn cancel_request(&self, id: u64) { + let body = serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": {"requestId": id} + }); + let mut request = self + .request() + .json(&body) + .header("MCP-Protocol-Version", MCP_PROTOCOL_VERSION); + if let Ok(session) = self.session_id.lock() + && let Some(session) = session.clone() + { + request = request.header("Mcp-Session-Id", session); + } + let _ = tokio::time::timeout(self.config.timeout, request.send()).await; + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentInterface { + pub url: String, + pub protocol_binding: String, + pub protocol_version: String, + #[serde(default)] + pub tenant: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentCard { + pub name: String, + pub description: String, + pub supported_interfaces: Vec, + #[serde(default)] + pub capabilities: Value, + #[serde(default)] + pub skills: Vec, + #[serde(default)] + pub security_schemes: Value, + #[serde(default)] + pub security: Value, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum A2aResponse { + Message(Value), + Task(Value), +} + +pub struct A2aClient { + client: Client, + card_config: ProtocolHttpConfig, + interface: Mutex>, + next_id: AtomicU64, + max_polls: usize, + poll_interval: Duration, +} + +impl A2aClient { + pub fn new(config: ProtocolHttpConfig) -> Result { + Ok(Self { + client: client()?, + card_config: config, + interface: Mutex::new(None), + next_id: AtomicU64::new(1), + max_polls: 100, + poll_interval: Duration::from_millis(100), + }) + } + + #[must_use] + pub fn with_poll_bounds(mut self, max_polls: usize, poll_interval: Duration) -> Self { + self.max_polls = max_polls; + self.poll_interval = poll_interval; + self + } + + pub async fn discover( + &self, + cancellation: &CancellationToken, + ) -> Result { + let request = self.card_config.headers.iter().fold( + self.client.get(self.card_config.url.clone()), + |request, (name, value)| request.header(name, value), + ); + let response = + execute_request(request, self.card_config.timeout, cancellation, None).await?; + let card: AgentCard = + response_json(response, self.card_config.timeout, cancellation).await?; + if card.name.trim().is_empty() || card.supported_interfaces.is_empty() { + return Err(ProtocolError::Malformed( + "Agent Card requires a name and supportedInterfaces".to_owned(), + )); + } + let interface = card + .supported_interfaces + .iter() + .find(|interface| { + interface.protocol_binding.eq_ignore_ascii_case("JSONRPC") + && interface.protocol_version == A2A_PROTOCOL_VERSION + }) + .cloned() + .ok_or_else(|| ProtocolError::Version { + found: card + .supported_interfaces + .iter() + .map(|interface| interface.protocol_version.as_str()) + .collect::>() + .join(","), + expected: A2A_PROTOCOL_VERSION, + })?; + let interface_url = Url::parse(&interface.url).map_err(|error| { + ProtocolError::Malformed(format!("Agent Card interface URL: {error}")) + })?; + if !same_origin(&self.card_config.url, &interface_url) { + return Err(ProtocolError::Unsupported( + "Agent Card interface must share the configured card origin".to_owned(), + )); + } + *self.interface.lock().map_err(|_| { + ProtocolError::Transport("A2A interface lock was poisoned".to_owned()) + })? = Some(interface); + Ok(card) + } + + pub async fn send_message( + &self, + message_id: &str, + text: &str, + context_id: Option<&str>, + cancellation: &CancellationToken, + ) -> Result { + let mut message = serde_json::json!({ + "messageId": message_id, + "role": "user", + "parts": [{"text": text}] + }); + if let Some(context_id) = context_id { + message["contextId"] = Value::String(context_id.to_owned()); + } + let result = self + .rpc( + "SendMessage", + serde_json::json!({"message": message}), + cancellation, + ) + .await?; + if let Some(task) = result.get("task") { + Ok(A2aResponse::Task(task.clone())) + } else if let Some(message) = result.get("message") { + Ok(A2aResponse::Message(message.clone())) + } else if result.get("id").is_some() && result.get("status").is_some() { + Ok(A2aResponse::Task(result)) + } else { + Ok(A2aResponse::Message(result)) + } + } + + pub async fn wait_for_task( + &self, + task: Value, + cancellation: &CancellationToken, + ) -> Result { + let task_id = string_field(&task, "id")?; + let mut current = task; + for _ in 0..self.max_polls { + match task_state(¤t) { + Some("completed") => return Ok(current), + Some("failed" | "rejected" | "canceled") => { + return Err(ProtocolError::Remote { + code: -1, + message: format!( + "A2A task `{task_id}` ended in {}", + task_state(¤t).unwrap_or("unknown") + ), + }); + } + Some("input_required" | "auth_required") => return Ok(current), + _ => {} + } + tokio::select! { + () = tokio::time::sleep(self.poll_interval) => {} + () = cancellation.cancelled() => { + let _ = self.cancel_task(&task_id, &CancellationToken::new()).await; + return Err(ProtocolError::Cancelled); + } + } + current = self + .rpc("GetTask", serde_json::json!({"id": task_id}), cancellation) + .await?; + if let Some(inner) = current.get("task") { + current = inner.clone(); + } + } + Err(ProtocolError::PollLimit(task_id)) + } + + pub async fn cancel_task( + &self, + task_id: &str, + cancellation: &CancellationToken, + ) -> Result { + self.rpc( + "CancelTask", + serde_json::json!({"id": task_id}), + cancellation, + ) + .await + } + + pub async fn send_streaming_message( + &self, + message_id: &str, + text: &str, + cancellation: &CancellationToken, + ) -> Result, ProtocolError> { + let interface = self.selected_interface()?; + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "SendStreamingMessage", + "params": {"message": {"messageId": message_id, "role": "user", "parts": [{"text": text}]}} + }); + let request = self.request(&interface)?.json(&body); + let response = + execute_request(request, self.card_config.timeout, cancellation, None).await?; + let values = response_values(response, self.card_config.timeout, cancellation).await?; + values.into_iter().map(json_rpc_result).collect() + } + + async fn rpc( + &self, + method: &str, + params: Value, + cancellation: &CancellationToken, + ) -> Result { + let interface = self.selected_interface()?; + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let body = + serde_json::json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); + let response = execute_request( + self.request(&interface)?.json(&body), + self.card_config.timeout, + cancellation, + None, + ) + .await?; + json_rpc_result(response_value(response, self.card_config.timeout, cancellation).await?) + } + + fn selected_interface(&self) -> Result { + self.interface + .lock() + .map_err(|_| ProtocolError::Transport("A2A interface lock was poisoned".to_owned()))? + .clone() + .ok_or_else(|| ProtocolError::Malformed("A2A discovery has not run".to_owned())) + } + + fn request( + &self, + interface: &AgentInterface, + ) -> Result { + let url = Url::parse(&interface.url) + .map_err(|error| ProtocolError::Malformed(format!("A2A interface URL: {error}")))?; + Ok(self.card_config.headers.iter().fold( + self.client + .post(url) + .header("A2A-Version", A2A_PROTOCOL_VERSION) + .header("Content-Type", "application/json"), + |request, (name, value)| request.header(name, value), + )) + } +} + +pub struct ProtocolActionHandler { + mcp: BTreeMap>, + a2a: BTreeMap>, +} + +impl ProtocolActionHandler { + #[must_use] + pub fn new( + mcp: BTreeMap>, + a2a: BTreeMap>, + ) -> Self { + Self { mcp, a2a } + } +} + +#[async_trait] +impl ExternalActionHandler for ProtocolActionHandler { + async fn execute( + &self, + kind: ActionKind, + input: &Value, + cancellation: &CancellationToken, + ) -> Result { + match kind { + ActionKind::McpCall => { + let server = string_field(input, "server") + .map_err(|error| RuntimeError::InvalidState(error.to_string()))?; + let tool = string_field(input, "tool") + .map_err(|error| RuntimeError::InvalidState(error.to_string()))?; + let arguments = input + .get("arguments") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + self.mcp + .get(&server) + .ok_or_else(|| { + RuntimeError::InvalidState(format!("unknown MCP server `{server}`")) + })? + .call_tool(&tool, arguments, cancellation) + .await + .map_err(map_effect_error) + } + ActionKind::A2aDelegate => { + let peer = string_field(input, "peer") + .map_err(|error| RuntimeError::InvalidState(error.to_string()))?; + let message_id = string_field(input, "messageId") + .map_err(|error| RuntimeError::InvalidState(error.to_string()))?; + let text = string_field(input, "message") + .map_err(|error| RuntimeError::InvalidState(error.to_string()))?; + let client = self.a2a.get(&peer).ok_or_else(|| { + RuntimeError::InvalidState(format!("unknown A2A peer `{peer}`")) + })?; + if client.selected_interface().is_err() { + client + .discover(cancellation) + .await + .map_err(map_effect_error)?; + } + let response = client + .send_message(&message_id, &text, None, cancellation) + .await + .map_err(map_effect_error)?; + match response { + A2aResponse::Message(message) => Ok(message), + A2aResponse::Task(task) => client + .wait_for_task(task, cancellation) + .await + .map_err(map_effect_error), + } + } + _ => Err(RuntimeError::InvalidState( + "protocol handler received a non-protocol action".to_owned(), + )), + } + } +} + +fn map_effect_error(error: ProtocolError) -> RuntimeError { + match error { + ProtocolError::Cancelled => RuntimeError::Cancelled, + error @ (ProtocolError::Timeout(_) + | ProtocolError::Transport(_) + | ProtocolError::Malformed(_) + | ProtocolError::SessionExpired + | ProtocolError::PollLimit(_)) => RuntimeError::ExternalEffectUncertain(error.to_string()), + error => RuntimeError::InvalidState(error.to_string()), + } +} + +async fn execute_request( + request: reqwest::RequestBuilder, + timeout: Duration, + cancellation: &CancellationToken, + mcp_cancel: Option<(u64, &McpClient)>, +) -> Result { + tokio::select! { + result = tokio::time::timeout(timeout, request.send()) => { + result + .map_err(|_| ProtocolError::Timeout(timeout))? + .map_err(|error| ProtocolError::Transport(error.to_string())) + .and_then(|response| { + if response.status() == StatusCode::NOT_FOUND && mcp_cancel.is_some() { + Err(ProtocolError::SessionExpired) + } else { + Ok(response) + } + }) + } + () = cancellation.cancelled() => { + if let Some((id, client)) = mcp_cancel { + client.cancel_request(id).await; + } + Err(ProtocolError::Cancelled) + } + } +} + +async fn response_json Deserialize<'de>>( + response: Response, + timeout: Duration, + cancellation: &CancellationToken, +) -> Result { + if !response.status().is_success() { + return Err(http_error(response).await); + } + let bytes = bounded_response(response, timeout, cancellation).await?; + serde_json::from_slice(&bytes).map_err(|error| ProtocolError::Malformed(error.to_string())) +} + +async fn response_value( + response: Response, + timeout: Duration, + cancellation: &CancellationToken, +) -> Result { + response_values(response, timeout, cancellation) + .await? + .into_iter() + .last() + .ok_or_else(|| ProtocolError::Malformed("empty protocol response".to_owned())) +} + +async fn response_values( + response: Response, + timeout: Duration, + cancellation: &CancellationToken, +) -> Result, ProtocolError> { + if !response.status().is_success() { + return Err(http_error(response).await); + } + let is_sse = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("text/event-stream")); + if is_sse { + let bytes = bounded_response(response, timeout, cancellation).await?; + let text = String::from_utf8(bytes) + .map_err(|error| ProtocolError::Malformed(format!("SSE encoding: {error}")))?; + text.lines() + .filter_map(|line| line.strip_prefix("data:")) + .map(|data| { + serde_json::from_str(data.trim()) + .map_err(|error| ProtocolError::Malformed(format!("SSE data: {error}"))) + }) + .collect() + } else { + let bytes = bounded_response(response, timeout, cancellation).await?; + serde_json::from_slice(&bytes) + .map(|value| vec![value]) + .map_err(|error| ProtocolError::Malformed(error.to_string())) + } +} + +async fn bounded_response( + response: Response, + timeout: Duration, + cancellation: &CancellationToken, +) -> Result, ProtocolError> { + let collect = async move { + let mut stream = response.bytes_stream(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| ProtocolError::Transport(error.to_string()))?; + if bytes.len().saturating_add(chunk.len()) > MAX_PROTOCOL_RESPONSE_BYTES { + return Err(ProtocolError::Malformed(format!( + "response exceeds {MAX_PROTOCOL_RESPONSE_BYTES} bytes" + ))); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) + }; + tokio::select! { + result = tokio::time::timeout(timeout, collect) => { + result.map_err(|_| ProtocolError::Timeout(timeout))? + } + () = cancellation.cancelled() => Err(ProtocolError::Cancelled), + } +} + +async fn http_error(response: Response) -> ProtocolError { + let status = response.status().as_u16(); + ProtocolError::Http { + status, + message: "remote protocol request failed; body omitted".to_owned(), + } +} + +fn same_origin(left: &Url, right: &Url) -> bool { + left.scheme() == right.scheme() + && left.host_str() == right.host_str() + && left.port_or_known_default() == right.port_or_known_default() +} + +fn json_rpc_result(value: Value) -> Result { + if value.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { + return Err(ProtocolError::Malformed( + "response is not JSON-RPC 2.0".to_owned(), + )); + } + if let Some(error) = value.get("error") { + return Err(ProtocolError::Remote { + code: error.get("code").and_then(Value::as_i64).unwrap_or(-1), + message: error + .get("message") + .and_then(Value::as_str) + .unwrap_or("remote error") + .to_owned(), + }); + } + value + .get("result") + .cloned() + .ok_or_else(|| ProtocolError::Malformed("JSON-RPC response omits result".to_owned())) +} + +fn string_field(value: &Value, field: &str) -> Result { + value + .get(field) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or_else(|| ProtocolError::Malformed(format!("missing string field `{field}`"))) +} + +fn summarize_remote(value: &Value) -> String { + serde_json::to_string(value) + .map(|mut value| { + value.truncate(512); + value + }) + .unwrap_or_else(|_| "remote tool failed".to_owned()) +} + +fn task_state(task: &Value) -> Option<&str> { + task.pointer("/status/state").and_then(Value::as_str) +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{body_partial_json, header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test] + async fn mcp_negotiates_session_lists_and_calls_tools() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/mcp")) + .and(header("authorization", "Bearer fixture")) + .and(body_partial_json(serde_json::json!({"method": "initialize"}))) + .respond_with( + ResponseTemplate::new(200) + .insert_header("Mcp-Session-Id", "session-1") + .set_body_json(serde_json::json!({ + "jsonrpc": "2.0", "id": 1, + "result": {"protocolVersion": MCP_PROTOCOL_VERSION, "capabilities": {}, "serverInfo": {"name": "mock", "version": "1"}} + })), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/mcp")) + .and(body_partial_json( + serde_json::json!({"method": "notifications/initialized"}), + )) + .respond_with(ResponseTemplate::new(202)) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/mcp")) + .and(header("mcp-session-id", "session-1")) + .and(header("mcp-protocol-version", MCP_PROTOCOL_VERSION)) + .and(body_partial_json(serde_json::json!({"method": "tools/list"}))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jsonrpc": "2.0", "id": 2, + "result": {"tools": [{"name": "echo", "inputSchema": {"type": "object"}, "annotations": {"readOnlyHint": true}}]} + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/mcp")) + .and(body_partial_json( + serde_json::json!({"method": "tools/call"}), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jsonrpc": "2.0", "id": 3, + "result": {"structuredContent": {"echo": "ok"}, "isError": false} + }))) + .mount(&server) + .await; + let client = McpClient::new(ProtocolHttpConfig { + url: Url::parse(&format!("{}/mcp", server.uri())).expect("url"), + headers: BTreeMap::from([("authorization".to_owned(), "Bearer fixture".to_owned())]), + timeout: Duration::from_secs(2), + }) + .expect("client"); + let cancellation = CancellationToken::new(); + client.initialize(&cancellation).await.expect("initialize"); + let tools = client.list_tools(&cancellation).await.expect("tools"); + assert_eq!(tools[0].name, "echo"); + // Annotations are retained as untrusted data; they never become policy decisions here. + assert!(tools[0].annotations.is_some()); + let result = client + .call_tool("echo", serde_json::json!({"text": "ok"}), &cancellation) + .await + .expect("call"); + assert_eq!(result, serde_json::json!({"echo": "ok"})); + } + + #[tokio::test] + async fn mcp_rejects_version_mismatch() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jsonrpc": "2.0", "id": 1, + "result": {"protocolVersion": "2099-01-01"} + }))) + .mount(&server) + .await; + let client = McpClient::new(ProtocolHttpConfig { + url: Url::parse(&server.uri()).expect("url"), + headers: BTreeMap::new(), + timeout: Duration::from_secs(2), + }) + .expect("client"); + assert!(matches!( + client.initialize(&CancellationToken::new()).await, + Err(ProtocolError::Version { .. }) + )); + } + + #[tokio::test] + async fn a2a_discovers_v1_and_polls_to_artifact() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/.well-known/agent-card.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": "mock-agent", + "description": "fixture", + "supportedInterfaces": [{ + "url": format!("{}/a2a", server.uri()), + "protocolBinding": "JSONRPC", + "protocolVersion": A2A_PROTOCOL_VERSION + }], + "capabilities": {"streaming": true}, + "skills": [] + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/a2a")) + .and(header("a2a-version", A2A_PROTOCOL_VERSION)) + .and(body_partial_json( + serde_json::json!({"method": "SendMessage"}), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jsonrpc": "2.0", "id": 1, + "result": {"task": {"id": "task-1", "status": {"state": "working"}}} + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/a2a")) + .and(body_partial_json(serde_json::json!({"method": "GetTask"}))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jsonrpc": "2.0", "id": 2, + "result": {"id": "task-1", "status": {"state": "completed"}, "artifacts": [{"artifactId": "a1", "parts": [{"text": "done"}]}]} + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/a2a")) + .and(body_partial_json(serde_json::json!({ + "method": "SendStreamingMessage" + }))) + .respond_with( + ResponseTemplate::new(200).set_body_raw( + "data: {\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{\"message\":{\"messageId\":\"stream-1\"}}}\n\n", + "text/event-stream", + ), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/a2a")) + .and(body_partial_json( + serde_json::json!({"method": "CancelTask"}), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jsonrpc": "2.0", "id": 4, + "result": {"id": "task-1", "status": {"state": "canceled"}} + }))) + .mount(&server) + .await; + let client = A2aClient::new(ProtocolHttpConfig { + url: Url::parse(&format!("{}/.well-known/agent-card.json", server.uri())).expect("url"), + headers: BTreeMap::new(), + timeout: Duration::from_secs(2), + }) + .expect("client") + .with_poll_bounds(2, Duration::from_millis(1)); + let cancellation = CancellationToken::new(); + client.discover(&cancellation).await.expect("discover"); + let response = client + .send_message("message-1", "do work", None, &cancellation) + .await + .expect("send"); + let A2aResponse::Task(task) = response else { + panic!("expected task"); + }; + let completed = client + .wait_for_task(task, &cancellation) + .await + .expect("poll"); + assert_eq!(task_state(&completed), Some("completed")); + assert_eq!( + completed.pointer("/artifacts/0/artifactId"), + Some(&Value::String("a1".to_owned())) + ); + let stream = client + .send_streaming_message("message-2", "stream", &cancellation) + .await + .expect("stream"); + assert_eq!(stream.len(), 1); + let cancelled = client + .cancel_task("task-1", &cancellation) + .await + .expect("cancel"); + assert_eq!(task_state(&cancelled), Some("canceled")); + } + + #[tokio::test] + async fn protocol_timeout_is_bounded() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(1))) + .mount(&server) + .await; + let client = A2aClient::new(ProtocolHttpConfig { + url: Url::parse(&server.uri()).expect("url"), + headers: BTreeMap::new(), + timeout: Duration::from_millis(10), + }) + .expect("client"); + assert!(matches!( + client.discover(&CancellationToken::new()).await, + Err(ProtocolError::Timeout(_)) + )); + } + + #[tokio::test] + async fn a2a_rejects_cross_origin_interface_and_honors_cancellation() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/cross-origin")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "name": "malicious", + "description": "fixture", + "supportedInterfaces": [{ + "url": "http://127.0.0.1:9/a2a", + "protocolBinding": "JSONRPC", + "protocolVersion": A2A_PROTOCOL_VERSION + }] + }))) + .mount(&server) + .await; + let client = A2aClient::new(ProtocolHttpConfig { + url: Url::parse(&format!("{}/cross-origin", server.uri())).expect("url"), + headers: BTreeMap::new(), + timeout: Duration::from_secs(2), + }) + .expect("client"); + assert!(matches!( + client.discover(&CancellationToken::new()).await, + Err(ProtocolError::Unsupported(_)) + )); + + Mock::given(method("GET")) + .and(path("/slow")) + .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(1))) + .mount(&server) + .await; + let slow = A2aClient::new(ProtocolHttpConfig { + url: Url::parse(&format!("{}/slow", server.uri())).expect("url"), + headers: BTreeMap::new(), + timeout: Duration::from_secs(2), + }) + .expect("client"); + let cancellation = CancellationToken::new(); + let signal = cancellation.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(10)).await; + signal.cancel(); + }); + assert!(matches!( + slow.discover(&cancellation).await, + Err(ProtocolError::Cancelled) + )); + } + + #[test] + fn ambiguous_protocol_failures_are_not_classified_as_definitive() { + assert!(matches!( + map_effect_error(ProtocolError::Timeout(Duration::from_secs(1))), + RuntimeError::ExternalEffectUncertain(_) + )); + assert!(matches!( + map_effect_error(ProtocolError::Transport("closed".to_owned())), + RuntimeError::ExternalEffectUncertain(_) + )); + assert!(matches!( + map_effect_error(ProtocolError::Cancelled), + RuntimeError::Cancelled + )); + } +} diff --git a/crates/agentctl-providers/Cargo.toml b/crates/agentctl-providers/Cargo.toml new file mode 100644 index 0000000..6b64ea3 --- /dev/null +++ b/crates/agentctl-providers/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "agentctl-providers" +description = "Native model provider adapters for agentctl" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +agentctl-core = { version = "0.2.0", path = "../agentctl-core" } +async-trait.workspace = true +futures-util.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +tokio-util.workspace = true +url.workspace = true + +[dev-dependencies] +wiremock.workspace = true + +[lints] +workspace = true diff --git a/crates/agentctl-providers/src/lib.rs b/crates/agentctl-providers/src/lib.rs new file mode 100644 index 0000000..4beac8b --- /dev/null +++ b/crates/agentctl-providers/src/lib.rs @@ -0,0 +1,1389 @@ +//! Native provider adapters for agentctl. + +use std::collections::{BTreeMap, VecDeque}; +use std::sync::Mutex; + +use agentctl_core::dsl::{ReasoningEffort, SecretReference}; +use agentctl_core::provider::{ + ContentBlock, ContinuationState, FinishReason, Message, ModelProvider, ProviderError, + ProviderRequest, ProviderResponse, ToolCall, Usage, +}; +use async_trait::async_trait; +use futures_util::StreamExt; +use reqwest::{Client, StatusCode}; +use serde_json::{Map, Value}; +use tokio_util::sync::CancellationToken; + +const ANTHROPIC_VERSION: &str = "2023-06-01"; +const MAX_PROVIDER_RESPONSE_BYTES: usize = 4 * 1024 * 1024; + +#[derive(Debug, Clone)] +pub struct HttpProviderConfig { + pub endpoint: String, + pub credential: SecretReference, + pub organization: Option, + pub project: Option, + pub api_version: Option, + pub headers: BTreeMap, +} + +impl HttpProviderConfig { + #[must_use] + pub fn openai(credential_env: impl Into) -> Self { + Self { + endpoint: "https://api.openai.com/v1/responses".to_owned(), + credential: SecretReference { + env: credential_env.into(), + }, + organization: None, + project: None, + api_version: None, + headers: BTreeMap::new(), + } + } + + #[must_use] + pub fn anthropic(credential_env: impl Into) -> Self { + Self { + endpoint: "https://api.anthropic.com/v1/messages".to_owned(), + credential: SecretReference { + env: credential_env.into(), + }, + organization: None, + project: None, + api_version: None, + headers: BTreeMap::new(), + } + } + + #[must_use] + pub fn google(credential_env: impl Into) -> Self { + Self { + endpoint: "https://generativelanguage.googleapis.com/v1beta/models".to_owned(), + credential: SecretReference { + env: credential_env.into(), + }, + organization: None, + project: None, + api_version: None, + headers: BTreeMap::new(), + } + } +} + +fn secure_client() -> Result { + Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .user_agent(concat!("agentctl/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|error| ProviderError::Malformed(error.to_string())) +} + +#[derive(Clone)] +pub struct OpenAiProvider { + client: Client, + config: HttpProviderConfig, + azure: bool, +} + +impl OpenAiProvider { + pub fn new(config: HttpProviderConfig) -> Result { + Ok(Self { + client: secure_client()?, + config, + azure: false, + }) + } + + pub fn azure(config: HttpProviderConfig) -> Result { + Ok(Self { + client: secure_client()?, + config, + azure: true, + }) + } +} + +#[async_trait] +impl ModelProvider for OpenAiProvider { + fn name(&self) -> &'static str { + if self.azure { "azure_openai" } else { "openai" } + } + + async fn complete( + &self, + request: &ProviderRequest, + cancellation: &CancellationToken, + ) -> Result { + let credential = load_credential(&self.config.credential)?; + let endpoint = if self.azure { + let separator = if self.config.endpoint.contains('?') { + '&' + } else { + '?' + }; + format!( + "{}{separator}api-version={}", + self.config.endpoint.trim_end_matches('/'), + self.config.api_version.as_deref().unwrap_or("v1") + ) + } else { + self.config.endpoint.clone() + }; + let mut http = self.client.post(endpoint).json(&openai_request(request)?); + for (name, value) in &self.config.headers { + http = http.header(name, value); + } + http = if self.azure { + http.header("api-key", &credential) + } else { + http.bearer_auth(&credential) + }; + if let Some(organization) = &self.config.organization { + http = http.header("OpenAI-Organization", organization); + } + if let Some(project) = &self.config.project { + http = http.header("OpenAI-Project", project); + } + let response = send(http, cancellation, &credential).await?; + parse_openai(response) + } +} + +fn openai_request(request: &ProviderRequest) -> Result { + let mut body = Map::from_iter([ + ("model".to_owned(), Value::String(request.model.clone())), + ( + "instructions".to_owned(), + Value::String(request.instructions.clone()), + ), + ( + "max_output_tokens".to_owned(), + Value::from(request.max_output_tokens), + ), + ( + "store".to_owned(), + Value::Bool( + request + .provider_options + .get("store") + .and_then(Value::as_bool) + .unwrap_or(true), + ), + ), + ("input".to_owned(), Value::Array(openai_input(request)?)), + ]); + if !request.tools.is_empty() { + body.insert( + "tools".to_owned(), + Value::Array( + request + .tools + .iter() + .map(|tool| { + serde_json::json!({ + "type": "function", + "name": tool.id, + "description": tool.description, + "parameters": tool.input_schema, + "strict": true, + }) + }) + .collect(), + ), + ); + body.insert( + "parallel_tool_calls".to_owned(), + Value::Bool( + request + .provider_options + .get("parallelToolCalls") + .and_then(Value::as_bool) + .unwrap_or(true), + ), + ); + } + if let Some(reasoning) = &request.reasoning { + let mut value = serde_json::json!({"effort": reasoning_effort(&reasoning.effort)}); + if let Some(mode) = &reasoning.mode { + value["mode"] = Value::String(mode.clone()); + } + if let Some(context) = request + .provider_options + .get("reasoningContext") + .and_then(Value::as_str) + { + value["context"] = Value::String(context.to_owned()); + } + body.insert("reasoning".to_owned(), value); + } + if let Some(schema) = &request.structured_output { + body.insert( + "text".to_owned(), + serde_json::json!({ + "format": { + "type": "json_schema", + "name": "agentctl_output", + "schema": schema, + "strict": true + } + }), + ); + } + if let Some(ContinuationState::OpenaiPreviousResponse(id)) = &request.continuation { + body.insert("previous_response_id".to_owned(), Value::String(id.clone())); + } + if let Some(key) = &request.prompt_cache_key { + body.insert("prompt_cache_key".to_owned(), Value::String(key.clone())); + let mode = request + .provider_options + .get("promptCacheMode") + .and_then(Value::as_str) + .unwrap_or("implicit"); + let ttl = request + .provider_options + .get("promptCacheTtl") + .and_then(Value::as_str) + .unwrap_or("30m"); + body.insert( + "prompt_cache_options".to_owned(), + serde_json::json!({"mode": mode, "ttl": ttl}), + ); + } + if let Some(identifier) = request.safety_identifier.as_deref().or_else(|| { + request + .provider_options + .get("safetyIdentifier") + .and_then(Value::as_str) + }) { + body.insert( + "safety_identifier".to_owned(), + Value::String(identifier.to_owned()), + ); + } + Ok(Value::Object(body)) +} + +fn openai_input(request: &ProviderRequest) -> Result, ProviderError> { + let only_latest_tool_results = matches!( + request.continuation, + Some(ContinuationState::OpenaiPreviousResponse(_)) + ); + let messages = if only_latest_tool_results { + request.messages.last().into_iter().collect::>() + } else { + request.messages.iter().collect::>() + }; + let mut output = Vec::new(); + for message in messages { + match message { + Message::User(blocks) => { + let mut text = Vec::new(); + for block in blocks { + match block { + ContentBlock::Text { text: value } => text.push(serde_json::json!({ + "type": "input_text", + "text": value + })), + ContentBlock::ToolResult { id, output: value, .. } => output.push( + serde_json::json!({ + "type": "function_call_output", + "call_id": id, + "output": serde_json::to_string(value).map_err(|error| ProviderError::Malformed(error.to_string()))? + }), + ), + ContentBlock::ToolCall { .. } | ContentBlock::OpaqueReasoning { .. } => {} + } + } + if !text.is_empty() { + output.push(serde_json::json!({"role": "user", "content": text})); + } + } + Message::Assistant(blocks) if !only_latest_tool_results => { + let mut text = Vec::new(); + for block in blocks { + match block { + ContentBlock::Text { text: value } => text.push(serde_json::json!({ + "type": "output_text", + "text": value + })), + ContentBlock::ToolCall { id, name, input } => output.push(serde_json::json!({ + "type": "function_call", + "call_id": id, + "name": name, + "arguments": serde_json::to_string(input).map_err(|error| ProviderError::Malformed(error.to_string()))? + })), + ContentBlock::OpaqueReasoning { value } => output.push(value.clone()), + ContentBlock::ToolResult { .. } => {} + } + } + if !text.is_empty() { + output.push(serde_json::json!({"role": "assistant", "content": text})); + } + } + Message::Assistant(_) => {} + } + } + Ok(output) +} + +fn parse_openai(value: Value) -> Result { + let response_id = value + .get("id") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + let mut text = String::new(); + let mut tool_calls = Vec::new(); + let mut assistant_content = Vec::new(); + let mut refusal = false; + for item in value + .get("output") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + match item.get("type").and_then(Value::as_str) { + Some("message") => { + for content in item + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + match content.get("type").and_then(Value::as_str) { + Some("output_text") => { + let value = content.get("text").and_then(Value::as_str).unwrap_or(""); + text.push_str(value); + assistant_content.push(ContentBlock::Text { + text: value.to_owned(), + }); + } + Some("refusal") => refusal = true, + _ => {} + } + } + } + Some("function_call") => { + let id = required_field(item, "call_id")?; + let name = required_field(item, "name")?; + let input: Value = item + .get("arguments") + .and_then(Value::as_str) + .ok_or_else(|| { + ProviderError::Malformed("function call missing arguments".to_owned()) + }) + .and_then(|raw| { + serde_json::from_str(raw).map_err(|error| { + ProviderError::Malformed(format!("function arguments: {error}")) + }) + })?; + tool_calls.push(ToolCall { + id: id.clone(), + name: name.clone(), + input: input.clone(), + }); + assistant_content.push(ContentBlock::ToolCall { id, name, input }); + } + Some("reasoning") => assistant_content.push(ContentBlock::OpaqueReasoning { + value: item.clone(), + }), + _ => {} + } + } + let finish_reason = if refusal { + FinishReason::Refusal + } else if !tool_calls.is_empty() { + FinishReason::ToolCalls + } else if value.get("status").and_then(Value::as_str) == Some("incomplete") { + FinishReason::MaxTokens + } else { + FinishReason::Complete + }; + let usage = value.get("usage").cloned().unwrap_or(Value::Null); + Ok(ProviderResponse { + continuation: response_id + .clone() + .map(ContinuationState::OpenaiPreviousResponse), + response_id, + text, + tool_calls, + assistant_content, + usage: Usage { + input_tokens: number(&usage, "input_tokens"), + output_tokens: number(&usage, "output_tokens"), + reasoning_tokens: nested_number(&usage, &["output_tokens_details", "reasoning_tokens"]), + cache_read_tokens: nested_number(&usage, &["input_tokens_details", "cached_tokens"]), + cache_write_tokens: nested_number( + &usage, + &["input_tokens_details", "cache_write_tokens"], + ) + .max(number(&usage, "cache_write_tokens")), + cost_microusd: None, + }, + finish_reason, + }) +} + +#[derive(Clone)] +pub struct AnthropicProvider { + client: Client, + config: HttpProviderConfig, +} + +impl AnthropicProvider { + pub fn new(config: HttpProviderConfig) -> Result { + Ok(Self { + client: secure_client()?, + config, + }) + } +} + +#[async_trait] +impl ModelProvider for AnthropicProvider { + fn name(&self) -> &'static str { + "anthropic" + } + + async fn complete( + &self, + request: &ProviderRequest, + cancellation: &CancellationToken, + ) -> Result { + let credential = load_credential(&self.config.credential)?; + let http = self + .client + .post(&self.config.endpoint) + .json(&anthropic_request(request)?); + let http = self + .config + .headers + .iter() + .fold(http, |request, (name, value)| request.header(name, value)); + let http = http + .header("x-api-key", &credential) + .header("anthropic-version", ANTHROPIC_VERSION); + let response = send(http, cancellation, &credential).await?; + parse_anthropic(response, request) + } +} + +fn anthropic_request(request: &ProviderRequest) -> Result { + let mut body = Map::from_iter([ + ("model".to_owned(), Value::String(request.model.clone())), + ( + "max_tokens".to_owned(), + Value::from(request.max_output_tokens), + ), + ( + "system".to_owned(), + Value::String(request.instructions.clone()), + ), + ( + "messages".to_owned(), + Value::Array(anthropic_messages(&request.messages)?), + ), + ]); + if !request.tools.is_empty() { + body.insert( + "tools".to_owned(), + Value::Array( + request + .tools + .iter() + .map(|tool| { + serde_json::json!({ + "name": tool.id, + "description": tool.description, + "input_schema": tool.input_schema, + }) + }) + .collect(), + ), + ); + } + if let Some(reasoning) = &request.reasoning { + body.insert( + "output_config".to_owned(), + serde_json::json!({"effort": reasoning_effort(&reasoning.effort)}), + ); + } + if let Some(schema) = &request.structured_output { + body.entry("output_config".to_owned()) + .or_insert_with(|| Value::Object(Map::new()))["format"] = serde_json::json!({ + "type": "json_schema", + "schema": schema, + }); + } + Ok(Value::Object(body)) +} + +fn anthropic_messages(messages: &[Message]) -> Result, ProviderError> { + messages + .iter() + .map(|message| { + let (role, blocks) = match message { + Message::User(blocks) => ("user", blocks), + Message::Assistant(blocks) => ("assistant", blocks), + }; + let content: Vec = blocks + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text } => { + Some(serde_json::json!({"type": "text", "text": text})) + } + ContentBlock::ToolCall { id, name, input } => Some(serde_json::json!({ + "type": "tool_use", "id": id, "name": name, "input": input + })), + ContentBlock::ToolResult { + id, + output, + is_error, + } => Some(serde_json::json!({ + "type": "tool_result", + "tool_use_id": id, + "content": serde_json::to_string(output).ok()?, + "is_error": is_error, + })), + ContentBlock::OpaqueReasoning { .. } => None, + }) + .collect(); + Some(serde_json::json!({"role": role, "content": content})) + }) + .collect::>>() + .ok_or_else(|| ProviderError::Malformed("tool result could not be serialized".to_owned())) +} + +fn parse_anthropic( + value: Value, + request: &ProviderRequest, +) -> Result { + let mut text = String::new(); + let mut tool_calls = Vec::new(); + let mut assistant_content = Vec::new(); + for block in value + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + match block.get("type").and_then(Value::as_str) { + Some("text") => { + let value = block.get("text").and_then(Value::as_str).unwrap_or(""); + text.push_str(value); + assistant_content.push(ContentBlock::Text { + text: value.to_owned(), + }); + } + Some("tool_use") => { + let call = ToolCall { + id: required_field(block, "id")?, + name: required_field(block, "name")?, + input: block.get("input").cloned().unwrap_or(Value::Null), + }; + assistant_content.push(ContentBlock::ToolCall { + id: call.id.clone(), + name: call.name.clone(), + input: call.input.clone(), + }); + tool_calls.push(call); + } + _ => {} + } + } + let finish_reason = match value.get("stop_reason").and_then(Value::as_str) { + Some("tool_use" | "pause_turn") => FinishReason::ToolCalls, + Some("max_tokens") => FinishReason::MaxTokens, + Some("refusal") => FinishReason::Refusal, + _ => FinishReason::Complete, + }; + let mut history = request.messages.clone(); + history.push(Message::Assistant(assistant_content.clone())); + let usage = value.get("usage").cloned().unwrap_or(Value::Null); + Ok(ProviderResponse { + response_id: value + .get("id") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + text, + tool_calls, + assistant_content, + continuation: Some(ContinuationState::Conversation(history)), + usage: Usage { + input_tokens: number(&usage, "input_tokens"), + output_tokens: number(&usage, "output_tokens"), + reasoning_tokens: 0, + cache_read_tokens: number(&usage, "cache_read_input_tokens"), + cache_write_tokens: number(&usage, "cache_creation_input_tokens"), + cost_microusd: None, + }, + finish_reason, + }) +} + +#[derive(Clone)] +pub struct GoogleProvider { + client: Client, + config: HttpProviderConfig, +} + +impl GoogleProvider { + pub fn new(config: HttpProviderConfig) -> Result { + Ok(Self { + client: secure_client()?, + config, + }) + } +} + +#[async_trait] +impl ModelProvider for GoogleProvider { + fn name(&self) -> &'static str { + "google" + } + + async fn complete( + &self, + request: &ProviderRequest, + cancellation: &CancellationToken, + ) -> Result { + let credential = load_credential(&self.config.credential)?; + let endpoint = format!( + "{}/{}:generateContent", + self.config.endpoint.trim_end_matches('/'), + request.model + ); + let http = self.client.post(endpoint).json(&google_request(request)?); + let http = self + .config + .headers + .iter() + .fold(http, |request, (name, value)| request.header(name, value)); + let http = http.header("x-goog-api-key", &credential); + let response = send(http, cancellation, &credential).await?; + parse_google(response, request) + } +} + +fn google_request(request: &ProviderRequest) -> Result { + let mut body = Map::from_iter([ + ( + "systemInstruction".to_owned(), + serde_json::json!({"parts": [{"text": request.instructions}]}), + ), + ( + "contents".to_owned(), + Value::Array(google_contents(&request.messages)?), + ), + ( + "generationConfig".to_owned(), + serde_json::json!({"maxOutputTokens": request.max_output_tokens}), + ), + ]); + if !request.tools.is_empty() { + body.insert( + "tools".to_owned(), + serde_json::json!([{ + "functionDeclarations": request.tools.iter().map(|tool| serde_json::json!({ + "name": tool.id, + "description": tool.description, + "parameters": tool.input_schema, + "response": tool.output_schema, + })).collect::>() + }]), + ); + } + if let Some(schema) = &request.structured_output { + body["generationConfig"]["responseMimeType"] = Value::String("application/json".to_owned()); + body["generationConfig"]["responseJsonSchema"] = schema.clone(); + } + if let Some(reasoning) = &request.reasoning { + body["generationConfig"]["thinkingConfig"] = serde_json::json!({ + "thinkingLevel": reasoning_effort(&reasoning.effort).to_ascii_uppercase() + }); + } + Ok(Value::Object(body)) +} + +fn google_contents(messages: &[Message]) -> Result, ProviderError> { + messages + .iter() + .map(|message| { + let (role, blocks) = match message { + Message::User(blocks) => ("user", blocks), + Message::Assistant(blocks) => ("model", blocks), + }; + let parts = blocks + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text } => Some(serde_json::json!({"text": text})), + ContentBlock::ToolCall { id, name, input } => Some(serde_json::json!({ + "functionCall": {"id": id, "name": name, "args": input} + })), + ContentBlock::ToolResult { id, output, .. } => Some(serde_json::json!({ + "functionResponse": {"id": id, "name": "agentctl_tool", "response": output} + })), + ContentBlock::OpaqueReasoning { .. } => None, + }) + .collect::>(); + Ok(serde_json::json!({"role": role, "parts": parts})) + }) + .collect() +} + +fn parse_google( + value: Value, + request: &ProviderRequest, +) -> Result { + let candidate = value + .get("candidates") + .and_then(Value::as_array) + .and_then(|values| values.first()) + .ok_or_else(|| ProviderError::Malformed("Gemini response has no candidate".to_owned()))?; + let mut text = String::new(); + let mut tool_calls = Vec::new(); + let mut assistant_content = Vec::new(); + for part in candidate + .pointer("/content/parts") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + if let Some(value) = part.get("text").and_then(Value::as_str) { + text.push_str(value); + assistant_content.push(ContentBlock::Text { + text: value.to_owned(), + }); + } + if let Some(call) = part.get("functionCall") { + let tool_call = ToolCall { + id: call.get("id").and_then(Value::as_str).map_or_else( + || format!("gemini-call-{}", tool_calls.len()), + ToOwned::to_owned, + ), + name: required_field(call, "name")?, + input: call + .get("args") + .cloned() + .unwrap_or_else(|| serde_json::json!({})), + }; + assistant_content.push(ContentBlock::ToolCall { + id: tool_call.id.clone(), + name: tool_call.name.clone(), + input: tool_call.input.clone(), + }); + tool_calls.push(tool_call); + } + } + let finish_reason = if !tool_calls.is_empty() { + FinishReason::ToolCalls + } else { + match candidate.get("finishReason").and_then(Value::as_str) { + Some("MAX_TOKENS") => FinishReason::MaxTokens, + Some("SAFETY" | "BLOCKLIST" | "PROHIBITED_CONTENT") => FinishReason::Refusal, + _ => FinishReason::Complete, + } + }; + let mut history = request.messages.clone(); + history.push(Message::Assistant(assistant_content.clone())); + let usage = value.get("usageMetadata").cloned().unwrap_or(Value::Null); + Ok(ProviderResponse { + response_id: value + .get("responseId") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + text, + tool_calls, + assistant_content, + continuation: Some(ContinuationState::Conversation(history)), + usage: Usage { + input_tokens: number(&usage, "promptTokenCount"), + output_tokens: number(&usage, "candidatesTokenCount"), + reasoning_tokens: number(&usage, "thoughtsTokenCount"), + cache_read_tokens: number(&usage, "cachedContentTokenCount"), + cache_write_tokens: 0, + cost_microusd: None, + }, + finish_reason, + }) +} + +#[derive(Default)] +pub struct FakeProvider { + script: Mutex>, + calls: Mutex, +} + +impl FakeProvider { + #[must_use] + pub fn scripted(responses: impl IntoIterator) -> Self { + Self { + script: Mutex::new(responses.into_iter().collect()), + calls: Mutex::new(0), + } + } +} + +#[async_trait] +impl ModelProvider for FakeProvider { + fn name(&self) -> &'static str { + "fake" + } + + async fn complete( + &self, + request: &ProviderRequest, + cancellation: &CancellationToken, + ) -> Result { + if cancellation.is_cancelled() { + return Err(ProviderError::Cancelled); + } + let delay_ms = request + .provider_options + .get("delayMs") + .and_then(Value::as_u64) + .unwrap_or(0); + if delay_ms > 0 { + tokio::select! { + () = tokio::time::sleep(std::time::Duration::from_millis(delay_ms)) => {} + () = cancellation.cancelled() => return Err(ProviderError::Cancelled), + } + } + let call_number = { + let mut calls = self.calls.lock().map_err(|_| { + ProviderError::Malformed("fake provider call counter was poisoned".to_owned()) + })?; + *calls = calls.saturating_add(1); + *calls + }; + if call_number + <= request + .provider_options + .get("failFirst") + .and_then(Value::as_u64) + .unwrap_or(0) + { + return Err(ProviderError::Http { + status: 503, + message: "scripted transient failure".to_owned(), + request_id: format!("fake-{call_number}"), + retryable: true, + }); + } + let scripted = self + .script + .lock() + .map_err(|_| ProviderError::Malformed("fake provider lock was poisoned".to_owned()))? + .pop_front(); + if let Some(response) = scripted { + return Ok(response); + } + let has_tool_result = request.messages.iter().any(|message| match message { + Message::User(blocks) | Message::Assistant(blocks) => blocks + .iter() + .any(|block| matches!(block, ContentBlock::ToolResult { .. })), + }); + if request.provider_options.contains_key("toolInput") + && !has_tool_result + && let Some(tool) = request.tools.first() + { + let input = request + .provider_options + .get("toolInput") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + let call = ToolCall { + id: "fake-call-1".to_owned(), + name: tool.id.clone(), + input: input.clone(), + }; + return Ok(ProviderResponse { + response_id: Some(format!("fake-response-{call_number}")), + text: String::new(), + tool_calls: vec![call], + assistant_content: vec![ContentBlock::ToolCall { + id: "fake-call-1".to_owned(), + name: tool.id.clone(), + input, + }], + continuation: None, + usage: Usage { + input_tokens: 1, + output_tokens: 1, + ..Usage::default() + }, + finish_reason: FinishReason::ToolCalls, + }); + } + let text = request + .messages + .iter() + .rev() + .find_map(|message| match message { + Message::User(blocks) => blocks.iter().find_map(|block| match block { + ContentBlock::Text { text } => Some(text.clone()), + _ => None, + }), + Message::Assistant(_) => None, + }) + .unwrap_or_default(); + let final_text = request + .provider_options + .get("finalText") + .and_then(Value::as_str) + .map_or_else(|| format!("fake: {text}"), ToOwned::to_owned); + Ok(ProviderResponse { + response_id: Some("fake-response".to_owned()), + text: final_text.clone(), + tool_calls: Vec::new(), + assistant_content: vec![ContentBlock::Text { text: final_text }], + continuation: None, + usage: Usage { + input_tokens: 1, + output_tokens: 1, + ..Usage::default() + }, + finish_reason: FinishReason::Complete, + }) + } +} + +async fn send( + request: reqwest::RequestBuilder, + cancellation: &CancellationToken, + credential: &str, +) -> Result { + let response = tokio::select! { + response = request.send() => response.map_err(normalize_transport)?, + () = cancellation.cancelled() => return Err(ProviderError::Cancelled), + }; + let status = response.status(); + let request_id = response + .headers() + .get("x-request-id") + .or_else(|| response.headers().get("request-id")) + .and_then(|value| value.to_str().ok()) + .unwrap_or("unavailable") + .to_owned(); + let mut stream = response.bytes_stream(); + let mut bytes = Vec::new(); + loop { + let next = tokio::select! { + next = stream.next() => next, + () = cancellation.cancelled() => return Err(ProviderError::Cancelled), + }; + let Some(chunk) = next else { break }; + let chunk = chunk.map_err(normalize_transport)?; + if bytes.len().saturating_add(chunk.len()) > MAX_PROVIDER_RESPONSE_BYTES { + return Err(ProviderError::Malformed(format!( + "response exceeds {MAX_PROVIDER_RESPONSE_BYTES} bytes" + ))); + } + bytes.extend_from_slice(&chunk); + } + let body: Value = serde_json::from_slice(&bytes) + .map_err(|error| ProviderError::Malformed(error.to_string()))?; + if status.is_success() { + Ok(body) + } else { + let message = body + .pointer("/error/message") + .or_else(|| body.get("message")) + .and_then(Value::as_str) + .unwrap_or("provider request failed"); + let mut safe = message.replace(credential, "[REDACTED]"); + safe.truncate(512); + Err(ProviderError::Http { + status: status.as_u16(), + message: safe, + request_id, + retryable: status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error(), + }) + } +} + +fn normalize_transport(error: reqwest::Error) -> ProviderError { + if error.is_timeout() { + ProviderError::Timeout + } else { + ProviderError::Http { + status: 0, + message: error.to_string(), + request_id: "unavailable".to_owned(), + retryable: false, + } + } +} + +fn load_credential(reference: &SecretReference) -> Result { + #[cfg(test)] + if reference.env == "AGENTCTL_PROVIDER_TEST_KEY" { + return Ok("test-key".to_owned()); + } + std::env::var(&reference.env).map_err(|_| ProviderError::Authentication(reference.env.clone())) +} + +fn required_field(value: &Value, field: &str) -> Result { + value + .get(field) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or_else(|| ProviderError::Malformed(format!("missing string field `{field}`"))) +} + +fn number(value: &Value, field: &str) -> u64 { + value.get(field).and_then(Value::as_u64).unwrap_or(0) +} + +fn nested_number(value: &Value, path: &[&str]) -> u64 { + path.iter() + .try_fold(value, |current, field| current.get(*field)) + .and_then(Value::as_u64) + .unwrap_or(0) +} + +const fn reasoning_effort(effort: &ReasoningEffort) -> &'static str { + match effort { + ReasoningEffort::None => "none", + ReasoningEffort::Low => "low", + ReasoningEffort::Medium => "medium", + ReasoningEffort::High => "high", + ReasoningEffort::Xhigh => "xhigh", + ReasoningEffort::Max => "max", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use agentctl_core::dsl::{ + ApprovalRequirement, EffectClass, Idempotency, ReasoningDefinition, ReasoningEffort, Risk, + }; + use agentctl_core::tool::ToolContract; + use wiremock::matchers::{body_json, header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn request() -> ProviderRequest { + ProviderRequest { + model: "test-model".to_owned(), + instructions: "Be concise.".to_owned(), + messages: vec![Message::User(vec![ContentBlock::Text { + text: "hello".to_owned(), + }])], + tools: vec![ToolContract { + id: "echo".to_owned(), + description: "Echo input".to_owned(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + "additionalProperties": false + }), + output_schema: serde_json::json!({"type": "object"}), + capability: "observe".to_owned(), + risk: Risk::Low, + effect_class: EffectClass::Pure, + idempotency: Idempotency::Pure, + retry_safe: true, + timeout_seconds: 5, + secret_requirements: Vec::new(), + network_requirements: Vec::new(), + approval: ApprovalRequirement::Never, + observability: Value::Null, + compensation: None, + }], + max_output_tokens: 64, + reasoning: None, + structured_output: None, + continuation: None, + prompt_cache_key: Some("cache-key".to_owned()), + safety_identifier: Some("user-hash".to_owned()), + provider_options: BTreeMap::new(), + } + } + + #[tokio::test] + async fn fake_provider_is_deterministic() { + let response = FakeProvider::default() + .complete(&request(), &CancellationToken::new()) + .await + .expect("fake response"); + assert_eq!(response.text, "fake: hello"); + assert_eq!(response.usage.input_tokens, 1); + } + + #[test] + fn openai_maps_explicit_gpt56_options_without_silent_fallback() { + let mut request = request(); + request.structured_output = Some(serde_json::json!({ + "type": "object", + "properties": {"verdict": {"type": "string"}}, + "required": ["verdict"], + "additionalProperties": false + })); + request.reasoning = Some(ReasoningDefinition { + effort: ReasoningEffort::Max, + mode: Some("pro".to_owned()), + }); + request.provider_options = BTreeMap::from([ + ("store".to_owned(), Value::Bool(false)), + ( + "reasoningContext".to_owned(), + Value::String("all_turns".to_owned()), + ), + ( + "promptCacheMode".to_owned(), + Value::String("explicit".to_owned()), + ), + ("promptCacheTtl".to_owned(), Value::String("30m".to_owned())), + ("parallelToolCalls".to_owned(), Value::Bool(false)), + ]); + let body = openai_request(&request).expect("request mapping"); + assert_eq!(body["store"], false); + assert_eq!(body["parallel_tool_calls"], false); + assert_eq!(body["reasoning"]["effort"], "max"); + assert_eq!(body["reasoning"]["mode"], "pro"); + assert_eq!(body["reasoning"]["context"], "all_turns"); + assert_eq!(body["prompt_cache_options"]["mode"], "explicit"); + assert_eq!(body["prompt_cache_options"]["ttl"], "30m"); + assert_eq!(body["tools"][0]["strict"], true); + assert_eq!(body["text"]["format"]["strict"], true); + } + + #[test] + fn openai_preserves_multiple_function_call_ids() { + let response = parse_openai(serde_json::json!({ + "id": "resp_tools", + "status": "completed", + "output": [ + {"type": "function_call", "call_id": "call-a", "name": "echo", "arguments": "{\"text\":\"a\"}"}, + {"type": "function_call", "call_id": "call-b", "name": "echo", "arguments": "{\"text\":\"b\"}"} + ] + })) + .expect("valid multiple function calls"); + assert_eq!(response.finish_reason, FinishReason::ToolCalls); + assert_eq!(response.tool_calls.len(), 2); + assert_eq!(response.tool_calls[0].id, "call-a"); + assert_eq!(response.tool_calls[1].id, "call-b"); + assert_eq!( + response.continuation, + Some(ContinuationState::OpenaiPreviousResponse( + "resp_tools".to_owned() + )) + ); + } + + #[tokio::test] + async fn openai_maps_responses_api_and_usage() { + let server = MockServer::start().await; + let expected = openai_request(&request()).expect("request mapping"); + Mock::given(method("POST")) + .and(path("/v1/responses")) + .and(header("authorization", "Bearer test-key")) + .and(body_json(expected)) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "resp_1", + "status": "completed", + "output": [{"type": "message", "content": [{"type": "output_text", "text": "ok"}]}], + "usage": { + "input_tokens": 10, + "output_tokens": 4, + "input_tokens_details": {"cached_tokens": 3}, + "output_tokens_details": {"reasoning_tokens": 2}, + "cache_write_tokens": 5 + } + }))) + .mount(&server) + .await; + let mut config = HttpProviderConfig::openai("AGENTCTL_PROVIDER_TEST_KEY"); + config.endpoint = format!("{}/v1/responses", server.uri()); + let response = OpenAiProvider::new(config) + .expect("provider") + .complete(&request(), &CancellationToken::new()) + .await + .expect("response"); + assert_eq!(response.text, "ok"); + assert_eq!(response.usage.cache_read_tokens, 3); + assert_eq!(response.usage.cache_write_tokens, 5); + assert_eq!(response.usage.reasoning_tokens, 2); + } + + #[tokio::test] + async fn anthropic_maps_native_tool_calls() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/messages")) + .and(header("x-api-key", "test-key")) + .and(header("anthropic-version", ANTHROPIC_VERSION)) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "msg_1", + "content": [{"type": "tool_use", "id": "toolu_1", "name": "echo", "input": {"text": "hello"}}], + "stop_reason": "tool_use", + "usage": {"input_tokens": 8, "output_tokens": 3, "cache_read_input_tokens": 2, "cache_creation_input_tokens": 1} + }))) + .mount(&server) + .await; + let mut config = HttpProviderConfig::anthropic("AGENTCTL_PROVIDER_TEST_KEY"); + config.endpoint = format!("{}/v1/messages", server.uri()); + let response = AnthropicProvider::new(config) + .expect("provider") + .complete(&request(), &CancellationToken::new()) + .await + .expect("response"); + assert_eq!(response.finish_reason, FinishReason::ToolCalls); + assert_eq!(response.tool_calls[0].id, "toolu_1"); + assert_eq!(response.usage.cache_write_tokens, 1); + } + + #[tokio::test] + async fn google_maps_native_generate_content() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/models/test-model:generateContent")) + .and(header("x-goog-api-key", "test-key")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "responseId": "gemini_1", + "candidates": [{"content": {"parts": [{"text": "ok"}]}, "finishReason": "STOP"}], + "usageMetadata": {"promptTokenCount": 7, "candidatesTokenCount": 2, "thoughtsTokenCount": 1, "cachedContentTokenCount": 4} + }))) + .mount(&server) + .await; + let mut config = HttpProviderConfig::google("AGENTCTL_PROVIDER_TEST_KEY"); + config.endpoint = format!("{}/models", server.uri()); + let response = GoogleProvider::new(config) + .expect("provider") + .complete(&request(), &CancellationToken::new()) + .await + .expect("response"); + assert_eq!(response.text, "ok"); + assert_eq!(response.usage.reasoning_tokens, 1); + assert_eq!(response.usage.cache_read_tokens, 4); + } + + #[tokio::test] + async fn azure_uses_api_key_and_v1_responses_path() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/openai/v1/responses")) + .and(header("api-key", "test-key")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "resp_azure", + "status": "completed", + "output": [{"type": "message", "content": [{"type": "output_text", "text": "azure-ok"}]}], + "usage": {"input_tokens": 1, "output_tokens": 1} + }))) + .mount(&server) + .await; + let config = HttpProviderConfig { + endpoint: format!("{}/openai/v1/responses", server.uri()), + credential: SecretReference { + env: "AGENTCTL_PROVIDER_TEST_KEY".to_owned(), + }, + organization: None, + project: None, + api_version: Some("v1".to_owned()), + headers: BTreeMap::new(), + }; + let response = OpenAiProvider::azure(config) + .expect("provider") + .complete(&request(), &CancellationToken::new()) + .await + .expect("response"); + assert_eq!(response.text, "azure-ok"); + } + + #[tokio::test] + async fn provider_authentication_errors_are_redacted_and_not_retryable() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with( + ResponseTemplate::new(401) + .insert_header("x-request-id", "request-auth") + .set_body_json(serde_json::json!({ + "error": {"message": "invalid credential test-key"} + })), + ) + .mount(&server) + .await; + let mut config = HttpProviderConfig::openai("AGENTCTL_PROVIDER_TEST_KEY"); + config.endpoint = format!("{}/v1/responses", server.uri()); + let error = OpenAiProvider::new(config) + .expect("provider") + .complete(&request(), &CancellationToken::new()) + .await + .expect_err("authentication failure"); + match error { + ProviderError::Http { + status, + message, + request_id, + retryable, + } => { + assert_eq!(status, 401); + assert_eq!(message, "invalid credential [REDACTED]"); + assert_eq!(request_id, "request-auth"); + assert!(!retryable); + } + other => panic!("unexpected error: {other}"), + } + } + + #[tokio::test] + async fn rate_limits_are_explicitly_retryable() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(ResponseTemplate::new(429).set_body_json(serde_json::json!({ + "error": {"message": "rate limited"} + }))) + .mount(&server) + .await; + let mut config = HttpProviderConfig::openai("AGENTCTL_PROVIDER_TEST_KEY"); + config.endpoint = format!("{}/v1/responses", server.uri()); + let error = OpenAiProvider::new(config) + .expect("provider") + .complete(&request(), &CancellationToken::new()) + .await + .expect_err("rate limit"); + assert!(matches!( + error, + ProviderError::Http { + status: 429, + retryable: true, + .. + } + )); + } + + #[tokio::test] + async fn malformed_success_responses_fail_explicitly() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(ResponseTemplate::new(200).set_body_raw("not-json", "text/plain")) + .mount(&server) + .await; + let mut config = HttpProviderConfig::openai("AGENTCTL_PROVIDER_TEST_KEY"); + config.endpoint = format!("{}/v1/responses", server.uri()); + let error = OpenAiProvider::new(config) + .expect("provider") + .complete(&request(), &CancellationToken::new()) + .await + .expect_err("malformed response"); + assert!(matches!(error, ProviderError::Malformed(_))); + } + + #[tokio::test] + async fn cancellation_is_normalized() { + let token = CancellationToken::new(); + token.cancel(); + let mut config = HttpProviderConfig::openai("AGENTCTL_PROVIDER_TEST_KEY"); + config.endpoint = "http://127.0.0.1:9/v1/responses".to_owned(); + let result = OpenAiProvider::new(config) + .expect("provider") + .complete(&request(), &token) + .await; + assert!(matches!(result, Err(ProviderError::Cancelled))); + } +} diff --git a/crates/agentctl-runtime/Cargo.toml b/crates/agentctl-runtime/Cargo.toml new file mode 100644 index 0000000..be2409c --- /dev/null +++ b/crates/agentctl-runtime/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "agentctl-runtime" +description = "Durable deterministic workflow runtime for agentctl" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +agentctl-core = { version = "0.2.0", path = "../agentctl-core" } +agentctl-observability = { version = "0.2.0", path = "../agentctl-observability" } +agentctl-store = { version = "0.2.0", path = "../agentctl-store" } +async-trait.workspace = true +chrono.workspace = true +hex.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +thiserror.workspace = true +tokio.workspace = true +tokio-util.workspace = true +uuid.workspace = true +url.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/agentctl-runtime/src/lib.rs b/crates/agentctl-runtime/src/lib.rs new file mode 100644 index 0000000..5af213d --- /dev/null +++ b/crates/agentctl-runtime/src/lib.rs @@ -0,0 +1,3198 @@ +//! Durable deterministic workflow runtime for agentctl. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use agentctl_core::compiler::{CompiledPlan, PlanPredictability, TaskUse}; +use agentctl_core::dsl::{ + API_VERSION, ActionDefinition, ActionKind, ApprovalRequirement, EffectClass, FailureBehavior, + Idempotency, Risk, ToolDefinition, ToolKind, Workflow, +}; +use agentctl_core::effect::{ActionResult, ChangeStatus, EffectRequest, EffectStatus}; +use agentctl_core::policy::{PolicyContext, PolicyDecision, PolicyEngine, PolicyError, redact}; +use agentctl_core::provider::{ + ContentBlock, FinishReason, Message, ModelProvider, ProviderError, ProviderRequest, + ProviderResponse, Usage, +}; +use agentctl_core::state::{RunState, TaskState}; +use agentctl_core::template::{EvalContext, TemplateError, evaluate_when, render}; +use agentctl_core::tool::{ToolContract, ToolContractError, ToolExecutor}; +use agentctl_observability::{NoopTraceSink, SpanKind, TraceEvent, TracePhase, TraceSink}; +use agentctl_store::{ApprovalRequest, RunMode, SqliteStore, StoreError, TaskRecord}; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; +use tokio_util::sync::CancellationToken; +use url::Url; +use uuid::Uuid; + +pub trait Clock: Send + Sync { + fn now(&self) -> DateTime; +} + +#[derive(Debug, Default)] +pub struct SystemClock; + +impl Clock for SystemClock { + fn now(&self) -> DateTime { + Utc::now() + } +} + +pub trait IdGenerator: Send + Sync { + fn next_id(&self, kind: &str) -> String; +} + +#[derive(Debug, Default)] +pub struct UuidGenerator; + +impl IdGenerator for UuidGenerator { + fn next_id(&self, kind: &str) -> String { + format!("{kind}-{}", Uuid::now_v7()) + } +} + +#[async_trait] +pub trait ExternalActionHandler: Send + Sync { + async fn execute( + &self, + kind: ActionKind, + input: &Value, + cancellation: &CancellationToken, + ) -> Result; +} + +const MAX_WORKSPACE_TOOL_BYTES: u64 = 1024 * 1024; + +pub struct BuiltinToolExecutor { + contract: ToolContract, + kind: ToolKind, + policy: PolicyEngine, +} + +impl BuiltinToolExecutor { + #[must_use] + pub fn new(id: impl Into, definition: &ToolDefinition, policy: PolicyEngine) -> Self { + Self { + contract: ToolContract { + id: id.into(), + description: definition.description.clone(), + input_schema: definition.input_schema.clone(), + output_schema: definition.output_schema.clone(), + capability: definition.capability.clone(), + risk: definition.risk, + effect_class: definition.effect_class, + idempotency: definition.idempotency, + retry_safe: definition.retry_safe, + timeout_seconds: definition.timeout_seconds, + secret_requirements: definition.secrets.clone(), + network_requirements: definition.network.clone(), + approval: definition.approval, + observability: Value::Null, + compensation: definition.compensation.clone(), + }, + kind: definition.kind, + policy, + } + } +} + +#[async_trait] +impl ToolExecutor for BuiltinToolExecutor { + fn contract(&self) -> &ToolContract { + &self.contract + } + + async fn execute( + &self, + input: Value, + cancellation: &CancellationToken, + ) -> Result { + if cancellation.is_cancelled() { + return Err(ToolContractError::Cancelled); + } + match self.kind { + ToolKind::Echo => Ok(ActionResult::unchanged(input)), + ToolKind::WorkspaceRead => { + let path = input.get("path").and_then(Value::as_str).ok_or_else(|| { + ToolContractError::Execution("workspace read requires string `path`".to_owned()) + })?; + let resolved = self + .policy + .resolve_read_path(path) + .map_err(|error| ToolContractError::Execution(error.to_string()))?; + let metadata = tokio::fs::metadata(&resolved) + .await + .map_err(|error| ToolContractError::Execution(error.to_string()))?; + if metadata.len() > MAX_WORKSPACE_TOOL_BYTES { + return Err(ToolContractError::Execution(format!( + "workspace read exceeds {MAX_WORKSPACE_TOOL_BYTES} bytes" + ))); + } + let content = tokio::fs::read_to_string(&resolved) + .await + .map_err(|error| ToolContractError::Execution(error.to_string()))?; + let bytes = content.len(); + Ok(ActionResult::unchanged(serde_json::json!({ + "path": path, + "content": content, + "bytes": bytes, + "sha256": digest(content.as_bytes()), + }))) + } + ToolKind::WorkspaceWrite => { + let path = input.get("path").and_then(Value::as_str).ok_or_else(|| { + ToolContractError::Execution( + "workspace write requires string `path`".to_owned(), + ) + })?; + let content = input + .get("content") + .and_then(Value::as_str) + .ok_or_else(|| { + ToolContractError::Execution( + "workspace write requires string `content`".to_owned(), + ) + })?; + let resolved = self + .policy + .resolve_write_path(path) + .map_err(|error| ToolContractError::Execution(error.to_string()))?; + write_atomic(&resolved, content.as_bytes()) + .await + .map_err(|error| ToolContractError::Execution(error.to_string()))?; + Ok(ActionResult::changed(serde_json::json!({ + "path": path, + "bytes": content.len(), + "sha256": digest(content.as_bytes()), + }))) + } + } + } +} + +#[derive(Default)] +pub struct RuntimeRegistry { + providers: BTreeMap>, + tools: BTreeMap>, + external_actions: Option>, +} + +impl RuntimeRegistry { + #[must_use] + pub fn with_provider( + mut self, + name: impl Into, + provider: Arc, + ) -> Self { + self.providers.insert(name.into(), provider); + self + } + + #[must_use] + pub fn with_tool(mut self, name: impl Into, tool: Arc) -> Self { + self.tools.insert(name.into(), tool); + self + } + + #[must_use] + pub fn with_external_actions(mut self, handler: Arc) -> Self { + self.external_actions = Some(handler); + self + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct RunOptions { + pub check: bool, + pub diff: bool, + pub interactive: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunOutcome { + pub run_id: String, + pub trace_id: String, + pub state: RunState, + pub output: Option, +} + +#[derive(Debug, Error)] +pub enum RuntimeError { + #[error(transparent)] + Store(#[from] StoreError), + #[error(transparent)] + Policy(#[from] PolicyError), + #[error(transparent)] + Template(#[from] TemplateError), + #[error(transparent)] + Provider(#[from] ProviderError), + #[error(transparent)] + Tool(#[from] ToolContractError), + #[error("workflow state is invalid: {0}")] + InvalidState(String), + #[error("task `{task}` failed: {message}")] + Task { task: String, message: String }, + #[error("run `{run_id}` failed in task `{task}` (trace `{trace_id}`): {message}")] + RunFailed { + run_id: String, + trace_id: String, + task: String, + message: String, + }, + #[error( + "effect `{effect_id}` in run `{run_id}` has an uncertain outcome and will not be repeated automatically (trace `{trace_id}`)" + )] + UncertainEffect { + run_id: String, + trace_id: String, + effect_id: String, + }, + #[error("external effect outcome is uncertain: {0}")] + ExternalEffectUncertain(String), + #[error("execution was cancelled")] + Cancelled, + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), +} + +pub struct Runtime { + store: SqliteStore, + registry: RuntimeRegistry, + clock: Arc, + ids: Arc, + traces: Arc, + base_path: PathBuf, +} + +impl Runtime { + #[must_use] + pub fn new(store: SqliteStore, base_path: impl Into) -> Self { + Self { + store, + registry: RuntimeRegistry::default(), + clock: Arc::new(SystemClock), + ids: Arc::new(UuidGenerator), + traces: Arc::new(NoopTraceSink), + base_path: base_path.into(), + } + } + + #[must_use] + pub fn with_registry(mut self, registry: RuntimeRegistry) -> Self { + self.registry = registry; + self + } + + #[must_use] + pub fn with_clock(mut self, clock: Arc) -> Self { + self.clock = clock; + self + } + + #[must_use] + pub fn with_ids(mut self, ids: Arc) -> Self { + self.ids = ids; + self + } + + #[must_use] + pub fn with_trace_sink(mut self, traces: Arc) -> Self { + self.traces = traces; + self + } + + pub async fn start( + &self, + workflow: &Workflow, + plan: &CompiledPlan, + inputs: Value, + options: RunOptions, + cancellation: &CancellationToken, + ) -> Result { + let run_id = self.ids.next_id("run"); + let trace_id = self.ids.next_id("trace"); + let mode = if options.check { + RunMode::Check + } else { + RunMode::Execute + }; + self.store.create_run( + &run_id, + API_VERSION, + &serde_json::to_value(workflow)?, + plan, + &inputs, + &Value::Object(workflow.spec.memory.working.clone().into_iter().collect()), + mode, + None, + &self.base_path, + self.clock.now(), + &trace_id, + )?; + self.trace(TraceEvent::new( + SpanKind::Run, + TracePhase::Started, + "run.execute", + &trace_id, + &run_id, + self.clock.now(), + ))?; + self.drive(&run_id, &trace_id, options, cancellation).await + } + + pub async fn resume( + &self, + run_id: &str, + options: RunOptions, + cancellation: &CancellationToken, + ) -> Result { + let trace_id = self.ids.next_id("trace"); + let unresolved = self.store.unresolved_effects(run_id)?; + if let Some(effect_id) = unresolved.first() { + return Err(RuntimeError::UncertainEffect { + run_id: run_id.to_owned(), + trace_id, + effect_id: effect_id.clone(), + }); + } + let run = self.store.load_run(run_id)?; + if run.state.is_terminal() { + return Err(RuntimeError::InvalidState(format!( + "run `{run_id}` is already terminal ({:?})", + run.state + ))); + } + let tasks = self.store.list_tasks(run_id)?; + for task in tasks + .iter() + .filter(|task| task.state == TaskState::WaitingForApproval) + { + let effect = self + .store + .latest_effect_for_task(run_id, &task.task_id)? + .ok_or_else(|| { + RuntimeError::InvalidState(format!( + "task `{}` waits for an approval without an effect", + task.task_id + )) + })?; + match effect.status { + EffectStatus::Requested => self.store.transition_task( + run_id, + &task.task_id, + TaskState::Running, + None, + None, + None, + self.clock.now(), + &trace_id, + )?, + EffectStatus::WaitingForApproval => { + return Ok(RunOutcome { + run_id: run_id.to_owned(), + trace_id, + state: RunState::Paused, + output: None, + }); + } + EffectStatus::Cancelled => { + self.store.transition_task( + run_id, + &task.task_id, + TaskState::Failed, + None, + Some("approval rejected"), + None, + self.clock.now(), + &trace_id, + )?; + self.store.update_run_state( + run_id, + RunState::Failed, + None, + self.clock.now(), + &trace_id, + )?; + return Ok(RunOutcome { + run_id: run_id.to_owned(), + trace_id, + state: RunState::Failed, + output: None, + }); + } + other => { + return Err(RuntimeError::InvalidState(format!( + "approval task `{}` has effect state {other:?}", + task.task_id + ))); + } + } + } + if run.state == RunState::Paused { + self.store.update_run_state( + run_id, + RunState::Running, + None, + self.clock.now(), + &trace_id, + )?; + } + self.drive(run_id, &trace_id, options, cancellation).await + } + + pub async fn replay(&self, source_run_id: &str) -> Result { + let source = self.store.load_run(source_run_id)?; + let source_tasks = self.store.list_tasks(source_run_id)?; + if !source.state.is_terminal() { + return Err(RuntimeError::InvalidState(format!( + "source run `{source_run_id}` is not terminal ({:?})", + source.state + ))); + } + let source_tasks = source_tasks + .into_iter() + .map(|task| { + let terminal = match task.state { + TaskState::Succeeded => TaskState::Succeeded, + TaskState::Failed => TaskState::Failed, + TaskState::Skipped => TaskState::Skipped, + TaskState::Cancelled => TaskState::Cancelled, + _ => { + return Err(RuntimeError::InvalidState(format!( + "source run has non-terminal task `{}`", + task.task_id + ))); + } + }; + Ok((task, terminal)) + }) + .collect::, _>>()?; + let replay_id = self.ids.next_id("replay"); + let trace_id = self.ids.next_id("trace"); + self.store.create_run( + &replay_id, + &source.workflow_schema_version, + &source.workflow, + &source.plan, + &source.inputs, + &source.working_memory, + RunMode::Replay, + Some(source_run_id), + Path::new(source.base_path.as_deref().unwrap_or(".")), + self.clock.now(), + &trace_id, + )?; + for (task, terminal) in source_tasks { + self.store.transition_task( + &replay_id, + &task.task_id, + TaskState::Ready, + None, + None, + None, + self.clock.now(), + &trace_id, + )?; + self.store.transition_task( + &replay_id, + &task.task_id, + TaskState::Running, + None, + None, + None, + self.clock.now(), + &trace_id, + )?; + self.store.transition_task( + &replay_id, + &task.task_id, + terminal, + task.output.as_ref(), + task.error.as_deref(), + None, + self.clock.now(), + &trace_id, + )?; + } + self.store.update_run_state( + &replay_id, + source.state, + source.output.as_ref(), + self.clock.now(), + &trace_id, + )?; + Ok(RunOutcome { + run_id: replay_id, + trace_id, + state: source.state, + output: source.output, + }) + } + + pub async fn fork( + &self, + source_run_id: &str, + options: RunOptions, + cancellation: &CancellationToken, + ) -> Result { + let source = self.store.load_run(source_run_id)?; + let workflow: Workflow = serde_json::from_value(source.workflow.clone())?; + let run_id = self.ids.next_id("fork"); + let trace_id = self.ids.next_id("trace"); + self.store.create_run( + &run_id, + &source.workflow_schema_version, + &source.workflow, + &source.plan, + &source.inputs, + &serde_json::to_value(&workflow.spec.memory.working)?, + RunMode::Fork, + Some(source_run_id), + &self.base_path, + self.clock.now(), + &trace_id, + )?; + self.drive(&run_id, &trace_id, options, cancellation).await + } + + async fn drive( + &self, + run_id: &str, + trace_id: &str, + options: RunOptions, + cancellation: &CancellationToken, + ) -> Result { + loop { + let run = self.store.load_run(run_id)?; + if run.cancellation_requested || cancellation.is_cancelled() { + self.cancel_non_terminal(run_id, trace_id)?; + return Ok(RunOutcome { + run_id: run_id.to_owned(), + trace_id: trace_id.to_owned(), + state: RunState::Cancelled, + output: None, + }); + } + let workflow: Workflow = serde_json::from_value(run.workflow.clone())?; + let policy = PolicyEngine::new(workflow.spec.policy.clone(), &self.base_path)?; + let tasks = self.store.list_tasks(run_id)?; + if tasks.iter().all(|task| task.state.is_terminal()) { + let failed = tasks.iter().any(|task| task.state == TaskState::Failed); + let state = if failed { + RunState::Failed + } else { + RunState::Succeeded + }; + let output = collect_outputs(&run, &tasks, &workflow.spec.outputs)?; + self.store.update_run_state( + run_id, + state, + Some(&output), + self.clock.now(), + trace_id, + )?; + self.trace( + TraceEvent::new( + SpanKind::Run, + if failed { + TracePhase::Failed + } else { + TracePhase::Completed + }, + "run.execute", + trace_id, + run_id, + self.clock.now(), + ) + .attributes(serde_json::json!({"state": state}), &[]), + )?; + return Ok(RunOutcome { + run_id: run_id.to_owned(), + trace_id: trace_id.to_owned(), + state, + output: Some(output), + }); + } + let Some(task) = next_task(&run.plan, &tasks) else { + return Err(RuntimeError::InvalidState( + "no runnable task exists and the run is not terminal".to_owned(), + )); + }; + let dependencies: Vec<&TaskRecord> = task + .needs + .iter() + .filter_map(|needed| tasks.iter().find(|candidate| &candidate.task_id == needed)) + .collect(); + if dependencies.iter().any(|dependency| { + matches!( + dependency.state, + TaskState::Failed | TaskState::Cancelled | TaskState::Skipped + ) + }) { + self.store.transition_task( + run_id, + &task.id, + TaskState::Skipped, + None, + Some("dependency did not succeed"), + None, + self.clock.now(), + trace_id, + )?; + continue; + } + let ready_state = tasks + .iter() + .find(|record| record.task_id == task.id) + .map(|record| record.state) + .ok_or_else(|| RuntimeError::InvalidState(format!("task `{}` missing", task.id)))?; + if ready_state == TaskState::Pending { + let context = context_for(&run, &tasks)?; + if let Some(condition) = &task.when + && !evaluate_when(condition, &context)? + { + self.store.transition_task( + run_id, + &task.id, + TaskState::Skipped, + Some(&serde_json::json!({"reason": "when condition was false"})), + None, + None, + self.clock.now(), + trace_id, + )?; + continue; + } + self.store.transition_task( + run_id, + &task.id, + TaskState::Ready, + None, + None, + None, + self.clock.now(), + trace_id, + )?; + continue; + } + if ready_state == TaskState::Ready { + self.store.transition_task( + run_id, + &task.id, + TaskState::Running, + None, + None, + None, + self.clock.now(), + trace_id, + )?; + self.trace( + TraceEvent::new( + SpanKind::Task, + TracePhase::Started, + "task.execute", + trace_id, + run_id, + self.clock.now(), + ) + .task(&task.id), + )?; + continue; + } + if ready_state != TaskState::Running { + return Err(RuntimeError::InvalidState(format!( + "scheduler selected task `{}` in state {ready_state:?}", + task.id + ))); + } + + let current = self + .store + .list_tasks(run_id)? + .into_iter() + .find(|record| record.task_id == task.id) + .ok_or_else(|| RuntimeError::InvalidState(format!("task `{}` missing", task.id)))?; + let execution = self + .execute_task( + &workflow, + &run, + ¤t, + task, + &policy, + trace_id, + options, + cancellation, + ) + .await; + match execution { + Ok(TaskExecution::Complete { output, memory }) => { + self.store.transition_task( + run_id, + &task.id, + TaskState::Succeeded, + Some(&output), + None, + memory.as_ref(), + self.clock.now(), + trace_id, + )?; + self.trace( + TraceEvent::new( + SpanKind::Task, + TracePhase::Completed, + "task.execute", + trace_id, + run_id, + self.clock.now(), + ) + .task(&task.id), + )?; + } + Ok(TaskExecution::Paused) => { + self.store.update_run_state( + run_id, + RunState::Paused, + None, + self.clock.now(), + trace_id, + )?; + self.trace( + TraceEvent::new( + SpanKind::Approval, + TracePhase::Waiting, + "approval.waiting", + trace_id, + run_id, + self.clock.now(), + ) + .task(&task.id), + )?; + return Ok(RunOutcome { + run_id: run_id.to_owned(), + trace_id: trace_id.to_owned(), + state: RunState::Paused, + output: None, + }); + } + Err(error) => { + if matches!(error, RuntimeError::Cancelled) { + self.cancel_non_terminal(run_id, trace_id)?; + return Ok(RunOutcome { + run_id: run_id.to_owned(), + trace_id: trace_id.to_owned(), + state: RunState::Cancelled, + output: None, + }); + } + if current.attempt < task.retry.max_attempts && retryable_error(&error) { + self.store.transition_task( + run_id, + &task.id, + TaskState::RetryScheduled, + None, + Some(&error.to_string()), + None, + self.clock.now(), + trace_id, + )?; + self.trace( + TraceEvent::new( + SpanKind::Retry, + TracePhase::Waiting, + "task.retry", + trace_id, + run_id, + self.clock.now(), + ) + .task(&task.id), + )?; + tokio::select! { + () = tokio::time::sleep(Duration::from_millis(task.retry.backoff_ms)) => {} + () = cancellation.cancelled() => return Err(RuntimeError::Cancelled), + } + self.store.transition_task( + run_id, + &task.id, + TaskState::Ready, + None, + None, + None, + self.clock.now(), + trace_id, + )?; + self.trace( + TraceEvent::new( + SpanKind::Task, + TracePhase::Failed, + "task.execute", + trace_id, + run_id, + self.clock.now(), + ) + .task(&task.id) + .attributes(serde_json::json!({"error": error.to_string()}), &[]), + )?; + } else { + self.store.transition_task( + run_id, + &task.id, + TaskState::Failed, + None, + Some(&error.to_string()), + None, + self.clock.now(), + trace_id, + )?; + if task.failure == FailureBehavior::Stop { + self.store.update_run_state( + run_id, + RunState::Failed, + None, + self.clock.now(), + trace_id, + )?; + return Err(RuntimeError::RunFailed { + run_id: run_id.to_owned(), + trace_id: trace_id.to_owned(), + task: task.id.clone(), + message: error.to_string(), + }); + } + } + } + } + } + } + + #[allow(clippy::too_many_arguments)] + async fn execute_task( + &self, + workflow: &Workflow, + run: &agentctl_store::RunRecord, + record: &TaskRecord, + task: &agentctl_core::CompiledTask, + policy: &PolicyEngine, + trace_id: &str, + options: RunOptions, + cancellation: &CancellationToken, + ) -> Result { + let tasks = self.store.list_tasks(&run.run_id)?; + let context = context_for(run, &tasks)?; + let raw_input = serde_json::to_value(&task.input)?; + let input = render(&raw_input, &context)?; + match &task.uses { + TaskUse::Action(name) => { + let action = workflow.spec.actions.get(name).ok_or_else(|| { + RuntimeError::InvalidState(format!("action `{name}` disappeared after compile")) + })?; + self.execute_action( + workflow, + run, + record, + action, + input, + policy, + trace_id, + options, + cancellation, + ) + .await + } + TaskUse::Agent(name) => { + if options.check { + return Ok(TaskExecution::Complete { + output: serde_json::json!({ + "status": "requires_execution", + "changed": false, + "provider": workflow.spec.agents[name].provider, + }), + memory: None, + }); + } + self.execute_agent( + workflow, + run, + record, + name, + input, + policy, + trace_id, + options.interactive, + cancellation, + ) + .await + } + } + } + + #[allow(clippy::too_many_arguments)] + async fn execute_action( + &self, + workflow: &Workflow, + run: &agentctl_store::RunRecord, + task: &TaskRecord, + action: &ActionDefinition, + input: Value, + policy: &PolicyEngine, + trace_id: &str, + options: RunOptions, + cancellation: &CancellationToken, + ) -> Result { + match action.kind { + ActionKind::Assign => Ok(TaskExecution::Complete { + output: serde_json::to_value(ActionResult { + status: ChangeStatus::Unchanged, + changed: false, + before: None, + after: Some(input.clone()), + diff: None, + output: input, + predictability: PlanPredictability::FullyPredictable, + })?, + memory: None, + }), + ActionKind::Assert => { + let passed = input.get("that").and_then(Value::as_bool).ok_or_else(|| { + RuntimeError::InvalidState("assert input requires boolean `that`".to_owned()) + })?; + if passed { + Ok(TaskExecution::Complete { + output: serde_json::json!({"status": "unchanged", "changed": false, "passed": true}), + memory: None, + }) + } else { + Err(RuntimeError::Task { + task: task.task_id.clone(), + message: input + .get("message") + .and_then(Value::as_str) + .unwrap_or("assertion failed") + .to_owned(), + }) + } + } + ActionKind::MemoryRead => { + let key = required_string(&input, "key")?; + let value = run.working_memory.get(&key).cloned().ok_or_else(|| { + RuntimeError::InvalidState(format!("working memory key `{key}` is missing")) + })?; + Ok(TaskExecution::Complete { + output: serde_json::json!({"status": "unchanged", "changed": false, "value": value}), + memory: None, + }) + } + ActionKind::MemoryWrite => { + let key = required_string(&input, "key")?; + let value = input.get("value").cloned().unwrap_or(Value::Null); + let mut memory = run.working_memory.clone(); + let object = memory.as_object_mut().ok_or_else(|| { + RuntimeError::InvalidState("working memory must be an object".to_owned()) + })?; + let before = object.insert(key.clone(), value.clone()); + let changed = before.as_ref() != Some(&value); + let output = serde_json::json!({ + "status": if changed {"changed"} else {"unchanged"}, + "changed": changed, + "before": before, + "after": value, + "key": key, + }); + let request = EffectRequest::new( + &run.run_id, + &task.task_id, + task.attempt, + 1, + "builtin.memory.write", + EffectClass::InternalState, + Risk::Low, + Idempotency::Keyed, + input, + "update transactional run working memory", + trace_id, + ); + match self.prepare_effect( + &request, + policy, + None, + "memory.write", + "internal_state", + options.interactive, + )? { + PreparedEffect::Paused => Ok(TaskExecution::Paused), + PreparedEffect::Recorded(recorded) => Ok(TaskExecution::Complete { + output: recorded, + memory: Some(memory), + }), + PreparedEffect::Execute => { + self.store + .mark_effect_started(&request.id, self.clock.now())?; + self.store + .complete_effect(&request.id, Ok(&output), self.clock.now())?; + Ok(TaskExecution::Complete { + output, + memory: Some(memory), + }) + } + } + } + ActionKind::Read => { + let path = required_string(&input, "path")?; + let resolved = policy.resolve_read_path(&path)?; + let request = EffectRequest::new( + &run.run_id, + &task.task_id, + task.attempt, + 1, + "builtin.read", + EffectClass::Observe, + Risk::Low, + Idempotency::Idempotent, + serde_json::json!({"path": path}), + "read a workspace file", + trace_id, + ); + let execution = self.prepare_effect( + &request, + policy, + None, + "filesystem.read", + "observe", + options.interactive, + )?; + match execution { + PreparedEffect::Paused => Ok(TaskExecution::Paused), + PreparedEffect::Recorded(value) => Ok(TaskExecution::Complete { + output: value, + memory: None, + }), + PreparedEffect::Execute => { + self.store + .mark_effect_started(&request.id, self.clock.now())?; + let content = tokio::fs::read_to_string(resolved).await; + match content { + Ok(content) => { + let output = serde_json::json!({"status": "unchanged", "changed": false, "content": content}); + self.store.complete_effect( + &request.id, + Ok(&output), + self.clock.now(), + )?; + Ok(TaskExecution::Complete { + output, + memory: None, + }) + } + Err(error) => { + self.store.complete_effect( + &request.id, + Err(&error.to_string()), + self.clock.now(), + )?; + Err(RuntimeError::Io(error)) + } + } + } + } + } + ActionKind::Write => { + let path = required_string(&input, "path")?; + let content = required_string(&input, "content")?; + let resolved = policy.resolve_write_path(&path)?; + let before = tokio::fs::read_to_string(&resolved).await.ok(); + let changed = before.as_deref() != Some(&content); + let diff = options + .diff + .then(|| unified_diff(before.as_deref(), &content)); + let output = serde_json::json!({ + "status": if changed {"changed"} else {"unchanged"}, + "changed": changed, + "before": before, + "after": content, + "diff": diff, + "path": path, + "predictability": "fully_predictable", + }); + if options.check || !changed { + return Ok(TaskExecution::Complete { + output, + memory: None, + }); + } + let request = EffectRequest::new( + &run.run_id, + &task.task_id, + task.attempt, + 1, + "builtin.write", + EffectClass::WorkspaceMutate, + Risk::Medium, + Idempotency::Idempotent, + serde_json::json!({"path": path, "contentDigest": digest(content.as_bytes())}), + "write a workspace file", + trace_id, + ); + match self.prepare_effect( + &request, + policy, + None, + "filesystem.write", + "mutate", + options.interactive, + )? { + PreparedEffect::Paused => Ok(TaskExecution::Paused), + PreparedEffect::Recorded(value) => Ok(TaskExecution::Complete { + output: value, + memory: None, + }), + PreparedEffect::Execute => { + self.store + .mark_effect_started(&request.id, self.clock.now())?; + let result = write_atomic(&resolved, content.as_bytes()).await; + match result { + Ok(()) => { + self.store.complete_effect( + &request.id, + Ok(&output), + self.clock.now(), + )?; + Ok(TaskExecution::Complete { + output, + memory: None, + }) + } + Err(error) => { + self.store.complete_effect( + &request.id, + Err(&error.to_string()), + self.clock.now(), + )?; + Err(RuntimeError::Io(error)) + } + } + } + } + } + ActionKind::ShellExec => { + if options.check { + return Ok(TaskExecution::Complete { + output: serde_json::json!({"status": "requires_execution", "changed": false, "predictability": "requires_execution"}), + memory: None, + }); + } + let command = action.command.as_deref().ok_or_else(|| { + RuntimeError::InvalidState("shell action requires `command`".to_owned()) + })?; + policy.authorize_process(command)?; + let cwd = action + .cwd + .as_deref() + .map(|path| policy.resolve_read_path(path)) + .transpose()? + .unwrap_or_else(|| self.base_path.clone()); + let mut resolved_environment = BTreeMap::new(); + let mut environment_digests = BTreeMap::new(); + for (name, reference) in &action.env { + policy.authorize_environment(name)?; + policy.authorize_environment(&reference.env)?; + let value = std::env::var(&reference.env).map_err(|_| { + RuntimeError::InvalidState(format!( + "required environment variable `{}` is unavailable", + reference.env + )) + })?; + environment_digests.insert( + name.clone(), + serde_json::json!({ + "source": reference.env, + "valueDigest": digest(value.as_bytes()), + }), + ); + resolved_environment.insert(name.clone(), value); + } + let request = EffectRequest::new( + &run.run_id, + &task.task_id, + task.attempt, + 1, + "builtin.shell.exec", + EffectClass::ProcessExecution, + Risk::High, + Idempotency::Unknown, + serde_json::json!({ + "command": command, + "args": action.args, + "cwd": action.cwd, + "environment": environment_digests, + }), + "execute an allowlisted subprocess", + trace_id, + ); + match self.prepare_effect( + &request, + policy, + None, + "process.exec", + "act", + options.interactive, + )? { + PreparedEffect::Paused => Ok(TaskExecution::Paused), + PreparedEffect::Recorded(value) => Ok(TaskExecution::Complete { + output: value, + memory: None, + }), + PreparedEffect::Execute => { + self.store + .mark_effect_started(&request.id, self.clock.now())?; + let mut process = Command::new(command); + process + .args(&action.args) + .current_dir(cwd) + .env_clear() + .kill_on_drop(true); + for (name, value) in &resolved_environment { + process.env(name, value); + } + let timeout = Duration::from_secs( + action + .timeout_seconds + .unwrap_or(task_timeout(workflow, &task.task_id)), + ); + let result = tokio::select! { + result = tokio::time::timeout(timeout, process.output()) => { + match result { + Ok(result) => result.map_err(RuntimeError::Io), + Err(_) => Err(RuntimeError::Task { + task: task.task_id.clone(), + message: "subprocess timed out".to_owned(), + }), + } + } + () = cancellation.cancelled() => Err(RuntimeError::Cancelled), + }; + match result { + Ok(result) => { + let secrets = resolved_environment + .values() + .map(String::as_str) + .collect::>(); + let output = serde_json::json!({ + "status": if result.status.success() {"changed"} else {"failed"}, + "changed": result.status.success(), + "exitCode": result.status.code(), + "stdout": redact_text( + &String::from_utf8_lossy(&result.stdout), + &secrets, + ), + "stderr": redact_text( + &String::from_utf8_lossy(&result.stderr), + &secrets, + ), + }); + if result.status.success() { + self.store.complete_effect( + &request.id, + Ok(&output), + self.clock.now(), + )?; + Ok(TaskExecution::Complete { + output, + memory: None, + }) + } else { + let message = + format!("subprocess exited with {}", result.status); + self.store.complete_effect( + &request.id, + Err(&message), + self.clock.now(), + )?; + Err(RuntimeError::Task { + task: task.task_id.clone(), + message, + }) + } + } + Err(error) => { + self.store.mark_effect_uncertain( + &request.id, + &error.to_string(), + self.clock.now(), + )?; + Err(error) + } + } + } + } + } + ActionKind::LongTermMemoryRead => { + let namespace = workflow + .spec + .memory + .long_term + .as_ref() + .map_or("default", |memory| memory.namespace.as_str()); + let key = required_string(&input, "key")?; + let value = self + .store + .get_long_term_memory(namespace, &key, self.clock.now())?; + Ok(TaskExecution::Complete { + output: serde_json::json!({"status": "unchanged", "changed": false, "value": value}), + memory: None, + }) + } + ActionKind::LongTermMemoryWrite => { + if options.check { + return Ok(TaskExecution::Complete { + output: serde_json::json!({"status": "requires_execution", "changed": false}), + memory: None, + }); + } + let namespace = workflow + .spec + .memory + .long_term + .as_ref() + .map_or("default", |memory| memory.namespace.as_str()); + let key = required_string(&input, "key")?; + let value = input.get("value").cloned().unwrap_or(Value::Null); + let request = EffectRequest::new( + &run.run_id, + &task.task_id, + task.attempt, + 1, + "builtin.long_term_memory.write", + EffectClass::ExternalMutate, + Risk::Medium, + Idempotency::Keyed, + serde_json::json!({"namespace": namespace, "key": key, "value": value}), + "write cross-run memory", + trace_id, + ); + match self.prepare_effect( + &request, + policy, + None, + "memory.write", + "mutate", + options.interactive, + )? { + PreparedEffect::Paused => Ok(TaskExecution::Paused), + PreparedEffect::Recorded(output) => Ok(TaskExecution::Complete { + output, + memory: None, + }), + PreparedEffect::Execute => { + self.store + .mark_effect_started(&request.id, self.clock.now())?; + self.store.put_long_term_memory( + namespace, + &key, + &value, + None, + self.clock.now(), + )?; + let output = + serde_json::json!({"status": "changed", "changed": true, "key": key}); + self.store + .complete_effect(&request.id, Ok(&output), self.clock.now())?; + Ok(TaskExecution::Complete { + output, + memory: None, + }) + } + } + } + ActionKind::McpCall | ActionKind::A2aDelegate => { + if options.check { + return Ok(TaskExecution::Complete { + output: serde_json::json!({"status": "requires_execution", "changed": false}), + memory: None, + }); + } + let handler = self.registry.external_actions.as_ref().ok_or_else(|| { + RuntimeError::InvalidState(format!( + "no handler is registered for {:?}", + action.kind + )) + })?; + let remote_url = if action.kind == ActionKind::McpCall { + let server = required_string(&input, "server")?; + workflow + .spec + .mcp_servers + .get(&server) + .map(|definition| definition.url.as_str()) + .ok_or_else(|| { + RuntimeError::InvalidState(format!("unknown MCP server `{server}`")) + })? + } else { + let peer = required_string(&input, "peer")?; + workflow + .spec + .a2a_peers + .get(&peer) + .map(|definition| definition.card_url.as_str()) + .ok_or_else(|| { + RuntimeError::InvalidState(format!("unknown A2A peer `{peer}`")) + })? + }; + let remote_url = Url::parse(remote_url).map_err(|error| { + RuntimeError::InvalidState(format!("remote URL is invalid: {error}")) + })?; + policy.authorize_network(&remote_url)?; + let (class, risk, operation) = if action.kind == ActionKind::McpCall { + (EffectClass::Network, Risk::Medium, "mcp.call") + } else { + (EffectClass::RemoteAgent, Risk::High, "a2a.delegate") + }; + let request = EffectRequest::new( + &run.run_id, + &task.task_id, + task.attempt, + 1, + operation, + class, + risk, + Idempotency::Unknown, + input.clone(), + operation, + trace_id, + ); + match self.prepare_effect( + &request, + policy, + None, + operation, + "network", + options.interactive, + )? { + PreparedEffect::Paused => Ok(TaskExecution::Paused), + PreparedEffect::Recorded(output) => Ok(TaskExecution::Complete { + output, + memory: None, + }), + PreparedEffect::Execute => { + self.store + .mark_effect_started(&request.id, self.clock.now())?; + let result = handler.execute(action.kind, &input, cancellation).await; + match result { + Ok(output) => { + self.store.complete_effect( + &request.id, + Ok(&output), + self.clock.now(), + )?; + Ok(TaskExecution::Complete { + output, + memory: None, + }) + } + Err(error) => { + if matches!( + error, + RuntimeError::Cancelled + | RuntimeError::ExternalEffectUncertain(_) + ) { + self.store.mark_effect_uncertain( + &request.id, + &error.to_string(), + self.clock.now(), + )?; + } else { + self.store.complete_effect( + &request.id, + Err(&error.to_string()), + self.clock.now(), + )?; + } + Err(error) + } + } + } + } + } + } + } + + #[allow(clippy::too_many_arguments)] + async fn execute_agent( + &self, + workflow: &Workflow, + run: &agentctl_store::RunRecord, + task: &TaskRecord, + agent_name: &str, + input: Value, + policy: &PolicyEngine, + trace_id: &str, + interactive: bool, + cancellation: &CancellationToken, + ) -> Result { + let agent = workflow.spec.agents.get(agent_name).ok_or_else(|| { + RuntimeError::InvalidState(format!("agent `{agent_name}` disappeared after compile")) + })?; + let provider_definition = + workflow + .spec + .providers + .get(&agent.provider) + .ok_or_else(|| { + RuntimeError::InvalidState(format!( + "provider `{}` disappeared after compile", + agent.provider + )) + })?; + let endpoint = provider_definition + .endpoint + .as_deref() + .unwrap_or(match provider_definition.kind { + agentctl_core::dsl::ProviderKind::Fake => "http://127.0.0.1", + agentctl_core::dsl::ProviderKind::Openai => "https://api.openai.com/v1/responses", + agentctl_core::dsl::ProviderKind::Anthropic => { + "https://api.anthropic.com/v1/messages" + } + agentctl_core::dsl::ProviderKind::Google => { + "https://generativelanguage.googleapis.com/v1beta/models" + } + agentctl_core::dsl::ProviderKind::AzureOpenai => { + return Err(RuntimeError::InvalidState( + "Azure OpenAI provider requires an explicit endpoint".to_owned(), + )); + } + }); + if provider_definition.kind != agentctl_core::dsl::ProviderKind::Fake { + let endpoint = Url::parse(endpoint).map_err(|error| { + RuntimeError::InvalidState(format!("provider endpoint is invalid: {error}")) + })?; + policy.authorize_network(&endpoint)?; + } + let provider = self + .registry + .providers + .get(&agent.provider) + .ok_or_else(|| { + RuntimeError::InvalidState(format!( + "provider `{}` is not registered", + agent.provider + )) + })?; + let mut ordinal = 0_u16; + let instructions = match (&agent.instructions, &agent.instructions_file) { + (Some(value), None) => value.clone(), + (None, Some(path)) => { + let resolved = policy.resolve_read_path(path)?; + ordinal = ordinal.saturating_add(1); + let request = EffectRequest::new( + &run.run_id, + &task.task_id, + task.attempt, + ordinal, + "agent.instructions.read", + EffectClass::Observe, + Risk::Low, + Idempotency::Idempotent, + serde_json::json!({"path": path}), + "read the agent instruction file", + trace_id, + ); + let output = match self.prepare_effect( + &request, + policy, + Some(agent_name), + "filesystem.read", + "observe", + interactive, + )? { + PreparedEffect::Paused => return Ok(TaskExecution::Paused), + PreparedEffect::Recorded(value) => value, + PreparedEffect::Execute => { + self.store + .mark_effect_started(&request.id, self.clock.now())?; + match tokio::fs::read_to_string(resolved).await { + Ok(content) => { + let output = serde_json::json!({"content": content}); + self.store.complete_effect( + &request.id, + Ok(&output), + self.clock.now(), + )?; + output + } + Err(error) => { + self.store.complete_effect( + &request.id, + Err(&error.to_string()), + self.clock.now(), + )?; + return Err(RuntimeError::Io(error)); + } + } + } + }; + output + .get("content") + .and_then(Value::as_str) + .ok_or_else(|| { + RuntimeError::InvalidState( + "recorded instruction-file effect has no string content".to_owned(), + ) + })? + .to_owned() + } + _ => { + return Err(RuntimeError::InvalidState(format!( + "agent `{agent_name}` must define exactly one instruction source" + ))); + } + }; + let prompt = input.get("prompt").and_then(Value::as_str).map_or_else( + || serde_json::to_string(&input), + |value| Ok(value.to_owned()), + )?; + let mut messages = vec![Message::User(vec![ContentBlock::Text { text: prompt }])]; + let contracts = agent + .tools + .iter() + .map(|name| { + self.registry + .tools + .get(name) + .map(|tool| tool.contract().clone()) + .ok_or_else(|| { + RuntimeError::InvalidState(format!("tool `{name}` is not registered")) + }) + }) + .collect::, _>>()?; + let mut continuation = None; + let mut usage = Usage::default(); + let mut tool_call_count = 0_u16; + for _turn in 0..agent.max_turns { + ordinal = ordinal.saturating_add(1); + let provider_request = ProviderRequest { + model: agent.model.clone(), + instructions: instructions.clone(), + messages: messages.clone(), + tools: contracts.clone(), + max_output_tokens: agent.max_output_tokens, + reasoning: agent.reasoning.clone(), + structured_output: agent.structured_output.clone(), + continuation: continuation.clone(), + prompt_cache_key: Some(format!("{}:{}", workflow.metadata.name, agent_name)), + safety_identifier: None, + provider_options: agent.provider_options.clone(), + }; + let effect = EffectRequest::new( + &run.run_id, + &task.task_id, + task.attempt, + ordinal, + &agent.provider, + EffectClass::Model, + Risk::Medium, + Idempotency::AtMostOnce, + serde_json::to_value(&provider_request)?, + "invoke a bounded model provider", + trace_id, + ); + let response: ProviderResponse = match self.prepare_effect( + &effect, + policy, + Some(agent_name), + &format!("provider.{}", provider.name()), + "model", + interactive, + )? { + PreparedEffect::Paused => return Ok(TaskExecution::Paused), + PreparedEffect::Recorded(value) => serde_json::from_value(value)?, + PreparedEffect::Execute => { + self.store + .mark_effect_started(&effect.id, self.clock.now())?; + let result = tokio::select! { + result = tokio::time::timeout( + Duration::from_secs(agent.timeout_seconds), + provider.complete(&provider_request, cancellation), + ) => result.unwrap_or(Err(ProviderError::Timeout)), + () = cancellation.cancelled() => Err(ProviderError::Cancelled), + }; + match result { + Ok(response) => { + let value = serde_json::to_value(&response)?; + self.store + .complete_effect(&effect.id, Ok(&value), self.clock.now())?; + response + } + Err(ProviderError::Cancelled) => { + self.store.mark_effect_uncertain( + &effect.id, + "provider request was cancelled after dispatch", + self.clock.now(), + )?; + return Err(RuntimeError::Cancelled); + } + Err(error) => { + if provider_effect_is_uncertain(&error) { + self.store.mark_effect_uncertain( + &effect.id, + &error.to_string(), + self.clock.now(), + )?; + } else { + self.store.complete_effect( + &effect.id, + Err(&error.to_string()), + self.clock.now(), + )?; + } + return Err(RuntimeError::Provider(error)); + } + } + } + }; + add_usage(&mut usage, &response.usage); + enforce_usage(agent, &usage, &task.task_id)?; + continuation = response.continuation.clone(); + self.store.put_provider_session( + &run.run_id, + &task.task_id, + &agent.provider, + &serde_json::to_value(&continuation)?, + self.clock.now(), + )?; + if response.finish_reason == FinishReason::ToolCalls || !response.tool_calls.is_empty() + { + messages.push(Message::Assistant(response.assistant_content.clone())); + let mut results = Vec::new(); + for call in response.tool_calls { + tool_call_count = tool_call_count.saturating_add(1); + if tool_call_count > agent.max_tool_calls { + return Err(RuntimeError::Task { + task: task.task_id.clone(), + message: "maximum tool-call count exceeded".to_owned(), + }); + } + ordinal = ordinal.saturating_add(1); + let tool = self.registry.tools.get(&call.name).ok_or_else(|| { + RuntimeError::InvalidState(format!( + "provider requested unavailable tool `{}`", + call.name + )) + })?; + tool.contract().validate_input(&call.input)?; + let contract = tool.contract(); + let call_id = call.id.clone(); + let tool_effect = EffectRequest::new( + &run.run_id, + &task.task_id, + task.attempt, + ordinal, + &format!("tool.{}", contract.id), + contract.effect_class, + contract.risk, + contract.idempotency, + call.input.clone(), + &format!("execute tool {}", contract.id), + trace_id, + ); + let output = match self.prepare_effect_with_approval( + &tool_effect, + policy, + Some(agent_name), + &contract.id, + &contract.capability, + contract.approval, + interactive, + )? { + PreparedEffect::Paused => return Ok(TaskExecution::Paused), + PreparedEffect::Recorded(value) => value, + PreparedEffect::Execute => { + self.store + .mark_effect_started(&tool_effect.id, self.clock.now())?; + self.store.start_tool_call( + &call_id, + &run.run_id, + &task.task_id, + &tool_effect.id, + &contract.id, + &tool_effect.input_digest, + self.clock.now(), + )?; + let result = tokio::select! { + result = tokio::time::timeout( + Duration::from_secs(contract.timeout_seconds), + tool.execute(call.input.clone(), cancellation), + ) => result.map_err(|_| ToolContractError::Execution(format!("tool `{}` timed out", contract.id))), + () = cancellation.cancelled() => Err(ToolContractError::Cancelled), + }; + match result { + Ok(Ok(result)) => { + if let Err(error) = contract.validate_output(&result.output) { + self.store.complete_effect( + &tool_effect.id, + Err(&error.to_string()), + self.clock.now(), + )?; + self.store.complete_tool_call( + &run.run_id, + &call_id, + None, + false, + self.clock.now(), + )?; + return Err(RuntimeError::Tool(error)); + } + self.store.complete_effect( + &tool_effect.id, + Ok(&result.output), + self.clock.now(), + )?; + let output_digest = + digest(&serde_json::to_vec(&result.output)?); + self.store.complete_tool_call( + &run.run_id, + &call_id, + Some(&output_digest), + true, + self.clock.now(), + )?; + result.output + } + Ok(Err(ToolContractError::Cancelled)) + | Err(ToolContractError::Cancelled) => { + self.store.mark_effect_uncertain( + &tool_effect.id, + "tool execution was cancelled after dispatch", + self.clock.now(), + )?; + self.store.mark_tool_call_uncertain( + &run.run_id, + &call_id, + self.clock.now(), + )?; + return Err(RuntimeError::Cancelled); + } + Err(error) => { + self.store.mark_effect_uncertain( + &tool_effect.id, + &error.to_string(), + self.clock.now(), + )?; + self.store.mark_tool_call_uncertain( + &run.run_id, + &call_id, + self.clock.now(), + )?; + return Err(RuntimeError::Tool(error)); + } + Ok(Err(error)) => { + self.store.complete_effect( + &tool_effect.id, + Err(&error.to_string()), + self.clock.now(), + )?; + self.store.complete_tool_call( + &run.run_id, + &call_id, + None, + false, + self.clock.now(), + )?; + return Err(RuntimeError::Tool(error)); + } + } + } + }; + results.push(ContentBlock::ToolResult { + id: call.id, + output, + is_error: false, + }); + } + messages.push(Message::User(results)); + continue; + } + match response.finish_reason { + FinishReason::Complete => { + return Ok(TaskExecution::Complete { + output: serde_json::json!({"text": response.text, "usage": usage}), + memory: None, + }); + } + FinishReason::MaxTokens => { + return Err(RuntimeError::Task { + task: task.task_id.clone(), + message: "provider reached maximum output tokens".to_owned(), + }); + } + FinishReason::Refusal => { + return Err(RuntimeError::Task { + task: task.task_id.clone(), + message: "provider refused the request".to_owned(), + }); + } + FinishReason::Cancelled => return Err(RuntimeError::Cancelled), + FinishReason::ToolCalls => {} + } + } + Err(RuntimeError::Task { + task: task.task_id.clone(), + message: "maximum agent turns exceeded".to_owned(), + }) + } + + fn prepare_effect( + &self, + request: &EffectRequest, + policy: &PolicyEngine, + agent: Option<&str>, + tool: &str, + capability: &str, + interactive: bool, + ) -> Result { + self.prepare_effect_with_approval( + request, + policy, + agent, + tool, + capability, + ApprovalRequirement::Policy, + interactive, + ) + } + + #[allow(clippy::too_many_arguments)] + fn prepare_effect_with_approval( + &self, + request: &EffectRequest, + policy: &PolicyEngine, + agent: Option<&str>, + tool: &str, + capability: &str, + approval: ApprovalRequirement, + interactive: bool, + ) -> Result { + match self.store.load_effect(&request.id) { + Ok(record) => { + return match record.status { + EffectStatus::Succeeded if record.confirmed => { + record.result.map(PreparedEffect::Recorded).ok_or_else(|| { + RuntimeError::InvalidState(format!( + "effect `{}` has no result", + request.id + )) + }) + } + EffectStatus::Requested => Ok(PreparedEffect::Execute), + EffectStatus::WaitingForApproval => Ok(PreparedEffect::Paused), + EffectStatus::Started | EffectStatus::Uncertain => { + Err(RuntimeError::UncertainEffect { + run_id: request.run_id.clone(), + trace_id: request.trace_id.clone(), + effect_id: request.id.clone(), + }) + } + EffectStatus::Failed => Err(RuntimeError::Task { + task: request.task_id.clone(), + message: record.error.unwrap_or_else(|| "effect failed".to_owned()), + }), + EffectStatus::Cancelled => Err(RuntimeError::Task { + task: request.task_id.clone(), + message: "effect was rejected or cancelled".to_owned(), + }), + EffectStatus::Succeeded => Err(RuntimeError::InvalidState(format!( + "effect `{}` is unconfirmed", + request.id + ))), + }; + } + Err(StoreError::EffectNotFound(_)) => {} + Err(error) => return Err(RuntimeError::Store(error)), + } + self.store + .record_effect_request(request, self.clock.now())?; + self.trace( + TraceEvent::new( + match request.effect_class { + EffectClass::Model => SpanKind::ProviderRequest, + EffectClass::RemoteAgent => SpanKind::A2aDelegation, + EffectClass::Network if request.operation.starts_with("mcp.") => { + SpanKind::McpRequest + } + _ => SpanKind::Effect, + }, + TracePhase::Started, + &request.operation, + &request.trace_id, + &request.run_id, + self.clock.now(), + ) + .task(&request.task_id) + .effect(&request.id) + .attributes( + serde_json::json!({ + "inputDigest": request.input_digest, + "effectClass": request.effect_class, + "risk": request.risk, + }), + &[], + ), + )?; + let context = PolicyContext { + run_id: request.run_id.clone(), + trace_id: request.trace_id.clone(), + task_id: request.task_id.clone(), + agent: agent.map(ToOwned::to_owned), + tool: tool.to_owned(), + capability: capability.to_owned(), + effect_class: request.effect_class, + risk: request.risk, + resource: None, + provider: (request.effect_class == EffectClass::Model).then(|| tool.to_owned()), + input: request.input.clone(), + interactive, + }; + let decision = match approval { + ApprovalRequirement::Never => PolicyDecision::Allow { + reason: "tool contract does not require approval".to_owned(), + }, + ApprovalRequirement::Always => PolicyDecision::RequireApproval { + reason: "tool contract always requires approval".to_owned(), + }, + ApprovalRequirement::Policy => policy.decide(&context), + }; + match decision { + PolicyDecision::Allow { .. } => Ok(PreparedEffect::Execute), + PolicyDecision::Deny { reason } => Err(RuntimeError::Task { + task: request.task_id.clone(), + message: format!("policy denied effect: {reason}"), + }), + PolicyDecision::RequireApproval { reason } => { + let approval_id = format!("approval-{}", &request.id[..16]); + self.store.create_approval(&ApprovalRequest { + approval_id, + run_id: request.run_id.clone(), + effect_id: request.id.clone(), + task_id: request.task_id.clone(), + agent: agent.map(ToOwned::to_owned), + tool: tool.to_owned(), + capability: capability.to_owned(), + risk: format!("{:?}", request.risk).to_ascii_lowercase(), + redacted_input: redact(&request.input, &[]), + expected_effect: request.expected_effect.clone(), + reason, + trace_id: request.trace_id.clone(), + requested_at: self.clock.now(), + })?; + self.store.transition_task( + &request.run_id, + &request.task_id, + TaskState::WaitingForApproval, + None, + None, + None, + self.clock.now(), + &request.trace_id, + )?; + Ok(PreparedEffect::Paused) + } + } + } + + fn cancel_non_terminal(&self, run_id: &str, trace_id: &str) -> Result<(), RuntimeError> { + for task in self.store.list_tasks(run_id)? { + if !task.state.is_terminal() { + let next = match task.state { + TaskState::Pending + | TaskState::Ready + | TaskState::Running + | TaskState::WaitingForApproval + | TaskState::WaitingForEffect + | TaskState::RetryScheduled => TaskState::Cancelled, + TaskState::Succeeded + | TaskState::Failed + | TaskState::Skipped + | TaskState::Cancelled => continue, + }; + self.store.transition_task( + run_id, + &task.task_id, + next, + None, + Some("run cancelled"), + None, + self.clock.now(), + trace_id, + )?; + } + } + self.store.update_run_state( + run_id, + RunState::Cancelled, + None, + self.clock.now(), + trace_id, + )?; + Ok(()) + } + + fn trace(&self, event: TraceEvent) -> Result<(), RuntimeError> { + self.store.record_trace_event( + &event.run_id, + &event.trace_id, + &serde_json::to_value(&event)?, + event.timestamp, + )?; + self.traces.record(&event); + Ok(()) + } +} + +enum PreparedEffect { + Execute, + Recorded(Value), + Paused, +} + +enum TaskExecution { + Complete { + output: Value, + memory: Option, + }, + Paused, +} + +fn next_task<'a>( + plan: &'a CompiledPlan, + records: &[TaskRecord], +) -> Option<&'a agentctl_core::CompiledTask> { + plan.order.iter().find_map(|id| { + let record = records.iter().find(|record| &record.task_id == id)?; + if record.state.is_terminal() || record.state == TaskState::WaitingForApproval { + return None; + } + let task = plan.tasks.get(id)?; + let dependencies_terminal = task.needs.iter().all(|needed| { + records + .iter() + .find(|record| &record.task_id == needed) + .is_some_and(|record| record.state.is_terminal()) + }); + dependencies_terminal.then_some(task) + }) +} + +fn context_for( + run: &agentctl_store::RunRecord, + tasks: &[TaskRecord], +) -> Result { + let inputs = run + .inputs + .as_object() + .ok_or_else(|| RuntimeError::InvalidState("run inputs must be an object".to_owned()))?; + let memory = run + .working_memory + .as_object() + .ok_or_else(|| RuntimeError::InvalidState("working memory must be an object".to_owned()))?; + let task_outputs = tasks + .iter() + .filter_map(|task| { + task.output + .clone() + .map(|output| (task.task_id.clone(), output)) + }) + .collect::>(); + Ok(EvalContext { + inputs: inputs + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + vars: BTreeMap::new(), + memory: memory + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + tasks: task_outputs, + }) +} + +fn collect_outputs( + run: &agentctl_store::RunRecord, + tasks: &[TaskRecord], + declared: &BTreeMap, +) -> Result { + let outputs = tasks + .iter() + .filter_map(|task| { + task.output + .clone() + .map(|output| (task.task_id.clone(), output)) + }) + .collect::>(); + if declared.is_empty() { + return Ok(serde_json::to_value(outputs)?); + } + let mut context = context_for(run, tasks)?; + context.tasks = outputs; + render(&serde_json::to_value(declared)?, &context).map_err(RuntimeError::from) +} + +fn required_string(input: &Value, name: &str) -> Result { + input + .get(name) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or_else(|| RuntimeError::InvalidState(format!("input requires string `{name}`"))) +} + +fn digest(bytes: &[u8]) -> String { + use sha2::Digest; + hex::encode(sha2::Sha256::digest(bytes)) +} + +fn redact_text(value: &str, secrets: &[&str]) -> String { + secrets + .iter() + .filter(|secret| !secret.is_empty()) + .fold(value.to_owned(), |text, secret| { + text.replace(secret, "[REDACTED]") + }) +} + +fn unified_diff(before: Option<&str>, after: &str) -> String { + let before = before.unwrap_or(""); + if before == after { + return String::new(); + } + format!( + "--- before\n+++ after\n-{}\n+{}", + before.replace('\n', "\n-"), + after.replace('\n', "\n+") + ) +} + +async fn write_atomic(path: &Path, content: &[u8]) -> Result<(), std::io::Error> { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("agentctl-output"); + let temporary = path.with_file_name(format!(".{file_name}.agentctl.tmp")); + let mut file = tokio::fs::File::create(&temporary).await?; + file.write_all(content).await?; + file.sync_all().await?; + drop(file); + tokio::fs::rename(temporary, path).await +} + +fn add_usage(total: &mut Usage, current: &Usage) { + total.input_tokens = total.input_tokens.saturating_add(current.input_tokens); + total.output_tokens = total.output_tokens.saturating_add(current.output_tokens); + total.reasoning_tokens = total + .reasoning_tokens + .saturating_add(current.reasoning_tokens); + total.cache_read_tokens = total + .cache_read_tokens + .saturating_add(current.cache_read_tokens); + total.cache_write_tokens = total + .cache_write_tokens + .saturating_add(current.cache_write_tokens); + total.cost_microusd = match (total.cost_microusd, current.cost_microusd) { + (Some(total), Some(current)) => Some(total.saturating_add(current)), + _ => None, + }; +} + +const fn provider_effect_is_uncertain(error: &ProviderError) -> bool { + matches!( + error, + ProviderError::Timeout | ProviderError::Cancelled | ProviderError::Http { status: 0, .. } + ) +} + +const fn retryable_error(error: &RuntimeError) -> bool { + match error { + RuntimeError::Provider(ProviderError::Http { retryable, .. }) => *retryable, + RuntimeError::Io(_) => true, + RuntimeError::Store(_) + | RuntimeError::Policy(_) + | RuntimeError::Template(_) + | RuntimeError::Provider(_) + | RuntimeError::Tool(_) + | RuntimeError::InvalidState(_) + | RuntimeError::Task { .. } + | RuntimeError::RunFailed { .. } + | RuntimeError::UncertainEffect { .. } + | RuntimeError::ExternalEffectUncertain(_) + | RuntimeError::Cancelled + | RuntimeError::Json(_) => false, + } +} + +fn enforce_usage( + agent: &agentctl_core::dsl::AgentDefinition, + usage: &Usage, + task_id: &str, +) -> Result<(), RuntimeError> { + let Some(limit) = &agent.usage_limit else { + return Ok(()); + }; + if limit + .max_input_tokens + .is_some_and(|limit| usage.input_tokens > limit) + || limit + .max_output_tokens + .is_some_and(|limit| usage.output_tokens > limit) + || limit.max_cost_usd.is_some_and(|limit| { + usage + .cost_microusd + .is_some_and(|cost| cost as f64 / 1_000_000.0 > limit) + }) + { + Err(RuntimeError::Task { + task: task_id.to_owned(), + message: "agent usage limit exceeded".to_owned(), + }) + } else { + Ok(()) + } +} + +fn task_timeout(workflow: &Workflow, task_id: &str) -> u64 { + workflow + .spec + .tasks + .iter() + .find(|task| task.id == task_id) + .and_then(|task| task.timeout_seconds) + .unwrap_or(workflow.spec.runtime.default_timeout_seconds) +} + +#[cfg(test)] +mod tests { + use super::*; + use agentctl_core::compile; + use agentctl_core::dsl::{ApprovalRequirement, EffectClass, Idempotency, Risk, parse_workflow}; + use agentctl_core::effect::{ActionResult, ChangeStatus}; + use agentctl_core::provider::{ProviderRequest, ProviderResponse, ToolCall}; + use agentctl_core::tool::{ToolContract, ToolContractError, ToolExecutor}; + use agentctl_observability::BufferedTraceSink; + use agentctl_store::ApprovalResolution; + use std::sync::atomic::{AtomicU64, Ordering}; + use tempfile::tempdir; + + struct FixedClock; + + impl Clock for FixedClock { + fn now(&self) -> DateTime { + DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .map(|value| value.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()) + } + } + + #[derive(Default)] + struct SequenceIds(AtomicU64); + + impl IdGenerator for SequenceIds { + fn next_id(&self, kind: &str) -> String { + format!("{kind}-{}", self.0.fetch_add(1, Ordering::SeqCst) + 1) + } + } + + #[derive(Default)] + struct CountingProvider(AtomicU64); + + #[async_trait] + impl ModelProvider for CountingProvider { + fn name(&self) -> &'static str { + "fake" + } + + async fn complete( + &self, + _request: &ProviderRequest, + cancellation: &CancellationToken, + ) -> Result { + if cancellation.is_cancelled() { + return Err(ProviderError::Cancelled); + } + let call = self.0.fetch_add(1, Ordering::SeqCst) + 1; + Ok(ProviderResponse { + response_id: Some(format!("fake-{call}")), + text: format!("answer-{call}"), + tool_calls: Vec::new(), + assistant_content: vec![ContentBlock::Text { + text: format!("answer-{call}"), + }], + continuation: None, + usage: Usage { + input_tokens: 2, + output_tokens: 1, + ..Usage::default() + }, + finish_reason: FinishReason::Complete, + }) + } + } + + #[derive(Default)] + struct ToolCallingProvider(AtomicU64); + + #[async_trait] + impl ModelProvider for ToolCallingProvider { + fn name(&self) -> &'static str { + "fake" + } + + async fn complete( + &self, + _request: &ProviderRequest, + _cancellation: &CancellationToken, + ) -> Result { + if self.0.fetch_add(1, Ordering::SeqCst) == 0 { + Ok(ProviderResponse { + response_id: Some("tool-turn".to_owned()), + text: String::new(), + tool_calls: vec![ToolCall { + id: "call-1".to_owned(), + name: "echo".to_owned(), + input: serde_json::json!({"text": "hello"}), + }], + assistant_content: vec![ContentBlock::ToolCall { + id: "call-1".to_owned(), + name: "echo".to_owned(), + input: serde_json::json!({"text": "hello"}), + }], + continuation: None, + usage: Usage::default(), + finish_reason: FinishReason::ToolCalls, + }) + } else { + Ok(ProviderResponse { + response_id: Some("final-turn".to_owned()), + text: "done".to_owned(), + tool_calls: Vec::new(), + assistant_content: vec![ContentBlock::Text { + text: "done".to_owned(), + }], + continuation: None, + usage: Usage::default(), + finish_reason: FinishReason::Complete, + }) + } + } + } + + struct FixtureTool { + contract: ToolContract, + malformed: bool, + delay: Duration, + } + + impl FixtureTool { + fn new(malformed: bool) -> Self { + Self { + contract: ToolContract { + id: "echo".to_owned(), + description: "echo input".to_owned(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + "additionalProperties": false + }), + output_schema: serde_json::json!({ + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + "additionalProperties": false + }), + capability: "observe".to_owned(), + risk: Risk::Low, + effect_class: EffectClass::Pure, + idempotency: Idempotency::Pure, + retry_safe: true, + timeout_seconds: 5, + secret_requirements: Vec::new(), + network_requirements: Vec::new(), + approval: ApprovalRequirement::Never, + observability: Value::Null, + compensation: None, + }, + malformed, + delay: Duration::ZERO, + } + } + + fn delayed(mut self, delay: Duration, timeout_seconds: u64) -> Self { + self.delay = delay; + self.contract.timeout_seconds = timeout_seconds; + self + } + } + + #[async_trait] + impl ToolExecutor for FixtureTool { + fn contract(&self) -> &ToolContract { + &self.contract + } + + async fn execute( + &self, + _input: Value, + cancellation: &CancellationToken, + ) -> Result { + if !self.delay.is_zero() { + tokio::select! { + () = tokio::time::sleep(self.delay) => {} + () = cancellation.cancelled() => return Err(ToolContractError::Cancelled), + } + } + Ok(ActionResult { + status: ChangeStatus::Unchanged, + changed: false, + before: None, + after: None, + diff: None, + output: if self.malformed { + Value::String("malicious success shape".to_owned()) + } else { + serde_json::json!({"text": "hello"}) + }, + predictability: PlanPredictability::RequiresExecution, + }) + } + } + + fn compile_fixture(source: &str) -> (Workflow, CompiledPlan) { + let workflow = parse_workflow(source, "fixture.yaml") + .expect("parse fixture") + .workflow; + let plan = compile(&workflow, "fixture.yaml").expect("compile fixture"); + (workflow, plan) + } + + fn runtime(store: SqliteStore, base: &Path) -> Runtime { + Runtime::new(store, base) + .with_clock(Arc::new(FixedClock)) + .with_ids(Arc::new(SequenceIds::default())) + } + + #[tokio::test] + async fn deterministic_dataflow_condition_and_working_memory() { + let directory = tempdir().expect("tempdir"); + let store = SqliteStore::open_memory().expect("store"); + let (workflow, plan) = compile_fixture( + r#" +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: dataflow } +spec: + inputs: { enabled: true, greeting: hello } + memory: + working: { count: 0 } + actions: + assign: { kind: builtin.assign } + remember: { kind: builtin.memory.write } + tasks: + - id: first + uses: action:assign + with: { message: "${{ inputs.greeting }}" } + - id: remember + uses: action:remember + needs: [first] + when: "${{ inputs.enabled == true }}" + with: { key: result, value: "${{ tasks.first.output.output.message }}" } +"#, + ); + let outcome = runtime(store.clone(), directory.path()) + .start( + &workflow, + &plan, + serde_json::json!({"enabled": true, "greeting": "hello"}), + RunOptions::default(), + &CancellationToken::new(), + ) + .await + .expect("run succeeds"); + assert_eq!(outcome.state, RunState::Succeeded); + assert_eq!( + store.load_run(&outcome.run_id).expect("run").working_memory["result"], + "hello" + ); + } + + #[tokio::test] + async fn check_diff_does_not_mutate_and_interactive_approval_resumes() { + let directory = tempdir().expect("tempdir"); + std::fs::create_dir(directory.path().join("out")).expect("out dir"); + let source = r#" +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: write } +spec: + policy: + workspaceRoot: . + writableRoots: [out] + approval: mutations + actions: + write: + kind: builtin.write + tasks: + - id: write + uses: action:write + with: { path: out/result.txt, content: hello } +"#; + let (workflow, plan) = compile_fixture(source); + let check_store = SqliteStore::open_memory().expect("check store"); + let check = runtime(check_store, directory.path()) + .start( + &workflow, + &plan, + serde_json::json!({}), + RunOptions { + check: true, + diff: true, + interactive: false, + }, + &CancellationToken::new(), + ) + .await + .expect("check succeeds"); + assert_eq!(check.state, RunState::Succeeded); + assert!(!directory.path().join("out/result.txt").exists()); + + let store = SqliteStore::open_memory().expect("store"); + let runtime = runtime(store.clone(), directory.path()); + let paused = runtime + .start( + &workflow, + &plan, + serde_json::json!({}), + RunOptions { + check: false, + diff: true, + interactive: true, + }, + &CancellationToken::new(), + ) + .await + .expect("pause"); + assert_eq!(paused.state, RunState::Paused); + let approvals = store.pending_approvals(&paused.run_id).expect("approvals"); + assert_eq!(approvals.len(), 1); + assert_eq!(approvals[0].expected_effect, "write a workspace file"); + store + .resolve_approval( + &approvals[0].approval_id, + ApprovalResolution::Approved, + "tester", + "fixture approval", + Utc::now(), + ) + .expect("approve"); + let resumed = runtime + .resume( + &paused.run_id, + RunOptions { + check: false, + diff: true, + interactive: true, + }, + &CancellationToken::new(), + ) + .await + .expect("resume"); + assert_eq!(resumed.state, RunState::Succeeded); + assert_eq!( + std::fs::read_to_string(directory.path().join("out/result.txt")).expect("content"), + "hello" + ); + } + + #[tokio::test] + async fn non_interactive_run_pauses_without_bypassing_required_approval() { + let directory = tempdir().expect("tempdir"); + std::fs::create_dir(directory.path().join("out")).expect("out dir"); + let (workflow, plan) = compile_fixture( + r#" +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: ci-safety } +spec: + policy: { workspaceRoot: ., writableRoots: [out], approval: mutations } + actions: { write: { kind: builtin.write } } + tasks: + - { id: write, uses: "action:write", with: { path: out/file, content: unsafe } } +"#, + ); + let result = runtime(SqliteStore::open_memory().expect("store"), directory.path()) + .start( + &workflow, + &plan, + serde_json::json!({}), + RunOptions::default(), + &CancellationToken::new(), + ) + .await + .expect("non-interactive run pauses"); + assert_eq!(result.state, RunState::Paused); + assert!(!directory.path().join("out/file").exists()); + } + + #[tokio::test] + async fn recorded_replay_never_calls_provider_and_fork_does() { + let directory = tempdir().expect("tempdir"); + let store = SqliteStore::open_memory().expect("store"); + let provider = Arc::new(CountingProvider::default()); + let registry = RuntimeRegistry::default().with_provider("fake", provider.clone()); + let traces = Arc::new(BufferedTraceSink::default()); + let runtime = runtime(store.clone(), directory.path()) + .with_registry(registry) + .with_trace_sink(traces.clone()); + let (workflow, plan) = compile_fixture( + r#" +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: provider } +spec: + providers: { fake: { kind: fake } } + agents: + answer: + provider: fake + model: scripted + instructions: answer briefly + maxTurns: 1 + tasks: + - { id: answer, uses: "agent:answer", with: { prompt: hello } } +"#, + ); + let first = runtime + .start( + &workflow, + &plan, + serde_json::json!({}), + RunOptions::default(), + &CancellationToken::new(), + ) + .await + .expect("first run"); + assert_eq!(provider.0.load(Ordering::SeqCst), 1); + let replay = runtime.replay(&first.run_id).await.expect("replay"); + assert_eq!(replay.state, RunState::Succeeded); + assert_eq!(provider.0.load(Ordering::SeqCst), 1); + let fork = runtime + .fork( + &first.run_id, + RunOptions::default(), + &CancellationToken::new(), + ) + .await + .expect("fork"); + assert_eq!(fork.state, RunState::Succeeded); + assert_eq!(provider.0.load(Ordering::SeqCst), 2); + assert!( + traces + .events() + .iter() + .any(|event| event.kind == SpanKind::ProviderRequest) + ); + } + + #[tokio::test] + async fn recorded_replay_never_calls_provider_or_tool_executor() { + let directory = tempdir().expect("tempdir"); + let source = r#" +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: replay-tools } +spec: + providers: { fake: { kind: fake } } + tools: + echo: + kind: builtin.echo + description: echo + inputSchema: { type: object, properties: { text: { type: string } }, required: [text], additionalProperties: false } + outputSchema: { type: object, properties: { text: { type: string } }, required: [text], additionalProperties: false } + capability: internal + risk: low + effectClass: pure + idempotency: pure + retrySafe: true + timeoutSeconds: 5 + approval: never + agents: + worker: + provider: fake + model: scripted + instructions: use the tool once + tools: [echo] + maxTurns: 2 + maxToolCalls: 1 + tasks: [{ id: work, uses: "agent:worker", with: { prompt: hello } }] +"#; + let (workflow, plan) = compile_fixture(source); + let provider = Arc::new(ToolCallingProvider::default()); + let store = SqliteStore::open_memory().expect("store"); + let runtime = runtime(store.clone(), directory.path()).with_registry( + RuntimeRegistry::default() + .with_provider("fake", provider.clone()) + .with_tool("echo", Arc::new(FixtureTool::new(false))), + ); + let first = runtime + .start( + &workflow, + &plan, + serde_json::json!({}), + RunOptions::default(), + &CancellationToken::new(), + ) + .await + .expect("first run"); + let replay = runtime.replay(&first.run_id).await.expect("replay"); + assert_eq!(provider.0.load(Ordering::SeqCst), 2); + assert_eq!(replay.output, first.output); + assert!( + store + .list_effects(&replay.run_id) + .expect("effects") + .is_empty() + ); + assert!(store.tool_calls(&replay.run_id).expect("calls").is_empty()); + } + + #[tokio::test] + async fn replay_rejects_nonterminal_source_without_creating_partial_state() { + let directory = tempdir().expect("tempdir"); + std::fs::create_dir(directory.path().join("out")).expect("out"); + let store = SqliteStore::open_memory().expect("store"); + let runtime = runtime(store.clone(), directory.path()); + let (workflow, plan) = compile_fixture( + r#" +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: paused-replay } +spec: + policy: { workspaceRoot: ., writableRoots: [out], approval: mutations } + actions: { write: { kind: builtin.write } } + tasks: [{ id: write, uses: "action:write", with: { path: out/file, content: value } }] +"#, + ); + let paused = runtime + .start( + &workflow, + &plan, + serde_json::json!({}), + RunOptions::default(), + &CancellationToken::new(), + ) + .await + .expect("paused run"); + assert_eq!(paused.state, RunState::Paused); + assert!(matches!( + runtime.replay(&paused.run_id).await, + Err(RuntimeError::InvalidState(_)) + )); + assert_eq!(store.stats().expect("stats").runs, 1); + } + + #[tokio::test] + async fn cancellation_is_durable() { + let directory = tempdir().expect("tempdir"); + let store = SqliteStore::open_memory().expect("store"); + let (workflow, plan) = compile_fixture( + r#" +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: cancel } +spec: + actions: { assign: { kind: builtin.assign } } + tasks: [{ id: one, uses: "action:assign" }] +"#, + ); + let cancellation = CancellationToken::new(); + cancellation.cancel(); + let outcome = runtime(store.clone(), directory.path()) + .start( + &workflow, + &plan, + serde_json::json!({}), + RunOptions::default(), + &cancellation, + ) + .await + .expect("cancelled outcome"); + assert_eq!(outcome.state, RunState::Cancelled); + assert_eq!( + store.load_run(&outcome.run_id).expect("run").state, + RunState::Cancelled + ); + } + + #[tokio::test] + async fn agent_tool_loop_validates_tool_output_before_model_continuation() { + let directory = tempdir().expect("tempdir"); + let source = r#" +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: tools } +spec: + providers: { fake: { kind: fake } } + tools: + echo: + kind: builtin.echo + description: echo + inputSchema: { type: object, properties: { text: { type: string } }, required: [text], additionalProperties: false } + outputSchema: { type: object, properties: { text: { type: string } }, required: [text], additionalProperties: false } + capability: internal + risk: low + effectClass: pure + idempotency: pure + retrySafe: true + timeoutSeconds: 5 + approval: never + agents: + worker: + provider: fake + model: scripted + instructions: use the tool once + tools: [echo] + maxTurns: 2 + maxToolCalls: 1 + tasks: + - { id: work, uses: "agent:worker", with: { prompt: hello } } +"#; + let (workflow, plan) = compile_fixture(source); + let provider = Arc::new(ToolCallingProvider::default()); + let registry = RuntimeRegistry::default() + .with_provider("fake", provider.clone()) + .with_tool("echo", Arc::new(FixtureTool::new(false))); + let store = SqliteStore::open_memory().expect("store"); + let outcome = runtime(store.clone(), directory.path()) + .with_registry(registry) + .start( + &workflow, + &plan, + serde_json::json!({}), + RunOptions::default(), + &CancellationToken::new(), + ) + .await + .expect("valid tool output"); + assert_eq!(outcome.state, RunState::Succeeded); + assert_eq!(provider.0.load(Ordering::SeqCst), 2); + let stats = store.stats().expect("stats"); + assert_eq!(stats.provider_sessions, 1); + assert_eq!(stats.tool_calls, 1); + let calls = store.tool_calls(&outcome.run_id).expect("tool calls"); + assert_eq!(calls[0].call_id, "call-1"); + + let bad_registry = RuntimeRegistry::default() + .with_provider("fake", Arc::new(ToolCallingProvider::default())) + .with_tool("echo", Arc::new(FixtureTool::new(true))); + let result = runtime(SqliteStore::open_memory().expect("store"), directory.path()) + .with_registry(bad_registry) + .start( + &workflow, + &plan, + serde_json::json!({}), + RunOptions::default(), + &CancellationToken::new(), + ) + .await; + assert!(matches!(result, Err(RuntimeError::RunFailed { .. }))); + } + + #[tokio::test] + async fn tool_timeout_and_cancellation_are_bounded_and_durable() { + let directory = tempdir().expect("tempdir"); + let source = r#" +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: tool-bounds } +spec: + providers: { fake: { kind: fake } } + tools: + echo: + kind: builtin.echo + description: echo + inputSchema: { type: object, properties: { text: { type: string } }, required: [text], additionalProperties: false } + outputSchema: { type: object, properties: { text: { type: string } }, required: [text], additionalProperties: false } + capability: internal + risk: low + effectClass: pure + idempotency: pure + retrySafe: true + timeoutSeconds: 5 + approval: never + agents: + worker: + provider: fake + model: scripted + instructions: use the tool + tools: [echo] + maxTurns: 2 + maxToolCalls: 1 + tasks: [{ id: work, uses: "agent:worker", with: { prompt: hello } }] +"#; + let (workflow, plan) = compile_fixture(source); + + let timeout_store = SqliteStore::open_memory().expect("timeout store"); + let timeout_registry = RuntimeRegistry::default() + .with_provider("fake", Arc::new(ToolCallingProvider::default())) + .with_tool( + "echo", + Arc::new(FixtureTool::new(false).delayed(Duration::from_millis(1_100), 1)), + ); + let timeout = runtime(timeout_store.clone(), directory.path()) + .with_registry(timeout_registry) + .start( + &workflow, + &plan, + serde_json::json!({}), + RunOptions::default(), + &CancellationToken::new(), + ) + .await; + let timeout_run_id = match timeout { + Err(RuntimeError::RunFailed { run_id, .. }) => run_id, + other => panic!("expected failed run, got {other:?}"), + }; + assert_eq!(timeout_store.stats().expect("timeout stats").tool_calls, 1); + assert_eq!( + timeout_store.tool_calls(&timeout_run_id).expect("calls")[0].status, + "uncertain" + ); + assert_eq!( + timeout_store + .list_effects(&timeout_run_id) + .expect("effects") + .last() + .expect("tool effect") + .status, + EffectStatus::Uncertain + ); + + let cancel_store = SqliteStore::open_memory().expect("cancel store"); + let cancel_registry = RuntimeRegistry::default() + .with_provider("fake", Arc::new(ToolCallingProvider::default())) + .with_tool( + "echo", + Arc::new(FixtureTool::new(false).delayed(Duration::from_secs(1), 5)), + ); + let cancellation = CancellationToken::new(); + let trigger = cancellation.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(10)).await; + trigger.cancel(); + }); + let cancelled = runtime(cancel_store.clone(), directory.path()) + .with_registry(cancel_registry) + .start( + &workflow, + &plan, + serde_json::json!({}), + RunOptions::default(), + &cancellation, + ) + .await + .expect("cancelled outcome"); + assert_eq!(cancelled.state, RunState::Cancelled); + assert_eq!( + cancel_store + .load_run(&cancelled.run_id) + .expect("cancelled run") + .state, + RunState::Cancelled + ); + assert_eq!( + cancel_store.tool_calls(&cancelled.run_id).expect("calls")[0].status, + "uncertain" + ); + assert_eq!( + cancel_store + .list_effects(&cancelled.run_id) + .expect("effects") + .last() + .expect("tool effect") + .status, + EffectStatus::Uncertain + ); + assert!(matches!( + runtime(cancel_store, directory.path()) + .resume( + &cancelled.run_id, + RunOptions::default(), + &CancellationToken::new() + ) + .await, + Err(RuntimeError::UncertainEffect { .. }) + )); + } + + #[cfg(unix)] + #[tokio::test] + async fn subprocess_timeout_is_durable_uncertainty_and_blocks_resume() { + let directory = tempdir().expect("tempdir"); + let (workflow, plan) = compile_fixture( + r#" +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: process-timeout } +spec: + policy: + workspaceRoot: . + processAllowlist: [sh] + approval: never + actions: + wait: + kind: builtin.shell.exec + command: /bin/sh + args: [-c, "sleep 5"] + timeoutSeconds: 1 + tasks: [{ id: wait, uses: "action:wait" }] +"#, + ); + let store = SqliteStore::open_memory().expect("store"); + let runtime = runtime(store.clone(), directory.path()); + let run_id = match runtime + .start( + &workflow, + &plan, + serde_json::json!({}), + RunOptions::default(), + &CancellationToken::new(), + ) + .await + { + Err(RuntimeError::RunFailed { run_id, .. }) => run_id, + other => panic!("expected failed run, got {other:?}"), + }; + assert_eq!( + store.list_effects(&run_id).expect("effects")[0].status, + EffectStatus::Uncertain + ); + assert!(matches!( + runtime + .resume(&run_id, RunOptions::default(), &CancellationToken::new()) + .await, + Err(RuntimeError::UncertainEffect { .. }) + )); + } + + #[test] + fn subprocess_output_redaction_removes_every_known_secret_value() { + let output = redact_text("token=top-secret; repeated=top-secret", &["top-secret"]); + assert_eq!(output, "token=[REDACTED]; repeated=[REDACTED]"); + } +} diff --git a/crates/agentctl-store/Cargo.toml b/crates/agentctl-store/Cargo.toml new file mode 100644 index 0000000..147960d --- /dev/null +++ b/crates/agentctl-store/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "agentctl-store" +description = "Versioned SQLite persistence for agentctl" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +agentctl-core = { version = "0.2.0", path = "../agentctl-core" } +chrono.workspace = true +hex.workspace = true +parking_lot.workspace = true +rusqlite.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/agentctl-store/src/lib.rs b/crates/agentctl-store/src/lib.rs new file mode 100644 index 0000000..f6f036c --- /dev/null +++ b/crates/agentctl-store/src/lib.rs @@ -0,0 +1,1778 @@ +//! Versioned SQLite persistence for agentctl. + +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use agentctl_core::effect::{EffectRecord, EffectRequest, EffectStatus}; +use agentctl_core::state::{RunState, TaskState}; +use agentctl_core::{CompiledPlan, PLAN_FORMAT_VERSION}; +use chrono::{DateTime, Utc}; +use parking_lot::Mutex; +use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +pub const DATABASE_SCHEMA_VERSION: u32 = 4; +pub const RUNTIME_STATE_VERSION: u32 = 1; +pub const CHECKPOINT_FORMAT_VERSION: u32 = 1; +pub const AUDIT_EVENT_VERSION: u32 = 1; + +const MIGRATION_1: &str = r#" +CREATE TABLE runs ( + run_id TEXT PRIMARY KEY, + runtime_state_version INTEGER NOT NULL, + workflow_digest TEXT NOT NULL, + workflow_schema_version TEXT NOT NULL, + plan_digest TEXT NOT NULL, + plan_format_version INTEGER NOT NULL, + workflow_json TEXT NOT NULL, + plan_json TEXT NOT NULL, + inputs_json TEXT NOT NULL, + working_memory_json TEXT NOT NULL, + output_json TEXT, + state TEXT NOT NULL, + mode TEXT NOT NULL, + parent_run_id TEXT REFERENCES runs(run_id), + cancellation_requested INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE task_states ( + run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE, + task_id TEXT NOT NULL, + position INTEGER NOT NULL, + state TEXT NOT NULL, + attempt INTEGER NOT NULL DEFAULT 0, + output_json TEXT, + error TEXT, + updated_at TEXT NOT NULL, + PRIMARY KEY (run_id, task_id) +); +CREATE TABLE effects ( + effect_id TEXT PRIMARY KEY, + format_version INTEGER NOT NULL, + run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE, + task_id TEXT NOT NULL, + task_attempt INTEGER NOT NULL, + ordinal INTEGER NOT NULL, + operation TEXT NOT NULL, + effect_class TEXT NOT NULL, + risk TEXT NOT NULL, + idempotency TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + input_digest TEXT NOT NULL, + input_json TEXT NOT NULL, + expected_effect TEXT NOT NULL, + trace_id TEXT NOT NULL, + status TEXT NOT NULL, + effect_attempt INTEGER NOT NULL, + requested_at TEXT NOT NULL, + started_at TEXT, + completed_at TEXT, + result_json TEXT, + error TEXT, + confirmed INTEGER NOT NULL DEFAULT 0, + UNIQUE (run_id, task_id, task_attempt, ordinal) +); +CREATE TABLE approvals ( + approval_id TEXT PRIMARY KEY, + run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE, + effect_id TEXT NOT NULL REFERENCES effects(effect_id), + task_id TEXT NOT NULL, + agent TEXT, + tool TEXT NOT NULL, + capability TEXT NOT NULL, + risk TEXT NOT NULL, + redacted_input_json TEXT NOT NULL, + expected_effect TEXT NOT NULL, + reason TEXT NOT NULL, + trace_id TEXT NOT NULL, + status TEXT NOT NULL, + requested_at TEXT NOT NULL, + resolved_at TEXT, + resolved_by TEXT, + resolution_reason TEXT +); +CREATE TABLE checkpoints ( + run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE, + sequence INTEGER NOT NULL, + format_version INTEGER NOT NULL, + state_json TEXT NOT NULL, + checksum TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (run_id, sequence) +); +CREATE TABLE audit_events ( + run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE, + sequence INTEGER NOT NULL, + event_version INTEGER NOT NULL, + event_type TEXT NOT NULL, + task_id TEXT, + trace_id TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (run_id, sequence) +); +"#; + +const MIGRATION_2: &str = r#" +CREATE TABLE provider_sessions ( + run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE, + task_id TEXT NOT NULL, + provider TEXT NOT NULL, + format_version INTEGER NOT NULL, + continuation_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (run_id, task_id) +); +CREATE TABLE tool_calls ( + call_id TEXT PRIMARY KEY, + run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE, + task_id TEXT NOT NULL, + effect_id TEXT REFERENCES effects(effect_id), + tool_id TEXT NOT NULL, + input_digest TEXT NOT NULL, + output_digest TEXT, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + completed_at TEXT +); +CREATE TABLE long_term_memory ( + namespace TEXT NOT NULL, + memory_key TEXT NOT NULL, + value_json TEXT NOT NULL, + expires_at TEXT, + updated_at TEXT NOT NULL, + PRIMARY KEY (namespace, memory_key) +); +CREATE INDEX idx_runs_state_updated ON runs(state, updated_at); +CREATE INDEX idx_effects_run_status ON effects(run_id, status); +CREATE INDEX idx_approvals_run_status ON approvals(run_id, status); +CREATE INDEX idx_audit_run_created ON audit_events(run_id, created_at); +"#; + +const MIGRATION_3: &str = r#" +ALTER TABLE runs ADD COLUMN base_path TEXT; +CREATE TABLE trace_events ( + run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE, + sequence INTEGER NOT NULL, + trace_id TEXT NOT NULL, + event_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (run_id, sequence) +); +CREATE INDEX idx_trace_run_created ON trace_events(run_id, created_at); +"#; + +const MIGRATION_4: &str = r#" +CREATE TABLE tool_calls_v4 ( + call_id TEXT NOT NULL, + run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE, + task_id TEXT NOT NULL, + effect_id TEXT REFERENCES effects(effect_id), + tool_id TEXT NOT NULL, + input_digest TEXT NOT NULL, + output_digest TEXT, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + completed_at TEXT, + PRIMARY KEY (run_id, call_id) +); +INSERT INTO tool_calls_v4 + (call_id, run_id, task_id, effect_id, tool_id, input_digest, output_digest, status, created_at, completed_at) +SELECT call_id, run_id, task_id, effect_id, tool_id, input_digest, output_digest, status, created_at, completed_at +FROM tool_calls; +DROP TABLE tool_calls; +ALTER TABLE tool_calls_v4 RENAME TO tool_calls; +"#; + +#[derive(Clone)] +pub struct SqliteStore { + connection: Arc>, +} + +#[derive(Debug, Error)] +pub enum StoreError { + #[error("database error: {0}")] + Sqlite(#[from] rusqlite::Error), + #[error("serialization error: {0}")] + Serialization(#[from] serde_json::Error), + #[error("database schema {found} is newer than supported schema {supported}")] + UnknownSchema { found: u32, supported: u32 }, + #[error("durable state is incompatible: {0}")] + Incompatible(String), + #[error("durable state is corrupt: {0}")] + Corrupt(String), + #[error("run `{0}` was not found")] + RunNotFound(String), + #[error("task `{task_id}` was not found in run `{run_id}`")] + TaskNotFound { run_id: String, task_id: String }, + #[error("invalid task transition: {0}")] + InvalidTransition(String), + #[error("approval `{0}` was not found or already resolved")] + ApprovalNotPending(String), + #[error("effect `{0}` was not found")] + EffectNotFound(String), + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunRecord { + pub run_id: String, + pub workflow_digest: String, + pub workflow_schema_version: String, + pub plan_digest: String, + pub workflow: Value, + pub plan: CompiledPlan, + pub inputs: Value, + pub working_memory: Value, + pub output: Option, + pub state: RunState, + pub mode: RunMode, + pub parent_run_id: Option, + pub base_path: Option, + pub cancellation_requested: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RunMode { + Execute, + Check, + Replay, + Fork, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskRecord { + pub run_id: String, + pub task_id: String, + pub position: i64, + pub state: TaskState, + pub attempt: u16, + pub output: Option, + pub error: Option, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApprovalRequest { + pub approval_id: String, + pub run_id: String, + pub effect_id: String, + pub task_id: String, + pub agent: Option, + pub tool: String, + pub capability: String, + pub risk: String, + pub redacted_input: Value, + pub expected_effect: String, + pub reason: String, + pub trace_id: String, + pub requested_at: DateTime, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApprovalResolution { + Approved, + Rejected, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuditEvent { + pub sequence: i64, + pub event_type: String, + pub task_id: Option, + pub trace_id: String, + pub payload: Value, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckpointRecord { + pub sequence: i64, + pub format_version: u32, + pub state: Value, + pub checksum: String, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderSessionRecord { + pub task_id: String, + pub provider: String, + pub format_version: u32, + pub continuation: Value, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolCallRecord { + pub call_id: String, + pub task_id: String, + pub effect_id: String, + pub tool_id: String, + pub input_digest: String, + pub output_digest: Option, + pub status: String, + pub created_at: DateTime, + pub completed_at: Option>, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TraceRecord { + pub sequence: i64, + pub trace_id: String, + pub event: Value, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DatabaseStats { + pub schema_version: u32, + pub runs: i64, + pub tasks: i64, + pub effects: i64, + pub approvals: i64, + pub checkpoints: i64, + pub audit_events: i64, + pub provider_sessions: i64, + pub tool_calls: i64, + pub trace_events: i64, + pub long_term_memory: i64, +} + +impl SqliteStore { + pub fn open(path: &Path) -> Result { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent)?; + } + let connection = Connection::open(path)?; + configure(&connection)?; + migrate(&mut { connection })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let permissions = std::fs::Permissions::from_mode(0o600); + std::fs::set_permissions(path, permissions)?; + } + let connection = Connection::open(path)?; + configure(&connection)?; + Ok(Self { + connection: Arc::new(Mutex::new(connection)), + }) + } + + pub fn open_memory() -> Result { + let mut connection = Connection::open_in_memory()?; + configure(&connection)?; + migrate(&mut connection)?; + Ok(Self { + connection: Arc::new(Mutex::new(connection)), + }) + } + + #[must_use] + pub fn schema_version(&self) -> u32 { + self.connection + .lock() + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap_or(0) + } + + #[allow(clippy::too_many_arguments)] + pub fn create_run( + &self, + run_id: &str, + workflow_schema_version: &str, + workflow: &Value, + plan: &CompiledPlan, + inputs: &Value, + working_memory: &Value, + mode: RunMode, + parent_run_id: Option<&str>, + base_path: &Path, + now: DateTime, + trace_id: &str, + ) -> Result<(), StoreError> { + let mut connection = self.connection.lock(); + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + transaction.execute( + "INSERT INTO runs (run_id, runtime_state_version, workflow_digest, workflow_schema_version, plan_digest, plan_format_version, workflow_json, plan_json, inputs_json, working_memory_json, state, mode, parent_run_id, base_path, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?15)", + params![ + run_id, + RUNTIME_STATE_VERSION, + plan.workflow_digest, + workflow_schema_version, + plan.plan_digest, + plan.format_version, + encode(workflow)?, + encode(plan)?, + encode(inputs)?, + encode(working_memory)?, + encode_enum(RunState::Running)?, + encode_enum(mode)?, + parent_run_id, + base_path.display().to_string(), + now.to_rfc3339(), + ], + )?; + for (position, task_id) in plan.order.iter().enumerate() { + let position = i64::try_from(position).map_err(|_| { + StoreError::Incompatible("task position exceeds SQLite integer range".to_owned()) + })?; + transaction.execute( + "INSERT INTO task_states (run_id, task_id, position, state, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)", + params![run_id, task_id, position, encode_enum(TaskState::Pending)?, now.to_rfc3339()], + )?; + } + append_audit_tx( + &transaction, + run_id, + "run.created", + None, + trace_id, + &serde_json::json!({"mode": mode, "planDigest": plan.plan_digest}), + now, + )?; + checkpoint_tx(&transaction, run_id, now)?; + transaction.commit()?; + Ok(()) + } + + pub fn load_run(&self, run_id: &str) -> Result { + let connection = self.connection.lock(); + connection + .query_row( + "SELECT runtime_state_version, workflow_digest, workflow_schema_version, plan_digest, plan_format_version, workflow_json, plan_json, inputs_json, working_memory_json, output_json, state, mode, parent_run_id, cancellation_requested, created_at, updated_at, base_path FROM runs WHERE run_id = ?1", + [run_id], + |row| { + let state_version: u32 = row.get(0)?; + let plan_version: u32 = row.get(4)?; + Ok(( + state_version, + plan_version, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, String>(8)?, + row.get::<_, Option>(9)?, + row.get::<_, String>(10)?, + row.get::<_, String>(11)?, + row.get::<_, Option>(12)?, + row.get::<_, bool>(13)?, + row.get::<_, String>(14)?, + row.get::<_, String>(15)?, + row.get::<_, Option>(16)?, + )) + }, + ) + .optional()? + .ok_or_else(|| StoreError::RunNotFound(run_id.to_owned())) + .and_then(|row| { + if row.0 != RUNTIME_STATE_VERSION { + return Err(StoreError::Incompatible(format!( + "run state version {} is not supported", + row.0 + ))); + } + if row.1 != PLAN_FORMAT_VERSION { + return Err(StoreError::Incompatible(format!( + "plan format version {} is not supported", + row.1 + ))); + } + Ok(RunRecord { + run_id: run_id.to_owned(), + workflow_digest: row.2, + workflow_schema_version: row.3, + plan_digest: row.4, + workflow: decode(&row.5, "workflow_json")?, + plan: decode(&row.6, "plan_json")?, + inputs: decode(&row.7, "inputs_json")?, + working_memory: decode(&row.8, "working_memory_json")?, + output: row.9.map(|value| decode(&value, "output_json")).transpose()?, + state: decode_enum(&row.10, "run.state")?, + mode: decode_enum(&row.11, "run.mode")?, + parent_run_id: row.12, + base_path: row.16, + cancellation_requested: row.13, + created_at: parse_time(&row.14, "created_at")?, + updated_at: parse_time(&row.15, "updated_at")?, + }) + }) + } + + pub fn list_tasks(&self, run_id: &str) -> Result, StoreError> { + let connection = self.connection.lock(); + let mut statement = connection.prepare( + "SELECT task_id, position, state, attempt, output_json, error, updated_at FROM task_states WHERE run_id = ?1 ORDER BY position", + )?; + let rows = statement.query_map([run_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + row.get::<_, u16>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, String>(6)?, + )) + })?; + rows.map(|row| { + let row = row?; + Ok(TaskRecord { + run_id: run_id.to_owned(), + task_id: row.0, + position: row.1, + state: decode_enum(&row.2, "task.state")?, + attempt: row.3, + output: row + .4 + .map(|value| decode(&value, "task.output")) + .transpose()?, + error: row.5, + updated_at: parse_time(&row.6, "task.updated_at")?, + }) + }) + .collect() + } + + #[allow(clippy::too_many_arguments)] + pub fn transition_task( + &self, + run_id: &str, + task_id: &str, + next: TaskState, + output: Option<&Value>, + error: Option<&str>, + working_memory: Option<&Value>, + now: DateTime, + trace_id: &str, + ) -> Result<(), StoreError> { + let mut connection = self.connection.lock(); + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let current: String = transaction + .query_row( + "SELECT state FROM task_states WHERE run_id = ?1 AND task_id = ?2", + params![run_id, task_id], + |row| row.get(0), + ) + .optional()? + .ok_or_else(|| StoreError::TaskNotFound { + run_id: run_id.to_owned(), + task_id: task_id.to_owned(), + })?; + let current: TaskState = decode_enum(¤t, "task.state")?; + current + .transition(next) + .map_err(|transition| StoreError::InvalidTransition(transition.to_string()))?; + transaction.execute( + "UPDATE task_states SET state = ?3, output_json = COALESCE(?4, output_json), error = ?5, attempt = attempt + ?7, updated_at = ?6 WHERE run_id = ?1 AND task_id = ?2", + params![run_id, task_id, encode_enum(next)?, output.map(encode).transpose()?, error, now.to_rfc3339(), i64::from(current == TaskState::Ready && next == TaskState::Running)], + )?; + if let Some(memory) = working_memory { + transaction.execute( + "UPDATE runs SET working_memory_json = ?2, updated_at = ?3 WHERE run_id = ?1", + params![run_id, encode(memory)?, now.to_rfc3339()], + )?; + } else { + transaction.execute( + "UPDATE runs SET updated_at = ?2 WHERE run_id = ?1", + params![run_id, now.to_rfc3339()], + )?; + } + append_audit_tx( + &transaction, + run_id, + "task.transition", + Some(task_id), + trace_id, + &serde_json::json!({"from": current, "to": next, "error": error}), + now, + )?; + checkpoint_tx(&transaction, run_id, now)?; + transaction.commit()?; + Ok(()) + } + + pub fn update_run_state( + &self, + run_id: &str, + state: RunState, + output: Option<&Value>, + now: DateTime, + trace_id: &str, + ) -> Result<(), StoreError> { + let mut connection = self.connection.lock(); + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let current: String = transaction + .query_row( + "SELECT state FROM runs WHERE run_id = ?1", + [run_id], + |row| row.get(0), + ) + .optional()? + .ok_or_else(|| StoreError::RunNotFound(run_id.to_owned()))?; + let current: RunState = decode_enum(¤t, "run.state")?; + current + .transition(state) + .map_err(|transition| StoreError::InvalidTransition(transition.to_string()))?; + let changed = transaction.execute( + "UPDATE runs SET state = ?2, output_json = COALESCE(?3, output_json), updated_at = ?4 WHERE run_id = ?1", + params![run_id, encode_enum(state)?, output.map(encode).transpose()?, now.to_rfc3339()], + )?; + debug_assert_eq!(changed, 1); + append_audit_tx( + &transaction, + run_id, + "run.state", + None, + trace_id, + &serde_json::json!({"from": current, "to": state}), + now, + )?; + checkpoint_tx(&transaction, run_id, now)?; + transaction.commit()?; + Ok(()) + } + + pub fn record_effect_request( + &self, + request: &EffectRequest, + now: DateTime, + ) -> Result { + let mut connection = self.connection.lock(); + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + transaction.execute( + "INSERT INTO effects (effect_id, format_version, run_id, task_id, task_attempt, ordinal, operation, effect_class, risk, idempotency, idempotency_key, input_digest, input_json, expected_effect, trace_id, status, effect_attempt, requested_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, 1, ?17)", + params![ + request.id, + request.format_version, + request.run_id, + request.task_id, + request.attempt, + request.ordinal, + request.operation, + encode_enum(request.effect_class)?, + encode_enum(request.risk)?, + encode_enum(request.idempotency)?, + request.idempotency_key, + request.input_digest, + encode(&request.input)?, + request.expected_effect, + request.trace_id, + encode_enum(EffectStatus::Requested)?, + now.to_rfc3339(), + ], + )?; + append_audit_tx( + &transaction, + &request.run_id, + "effect.requested", + Some(&request.task_id), + &request.trace_id, + &serde_json::json!({ + "effectId": request.id, + "operation": request.operation, + "inputDigest": request.input_digest, + "effectClass": request.effect_class, + "risk": request.risk, + }), + now, + )?; + transaction.commit()?; + Ok(EffectRecord { + request: request.clone(), + status: EffectStatus::Requested, + attempt_number: 1, + requested_at: now, + started_at: None, + completed_at: None, + result: None, + error: None, + confirmed: false, + }) + } + + pub fn mark_effect_started( + &self, + effect_id: &str, + now: DateTime, + ) -> Result<(), StoreError> { + let changed = self.connection.lock().execute( + "UPDATE effects SET status = ?2, started_at = ?3 WHERE effect_id = ?1 AND status = ?4", + params![ + effect_id, + encode_enum(EffectStatus::Started)?, + now.to_rfc3339(), + encode_enum(EffectStatus::Requested)? + ], + )?; + if changed == 0 { + Err(StoreError::EffectNotFound(effect_id.to_owned())) + } else { + Ok(()) + } + } + + pub fn complete_effect( + &self, + effect_id: &str, + result: Result<&Value, &str>, + now: DateTime, + ) -> Result<(), StoreError> { + let (status, output, error, confirmed) = match result { + Ok(output) => (EffectStatus::Succeeded, Some(encode(output)?), None, true), + Err(error) => (EffectStatus::Failed, None, Some(error), false), + }; + let changed = self.connection.lock().execute( + "UPDATE effects SET status = ?2, result_json = ?3, error = ?4, confirmed = ?5, completed_at = ?6 WHERE effect_id = ?1 AND status = ?7", + params![effect_id, encode_enum(status)?, output, error, confirmed, now.to_rfc3339(), encode_enum(EffectStatus::Started)?], + )?; + if changed == 0 { + Err(StoreError::EffectNotFound(effect_id.to_owned())) + } else { + Ok(()) + } + } + + pub fn mark_effect_uncertain( + &self, + effect_id: &str, + error: &str, + now: DateTime, + ) -> Result<(), StoreError> { + let changed = self.connection.lock().execute( + "UPDATE effects SET status = ?2, error = ?3, completed_at = ?4, confirmed = 0 WHERE effect_id = ?1 AND status = ?5", + params![ + effect_id, + encode_enum(EffectStatus::Uncertain)?, + error, + now.to_rfc3339(), + encode_enum(EffectStatus::Started)? + ], + )?; + if changed == 0 { + Err(StoreError::EffectNotFound(effect_id.to_owned())) + } else { + Ok(()) + } + } + + pub fn load_effect(&self, effect_id: &str) -> Result { + let connection = self.connection.lock(); + connection + .query_row( + "SELECT format_version, run_id, task_id, task_attempt, ordinal, operation, effect_class, risk, idempotency, idempotency_key, input_digest, input_json, expected_effect, trace_id, status, effect_attempt, requested_at, started_at, completed_at, result_json, error, confirmed FROM effects WHERE effect_id = ?1", + [effect_id], + |row| { + Ok(( + row.get::<_, u32>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, + row.get::<_, u16>(3)?, row.get::<_, u16>(4)?, row.get::<_, String>(5)?, + row.get::<_, String>(6)?, row.get::<_, String>(7)?, row.get::<_, String>(8)?, + row.get::<_, String>(9)?, row.get::<_, String>(10)?, row.get::<_, String>(11)?, + row.get::<_, String>(12)?, row.get::<_, String>(13)?, row.get::<_, String>(14)?, + row.get::<_, u16>(15)?, row.get::<_, String>(16)?, row.get::<_, Option>(17)?, + row.get::<_, Option>(18)?, row.get::<_, Option>(19)?, + row.get::<_, Option>(20)?, row.get::<_, bool>(21)?, + )) + }, + ) + .optional()? + .ok_or_else(|| StoreError::EffectNotFound(effect_id.to_owned())) + .and_then(|row| { + if row.0 != agentctl_core::EFFECT_FORMAT_VERSION { + return Err(StoreError::Incompatible(format!("effect format version {}", row.0))); + } + Ok(EffectRecord { + request: EffectRequest { + format_version: row.0, + id: effect_id.to_owned(), + run_id: row.1, + task_id: row.2, + attempt: row.3, + ordinal: row.4, + operation: row.5, + effect_class: decode_enum(&row.6, "effect.effect_class")?, + risk: decode_enum(&row.7, "effect.risk")?, + idempotency: decode_enum(&row.8, "effect.idempotency")?, + idempotency_key: row.9, + input_digest: row.10, + input: decode(&row.11, "effect.input")?, + expected_effect: row.12, + trace_id: row.13, + }, + status: decode_enum(&row.14, "effect.status")?, + attempt_number: row.15, + requested_at: parse_time(&row.16, "effect.requested_at")?, + started_at: row.17.map(|value| parse_time(&value, "effect.started_at")).transpose()?, + completed_at: row.18.map(|value| parse_time(&value, "effect.completed_at")).transpose()?, + result: row.19.map(|value| decode(&value, "effect.result")).transpose()?, + error: row.20, + confirmed: row.21, + }) + }) + } + + pub fn unresolved_effects(&self, run_id: &str) -> Result, StoreError> { + let connection = self.connection.lock(); + let mut statement = connection.prepare( + "SELECT effect_id FROM effects WHERE run_id = ?1 AND status IN ('started', 'uncertain') ORDER BY rowid", + )?; + statement + .query_map([run_id], |row| row.get(0))? + .collect::, _>>() + .map_err(StoreError::from) + } + + pub fn latest_effect_for_task( + &self, + run_id: &str, + task_id: &str, + ) -> Result, StoreError> { + let effect_id: Option = self + .connection + .lock() + .query_row( + "SELECT effect_id FROM effects WHERE run_id = ?1 AND task_id = ?2 ORDER BY rowid DESC LIMIT 1", + params![run_id, task_id], + |row| row.get(0), + ) + .optional()?; + effect_id.map(|id| self.load_effect(&id)).transpose() + } + + pub fn create_approval(&self, request: &ApprovalRequest) -> Result<(), StoreError> { + let mut connection = self.connection.lock(); + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + transaction.execute( + "INSERT INTO approvals (approval_id, run_id, effect_id, task_id, agent, tool, capability, risk, redacted_input_json, expected_effect, reason, trace_id, status, requested_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'pending', ?13)", + params![request.approval_id, request.run_id, request.effect_id, request.task_id, request.agent, request.tool, request.capability, request.risk, encode(&request.redacted_input)?, request.expected_effect, request.reason, request.trace_id, request.requested_at.to_rfc3339()], + )?; + transaction.execute( + "UPDATE effects SET status = ?2 WHERE effect_id = ?1", + params![ + request.effect_id, + encode_enum(EffectStatus::WaitingForApproval)? + ], + )?; + transaction.commit()?; + Ok(()) + } + + pub fn resolve_approval( + &self, + approval_id: &str, + resolution: ApprovalResolution, + actor: &str, + reason: &str, + now: DateTime, + ) -> Result<(), StoreError> { + let mut connection = self.connection.lock(); + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let effect_id: Option = transaction + .query_row( + "SELECT effect_id FROM approvals WHERE approval_id = ?1 AND status = 'pending'", + [approval_id], + |row| row.get(0), + ) + .optional()?; + let Some(effect_id) = effect_id else { + return Err(StoreError::ApprovalNotPending(approval_id.to_owned())); + }; + let status = match resolution { + ApprovalResolution::Approved => "approved", + ApprovalResolution::Rejected => "rejected", + }; + transaction.execute( + "UPDATE approvals SET status = ?2, resolved_at = ?3, resolved_by = ?4, resolution_reason = ?5 WHERE approval_id = ?1", + params![approval_id, status, now.to_rfc3339(), actor, reason], + )?; + let effect_status = match resolution { + ApprovalResolution::Approved => EffectStatus::Requested, + ApprovalResolution::Rejected => EffectStatus::Cancelled, + }; + transaction.execute( + "UPDATE effects SET status = ?2 WHERE effect_id = ?1", + params![effect_id, encode_enum(effect_status)?], + )?; + transaction.commit()?; + Ok(()) + } + + pub fn pending_approvals(&self, run_id: &str) -> Result, StoreError> { + let connection = self.connection.lock(); + let mut statement = connection.prepare( + "SELECT approval_id, effect_id, task_id, agent, tool, capability, risk, redacted_input_json, expected_effect, reason, trace_id, requested_at FROM approvals WHERE run_id = ?1 AND status = 'pending' ORDER BY requested_at, approval_id", + )?; + statement + .query_map([run_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, String>(8)?, + row.get::<_, String>(9)?, + row.get::<_, String>(10)?, + row.get::<_, String>(11)?, + )) + })? + .map(|row| { + let row = row?; + Ok(ApprovalRequest { + approval_id: row.0, + run_id: run_id.to_owned(), + effect_id: row.1, + task_id: row.2, + agent: row.3, + tool: row.4, + capability: row.5, + risk: row.6, + redacted_input: decode(&row.7, "approval.input")?, + expected_effect: row.8, + reason: row.9, + trace_id: row.10, + requested_at: parse_time(&row.11, "approval.requested_at")?, + }) + }) + .collect() + } + + pub fn request_cancellation( + &self, + run_id: &str, + now: DateTime, + trace_id: &str, + ) -> Result<(), StoreError> { + let mut connection = self.connection.lock(); + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let changed = transaction.execute( + "UPDATE runs SET cancellation_requested = 1, updated_at = ?2 WHERE run_id = ?1 AND state IN ('running', 'paused')", + params![run_id, now.to_rfc3339()], + )?; + if changed == 0 { + return Err(StoreError::RunNotFound(run_id.to_owned())); + } + append_audit_tx( + &transaction, + run_id, + "run.cancellation_requested", + None, + trace_id, + &Value::Null, + now, + )?; + transaction.commit()?; + Ok(()) + } + + pub fn audit_events(&self, run_id: &str) -> Result, StoreError> { + let connection = self.connection.lock(); + let mut statement = connection.prepare( + "SELECT sequence, event_type, task_id, trace_id, payload_json, created_at, event_version FROM audit_events WHERE run_id = ?1 ORDER BY sequence", + )?; + statement + .query_map([run_id], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, u32>(6)?, + )) + })? + .map(|row| { + let row = row?; + if row.6 != AUDIT_EVENT_VERSION { + return Err(StoreError::Incompatible(format!( + "audit event version {}", + row.6 + ))); + } + Ok(AuditEvent { + sequence: row.0, + event_type: row.1, + task_id: row.2, + trace_id: row.3, + payload: decode(&row.4, "audit.payload")?, + created_at: parse_time(&row.5, "audit.created_at")?, + }) + }) + .collect() + } + + pub fn list_effects(&self, run_id: &str) -> Result, StoreError> { + let ids = { + let connection = self.connection.lock(); + let mut statement = connection.prepare( + "SELECT effect_id FROM effects WHERE run_id = ?1 ORDER BY task_id, task_attempt, ordinal", + )?; + statement + .query_map([run_id], |row| row.get::<_, String>(0))? + .collect::, _>>()? + }; + ids.into_iter().map(|id| self.load_effect(&id)).collect() + } + + pub fn checkpoints(&self, run_id: &str) -> Result, StoreError> { + let connection = self.connection.lock(); + let mut statement = connection.prepare( + "SELECT sequence, format_version, state_json, checksum, created_at FROM checkpoints WHERE run_id = ?1 ORDER BY sequence", + )?; + statement + .query_map([run_id], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, u32>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + )) + })? + .map(|row| { + let row = row?; + if row.1 != CHECKPOINT_FORMAT_VERSION { + return Err(StoreError::Incompatible(format!( + "checkpoint format version {}", + row.1 + ))); + } + let actual = hex::encode(Sha256::digest(row.2.as_bytes())); + if actual != row.3 { + return Err(StoreError::Corrupt(format!( + "checkpoint {} checksum mismatch", + row.0 + ))); + } + Ok(CheckpointRecord { + sequence: row.0, + format_version: row.1, + state: decode(&row.2, "checkpoint.state")?, + checksum: row.3, + created_at: parse_time(&row.4, "checkpoint.created_at")?, + }) + }) + .collect() + } + + pub fn provider_sessions( + &self, + run_id: &str, + ) -> Result, StoreError> { + let connection = self.connection.lock(); + let mut statement = connection.prepare( + "SELECT task_id, provider, format_version, continuation_json, updated_at FROM provider_sessions WHERE run_id = ?1 ORDER BY task_id", + )?; + statement + .query_map([run_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, u32>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + )) + })? + .map(|row| { + let row = row?; + if row.2 != 1 { + return Err(StoreError::Incompatible(format!( + "provider session format version {}", + row.2 + ))); + } + Ok(ProviderSessionRecord { + task_id: row.0, + provider: row.1, + format_version: row.2, + continuation: decode(&row.3, "provider_session.continuation")?, + updated_at: parse_time(&row.4, "provider_session.updated_at")?, + }) + }) + .collect() + } + + pub fn tool_calls(&self, run_id: &str) -> Result, StoreError> { + let connection = self.connection.lock(); + let mut statement = connection.prepare( + "SELECT call_id, task_id, effect_id, tool_id, input_digest, output_digest, status, created_at, completed_at FROM tool_calls WHERE run_id = ?1 ORDER BY created_at, call_id", + )?; + statement + .query_map([run_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, Option>(8)?, + )) + })? + .map(|row| { + let row = row?; + Ok(ToolCallRecord { + call_id: row.0, + task_id: row.1, + effect_id: row.2, + tool_id: row.3, + input_digest: row.4, + output_digest: row.5, + status: row.6, + created_at: parse_time(&row.7, "tool_call.created_at")?, + completed_at: row + .8 + .map(|value| parse_time(&value, "tool_call.completed_at")) + .transpose()?, + }) + }) + .collect() + } + + pub fn record_trace_event( + &self, + run_id: &str, + trace_id: &str, + event: &Value, + now: DateTime, + ) -> Result<(), StoreError> { + let connection = self.connection.lock(); + let sequence: i64 = connection.query_row( + "SELECT COALESCE(MAX(sequence), 0) + 1 FROM trace_events WHERE run_id = ?1", + [run_id], + |row| row.get(0), + )?; + connection.execute( + "INSERT INTO trace_events (run_id, sequence, trace_id, event_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", + params![run_id, sequence, trace_id, encode(event)?, now.to_rfc3339()], + )?; + Ok(()) + } + + pub fn trace_events(&self, run_id: &str) -> Result, StoreError> { + let connection = self.connection.lock(); + let mut statement = connection.prepare( + "SELECT sequence, trace_id, event_json, created_at FROM trace_events WHERE run_id = ?1 ORDER BY sequence", + )?; + statement + .query_map([run_id], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + })? + .map(|row| { + let row = row?; + Ok(TraceRecord { + sequence: row.0, + trace_id: row.1, + event: decode(&row.2, "trace.event")?, + created_at: parse_time(&row.3, "trace.created_at")?, + }) + }) + .collect() + } + + pub fn put_long_term_memory( + &self, + namespace: &str, + key: &str, + value: &Value, + expires_at: Option>, + now: DateTime, + ) -> Result<(), StoreError> { + self.connection.lock().execute( + "INSERT INTO long_term_memory (namespace, memory_key, value_json, expires_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(namespace, memory_key) DO UPDATE SET value_json = excluded.value_json, expires_at = excluded.expires_at, updated_at = excluded.updated_at", + params![namespace, key, encode(value)?, expires_at.map(|value| value.to_rfc3339()), now.to_rfc3339()], + )?; + Ok(()) + } + + pub fn put_provider_session( + &self, + run_id: &str, + task_id: &str, + provider: &str, + continuation: &Value, + now: DateTime, + ) -> Result<(), StoreError> { + self.connection.lock().execute( + "INSERT INTO provider_sessions (run_id, task_id, provider, format_version, continuation_json, updated_at) VALUES (?1, ?2, ?3, 1, ?4, ?5) ON CONFLICT(run_id, task_id) DO UPDATE SET provider = excluded.provider, format_version = excluded.format_version, continuation_json = excluded.continuation_json, updated_at = excluded.updated_at", + params![run_id, task_id, provider, encode(continuation)?, now.to_rfc3339()], + )?; + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + pub fn start_tool_call( + &self, + call_id: &str, + run_id: &str, + task_id: &str, + effect_id: &str, + tool_id: &str, + input_digest: &str, + now: DateTime, + ) -> Result<(), StoreError> { + self.connection.lock().execute( + "INSERT INTO tool_calls (call_id, run_id, task_id, effect_id, tool_id, input_digest, status, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'started', ?7)", + params![call_id, run_id, task_id, effect_id, tool_id, input_digest, now.to_rfc3339()], + )?; + Ok(()) + } + + pub fn complete_tool_call( + &self, + run_id: &str, + call_id: &str, + output_digest: Option<&str>, + succeeded: bool, + now: DateTime, + ) -> Result<(), StoreError> { + let changed = self.connection.lock().execute( + "UPDATE tool_calls SET output_digest = ?3, status = ?4, completed_at = ?5 WHERE run_id = ?1 AND call_id = ?2 AND status = 'started'", + params![run_id, call_id, output_digest, if succeeded { "succeeded" } else { "failed" }, now.to_rfc3339()], + )?; + if changed == 1 { + Ok(()) + } else { + Err(StoreError::Incompatible(format!( + "tool call `{call_id}` in run `{run_id}` is missing or terminal" + ))) + } + } + + pub fn mark_tool_call_uncertain( + &self, + run_id: &str, + call_id: &str, + now: DateTime, + ) -> Result<(), StoreError> { + let changed = self.connection.lock().execute( + "UPDATE tool_calls SET status = 'uncertain', completed_at = ?3 WHERE run_id = ?1 AND call_id = ?2 AND status = 'started'", + params![run_id, call_id, now.to_rfc3339()], + )?; + if changed == 1 { + Ok(()) + } else { + Err(StoreError::Incompatible(format!( + "tool call `{call_id}` in run `{run_id}` is missing or terminal" + ))) + } + } + + pub fn get_long_term_memory( + &self, + namespace: &str, + key: &str, + now: DateTime, + ) -> Result, StoreError> { + let value: Option = self.connection.lock().query_row( + "SELECT value_json FROM long_term_memory WHERE namespace = ?1 AND memory_key = ?2 AND (expires_at IS NULL OR expires_at > ?3)", + params![namespace, key, now.to_rfc3339()], + |row| row.get(0), + ).optional()?; + value + .map(|value| decode(&value, "long_term_memory.value")) + .transpose() + } + + pub fn garbage_collect(&self, before: DateTime) -> Result { + let connection = self.connection.lock(); + let expired = connection.execute( + "DELETE FROM long_term_memory WHERE expires_at IS NOT NULL AND expires_at <= ?1", + [before.to_rfc3339()], + )?; + let runs = connection.execute( + "DELETE FROM runs WHERE state IN ('succeeded', 'failed', 'cancelled') AND updated_at < ?1", + [before.to_rfc3339()], + )?; + Ok(expired + runs) + } + + pub fn checkpoint_count(&self, run_id: &str) -> Result { + self.connection + .lock() + .query_row( + "SELECT COUNT(*) FROM checkpoints WHERE run_id = ?1", + [run_id], + |row| row.get(0), + ) + .map_err(StoreError::from) + } + + pub fn stats(&self) -> Result { + let connection = self.connection.lock(); + let count = |table: &str| -> Result { + let allowed = [ + "runs", + "task_states", + "effects", + "approvals", + "checkpoints", + "audit_events", + "provider_sessions", + "tool_calls", + "trace_events", + "long_term_memory", + ]; + if !allowed.contains(&table) { + return Err(StoreError::Incompatible( + "invalid statistics table".to_owned(), + )); + } + connection + .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + }) + .map_err(StoreError::from) + }; + let schema_version = + connection.pragma_query_value(None, "user_version", |row| row.get(0))?; + Ok(DatabaseStats { + schema_version, + runs: count("runs")?, + tasks: count("task_states")?, + effects: count("effects")?, + approvals: count("approvals")?, + checkpoints: count("checkpoints")?, + audit_events: count("audit_events")?, + provider_sessions: count("provider_sessions")?, + tool_calls: count("tool_calls")?, + trace_events: count("trace_events")?, + long_term_memory: count("long_term_memory")?, + }) + } + + #[cfg(test)] + fn connection(&self) -> parking_lot::MutexGuard<'_, Connection> { + self.connection.lock() + } +} + +fn configure(connection: &Connection) -> Result<(), StoreError> { + connection.pragma_update(None, "foreign_keys", "ON")?; + connection.pragma_update(None, "journal_mode", "WAL")?; + connection.busy_timeout(Duration::from_secs(5))?; + Ok(()) +} + +fn migrate(connection: &mut Connection) -> Result<(), StoreError> { + let current: u32 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; + if current > DATABASE_SCHEMA_VERSION { + return Err(StoreError::UnknownSchema { + found: current, + supported: DATABASE_SCHEMA_VERSION, + }); + } + let migrations = [ + (1_u32, MIGRATION_1), + (2_u32, MIGRATION_2), + (3_u32, MIGRATION_3), + (4_u32, MIGRATION_4), + ]; + for (version, sql) in migrations + .into_iter() + .filter(|(version, _)| *version > current) + { + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + transaction.execute_batch(sql)?; + transaction.pragma_update(None, "user_version", version)?; + transaction.commit()?; + } + Ok(()) +} + +fn checkpoint_tx( + transaction: &Transaction<'_>, + run_id: &str, + now: DateTime, +) -> Result<(), StoreError> { + let run_state: (String, String, Option, bool) = transaction.query_row( + "SELECT state, working_memory_json, output_json, cancellation_requested FROM runs WHERE run_id = ?1", + [run_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + )?; + let mut statement = transaction.prepare( + "SELECT task_id, state, attempt, output_json, error FROM task_states WHERE run_id = ?1 ORDER BY position", + )?; + let tasks: Vec = statement + .query_map([run_id], |row| { + Ok(serde_json::json!({ + "taskId": row.get::<_, String>(0)?, + "state": row.get::<_, String>(1)?, + "attempt": row.get::<_, u16>(2)?, + "output": row.get::<_, Option>(3)?.and_then(|raw| serde_json::from_str::(&raw).ok()), + "error": row.get::<_, Option>(4)?, + })) + })? + .collect::>()?; + let state = serde_json::json!({ + "runId": run_id, + "state": run_state.0, + "workingMemory": decode::(&run_state.1, "working_memory")?, + "output": run_state.2.map(|raw| decode::(&raw, "output")).transpose()?, + "cancellationRequested": run_state.3, + "tasks": tasks, + }); + let state_json = encode(&state)?; + let checksum = hex::encode(Sha256::digest(state_json.as_bytes())); + let sequence: i64 = transaction.query_row( + "SELECT COALESCE(MAX(sequence), 0) + 1 FROM checkpoints WHERE run_id = ?1", + [run_id], + |row| row.get(0), + )?; + transaction.execute( + "INSERT INTO checkpoints (run_id, sequence, format_version, state_json, checksum, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![run_id, sequence, CHECKPOINT_FORMAT_VERSION, state_json, checksum, now.to_rfc3339()], + )?; + Ok(()) +} + +fn append_audit_tx( + transaction: &Transaction<'_>, + run_id: &str, + event_type: &str, + task_id: Option<&str>, + trace_id: &str, + payload: &Value, + now: DateTime, +) -> Result<(), StoreError> { + let sequence: i64 = transaction.query_row( + "SELECT COALESCE(MAX(sequence), 0) + 1 FROM audit_events WHERE run_id = ?1", + [run_id], + |row| row.get(0), + )?; + transaction.execute( + "INSERT INTO audit_events (run_id, sequence, event_version, event_type, task_id, trace_id, payload_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![run_id, sequence, AUDIT_EVENT_VERSION, event_type, task_id, trace_id, encode(payload)?, now.to_rfc3339()], + )?; + Ok(()) +} + +fn encode(value: &T) -> Result { + serde_json::to_string(value).map_err(StoreError::from) +} + +fn encode_enum(value: T) -> Result { + let value = serde_json::to_value(value)?; + value + .as_str() + .map(ToOwned::to_owned) + .ok_or_else(|| StoreError::Corrupt("enum did not serialize as a string".to_owned())) +} + +fn decode(value: &str, field: &str) -> Result { + serde_json::from_str(value).map_err(|error| StoreError::Corrupt(format!("{field}: {error}"))) +} + +fn decode_enum(value: &str, field: &str) -> Result { + decode(&format!("\"{value}\""), field) +} + +fn parse_time(value: &str, field: &str) -> Result, StoreError> { + DateTime::parse_from_rfc3339(value) + .map(|value| value.with_timezone(&Utc)) + .map_err(|error| StoreError::Corrupt(format!("{field}: {error}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use agentctl_core::compile; + use agentctl_core::dsl::{API_VERSION, EffectClass, Idempotency, Risk, parse_workflow}; + use agentctl_core::effect::EffectRequest; + use tempfile::tempdir; + + fn fixture() -> (Value, CompiledPlan) { + let source = r#" +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: store } +spec: + actions: + assign: { kind: builtin.assign } + tasks: + - { id: one, uses: "action:assign", with: { value: 1 } } +"#; + let workflow = parse_workflow(source, "fixture.yaml") + .expect("parse") + .workflow; + let plan = compile(&workflow, "fixture.yaml").expect("compile"); + (serde_json::to_value(workflow).expect("json"), plan) + } + + fn create(store: &SqliteStore, run_id: &str) { + let (workflow, plan) = fixture(); + store + .create_run( + run_id, + API_VERSION, + &workflow, + &plan, + &serde_json::json!({}), + &serde_json::json!({}), + RunMode::Execute, + None, + Path::new("."), + Utc::now(), + "trace", + ) + .expect("create run"); + } + + #[test] + fn fresh_database_migrates_and_permissions_are_private() { + let directory = tempdir().expect("temp dir"); + let path = directory.path().join("runtime.db"); + let store = SqliteStore::open(&path).expect("open"); + assert_eq!(store.schema_version(), DATABASE_SCHEMA_VERSION); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(path) + .expect("metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + } + + #[test] + fn unknown_future_schema_fails_explicitly() { + let directory = tempdir().expect("temp dir"); + let path = directory.path().join("runtime.db"); + let connection = Connection::open(&path).expect("open raw"); + connection + .pragma_update(None, "user_version", 999) + .expect("set version"); + drop(connection); + assert!(matches!( + SqliteStore::open(&path), + Err(StoreError::UnknownSchema { found: 999, .. }) + )); + } + + #[test] + fn transition_and_checkpoint_commit_together() { + let store = SqliteStore::open_memory().expect("store"); + create(&store, "run"); + store + .transition_task( + "run", + "one", + TaskState::Ready, + None, + None, + None, + Utc::now(), + "trace", + ) + .expect("ready"); + store + .transition_task( + "run", + "one", + TaskState::Running, + None, + None, + None, + Utc::now(), + "trace", + ) + .expect("running"); + let output = serde_json::json!({"value": 1}); + store + .transition_task( + "run", + "one", + TaskState::Succeeded, + Some(&output), + None, + None, + Utc::now(), + "trace", + ) + .expect("succeeded"); + let tasks = store.list_tasks("run").expect("tasks"); + assert_eq!(tasks[0].state, TaskState::Succeeded); + assert_eq!(tasks[0].attempt, 1); + assert_eq!(store.checkpoint_count("run").expect("count"), 4); + } + + #[test] + fn corrupt_rows_fail_without_panicking() { + let store = SqliteStore::open_memory().expect("store"); + create(&store, "run"); + store + .connection() + .execute( + "UPDATE runs SET plan_json = 'not-json' WHERE run_id = 'run'", + [], + ) + .expect("corrupt"); + assert!(matches!(store.load_run("run"), Err(StoreError::Corrupt(_)))); + } + + #[test] + fn effect_identity_is_durable_and_started_effect_is_not_repeatable() { + let store = SqliteStore::open_memory().expect("store"); + create(&store, "run"); + let request = EffectRequest::new( + "run", + "one", + 1, + 1, + "builtin.write", + EffectClass::WorkspaceMutate, + Risk::Medium, + Idempotency::Idempotent, + serde_json::json!({"path": "out.txt"}), + "write out.txt", + "trace", + ); + store + .record_effect_request(&request, Utc::now()) + .expect("record"); + store + .mark_effect_started(&request.id, Utc::now()) + .expect("start"); + assert_eq!( + store.unresolved_effects("run").expect("unresolved"), + [request.id] + ); + } + + #[test] + fn audit_sequence_continues_across_resume() { + let store = SqliteStore::open_memory().expect("store"); + create(&store, "run"); + store + .transition_task( + "run", + "one", + TaskState::Ready, + None, + None, + None, + Utc::now(), + "trace", + ) + .expect("ready"); + let events = store.audit_events("run").expect("events"); + assert_eq!( + events + .iter() + .map(|event| event.sequence) + .collect::>(), + [1, 2] + ); + } + + #[test] + fn upgrades_a_version_one_database_transactionally() { + let directory = tempdir().expect("temp dir"); + let path = directory.path().join("runtime.db"); + let connection = Connection::open(&path).expect("raw connection"); + connection.execute_batch(MIGRATION_1).expect("v1 schema"); + connection + .pragma_update(None, "user_version", 1) + .expect("v1 marker"); + drop(connection); + + let store = SqliteStore::open(&path).expect("upgrade"); + assert_eq!(store.schema_version(), DATABASE_SCHEMA_VERSION); + assert_eq!(store.stats().expect("stats").long_term_memory, 0); + } + + #[test] + fn concurrent_readers_and_bounded_lock_wait_succeed() { + let directory = tempdir().expect("temp dir"); + let path = directory.path().join("runtime.db"); + let store = SqliteStore::open(&path).expect("store"); + create(&store, "run"); + let reader_path = path.clone(); + let reader = std::thread::spawn(move || { + let store = SqliteStore::open(&reader_path).expect("reader store"); + store.load_run("run").expect("concurrent read").run_id + }); + assert_eq!(reader.join().expect("reader thread"), "run"); + + let writer_path = path.clone(); + let blocker = Connection::open(&path).expect("blocker"); + blocker.execute_batch("BEGIN IMMEDIATE").expect("lock"); + let writer = std::thread::spawn(move || { + let store = SqliteStore::open(&writer_path).expect("writer store"); + store.put_long_term_memory("test", "key", &serde_json::json!(true), None, Utc::now()) + }); + std::thread::sleep(Duration::from_millis(25)); + blocker.execute_batch("ROLLBACK").expect("unlock"); + writer.join().expect("writer thread").expect("bounded wait"); + } + + #[test] + fn garbage_collection_removes_only_expired_memory() { + let store = SqliteStore::open_memory().expect("store"); + let now = Utc::now(); + store + .put_long_term_memory( + "test", + "expired", + &serde_json::json!(1), + Some(now - chrono::Duration::seconds(1)), + now, + ) + .expect("expired memory"); + store + .put_long_term_memory( + "test", + "live", + &serde_json::json!(2), + Some(now + chrono::Duration::days(1)), + now, + ) + .expect("live memory"); + assert_eq!(store.garbage_collect(now).expect("gc"), 1); + assert_eq!( + store + .get_long_term_memory("test", "live", now) + .expect("read"), + Some(serde_json::json!(2)) + ); + } +} diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..be84772 --- /dev/null +++ b/deny.toml @@ -0,0 +1,27 @@ +[advisories] +version = 2 +yanked = "deny" + +[bans] +multiple-versions = "warn" +wildcards = "deny" + +[licenses] +version = 2 +confidence-threshold = 0.93 +allow = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "MIT", + "MIT-0", + "Unicode-3.0", + "Zlib", + "CDLA-Permissive-2.0", +] + +[sources] +unknown-git = "deny" +unknown-registry = "deny" diff --git a/examples/acceptance/mock-tool/artifacts/.gitkeep b/examples/acceptance/mock-tool/artifacts/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/acceptance/mock-tool/artifacts/.gitkeep @@ -0,0 +1 @@ + diff --git a/examples/acceptance/mock-tool/fixture/service.txt b/examples/acceptance/mock-tool/fixture/service.txt new file mode 100644 index 0000000..5aa8105 --- /dev/null +++ b/examples/acceptance/mock-tool/fixture/service.txt @@ -0,0 +1,3 @@ +service=agentctl-acceptance +status=ready +marker=READ_TOOL_CONFIRMED diff --git a/examples/acceptance/mock-tool/workflow.yaml b/examples/acceptance/mock-tool/workflow.yaml new file mode 100644 index 0000000..ff6d5d4 --- /dev/null +++ b/examples/acceptance/mock-tool/workflow.yaml @@ -0,0 +1,78 @@ +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: + name: acceptance-mock-tool +spec: + inputs: + reportPath: artifacts/mock-report.txt + outputs: + verdict: "${{ tasks.inspect.output.text }}" + artifact: "${{ inputs.reportPath }}" + providers: + fake: + kind: fake + policy: + workspaceRoot: . + writableRoots: [artifacts] + approval: never + tools: + read_fixture: + kind: builtin.workspace.read + description: Read a UTF-8 file inside the authorized workspace. + inputSchema: + type: object + properties: + path: { type: string } + required: [path] + additionalProperties: false + outputSchema: + type: object + properties: + path: { type: string } + content: { type: string } + bytes: { type: integer } + sha256: { type: string } + required: [path, content, bytes, sha256] + additionalProperties: false + capability: filesystem.read + risk: low + effectClass: observe + idempotency: idempotent + retrySafe: true + timeoutSeconds: 5 + approval: never + agents: + inspector: + provider: fake + model: scripted + instructions: Read the fixture and report its marker. + tools: [read_fixture] + maxTurns: 2 + maxToolCalls: 1 + maxOutputTokens: 32 + timeoutSeconds: 5 + providerOptions: + toolInput: { path: fixture/service.txt } + finalText: AGENTCTL_MOCK_FIXTURE_VERIFIED + actions: + assert: + kind: builtin.assert + write: + kind: builtin.write + tasks: + - id: inspect + uses: agent:inspector + with: + prompt: Use read_fixture before answering. + - id: verify + uses: action:assert + needs: [inspect] + with: + that: "${{ tasks.inspect.output.text == 'AGENTCTL_MOCK_FIXTURE_VERIFIED' }}" + message: the mock provider did not complete its tool continuation + - id: report + uses: action:write + needs: [inspect, verify] + with: + path: "${{ inputs.reportPath }}" + content: "${{ tasks.inspect.output.text }}" diff --git a/examples/memory-flow/state/long-term.db b/examples/memory-flow/state/long-term.db deleted file mode 100644 index c7994804bfecce59a40310298f70b883d916371f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12288 zcmeI$O-tiI7zgl~Ze44kc5gj+87hcEQ<7L+sT8(K4vW=YYs5DsjAmN0o0o28>dVpH zo3Nk8FSQ?G!Hd&^9*p9}i}HV%A(`i4Nb)0>XLhzbffB5jq!Cwak$fVQk}r%ALMHV5 zpyz#1eUram=<8_xQcRHg-`i68KgmsBlj-a7%}X0lhX4d1009U<00Izz00bZaf%h)( zdyY<4EQ|g*SNtRtVG{Q}CDO=?M3kg~kl9Rbz3a4Hhq>*wj>EEbHeZ~Ic_ie3p9Ff^h-%2D>2^pVbUy-i42tyTHZ4^;KoHtl7@Y#5g1nk&tQ)wJ#U zqTM)r$lI=MHg(Bdu3Jm?VNrh=0.2.0, <1.0.0" +capabilities: [internal] +providers: [] +actions: + assign: + kind: builtin.assign diff --git a/examples/v1/fake-provider.yaml b/examples/v1/fake-provider.yaml new file mode 100644 index 0000000..cbc6663 --- /dev/null +++ b/examples/v1/fake-provider.yaml @@ -0,0 +1,21 @@ +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: + name: fake-provider +spec: + providers: + fake: + kind: fake + agents: + answer: + provider: fake + model: scripted + instructions: Reply deterministically. + maxTurns: 1 + maxToolCalls: 0 + maxOutputTokens: 64 + timeoutSeconds: 5 + tasks: + - id: answer + uses: agent:answer + with: { prompt: hello } diff --git a/examples/v1/google-live.yaml b/examples/v1/google-live.yaml new file mode 100644 index 0000000..ebfefcb --- /dev/null +++ b/examples/v1/google-live.yaml @@ -0,0 +1,19 @@ +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: google-live } +spec: + providers: + google: + kind: google + credential: { env: GEMINI_API_KEY } + policy: + networkAllowlist: [generativelanguage.googleapis.com] + agents: + answer: + provider: google + model: gemini-3.5-flash + instructions: Reply with one short sentence. + maxTurns: 1 + maxOutputTokens: 64 + tasks: + - { id: answer, uses: "agent:answer", with: { prompt: Say hello. } } diff --git a/examples/v1/hello.yaml b/examples/v1/hello.yaml new file mode 100644 index 0000000..9c5c302 --- /dev/null +++ b/examples/v1/hello.yaml @@ -0,0 +1,18 @@ +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: + name: hello + description: Deterministic hello world with typed inputs and outputs. +spec: + inputs: + name: world + outputs: + greeting: "${{ tasks.greet.output.output.message }}" + actions: + assign: + kind: builtin.assign + tasks: + - id: greet + uses: action:assign + with: + message: "hello, ${{ inputs.name }}" diff --git a/examples/v1/long-term-memory.yaml b/examples/v1/long-term-memory.yaml new file mode 100644 index 0000000..662f6a3 --- /dev/null +++ b/examples/v1/long-term-memory.yaml @@ -0,0 +1,25 @@ +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: + name: long-term-memory +spec: + memory: + longTerm: + provider: sqlite + namespace: example + retentionDays: 30 + policy: + approval: never + actions: + write: + kind: builtin.long_term_memory.write + read: + kind: builtin.long_term_memory.read + tasks: + - id: write + uses: action:write + with: { key: greeting, value: hello } + - id: read + uses: action:read + needs: [write] + with: { key: greeting } diff --git a/examples/v1/mcp.yaml b/examples/v1/mcp.yaml new file mode 100644 index 0000000..8489eeb --- /dev/null +++ b/examples/v1/mcp.yaml @@ -0,0 +1,23 @@ +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: + name: mcp-tool +spec: + policy: + networkAllowlist: [127.0.0.1] + approval: never + mcpServers: + local: + url: http://127.0.0.1:8765/mcp + protocolVersion: 2025-11-25 + timeoutSeconds: 5 + actions: + call: + kind: mcp.call + tasks: + - id: call + uses: action:call + with: + server: local + tool: echo + arguments: { text: hello } diff --git a/examples/v1/openai-live.yaml b/examples/v1/openai-live.yaml new file mode 100644 index 0000000..afb3004 --- /dev/null +++ b/examples/v1/openai-live.yaml @@ -0,0 +1,25 @@ +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: + name: openai-live +spec: + providers: + openai: + kind: openai + credential: { env: OPENAI_API_KEY } + policy: + networkAllowlist: [api.openai.com] + agents: + answer: + provider: openai + model: gpt-5.6 + instructions: Reply with exactly one short sentence. + maxTurns: 1 + maxToolCalls: 0 + maxOutputTokens: 64 + timeoutSeconds: 30 + reasoning: { effort: low } + tasks: + - id: answer + uses: agent:answer + with: { prompt: Say hello. } diff --git a/examples/v1/policy-denial.yaml b/examples/v1/policy-denial.yaml new file mode 100644 index 0000000..ec894d3 --- /dev/null +++ b/examples/v1/policy-denial.yaml @@ -0,0 +1,17 @@ +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: + name: policy-denial +spec: + policy: + workspaceRoot: . + writableRoots: [artifacts] + toolsDeny: [filesystem.write] + approval: never + actions: + denied: + kind: builtin.write + tasks: + - id: denied + uses: action:denied + with: { path: artifacts/denied.txt, content: must-not-exist } diff --git a/examples/v1/reusable-pack.yaml b/examples/v1/reusable-pack.yaml new file mode 100644 index 0000000..9d72920 --- /dev/null +++ b/examples/v1/reusable-pack.yaml @@ -0,0 +1,15 @@ +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: + name: reusable-pack +spec: + packs: + - name: example.utility + version: 1.0.0 + path: example.pack.yaml + integrity: sha256:1996dafe44c3b1ceec5f3afe39eae566e35155eec298f148377c5b85ee964ea5 + tasks: + - id: packed + uses: action:example.utility.assign + with: + source: verified-pack diff --git a/examples/v1/secret-reference.yaml b/examples/v1/secret-reference.yaml new file mode 100644 index 0000000..4b39f36 --- /dev/null +++ b/examples/v1/secret-reference.yaml @@ -0,0 +1,23 @@ +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: + name: secret-reference +spec: + providers: + openai: + kind: openai + credential: + env: OPENAI_API_KEY + policy: + networkAllowlist: [api.openai.com] + agents: + answer: + provider: openai + model: gpt-5.6 + instructions: Reply concisely. + maxTurns: 1 + maxOutputTokens: 64 + tasks: + - id: answer + uses: agent:answer + with: { prompt: hello } diff --git a/examples/v1/working-memory.yaml b/examples/v1/working-memory.yaml new file mode 100644 index 0000000..8aadf63 --- /dev/null +++ b/examples/v1/working-memory.yaml @@ -0,0 +1,21 @@ +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: + name: working-memory +spec: + memory: + working: + counter: 0 + actions: + remember: + kind: builtin.memory.write + recall: + kind: builtin.memory.read + tasks: + - id: remember + uses: action:remember + with: { key: counter, value: 1 } + - id: recall + uses: action:recall + needs: [remember] + with: { key: counter } diff --git a/fixture/service.txt b/fixture/service.txt new file mode 100644 index 0000000..5aa8105 --- /dev/null +++ b/fixture/service.txt @@ -0,0 +1,3 @@ +service=agentctl-acceptance +status=ready +marker=READ_TOOL_CONFIRMED diff --git a/fixtures/compat/v0/assign.expected.json b/fixtures/compat/v0/assign.expected.json new file mode 100644 index 0000000..5d2d9dc --- /dev/null +++ b/fixtures/compat/v0/assign.expected.json @@ -0,0 +1,11 @@ +{ + "migratedLegacy": true, + "workflowName": "compatibility-assign", + "order": ["copy"], + "task": { + "id": "copy", + "useKind": "action", + "reference": "copy", + "needs": [] + } +} diff --git a/fixtures/compat/v0/assign.playbook.yaml b/fixtures/compat/v0/assign.playbook.yaml new file mode 100644 index 0000000..1fd7850 --- /dev/null +++ b/fixtures/compat/v0/assign.playbook.yaml @@ -0,0 +1,12 @@ +playbook: compatibility-assign +version: 0.1.0 +inputs: + message: hello +modules: + copy: + kind: builtin.assign +tasks: + - id: copy + uses: module:copy + with: + message: "${{ inputs.message }}" diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock new file mode 100644 index 0000000..11330ff --- /dev/null +++ b/fuzz/Cargo.lock @@ -0,0 +1,2302 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "agentctl-core" +version = "0.2.0" +dependencies = [ + "async-trait", + "chrono", + "hex", + "jsonschema", + "schemars", + "semver", + "serde", + "serde_json", + "serde_path_to_error", + "serde_yaml_ng", + "sha2", + "thiserror", + "tokio-util", + "url", +] + +[[package]] +name = "agentctl-fuzz" +version = "0.0.0" +dependencies = [ + "agentctl-core", + "agentctl-protocols", + "agentctl-store", + "libfuzzer-sys", + "serde_json", +] + +[[package]] +name = "agentctl-observability" +version = "0.2.0" +dependencies = [ + "agentctl-core", + "chrono", + "opentelemetry", + "serde", + "serde_json", +] + +[[package]] +name = "agentctl-protocols" +version = "0.2.0" +dependencies = [ + "agentctl-core", + "agentctl-runtime", + "async-trait", + "futures-util", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "url", +] + +[[package]] +name = "agentctl-runtime" +version = "0.2.0" +dependencies = [ + "agentctl-core", + "agentctl-observability", + "agentctl-store", + "async-trait", + "chrono", + "hex", + "serde", + "serde_json", + "sha2", + "thiserror", + "tokio", + "tokio-util", + "url", + "uuid", +] + +[[package]] +name = "agentctl-store" +version = "0.2.0" +dependencies = [ + "agentctl-core", + "chrono", + "hex", + "parking_lot", + "rusqlite", + "serde", + "serde_json", + "sha2", + "thiserror", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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 = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonschema" +version = "0.37.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73c9ffb2b5c56d58030e1b532d8e8389da94590515f118cf35b5cb68e4764a7e" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", +] + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "referencing" +version = "0.37.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4283168a506f0dcbdce31c9f9cce3129c924da4c6bca46e46707fcb746d2d70c" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[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 = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" +dependencies = [ + "bitflags", + "chrono", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[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.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[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.119", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[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", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[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.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[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-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +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 = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[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.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.119", + "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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..4d5ee1d --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "agentctl-fuzz" +version = "0.0.0" +publish = false +edition = "2024" +rust-version = "1.88" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +agentctl-core = { version = "0.2.0", path = "../crates/agentctl-core" } +agentctl-protocols = { version = "0.2.0", path = "../crates/agentctl-protocols" } +agentctl-store = { version = "0.2.0", path = "../crates/agentctl-store" } +libfuzzer-sys = "0.4" +serde_json = "1" + +[[bin]] +name = "workflow_yaml" +path = "fuzz_targets/workflow_yaml.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "provider_response" +path = "fuzz_targets/provider_response.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "template" +path = "fuzz_targets/template.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "protocol_response" +path = "fuzz_targets/protocol_response.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "persisted_state" +path = "fuzz_targets/persisted_state.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "tool_schema_input" +path = "fuzz_targets/tool_schema_input.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/persisted_state.rs b/fuzz/fuzz_targets/persisted_state.rs new file mode 100644 index 0000000..5c5f518 --- /dev/null +++ b/fuzz/fuzz_targets/persisted_state.rs @@ -0,0 +1,11 @@ +#![no_main] + +use agentctl_core::effect::EffectRecord; +use agentctl_store::{RunRecord, TaskRecord}; +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + let _ = serde_json::from_slice::(data); + let _ = serde_json::from_slice::(data); + let _ = serde_json::from_slice::(data); +}); diff --git a/fuzz/fuzz_targets/protocol_response.rs b/fuzz/fuzz_targets/protocol_response.rs new file mode 100644 index 0000000..0a98275 --- /dev/null +++ b/fuzz/fuzz_targets/protocol_response.rs @@ -0,0 +1,9 @@ +#![no_main] + +use agentctl_protocols::{AgentCard, McpTool}; +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + let _ = serde_json::from_slice::(data); + let _ = serde_json::from_slice::(data); +}); diff --git a/fuzz/fuzz_targets/provider_response.rs b/fuzz/fuzz_targets/provider_response.rs new file mode 100644 index 0000000..9d948e5 --- /dev/null +++ b/fuzz/fuzz_targets/provider_response.rs @@ -0,0 +1,8 @@ +#![no_main] + +use agentctl_core::provider::ProviderResponse; +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + let _ = serde_json::from_slice::(data); +}); diff --git a/fuzz/fuzz_targets/template.rs b/fuzz/fuzz_targets/template.rs new file mode 100644 index 0000000..722c9c7 --- /dev/null +++ b/fuzz/fuzz_targets/template.rs @@ -0,0 +1,12 @@ +#![no_main] + +use agentctl_core::template::{EvalContext, render, validate_expression}; +use libfuzzer_sys::fuzz_target; +use serde_json::Value; + +fuzz_target!(|data: &[u8]| { + if let Ok(template) = std::str::from_utf8(data) { + let _ = validate_expression(template); + let _ = render(&Value::String(template.to_owned()), &EvalContext::default()); + } +}); diff --git a/fuzz/fuzz_targets/tool_schema_input.rs b/fuzz/fuzz_targets/tool_schema_input.rs new file mode 100644 index 0000000..8320d2f --- /dev/null +++ b/fuzz/fuzz_targets/tool_schema_input.rs @@ -0,0 +1,14 @@ +#![no_main] + +use agentctl_core::tool::ToolContract; +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + let Ok(value) = serde_json::from_slice(data) else { + return; + }; + if let Ok(contract) = serde_json::from_value::(value) { + let _ = contract.validate_input(&serde_json::Value::Null); + let _ = contract.validate_output(&serde_json::Value::Null); + } +}); diff --git a/fuzz/fuzz_targets/workflow_yaml.rs b/fuzz/fuzz_targets/workflow_yaml.rs new file mode 100644 index 0000000..9ee189c --- /dev/null +++ b/fuzz/fuzz_targets/workflow_yaml.rs @@ -0,0 +1,10 @@ +#![no_main] + +use agentctl_core::parse_workflow; +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + if let Ok(source) = std::str::from_utf8(data) { + let _ = parse_workflow(source, "fuzz.yaml"); + } +}); diff --git a/package-lock.json b/package-lock.json index 71e9dc6..dba6215 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "agentctl", + "name": "agentctl-typescript-reference", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "agentctl", + "name": "agentctl-typescript-reference", "version": "0.1.0", "dependencies": { "@opentelemetry/api": "^1.9.0", @@ -15,9 +15,6 @@ "yaml": "^2.8.1", "zod": "^4.1.12" }, - "bin": { - "agentctl": "dist/cli.js" - }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.3.0", diff --git a/package.json b/package.json index 93fb710..d3425b6 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,12 @@ { - "name": "agentctl", + "name": "agentctl-typescript-reference", "version": "0.1.0", "private": true, "type": "module", - "description": "Declarative autonomous agent runtime with playbooks, packs, durable checkpoints, and replay.", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "bin": { - "agentctl": "./dist/cli.js" - }, - "files": [ - "dist", - "README.md" - ], + "description": "Archived TypeScript behavioral oracle; the production agentctl is Rust.", "scripts": { - "build": "node ./node_modules/typescript/bin/tsc -p tsconfig.json", - "clean": "rm -rf dist .runtime", - "docs:cli": "node ./node_modules/tsx/dist/cli.mjs scripts/generate-cli-docs.ts", - "test": "node ./node_modules/vitest/vitest.mjs --run", - "sample": "node ./node_modules/tsx/dist/cli.mjs src/cli.ts run examples/hello.playbook.yaml --db .runtime/sample.db", - "schema": "node ./node_modules/tsx/dist/cli.mjs src/cli.ts schema" + "legacy:build": "node ./node_modules/typescript/bin/tsc -p tsconfig.json", + "legacy:test": "node ./node_modules/vitest/vitest.mjs --run" }, "engines": { "node": ">=22.0.0" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..308350f --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.88.0" +components = ["clippy", "rustfmt"] +profile = "minimal" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..4937083 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,3 @@ +edition = "2024" +max_width = 100 +use_field_init_shorthand = true diff --git a/schemas/workflow.schema.json b/schemas/workflow.schema.json new file mode 100644 index 0000000..a7c349c --- /dev/null +++ b/schemas/workflow.schema.json @@ -0,0 +1,913 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Workflow", + "type": "object", + "properties": { + "apiVersion": { + "type": "string" + }, + "kind": { + "$ref": "#/$defs/WorkflowKind" + }, + "metadata": { + "$ref": "#/$defs/Metadata" + }, + "spec": { + "$ref": "#/$defs/WorkflowSpec" + } + }, + "additionalProperties": false, + "required": [ + "apiVersion", + "kind", + "metadata", + "spec" + ], + "$defs": { + "WorkflowKind": { + "type": "string", + "enum": [ + "Workflow" + ] + }, + "Metadata": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false, + "required": [ + "name" + ] + }, + "WorkflowSpec": { + "type": "object", + "properties": { + "inputs": { + "type": "object", + "additionalProperties": true, + "default": {} + }, + "outputs": { + "type": "object", + "additionalProperties": true, + "default": {} + }, + "providers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/ProviderDefinition" + }, + "default": {} + }, + "agents": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/AgentDefinition" + }, + "default": {} + }, + "actions": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/ActionDefinition" + }, + "default": {} + }, + "tools": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/ToolDefinition" + }, + "default": {} + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/$defs/TaskDefinition" + } + }, + "policy": { + "$ref": "#/$defs/PolicyDefinition", + "default": { + "workspaceRoot": ".", + "writableRoots": [], + "environmentAllowlist": [], + "networkAllowlist": [], + "processAllowlist": [], + "providers": [], + "toolsAllow": [], + "toolsDeny": [], + "approval": "mutations", + "nonInteractive": "pause" + } + }, + "memory": { + "$ref": "#/$defs/MemoryDefinition", + "default": { + "working": {} + } + }, + "mcpServers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/McpServerDefinition" + }, + "default": {} + }, + "a2aPeers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/A2aPeerDefinition" + }, + "default": {} + }, + "packs": { + "type": "array", + "items": { + "$ref": "#/$defs/PackReference" + }, + "default": [] + }, + "runtime": { + "$ref": "#/$defs/RuntimeDefinition", + "default": { + "maxConcurrency": 1, + "defaultTimeoutSeconds": 120 + } + }, + "output": { + "$ref": "#/$defs/OutputDefinition", + "default": { + "verbose": false, + "showDiff": true + } + } + }, + "additionalProperties": false, + "required": [ + "tasks" + ] + }, + "ProviderDefinition": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/$defs/ProviderKind" + }, + "endpoint": { + "type": [ + "string", + "null" + ] + }, + "credential": { + "anyOf": [ + { + "$ref": "#/$defs/SecretReference" + }, + { + "type": "null" + } + ] + }, + "apiVersion": { + "type": [ + "string", + "null" + ] + }, + "headers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/SecretReference" + }, + "default": {} + } + }, + "additionalProperties": false, + "required": [ + "kind" + ] + }, + "ProviderKind": { + "type": "string", + "enum": [ + "fake", + "openai", + "anthropic", + "google", + "azure_openai" + ] + }, + "SecretReference": { + "type": "object", + "properties": { + "env": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "env" + ] + }, + "AgentDefinition": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "model": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "instructions": { + "type": [ + "string", + "null" + ] + }, + "instructionsFile": { + "type": [ + "string", + "null" + ] + }, + "vars": { + "type": "object", + "additionalProperties": true, + "default": {} + }, + "tools": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "maxTurns": { + "type": "integer", + "format": "uint16", + "minimum": 0, + "maximum": 65535, + "default": 8 + }, + "maxToolCalls": { + "type": "integer", + "format": "uint16", + "minimum": 0, + "maximum": 65535, + "default": 16 + }, + "maxOutputTokens": { + "type": "integer", + "format": "uint32", + "minimum": 0, + "default": 2048 + }, + "timeoutSeconds": { + "type": "integer", + "format": "uint64", + "minimum": 0, + "default": 120 + }, + "retry": { + "$ref": "#/$defs/RetryDefinition", + "default": { + "maxAttempts": 1, + "backoffMs": 0 + } + }, + "reasoning": { + "anyOf": [ + { + "$ref": "#/$defs/ReasoningDefinition" + }, + { + "type": "null" + } + ] + }, + "structuredOutput": true, + "usageLimit": { + "anyOf": [ + { + "$ref": "#/$defs/UsageLimitDefinition" + }, + { + "type": "null" + } + ] + }, + "providerOptions": { + "type": "object", + "additionalProperties": true, + "default": {} + } + }, + "additionalProperties": false, + "required": [ + "provider", + "model" + ] + }, + "RetryDefinition": { + "type": "object", + "properties": { + "maxAttempts": { + "type": "integer", + "format": "uint16", + "minimum": 0, + "maximum": 65535, + "default": 1 + }, + "backoffMs": { + "type": "integer", + "format": "uint64", + "minimum": 0, + "default": 0 + } + }, + "additionalProperties": false + }, + "ReasoningDefinition": { + "type": "object", + "properties": { + "effort": { + "$ref": "#/$defs/ReasoningEffort" + }, + "mode": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false, + "required": [ + "effort" + ] + }, + "ReasoningEffort": { + "type": "string", + "enum": [ + "none", + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + "UsageLimitDefinition": { + "type": "object", + "properties": { + "maxInputTokens": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "maxOutputTokens": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "maxCostUsd": { + "type": [ + "number", + "null" + ], + "format": "double" + } + }, + "additionalProperties": false + }, + "ActionDefinition": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/$defs/ActionKind" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "with": { + "type": "object", + "additionalProperties": true, + "default": {} + }, + "command": { + "type": [ + "string", + "null" + ] + }, + "args": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "cwd": { + "type": [ + "string", + "null" + ] + }, + "env": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/SecretReference" + }, + "default": {} + }, + "timeoutSeconds": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0, + "default": null + } + }, + "additionalProperties": false, + "required": [ + "kind" + ] + }, + "ActionKind": { + "type": "string", + "enum": [ + "builtin.assign", + "builtin.assert", + "builtin.read", + "builtin.write", + "builtin.shell.exec", + "builtin.memory.read", + "builtin.memory.write", + "builtin.long_term_memory.read", + "builtin.long_term_memory.write", + "mcp.call", + "a2a.delegate" + ] + }, + "ToolDefinition": { + "type": "object", + "properties": { + "kind": { + "$ref": "#/$defs/ToolKind" + }, + "description": { + "type": "string" + }, + "inputSchema": true, + "outputSchema": true, + "capability": { + "type": "string" + }, + "risk": { + "$ref": "#/$defs/Risk" + }, + "effectClass": { + "$ref": "#/$defs/EffectClass" + }, + "idempotency": { + "$ref": "#/$defs/Idempotency" + }, + "retrySafe": { + "type": "boolean" + }, + "timeoutSeconds": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "secrets": { + "type": "array", + "items": { + "$ref": "#/$defs/SecretReference" + }, + "default": [] + }, + "network": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "approval": { + "$ref": "#/$defs/ApprovalRequirement", + "default": "policy" + }, + "compensation": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false, + "required": [ + "kind", + "description", + "inputSchema", + "outputSchema", + "capability", + "risk", + "effectClass", + "idempotency", + "retrySafe", + "timeoutSeconds" + ] + }, + "ToolKind": { + "type": "string", + "enum": [ + "builtin.workspace.read", + "builtin.workspace.write", + "builtin.echo" + ] + }, + "Risk": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical" + ] + }, + "EffectClass": { + "type": "string", + "enum": [ + "pure", + "internal_state", + "observe", + "workspace_mutate", + "external_mutate", + "process_execution", + "network", + "model", + "remote_agent" + ] + }, + "Idempotency": { + "type": "string", + "enum": [ + "pure", + "idempotent", + "keyed", + "at_most_once", + "unknown" + ] + }, + "ApprovalRequirement": { + "type": "string", + "enum": [ + "policy", + "never", + "always" + ] + }, + "TaskDefinition": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "uses": { + "type": "string" + }, + "needs": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "when": { + "type": [ + "string", + "null" + ], + "default": null + }, + "vars": { + "type": "object", + "additionalProperties": true, + "default": {} + }, + "with": { + "type": "object", + "additionalProperties": true, + "default": {} + }, + "retry": { + "$ref": "#/$defs/RetryDefinition", + "default": { + "maxAttempts": 1, + "backoffMs": 0 + } + }, + "timeoutSeconds": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0, + "default": null + }, + "failure": { + "$ref": "#/$defs/FailureBehavior", + "default": "stop" + } + }, + "additionalProperties": false, + "required": [ + "id", + "uses" + ] + }, + "FailureBehavior": { + "type": "string", + "enum": [ + "stop", + "continue" + ] + }, + "PolicyDefinition": { + "type": "object", + "properties": { + "workspaceRoot": { + "type": "string", + "default": "." + }, + "writableRoots": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "environmentAllowlist": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "networkAllowlist": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "processAllowlist": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "providers": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "toolsAllow": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "toolsDeny": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "approval": { + "$ref": "#/$defs/ApprovalMode", + "default": "mutations" + }, + "nonInteractive": { + "$ref": "#/$defs/NonInteractiveMode", + "default": "pause" + } + }, + "additionalProperties": false + }, + "ApprovalMode": { + "type": "string", + "enum": [ + "never", + "mutations", + "high_risk", + "always" + ] + }, + "NonInteractiveMode": { + "type": "string", + "enum": [ + "pause", + "deny_approval", + "fail" + ] + }, + "MemoryDefinition": { + "type": "object", + "properties": { + "working": { + "type": "object", + "additionalProperties": true, + "default": {} + }, + "longTerm": { + "anyOf": [ + { + "$ref": "#/$defs/LongTermMemoryDefinition" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "LongTermMemoryDefinition": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "default": "sqlite" + }, + "namespace": { + "type": "string", + "default": "default" + }, + "retentionDays": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "McpServerDefinition": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/SecretReference" + }, + "default": {} + }, + "timeoutSeconds": { + "type": "integer", + "format": "uint64", + "minimum": 0, + "default": 120 + }, + "protocolVersion": { + "type": "string", + "default": "2025-11-25" + } + }, + "additionalProperties": false, + "required": [ + "url" + ] + }, + "A2aPeerDefinition": { + "type": "object", + "properties": { + "cardUrl": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/SecretReference" + }, + "default": {} + }, + "timeoutSeconds": { + "type": "integer", + "format": "uint64", + "minimum": 0, + "default": 120 + }, + "protocolVersion": { + "type": "string", + "default": "1.0" + } + }, + "additionalProperties": false, + "required": [ + "cardUrl" + ] + }, + "PackReference": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "path": { + "type": "string" + }, + "integrity": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "name", + "version", + "path", + "integrity" + ] + }, + "RuntimeDefinition": { + "type": "object", + "properties": { + "maxConcurrency": { + "type": "integer", + "format": "uint", + "minimum": 0, + "default": 1 + }, + "defaultTimeoutSeconds": { + "type": "integer", + "format": "uint64", + "minimum": 0, + "default": 120 + } + }, + "additionalProperties": false + }, + "OutputDefinition": { + "type": "object", + "properties": { + "verbose": { + "type": "boolean", + "default": false + }, + "showDiff": { + "type": "boolean", + "default": false + } + }, + "additionalProperties": false + } + } +} diff --git a/workflow.yaml b/workflow.yaml new file mode 100644 index 0000000..ff6d5d4 --- /dev/null +++ b/workflow.yaml @@ -0,0 +1,78 @@ +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: + name: acceptance-mock-tool +spec: + inputs: + reportPath: artifacts/mock-report.txt + outputs: + verdict: "${{ tasks.inspect.output.text }}" + artifact: "${{ inputs.reportPath }}" + providers: + fake: + kind: fake + policy: + workspaceRoot: . + writableRoots: [artifacts] + approval: never + tools: + read_fixture: + kind: builtin.workspace.read + description: Read a UTF-8 file inside the authorized workspace. + inputSchema: + type: object + properties: + path: { type: string } + required: [path] + additionalProperties: false + outputSchema: + type: object + properties: + path: { type: string } + content: { type: string } + bytes: { type: integer } + sha256: { type: string } + required: [path, content, bytes, sha256] + additionalProperties: false + capability: filesystem.read + risk: low + effectClass: observe + idempotency: idempotent + retrySafe: true + timeoutSeconds: 5 + approval: never + agents: + inspector: + provider: fake + model: scripted + instructions: Read the fixture and report its marker. + tools: [read_fixture] + maxTurns: 2 + maxToolCalls: 1 + maxOutputTokens: 32 + timeoutSeconds: 5 + providerOptions: + toolInput: { path: fixture/service.txt } + finalText: AGENTCTL_MOCK_FIXTURE_VERIFIED + actions: + assert: + kind: builtin.assert + write: + kind: builtin.write + tasks: + - id: inspect + uses: agent:inspector + with: + prompt: Use read_fixture before answering. + - id: verify + uses: action:assert + needs: [inspect] + with: + that: "${{ tasks.inspect.output.text == 'AGENTCTL_MOCK_FIXTURE_VERIFIED' }}" + message: the mock provider did not complete its tool continuation + - id: report + uses: action:write + needs: [inspect, verify] + with: + path: "${{ inputs.reportPath }}" + content: "${{ tasks.inspect.output.text }}" diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..be3838e --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "xtask" +description = "Repository verification tasks for agentctl" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +anyhow.workspace = true +hex.workspace = true +serde_json.workspace = true +sha2.workspace = true +tempfile.workspace = true + +[lints] +workspace = true diff --git a/xtask/src/acceptance.rs b/xtask/src/acceptance.rs new file mode 100644 index 0000000..0dea3de --- /dev/null +++ b/xtask/src/acceptance.rs @@ -0,0 +1,1878 @@ +use std::env; +use std::ffi::OsStr; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::thread; +use std::time::Duration; + +use anyhow::{Context, Result, bail, ensure}; +use serde_json::Value; + +const VERIFY_TOKEN: &str = "AGENTCTL_MOCK_FIXTURE_VERIFIED"; +const LIVE_VERIFY_TOKEN: &str = "AGENTCTL_LIVE_FIXTURE_VERIFIED"; + +pub fn run(root: &Path) -> Result<()> { + command(root, "cargo", &["build", "-p", "agentctl", "--locked"])?; + let binary = debug_binary(root); + let directory = tempfile::tempdir()?; + let workspace = directory.path().join("workspace"); + fs::create_dir_all(workspace.join("artifacts"))?; + + scenario(1, "deterministic check, plan, and run"); + let hello = root.join("examples/v1/hello.yaml"); + successful_json( + &binary, + root, + &strings([ + "check", + path(&hello)?, + "--output", + "json", + "--color", + "never", + ]), + )?; + let plan = successful_json( + &binary, + root, + &strings([ + "plan", + path(&hello)?, + "--output", + "json", + "--color", + "never", + ]), + )?; + ensure!( + plan.pointer("/data/planDigest") + .and_then(Value::as_str) + .is_some() + ); + let hello_db = directory.path().join("hello.db"); + let hello_run = successful_json(&binary, root, &run_args(&hello, &hello_db, root, &[]))?; + ensure_eq(&hello_run, "/data/output/greeting", "hello, world")?; + + scenario(2, "invalid YAML includes structured location diagnostics"); + let invalid = workspace.join("invalid.yaml"); + write(&invalid, "apiVersion: [\n")?; + let invalid_result = json_with_code( + &binary, + &workspace, + &strings([ + "check", + path(&invalid)?, + "--output", + "json", + "--color", + "never", + ]), + 2, + )?; + ensure!( + invalid_result["diagnostics"] + .as_array() + .is_some_and(|items| !items.is_empty()) + ); + + scenario( + 3, + "missing references and capability violations fail compilation", + ); + let missing = workspace.join("missing-reference.yaml"); + write( + &missing, + &deterministic_workflow("missing-reference", "action:not_declared", "{}"), + )?; + expect_code( + &binary, + &workspace, + &strings(["check", path(&missing)?, "--output", "json"]), + 2, + )?; + expect_code( + &binary, + root, + &strings([ + "check", + path(&root.join("examples/v1/capability-failure.yaml"))?, + "--output", + "json", + ]), + 2, + )?; + + scenario( + 4, + "unsupported streaming and programmatic tool options fail explicitly", + ); + for (name, option) in [ + ("stream", "stream: true"), + ("ptc", "programmaticToolCalling: true"), + ] { + let fixture = workspace.join(format!("unsupported-{name}.yaml")); + write(&fixture, &agent_workflow("unsupported", option, "", ""))?; + expect_code( + &binary, + &workspace, + &strings(["check", path(&fixture)?, "--output", "json"]), + 2, + )?; + } + let stateless_tools = workspace.join("openai-stateless-tools.yaml"); + write(&stateless_tools, OPENAI_STATELESS_TOOL_WORKFLOW)?; + expect_code( + &binary, + &workspace, + &strings(["check", path(&stateless_tools)?, "--output", "json"]), + 2, + )?; + + scenario( + 5, + "fake provider completion is executable and deterministic", + ); + let fake_db = directory.path().join("fake.db"); + let fake = root.join("examples/v1/fake-provider.yaml"); + successful_json(&binary, root, &run_args(&fake, &fake_db, root, &[]))?; + + scenario( + 6, + "fake provider tool call, continuation, schemas, traces, and artifact", + ); + let mock_workspace = directory.path().join("mock-tool"); + copy_example(root, "examples/acceptance/mock-tool", &mock_workspace)?; + let mock_db = mock_workspace.join("runtime.db"); + let mock_run = successful_json( + &binary, + &mock_workspace, + &run_args( + &mock_workspace.join("workflow.yaml"), + &mock_db, + &mock_workspace, + &[], + ), + )?; + ensure_eq(&mock_run, "/data/output/verdict", VERIFY_TOKEN)?; + ensure!(fs::read_to_string(mock_workspace.join("artifacts/mock-report.txt"))? == VERIFY_TOKEN); + let mock_id = string_at(&mock_run, "/data/runId")?; + let mock_inspect = inspect(&binary, &mock_workspace, &mock_db, mock_id)?; + ensure!(array_len(&mock_inspect, "/data/effects")? >= 3); + ensure!(array_len(&mock_inspect, "/data/toolCalls")? == 1); + ensure!(array_len(&mock_inspect, "/data/providerSessions")? == 1); + ensure!(array_len(&mock_inspect, "/data/checkpoints")? > 0); + ensure!(array_len(&mock_inspect, "/data/audit")? > 0); + ensure!(array_len(&mock_inspect, "/data/traces")? > 0); + ensure!( + mock_inspect + .pointer("/data/toolCalls/0/callId") + .and_then(Value::as_str) + != mock_inspect + .pointer("/data/toolCalls/0/effectId") + .and_then(Value::as_str) + ); + + scenario( + 7, + "tool input and output schema failures retain run and trace metadata", + ); + let bad_input = workspace.join("bad-tool-input.yaml"); + write( + &bad_input, + &agent_workflow( + "bad-input", + "toolInput: { text: 7 }", + echo_tool(), + "finalText: never", + ), + )?; + let bad_input_error = run_error( + &binary, + &workspace, + &bad_input, + directory.path(), + "bad-input.db", + )?; + assert_error_metadata(&bad_input_error)?; + let bad_output = workspace.join("bad-tool-output.yaml"); + write( + &bad_output, + &agent_workflow( + "bad-output", + "toolInput: { text: hello }", + mismatched_echo_tool(), + "finalText: never", + ), + )?; + let bad_output_error = run_error( + &binary, + &workspace, + &bad_output, + directory.path(), + "bad-output.db", + )?; + assert_error_metadata(&bad_output_error)?; + let invalid_utf8 = workspace.join("fixture-invalid.bin"); + fs::write(&invalid_utf8, [0xff, 0xfe])?; + let invalid_utf8_workflow = workspace.join("invalid-utf8-tool.yaml"); + write( + &invalid_utf8_workflow, + &read_tool_workflow("invalid-utf8", "fixture-invalid.bin"), + )?; + assert_error_metadata(&run_error( + &binary, + &workspace, + &invalid_utf8_workflow, + directory.path(), + "invalid-utf8.db", + )?)?; + let oversized = workspace.join("fixture-oversized.txt"); + fs::write(&oversized, vec![b'x'; 1_048_577])?; + let oversized_workflow = workspace.join("oversized-tool.yaml"); + write( + &oversized_workflow, + &read_tool_workflow("oversized", "fixture-oversized.txt"), + )?; + assert_error_metadata(&run_error( + &binary, + &workspace, + &oversized_workflow, + directory.path(), + "oversized.db", + )?)?; + + scenario(8, "policy denial prevents filesystem mutation"); + let denied_db = directory.path().join("denied.db"); + expect_code( + &binary, + root, + &run_args( + &root.join("examples/v1/policy-denial.yaml"), + &denied_db, + root, + &[], + ), + 4, + )?; + ensure!(!root.join("examples/v1/artifacts/denied.txt").exists()); + + scenario(9, "non-TTY approval pauses durably with exit 3"); + let approval_workflow = workspace.join("approval.yaml"); + write(&approval_workflow, APPROVAL_WORKFLOW)?; + let approval_db = directory.path().join("approval.db"); + let paused = json_with_code( + &binary, + &workspace, + &run_args(&approval_workflow, &approval_db, &workspace, &[]), + 3, + )?; + ensure_eq(&paused, "/data/state", "paused")?; + let paused_id = string_at(&paused, "/data/runId")?; + let list = approvals(&binary, &workspace, &approval_db, paused_id)?; + ensure!(array_len(&list, "/data")? == 1); + let approval_id = string_at(&list, "/data/0/approvalId")?; + + scenario( + 10, + "approve then resume executes the confirmed effect exactly once", + ); + successful_json( + &binary, + &workspace, + &strings([ + "approvals", + "--db", + path(&approval_db)?, + "approve", + approval_id, + "--reason", + "acceptance approved", + "--output", + "json", + ]), + )?; + let resumed = successful_json( + &binary, + &workspace, + &strings([ + "resume", + paused_id, + "--db", + path(&approval_db)?, + "--output", + "json", + "--color", + "never", + ]), + )?; + ensure_eq(&resumed, "/data/state", "succeeded")?; + ensure!(fs::read_to_string(workspace.join("artifacts/approved.txt"))? == "approved"); + + scenario(11, "rejection blocks resume and leaves no artifact"); + fs::remove_file(workspace.join("artifacts/approved.txt"))?; + let reject_db = directory.path().join("reject.db"); + let rejected_run = json_with_code( + &binary, + &workspace, + &run_args(&approval_workflow, &reject_db, &workspace, &[]), + 3, + )?; + let rejected_id = string_at(&rejected_run, "/data/runId")?; + let rejected_list = approvals(&binary, &workspace, &reject_db, rejected_id)?; + let rejected_approval = string_at(&rejected_list, "/data/0/approvalId")?; + successful_json( + &binary, + &workspace, + &strings([ + "approvals", + "--db", + path(&reject_db)?, + "reject", + rejected_approval, + "--reason", + "acceptance rejected", + "--output", + "json", + ]), + )?; + let rejected = json_with_code( + &binary, + &workspace, + &strings([ + "resume", + rejected_id, + "--db", + path(&reject_db)?, + "--output", + "json", + ]), + 4, + )?; + ensure_eq(&rejected, "/data/state", "failed")?; + ensure_eq(&rejected, "/data/runId", rejected_id)?; + ensure!(!workspace.join("artifacts/approved.txt").exists()); + + scenario( + 12, + "completed provider effects are not repeated across approval resume", + ); + let confirmed_workflow = workspace.join("confirmed-before-approval.yaml"); + write(&confirmed_workflow, CONFIRMED_BEFORE_APPROVAL_WORKFLOW)?; + let confirmed_db = directory.path().join("confirmed.db"); + let confirmed_paused = json_with_code( + &binary, + &workspace, + &run_args(&confirmed_workflow, &confirmed_db, &workspace, &[]), + 3, + )?; + let confirmed_id = string_at(&confirmed_paused, "/data/runId")?; + let before = inspect(&binary, &workspace, &confirmed_db, confirmed_id)?; + ensure!(model_effects(&before) == 1); + let confirmed_list = approvals(&binary, &workspace, &confirmed_db, confirmed_id)?; + let confirmed_approval = string_at(&confirmed_list, "/data/0/approvalId")?; + successful_json( + &binary, + &workspace, + &strings([ + "approvals", + "--db", + path(&confirmed_db)?, + "approve", + confirmed_approval, + "--reason", + "continue", + "--output", + "json", + ]), + )?; + successful_json( + &binary, + &workspace, + &strings([ + "resume", + confirmed_id, + "--db", + path(&confirmed_db)?, + "--output", + "json", + ]), + )?; + let after = inspect(&binary, &workspace, &confirmed_db, confirmed_id)?; + ensure!(model_effects(&after) == 1); + + scenario(13, "recorded replay is keyless and creates no effects"); + let replay = json_with_removed_env( + &binary, + &mock_workspace, + &strings([ + "replay", + mock_id, + "--db", + path(&mock_db)?, + "--output", + "json", + ]), + "OPENAI_API_KEY", + 0, + )?; + let replay_id = string_at(&replay, "/data/runId")?; + ensure!(replay_id != mock_id); + let replay_inspect = inspect(&binary, &mock_workspace, &mock_db, replay_id)?; + ensure!(array_len(&replay_inspect, "/data/effects")? == 0); + + scenario(14, "fork creates a distinct run with fresh effects"); + let fork = successful_json( + &binary, + &mock_workspace, + &strings(["fork", mock_id, "--db", path(&mock_db)?, "--output", "json"]), + )?; + let fork_id = string_at(&fork, "/data/runId")?; + ensure!(fork_id != mock_id); + let fork_inspect = inspect(&binary, &mock_workspace, &mock_db, fork_id)?; + ensure!(array_len(&fork_inspect, "/data/effects")? >= 3); + + scenario( + 15, + "provider timeout is bounded and ambiguous effects block resume", + ); + let timeout_workflow = workspace.join("timeout.yaml"); + write( + &timeout_workflow, + &agent_workflow("timeout", "delayMs: 2500", "", ""), + )?; + let timeout_db = directory.path().join("timeout.db"); + let timeout_error = json_with_code( + &binary, + &workspace, + &run_args(&timeout_workflow, &timeout_db, &workspace, &[]), + 4, + )?; + assert_error_metadata(&timeout_error)?; + let timeout_id = string_at(&timeout_error, "/error/runId")?; + let timeout_inspect = inspect(&binary, &workspace, &timeout_db, timeout_id)?; + ensure_eq(&timeout_inspect, "/data/effects/0/status", "uncertain")?; + let resume_error = json_with_code( + &binary, + &workspace, + &strings([ + "resume", + timeout_id, + "--db", + path(&timeout_db)?, + "--output", + "json", + ]), + 3, + )?; + ensure_eq(&resume_error, "/error/runId", timeout_id)?; + ensure!( + resume_error + .pointer("/error/traceId") + .and_then(Value::as_str) + .is_some() + ); + + scenario( + 16, + "explicit retry recovers a definitive transient provider failure", + ); + let retry_workflow = workspace.join("retry.yaml"); + write(&retry_workflow, RETRY_WORKFLOW)?; + let retry_db = directory.path().join("retry.db"); + let retry = successful_json( + &binary, + &workspace, + &run_args(&retry_workflow, &retry_db, &workspace, &[]), + )?; + let retry_id = string_at(&retry, "/data/runId")?; + let retry_inspect = inspect(&binary, &workspace, &retry_db, retry_id)?; + ensure!(model_effects(&retry_inspect) == 2); + + scenario( + 17, + "missing credential fails before a database or run is created", + ); + let auth_workflow = workspace.join("missing-auth.yaml"); + write(&auth_workflow, OPENAI_AUTH_WORKFLOW)?; + let auth_db = directory.path().join("missing-auth.db"); + let auth = json_with_removed_env( + &binary, + &workspace, + &run_args(&auth_workflow, &auth_db, &workspace, &[]), + "OPENAI_API_KEY", + 6, + )?; + ensure_eq(&auth, "/error/exitCode", 6_u64)?; + ensure!(!auth_db.exists()); + + scenario( + 18, + "JSON and human output contracts are stable and color-safe", + ); + let version = successful_json( + &binary, + root, + &strings(["version", "--output", "json", "--color", "never"]), + )?; + ensure_eq(&version, "/apiVersion", "agentctl.dev/cli/v1")?; + for arguments in [ + strings(["unknown", "--output", "json"]), + strings(["run", "--output", "json"]), + strings(["--output", "json", "--color", "invalid", "version"]), + ] { + let error = json_with_code(&binary, root, &arguments, 2)?; + ensure_eq(&error, "/apiVersion", "agentctl.dev/cli/v1")?; + ensure_eq(&error, "/error/exitCode", 2_u64)?; + } + let human = output_with_code( + command_for( + &binary, + root, + &strings(["version", "--output", "human", "--color", "never"]), + ), + 0, + "human output", + )?; + ensure!(!human.stdout.contains(&27)); + + scenario( + 19, + "input files and repeated KEY=VALUE overrides compose predictably", + ); + let inputs_workflow = workspace.join("inputs.yaml"); + write(&inputs_workflow, INPUTS_WORKFLOW)?; + let inputs_file = workspace.join("inputs.json"); + write(&inputs_file, r#"{"name":"file","count":2}"#)?; + let inputs_db = directory.path().join("inputs.db"); + let input_run = successful_json( + &binary, + &workspace, + &run_args( + &inputs_workflow, + &inputs_db, + &workspace, + &[ + "--inputs-file".to_owned(), + path(&inputs_file)?.to_owned(), + "--input".to_owned(), + "count=3".to_owned(), + ], + ), + )?; + ensure_eq(&input_run, "/data/output/name", "file")?; + ensure_eq(&input_run, "/data/output/count", 3_u64)?; + + scenario( + 20, + "artifact traversal is rejected without writing outside the workspace", + ); + let traversal = workspace.join("traversal.yaml"); + write(&traversal, TRAVERSAL_WORKFLOW)?; + let traversal_db = directory.path().join("traversal.db"); + expect_code( + &binary, + &workspace, + &run_args(&traversal, &traversal_db, &workspace, &[]), + 4, + )?; + ensure!(!directory.path().join("escaped.txt").exists()); + read_only_write_acceptance(&binary, directory.path())?; + + scenario(21, "concurrent runs share one SQLite database safely"); + let concurrent_db = directory.path().join("concurrent.db"); + successful_json( + &binary, + root, + &strings([ + "db", + "--db", + path(&concurrent_db)?, + "migrate", + "--output", + "json", + ]), + )?; + let arguments = run_args(&hello, &concurrent_db, root, &[]); + let mut first_command = command_for(&binary, root, &arguments); + first_command.stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut second_command = command_for(&binary, root, &arguments); + second_command.stdout(Stdio::piped()).stderr(Stdio::piped()); + let first = first_command.spawn()?; + let second = second_command.spawn()?; + ensure!(first.wait_with_output()?.status.success()); + ensure!(second.wait_with_output()?.status.success()); + + scenario(22, "SIGTERM produces a durable cancelled run"); + signal_acceptance(&binary, &workspace, directory.path())?; + + scenario( + 23, + "copied packaged-style binary works outside the repository", + ); + let isolated = directory.path().join("isolated"); + fs::create_dir_all(isolated.join("bin"))?; + let copied = isolated.join("bin/agentctl"); + fs::copy(&binary, &copied)?; + let help = output_with_code( + command_for(&copied, &isolated, &strings(["--help"])), + 0, + "isolated --help", + )?; + ensure!(!help.stdout.is_empty()); + successful_json( + &copied, + &isolated, + &strings(["version", "--output", "json", "--color", "never"]), + )?; + successful_json( + &copied, + &isolated, + &strings(["schema", "--output", "json", "--color", "never"]), + )?; + successful_json( + &copied, + &isolated, + &strings([ + "providers", + "inspect", + path(&mock_workspace.join("workflow.yaml"))?, + "--output", + "json", + "--color", + "never", + ]), + )?; + let completion = output_with_code( + command_for(&copied, &isolated, &strings(["completion", "zsh"])), + 0, + "isolated completion", + )?; + ensure!(!completion.stdout.is_empty()); + + scenario( + 24, + "cron-like empty environment and non-TTY execution succeeds", + ); + let cron_db = directory.path().join("cron.db"); + let mut cron = command_for(&copied, &isolated, &run_args(&hello, &cron_db, root, &[])); + cron.env_clear(); + output_with_code(cron, 0, "cron-like run")?; + + scenario( + 25, + "quickstart mock workflow succeeds from an isolated directory", + ); + let quickstart = isolated.join("quickstart"); + copy_example(root, "examples/acceptance/mock-tool", &quickstart)?; + let quickstart_db = quickstart.join("runtime.db"); + let quickstart_run = successful_json( + &copied, + &quickstart, + &run_args( + &quickstart.join("workflow.yaml"), + &quickstart_db, + &quickstart, + &[], + ), + )?; + ensure_eq(&quickstart_run, "/data/output/verdict", VERIFY_TOKEN)?; + + println!("agentctl credential-free acceptance passed (25 scenarios)"); + Ok(()) +} + +pub fn container(root: &Path) -> Result<()> { + let engine = container_engine()?; + ensure_engine_ready(&engine)?; + build_image(root, &engine)?; + let directory = tempfile::tempdir()?; + let layout = container_layout(directory.path(), false)?; + let run = run_container(&engine, &layout, false, None)?; + ensure_eq(&run, "/data/state", "succeeded")?; + ensure_eq(&run, "/data/output/verdict", VERIFY_TOKEN)?; + ensure!(fs::read_to_string(layout.artifacts.join("report.txt"))? == VERIFY_TOKEN); + let run_id = string_at(&run, "/data/runId")?; + let inspect = inspect_container(&engine, &layout, run_id)?; + ensure!(array_len(&inspect, "/data/toolCalls")? == 1); + ensure!(array_len(&inspect, "/data/traces")? > 0); + ensure!(array_len(&inspect, "/data/effects")? >= 3); + + let replay = replay_container(&engine, &layout, run_id)?; + let replay_id = string_at(&replay, "/data/runId")?; + ensure!(replay_id != run_id); + ensure!(replay.pointer("/data/output") == run.pointer("/data/output")); + let replay_inspect = inspect_container(&engine, &layout, replay_id)?; + ensure!(array_len(&replay_inspect, "/data/effects")? == 0); + ensure!(array_len(&replay_inspect, "/data/toolCalls")? == 0); + + let missing_directory = tempfile::tempdir()?; + let missing = container_layout(missing_directory.path(), false)?; + write(&missing.config.join("workflow.yaml"), OPENAI_AUTH_WORKFLOW)?; + let missing_output = run_container_with_code(&engine, &missing, 6, "missing-secret OCI run")?; + ensure_eq(&missing_output, "/error/exitCode", 6_u64)?; + ensure!(!missing.state.join("runtime.db").exists()); + + let invalid_directory = tempfile::tempdir()?; + let invalid = container_layout(invalid_directory.path(), false)?; + write(&invalid.config.join("workflow.yaml"), "apiVersion: [\n")?; + let invalid_output = run_container_with_code(&engine, &invalid, 2, "invalid OCI run")?; + ensure_eq(&invalid_output, "/error/exitCode", 2_u64)?; + ensure!(!invalid.state.join("runtime.db").exists()); + + container_signal_acceptance(&engine, directory.path())?; + println!( + "agentctl OCI acceptance passed: success, artifact, inspect, network-disabled replay, missing-secret, invalid-input, SIGTERM, non-root, read-only root, mounted state/artifacts" + ); + Ok(()) +} + +pub fn live_openai(root: &Path) -> Result<()> { + ensure!( + env::var_os("OPENAI_API_KEY").is_some(), + "OPENAI_API_KEY is required for the explicit live acceptance command" + ); + super::package(root)?; + let binary = packaged_binary(root)?; + let directory = tempfile::tempdir()?; + let workspace = directory.path().join("local-live"); + copy_example(root, "examples/openai-live", &workspace)?; + let workflow = workspace.join("workflow.yaml"); + let db = workspace.join("runtime.db"); + successful_json( + &binary, + &workspace, + &strings(["auth", "check", path(&workflow)?, "--output", "json"]), + )?; + successful_json( + &binary, + &workspace, + &strings(["plan", path(&workflow)?, "--output", "json"]), + )?; + let run = successful_json( + &binary, + &workspace, + &run_args(&workflow, &db, &workspace, &[]), + )?; + ensure_eq(&run, "/data/output/verdict", LIVE_VERIFY_TOKEN)?; + ensure!( + fs::read_to_string(workspace.join("artifacts/openai-live-report.txt"))? + == LIVE_VERIFY_TOKEN + ); + let run_id = string_at(&run, "/data/runId")?; + let live_evidence = inspect(&binary, &workspace, &db, run_id)?; + assert_live_evidence(&live_evidence)?; + assert_secret_absent(&live_evidence)?; + let replay = json_with_removed_env( + &binary, + &workspace, + &strings(["replay", run_id, "--db", path(&db)?, "--output", "json"]), + "OPENAI_API_KEY", + 0, + )?; + let replay_id = string_at(&replay, "/data/runId")?; + let replay_inspect = inspect(&binary, &workspace, &db, replay_id)?; + ensure!(array_len(&replay_inspect, "/data/effects")? == 0); + + let engine = container_engine()?; + ensure_engine_ready(&engine)?; + build_image(root, &engine)?; + let container_directory = tempfile::tempdir()?; + let layout = container_layout(container_directory.path(), true)?; + let container_run = run_container(&engine, &layout, true, Some("OPENAI_API_KEY"))?; + ensure_eq(&container_run, "/data/output/verdict", LIVE_VERIFY_TOKEN)?; + let container_run_id = string_at(&container_run, "/data/runId")?; + let container_inspect = inspect_container(&engine, &layout, container_run_id)?; + assert_live_evidence(&container_inspect)?; + assert_secret_absent(&container_inspect)?; + let container_replay = replay_container(&engine, &layout, container_run_id)?; + let container_replay_id = string_at(&container_replay, "/data/runId")?; + let replay_evidence = inspect_container(&engine, &layout, container_replay_id)?; + ensure!(array_len(&replay_evidence, "/data/effects")? == 0); + + let local_requests = model_effects(&live_evidence); + let container_requests = model_effects(&container_inspect); + let usage = usage_totals(&live_evidence).plus(usage_totals(&container_inspect)); + println!( + "live OpenAI acceptance passed: model=gpt-5.6 localRequests={local_requests} containerRequests={container_requests} inputTokens={} outputTokens={} reasoningTokens={} cacheReadTokens={} cacheWriteTokens={} toolCalls=verified continuations=verified keylessReplays=2", + usage.input, usage.output, usage.reasoning, usage.cache_read, usage.cache_write, + ); + Ok(()) +} + +fn run_error( + binary: &Path, + cwd: &Path, + workflow: &Path, + directory: &Path, + db_name: &str, +) -> Result { + json_with_code( + binary, + cwd, + &run_args(workflow, &directory.join(db_name), cwd, &[]), + 4, + ) +} + +fn signal_acceptance(binary: &Path, workspace: &Path, directory: &Path) -> Result<()> { + #[cfg(unix)] + { + let workflow = workspace.join("signal.yaml"); + write( + &workflow, + &agent_workflow("signal", "delayMs: 10000", "", ""), + )?; + let db = directory.join("signal.db"); + let args = run_args(&workflow, &db, workspace, &[]); + let mut command = command_for(binary, workspace, &args); + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + let child = command.spawn()?; + for _ in 0..50 { + if db.exists() { + break; + } + thread::sleep(Duration::from_millis(20)); + } + thread::sleep(Duration::from_millis(100)); + let status = Command::new("kill") + .args(["-TERM", &child.id().to_string()]) + .status()?; + ensure!(status.success(), "failed to deliver SIGTERM"); + let output = child.wait_with_output()?; + ensure!( + output.status.code() == Some(130), + "SIGTERM exit was not 130" + ); + let value = parse_output(&output)?; + ensure_eq(&value, "/data/state", "cancelled")?; + } + #[cfg(not(unix))] + println!("SIGTERM acceptance is not applicable on this platform"); + Ok(()) +} + +fn read_only_write_acceptance(binary: &Path, directory: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + + let workspace = directory.join("read-only-workspace"); + fs::create_dir_all(&workspace)?; + let workflow = workspace.join("workflow.yaml"); + write( + &workflow, + &TRAVERSAL_WORKFLOW + .replace("writableRoots: [artifacts]", "writableRoots: [.]") + .replace("../escaped.txt", "result.txt"), + )?; + fs::set_permissions(&workspace, fs::Permissions::from_mode(0o555))?; + let result = expect_code( + binary, + &workspace, + &run_args(&workflow, &directory.join("read-only.db"), &workspace, &[]), + 4, + ); + fs::set_permissions(&workspace, fs::Permissions::from_mode(0o755))?; + result?; + ensure!(!workspace.join("result.txt").exists()); + } + Ok(()) +} + +fn assert_live_evidence(value: &Value) -> Result<()> { + ensure!( + model_effects(value) == 2, + "expected exactly two model requests" + ); + ensure!(array_len(value, "/data/toolCalls")? == 1); + ensure!(array_len(value, "/data/providerSessions")? == 1); + ensure!(array_len(value, "/data/checkpoints")? > 0); + ensure!(array_len(value, "/data/traces")? > 0); + ensure_eq(value, "/data/toolCalls/0/status", "succeeded")?; + Ok(()) +} + +fn assert_secret_absent(value: &Value) -> Result<()> { + if let Ok(secret) = env::var("OPENAI_API_KEY") { + ensure!( + !value.to_string().contains(&secret), + "credential appeared in durable evidence" + ); + } + Ok(()) +} + +fn approvals(binary: &Path, cwd: &Path, db: &Path, run_id: &str) -> Result { + successful_json( + binary, + cwd, + &strings([ + "approvals", + "--db", + path(db)?, + "list", + run_id, + "--output", + "json", + "--color", + "never", + ]), + ) +} + +fn inspect(binary: &Path, cwd: &Path, db: &Path, run_id: &str) -> Result { + successful_json( + binary, + cwd, + &strings([ + "inspect", + run_id, + "--db", + path(db)?, + "--output", + "json", + "--color", + "never", + ]), + ) +} + +fn model_effects(value: &Value) -> usize { + value + .pointer("/data/effects") + .and_then(Value::as_array) + .map_or(0, |effects| { + effects + .iter() + .filter(|effect| { + effect.pointer("/request/effectClass") + == Some(&Value::String("model".to_owned())) + }) + .count() + }) +} + +#[derive(Debug, Default)] +struct UsageTotals { + input: u64, + output: u64, + reasoning: u64, + cache_read: u64, + cache_write: u64, +} + +impl UsageTotals { + const fn plus(self, other: Self) -> Self { + Self { + input: self.input.saturating_add(other.input), + output: self.output.saturating_add(other.output), + reasoning: self.reasoning.saturating_add(other.reasoning), + cache_read: self.cache_read.saturating_add(other.cache_read), + cache_write: self.cache_write.saturating_add(other.cache_write), + } + } +} + +fn usage_totals(value: &Value) -> UsageTotals { + let mut total = UsageTotals::default(); + let Some(tasks) = value.pointer("/data/tasks").and_then(Value::as_array) else { + return total; + }; + for usage in tasks + .iter() + .filter_map(|task| task.pointer("/output/usage")) + { + total.input = total + .input + .saturating_add(usage["inputTokens"].as_u64().unwrap_or(0)); + total.output = total + .output + .saturating_add(usage["outputTokens"].as_u64().unwrap_or(0)); + total.reasoning = total + .reasoning + .saturating_add(usage["reasoningTokens"].as_u64().unwrap_or(0)); + total.cache_read = total + .cache_read + .saturating_add(usage["cacheReadTokens"].as_u64().unwrap_or(0)); + total.cache_write = total + .cache_write + .saturating_add(usage["cacheWriteTokens"].as_u64().unwrap_or(0)); + } + total +} + +fn assert_error_metadata(value: &Value) -> Result<()> { + ensure!( + value + .pointer("/error/runId") + .and_then(Value::as_str) + .is_some() + ); + ensure!( + value + .pointer("/error/traceId") + .and_then(Value::as_str) + .is_some() + ); + Ok(()) +} + +fn run_args(workflow: &Path, db: &Path, workspace: &Path, extra: &[String]) -> Vec { + let mut args = vec![ + "run".to_owned(), + workflow.to_string_lossy().into_owned(), + "--workspace".to_owned(), + workspace.to_string_lossy().into_owned(), + "--db".to_owned(), + db.to_string_lossy().into_owned(), + "--output".to_owned(), + "json".to_owned(), + "--color".to_owned(), + "never".to_owned(), + ]; + args.extend_from_slice(extra); + args +} + +fn successful_json(binary: &Path, cwd: &Path, args: &[String]) -> Result { + json_with_code(binary, cwd, args, 0) +} + +fn expect_code(binary: &Path, cwd: &Path, args: &[String], code: i32) -> Result<()> { + output_with_code(command_for(binary, cwd, args), code, "agentctl")?; + Ok(()) +} + +fn json_with_code(binary: &Path, cwd: &Path, args: &[String], code: i32) -> Result { + let output = output_with_code(command_for(binary, cwd, args), code, "agentctl")?; + parse_output(&output) +} + +fn json_with_removed_env( + binary: &Path, + cwd: &Path, + args: &[String], + removed: &str, + code: i32, +) -> Result { + let mut command = command_for(binary, cwd, args); + command.env_remove(removed); + let output = output_with_code(command, code, "agentctl with removed credential")?; + parse_output(&output) +} + +fn command_for(binary: &Path, cwd: &Path, args: &[String]) -> Command { + let mut command = Command::new(binary); + command.current_dir(cwd).args(args); + command +} + +fn output_with_code(mut command: Command, code: i32, label: &str) -> Result { + let output = command.output().with_context(|| format!("run {label}"))?; + if output.status.code() == Some(code) { + Ok(output) + } else { + bail!( + "{label} returned {:?}, expected {code}\nstdout: {}\nstderr: {}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + } +} + +fn parse_output(output: &Output) -> Result { + let bytes = if output.stdout.iter().any(|byte| !byte.is_ascii_whitespace()) { + &output.stdout + } else { + &output.stderr + }; + serde_json::from_slice(bytes) + .with_context(|| format!("parse machine output: {}", String::from_utf8_lossy(bytes))) +} + +fn ensure_eq(value: &Value, pointer: &str, expected: T) -> Result<()> +where + T: Into, +{ + let expected = expected.into(); + ensure!( + value.pointer(pointer) == Some(&expected), + "{pointer} was {:?}, expected {expected}", + value.pointer(pointer) + ); + Ok(()) +} + +fn string_at<'a>(value: &'a Value, pointer: &str) -> Result<&'a str> { + value + .pointer(pointer) + .and_then(Value::as_str) + .with_context(|| format!("missing string at {pointer}")) +} + +fn array_len(value: &Value, pointer: &str) -> Result { + value + .pointer(pointer) + .and_then(Value::as_array) + .map(Vec::len) + .with_context(|| format!("missing array at {pointer}")) +} + +fn path(value: &Path) -> Result<&str> { + value.to_str().context("path is not UTF-8") +} + +fn strings(values: [&str; N]) -> Vec { + values.into_iter().map(ToOwned::to_owned).collect() +} + +fn debug_binary(root: &Path) -> PathBuf { + root.join("target/debug").join(binary_name()) +} + +fn packaged_binary(root: &Path) -> Result { + let output = Command::new("rustc").arg("-vV").output()?; + ensure!(output.status.success(), "rustc -vV failed"); + let version = String::from_utf8(output.stdout)?; + let host = version + .lines() + .find_map(|line| line.strip_prefix("host: ")) + .context("rustc did not report a host target")?; + Ok(root + .join("dist") + .join(format!("agentctl-{}-{host}", env!("CARGO_PKG_VERSION"))) + .join(binary_name())) +} + +fn binary_name() -> &'static str { + if cfg!(windows) { + "agentctl.exe" + } else { + "agentctl" + } +} + +fn command(root: &Path, program: &str, args: &[&str]) -> Result<()> { + let status = Command::new(program) + .current_dir(root) + .args(args) + .status()?; + ensure!( + status.success(), + "{program} {} failed: {status}", + args.join(" ") + ); + Ok(()) +} + +fn scenario(number: usize, label: &str) { + println!("[{number}/25] {label}"); +} + +fn write(path: &Path, contents: &str) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, contents)?; + Ok(()) +} + +fn copy_example(root: &Path, source: &str, destination: &Path) -> Result<()> { + let source = root.join(source); + fs::create_dir_all(destination.join("fixture"))?; + fs::create_dir_all(destination.join("artifacts"))?; + fs::copy( + source.join("workflow.yaml"), + destination.join("workflow.yaml"), + )?; + fs::copy( + source.join("fixture/service.txt"), + destination.join("fixture/service.txt"), + )?; + Ok(()) +} + +fn deterministic_workflow(name: &str, uses: &str, input: &str) -> String { + format!( + "apiVersion: agentctl.dev/v1alpha1\nkind: Workflow\nmetadata: {{ name: {name} }}\nspec:\n tasks:\n - id: task\n uses: {uses}\n with: {input}\n" + ) +} + +fn agent_workflow(name: &str, option: &str, tool: &str, extra_option: &str) -> String { + let tools = if tool.is_empty() { + "" + } else { + " tools: [echo]\n" + }; + let provider_options = if option.is_empty() && extra_option.is_empty() { + String::new() + } else { + format!(" providerOptions:\n {option}\n {extra_option}\n") + }; + format!( + "apiVersion: agentctl.dev/v1alpha1\nkind: Workflow\nmetadata: {{ name: {name} }}\nspec:\n providers:\n fake: {{ kind: fake }}\n{tool} agents:\n worker:\n provider: fake\n model: scripted\n instructions: complete the fixture\n{tools} maxTurns: 2\n maxToolCalls: 1\n timeoutSeconds: 1\n{provider_options} tasks:\n - id: work\n uses: agent:worker\n retry: {{ maxAttempts: 1 }}\n with: {{ prompt: hello }}\n" + ) +} + +fn echo_tool() -> &'static str { + r#" tools: + echo: + kind: builtin.echo + description: Echo structured input. + inputSchema: + type: object + properties: { text: { type: string } } + required: [text] + additionalProperties: false + outputSchema: + type: object + properties: { text: { type: string } } + required: [text] + additionalProperties: false + capability: internal + risk: low + effectClass: pure + idempotency: pure + retrySafe: true + timeoutSeconds: 2 + approval: never +"# +} + +fn mismatched_echo_tool() -> &'static str { + r#" tools: + echo: + kind: builtin.echo + description: Deliberately incompatible output contract. + inputSchema: + type: object + properties: { text: { type: string } } + required: [text] + additionalProperties: false + outputSchema: + type: object + properties: { result: { type: string } } + required: [result] + additionalProperties: false + capability: internal + risk: low + effectClass: pure + idempotency: pure + retrySafe: true + timeoutSeconds: 2 + approval: never +"# +} + +fn read_tool_workflow(name: &str, file: &str) -> String { + format!( + r#"apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: {{ name: {name} }} +spec: + providers: + fake: {{ kind: fake }} + tools: + read_fixture: + kind: builtin.workspace.read + description: Read a bounded UTF-8 fixture. + inputSchema: + type: object + properties: {{ path: {{ type: string }} }} + required: [path] + additionalProperties: false + outputSchema: + type: object + properties: + path: {{ type: string }} + content: {{ type: string }} + bytes: {{ type: integer }} + sha256: {{ type: string }} + required: [path, content, bytes, sha256] + additionalProperties: false + capability: filesystem.read + risk: low + effectClass: observe + idempotency: idempotent + retrySafe: true + timeoutSeconds: 2 + approval: never + agents: + reader: + provider: fake + model: scripted + instructions: read once + tools: [read_fixture] + maxTurns: 2 + maxToolCalls: 1 + providerOptions: + toolInput: {{ path: {file} }} + finalText: unreachable + tasks: + - id: read + uses: agent:reader + with: {{ prompt: read }} +"# + ) +} + +#[derive(Debug)] +struct ContainerLayout { + config: PathBuf, + workspace: PathBuf, + state: PathBuf, + artifacts: PathBuf, +} + +fn container_layout(root: &Path, live: bool) -> Result { + let layout = ContainerLayout { + config: root.join("config"), + workspace: root.join("workspace"), + state: root.join("state"), + artifacts: root.join("artifacts"), + }; + for path in [ + &layout.config, + &layout.workspace, + &layout.state, + &layout.artifacts, + ] { + fs::create_dir_all(path)?; + } + write( + &layout.workspace.join("fixture/service.txt"), + if live { + "service=agentctl\nmarker=OPENAI_TOOL_PATH_CONFIRMED\n" + } else { + "service=agentctl\nmarker=MOCK_TOOL_PATH_CONFIRMED\n" + }, + )?; + write( + &layout.config.join("workflow.yaml"), + if live { + CONTAINER_LIVE_WORKFLOW + } else { + CONTAINER_MOCK_WORKFLOW + }, + )?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&layout.state, fs::Permissions::from_mode(0o777))?; + fs::set_permissions(&layout.artifacts, fs::Permissions::from_mode(0o777))?; + } + Ok(layout) +} + +fn container_engine() -> Result { + if let Some(engine) = env::var_os("AGENTCTL_CONTAINER_ENGINE") { + return Ok(PathBuf::from(engine)); + } + for candidate in ["docker", "podman"] { + if executable_on_path(candidate) { + return Ok(PathBuf::from(candidate)); + } + } + let podman = PathBuf::from("/opt/podman/bin/podman"); + if podman.is_file() { + return Ok(podman); + } + bail!("Docker or Podman is required for OCI acceptance") +} + +fn executable_on_path(name: &str) -> bool { + env::var_os("PATH").is_some_and(|paths| { + env::split_paths(&paths).any(|directory| directory.join(name).is_file()) + }) +} + +fn ensure_engine_ready(engine: &Path) -> Result<()> { + let output = Command::new(engine).arg("info").output()?; + if output.status.success() { + Ok(()) + } else { + bail!( + "container engine is installed but unavailable: {}", + String::from_utf8_lossy(&output.stderr).trim() + ) + } +} + +fn build_image(root: &Path, engine: &Path) -> Result<()> { + let output = Command::new(engine) + .current_dir(root) + .args([ + OsStr::new("build"), + OsStr::new("--file"), + OsStr::new("Containerfile"), + OsStr::new("--tag"), + OsStr::new("agentctl-acceptance:local"), + OsStr::new("."), + ]) + .output()?; + if output.status.success() { + Ok(()) + } else { + bail!( + "OCI image build failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ) + } +} + +fn container_base(engine: &Path, layout: &ContainerLayout) -> Result { + let mut command = Command::new(engine); + command.args([ + "run", + "--rm", + "--read-only", + "--user", + "65532:65532", + "--tmpfs", + "/tmp:rw,noexec,nosuid,size=16m", + "--volume", + &format!("{}:/config:ro", path(&layout.config)?), + "--volume", + &format!("{}:/workspace:ro", path(&layout.workspace)?), + "--volume", + &format!("{}:/state:rw", path(&layout.state)?), + "--volume", + &format!("{}:/artifacts:rw", path(&layout.artifacts)?), + ]); + Ok(command) +} + +fn run_container( + engine: &Path, + layout: &ContainerLayout, + live: bool, + credential: Option<&str>, +) -> Result { + let mut command = container_base(engine, layout)?; + if let Some(name) = credential { + command.args(["--env", name]); + } + command.args([ + "agentctl-acceptance:local", + "run", + "/config/workflow.yaml", + "--workspace", + "/workspace", + "--db", + "/state/runtime.db", + "--input", + "reportPath=/artifacts/report.txt", + "--output", + "json", + "--color", + "never", + ]); + let output = output_with_code(command, 0, if live { "live OCI run" } else { "OCI run" })?; + parse_output(&output) +} + +fn run_container_with_code( + engine: &Path, + layout: &ContainerLayout, + code: i32, + label: &str, +) -> Result { + let mut command = container_base(engine, layout)?; + command.args([ + "agentctl-acceptance:local", + "run", + "/config/workflow.yaml", + "--workspace", + "/workspace", + "--db", + "/state/runtime.db", + "--output", + "json", + "--color", + "never", + ]); + parse_output(&output_with_code(command, code, label)?) +} + +fn inspect_container(engine: &Path, layout: &ContainerLayout, run_id: &str) -> Result { + let mut command = container_base(engine, layout)?; + command.args([ + "agentctl-acceptance:local", + "inspect", + run_id, + "--db", + "/state/runtime.db", + "--output", + "json", + "--color", + "never", + ]); + parse_output(&output_with_code(command, 0, "OCI inspect")?) +} + +fn replay_container(engine: &Path, layout: &ContainerLayout, run_id: &str) -> Result { + let mut command = container_base(engine, layout)?; + command.args(["--network", "none"]); + command.args([ + "agentctl-acceptance:local", + "replay", + run_id, + "--db", + "/state/runtime.db", + "--output", + "json", + "--color", + "never", + ]); + parse_output(&output_with_code(command, 0, "keyless OCI replay")?) +} + +fn container_signal_acceptance(engine: &Path, root: &Path) -> Result<()> { + let layout = container_layout(&root.join("container-signal"), false)?; + write( + &layout.config.join("workflow.yaml"), + CONTAINER_SIGNAL_WORKFLOW, + )?; + let name = format!("agentctl-signal-{}", std::process::id()); + let mut command = container_base(engine, &layout)?; + command.args([ + "--name", + &name, + "agentctl-acceptance:local", + "run", + "/config/workflow.yaml", + "--workspace", + "/workspace", + "--db", + "/state/runtime.db", + "--output", + "json", + "--color", + "never", + ]); + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + let child = command.spawn()?; + for _ in 0..100 { + if layout.state.join("runtime.db").exists() { + break; + } + thread::sleep(Duration::from_millis(25)); + } + ensure!( + layout.state.join("runtime.db").exists(), + "OCI run did not create durable state" + ); + thread::sleep(Duration::from_millis(100)); + let stopped = Command::new(engine) + .args(["stop", "--time", "10", &name]) + .output()?; + ensure!( + stopped.status.success(), + "failed to stop OCI run: {}", + String::from_utf8_lossy(&stopped.stderr) + ); + let output = child.wait_with_output()?; + ensure!( + output.status.code() == Some(130), + "OCI SIGTERM exit was {:?}, expected 130; stderr: {}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + let value = parse_output(&output)?; + ensure_eq(&value, "/data/state", "cancelled")?; + Ok(()) +} + +const APPROVAL_WORKFLOW: &str = r#"apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: approval } +spec: + policy: + workspaceRoot: . + writableRoots: [artifacts] + approval: mutations + actions: + write: { kind: builtin.write } + tasks: + - id: write + uses: action:write + with: { path: artifacts/approved.txt, content: approved } +"#; + +const CONFIRMED_BEFORE_APPROVAL_WORKFLOW: &str = r#"apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: confirmed-before-approval } +spec: + providers: + fake: { kind: fake } + policy: + workspaceRoot: . + writableRoots: [artifacts] + approval: mutations + agents: + worker: + provider: fake + model: scripted + instructions: reply + actions: + write: { kind: builtin.write } + tasks: + - id: model + uses: agent:worker + with: { prompt: hello } + - id: write + uses: action:write + needs: [model] + with: { path: artifacts/confirmed.txt, content: confirmed } +"#; + +const RETRY_WORKFLOW: &str = r#"apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: retry } +spec: + providers: + fake: { kind: fake } + agents: + worker: + provider: fake + model: scripted + instructions: retry once + providerOptions: { failFirst: 1, finalText: recovered } + outputs: + verdict: "${{ tasks.work.output.text }}" + tasks: + - id: work + uses: agent:worker + retry: { maxAttempts: 2, backoffMs: 1 } + with: { prompt: hello } +"#; + +const OPENAI_AUTH_WORKFLOW: &str = r#"apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: missing-auth } +spec: + providers: + openai: + kind: openai + credential: { env: OPENAI_API_KEY } + agents: + worker: + provider: openai + model: gpt-5.6 + instructions: reply + tasks: + - id: work + uses: agent:worker + with: { prompt: hello } +"#; + +const OPENAI_STATELESS_TOOL_WORKFLOW: &str = r#"apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: stateless-tools } +spec: + providers: { openai: { kind: openai } } + tools: + echo: + kind: builtin.echo + description: echo + inputSchema: { type: object } + outputSchema: { type: object } + capability: internal + risk: low + effectClass: pure + idempotency: pure + retrySafe: true + timeoutSeconds: 5 + approval: never + agents: + worker: + provider: openai + model: gpt-5.6 + instructions: use echo + tools: [echo] + providerOptions: { store: false } + tasks: [{ id: work, uses: "agent:worker" }] +"#; + +const INPUTS_WORKFLOW: &str = r#"apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: inputs } +spec: + inputs: { name: default, count: 1 } + outputs: + name: "${{ inputs.name }}" + count: "${{ inputs.count }}" + actions: + assign: { kind: builtin.assign } + tasks: + - id: capture + uses: action:assign + with: { name: "${{ inputs.name }}", count: "${{ inputs.count }}" } +"#; + +const TRAVERSAL_WORKFLOW: &str = r#"apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: traversal } +spec: + policy: + workspaceRoot: . + writableRoots: [artifacts] + approval: never + actions: + write: { kind: builtin.write } + tasks: + - id: write + uses: action:write + with: { path: ../escaped.txt, content: blocked } +"#; + +const CONTAINER_MOCK_WORKFLOW: &str = r#"apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: container-mock } +spec: + inputs: { reportPath: /artifacts/report.txt } + outputs: + verdict: "${{ tasks.inspect.output.text }}" + providers: + fake: { kind: fake } + policy: + workspaceRoot: /workspace + writableRoots: [/artifacts] + approval: never + tools: + read_fixture: + kind: builtin.workspace.read + description: Read the mounted fixture. + inputSchema: + type: object + properties: { path: { type: string } } + required: [path] + additionalProperties: false + outputSchema: + type: object + properties: + path: { type: string } + content: { type: string } + bytes: { type: integer } + sha256: { type: string } + required: [path, content, bytes, sha256] + additionalProperties: false + capability: filesystem.read + risk: low + effectClass: observe + idempotency: idempotent + retrySafe: true + timeoutSeconds: 5 + approval: never + agents: + inspector: + provider: fake + model: scripted + instructions: inspect fixture + tools: [read_fixture] + maxTurns: 2 + maxToolCalls: 1 + providerOptions: + toolInput: { path: fixture/service.txt } + finalText: AGENTCTL_MOCK_FIXTURE_VERIFIED + actions: + write: { kind: builtin.write } + tasks: + - id: inspect + uses: agent:inspector + with: { prompt: inspect } + - id: report + uses: action:write + needs: [inspect] + with: { path: "${{ inputs.reportPath }}", content: "${{ tasks.inspect.output.text }}" } +"#; + +const CONTAINER_SIGNAL_WORKFLOW: &str = r#"apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: container-signal } +spec: + providers: { fake: { kind: fake } } + agents: + worker: + provider: fake + model: scripted + instructions: wait + timeoutSeconds: 30 + providerOptions: { delayMs: 10000 } + tasks: + - id: wait + uses: agent:worker + with: { prompt: wait } +"#; + +const CONTAINER_LIVE_WORKFLOW: &str = r#"apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: container-openai-live } +spec: + inputs: { reportPath: /artifacts/report.txt } + outputs: + verdict: "${{ tasks.inspect.output.text }}" + providers: + openai: + kind: openai + credential: { env: OPENAI_API_KEY } + policy: + workspaceRoot: /workspace + writableRoots: [/artifacts] + networkAllowlist: [api.openai.com] + approval: never + tools: + read_fixture: + kind: builtin.workspace.read + description: Read one UTF-8 fixture file inside the mounted workspace. + inputSchema: + type: object + properties: { path: { type: string } } + required: [path] + additionalProperties: false + outputSchema: + type: object + properties: + path: { type: string } + content: { type: string } + bytes: { type: integer } + sha256: { type: string } + required: [path, content, bytes, sha256] + additionalProperties: false + capability: filesystem.read + risk: low + effectClass: observe + idempotency: idempotent + retrySafe: true + timeoutSeconds: 5 + approval: never + agents: + inspector: + provider: openai + model: gpt-5.6 + instructions: Call read_fixture exactly once with path fixture/service.txt. If the result contains marker=OPENAI_TOOL_PATH_CONFIRMED, reply exactly AGENTCTL_LIVE_FIXTURE_VERIFIED with no other text. + tools: [read_fixture] + maxTurns: 3 + maxToolCalls: 1 + maxOutputTokens: 64 + timeoutSeconds: 45 + reasoning: { effort: low } + providerOptions: + store: true + reasoningContext: current_turn + promptCacheMode: implicit + promptCacheTtl: 30m + parallelToolCalls: false + actions: + write: { kind: builtin.write } + tasks: + - id: inspect + uses: agent:inspector + with: { prompt: Perform the required fixture inspection now. } + - id: report + uses: action:write + needs: [inspect] + with: { path: "${{ inputs.reportPath }}", content: "${{ tasks.inspect.output.text }}" } +"#; diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..2101845 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,598 @@ +use std::env; +use std::ffi::OsStr; +use std::fmt::Write as _; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use anyhow::{Context, Result, anyhow, bail}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +mod acceptance; + +fn main() -> Result<()> { + let command = env::args().nth(1).unwrap_or_else(|| "help".to_owned()); + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .context("xtask must be inside the workspace")? + .to_path_buf(); + match command.as_str() { + "verify" => verify(&root), + "acceptance" => acceptance::run(&root), + "acceptance-container" => acceptance::container(&root), + "acceptance-live-openai" => acceptance::live_openai(&root), + "generate" => generate(&root), + "package" => package(&root), + "help" | "--help" | "-h" => { + println!( + "cargo xtask verify\ncargo xtask acceptance\ncargo xtask acceptance-container\ncargo xtask acceptance-live-openai\ncargo xtask generate\ncargo xtask package" + ); + Ok(()) + } + other => bail!("unknown xtask command `{other}`"), + } +} + +pub(crate) fn package(root: &Path) -> Result<()> { + run( + root, + "cargo", + &["build", "--release", "-p", "agentctl", "--locked"], + )?; + let host_output = Command::new("rustc").arg("-vV").output()?; + ensure_success(&host_output, "rustc -vV")?; + let version = String::from_utf8_lossy(&host_output.stdout); + let host = version + .lines() + .find_map(|line| line.strip_prefix("host: ")) + .context("rustc did not report a host target")?; + let package = root + .join("dist") + .join(format!("agentctl-{}-{host}", env!("CARGO_PKG_VERSION"))); + fs::create_dir_all(&package)?; + let source = root.join("target").join("release").join(if cfg!(windows) { + "agentctl.exe" + } else { + "agentctl" + }); + let binary = package.join(source.file_name().context("release binary name")?); + fs::copy(&source, &binary)?; + fs::copy(root.join("LICENSE"), package.join("LICENSE"))?; + fs::copy(root.join("README.md"), package.join("README.md"))?; + for (shell, name) in [ + ("bash", "agentctl.bash"), + ("zsh", "_agentctl"), + ("fish", "agentctl.fish"), + ("powershell", "_agentctl.ps1"), + ] { + let output = Command::new(&binary).args(["completion", shell]).output()?; + ensure_success(&output, "agentctl completion")?; + fs::write(package.join(name), output.stdout)?; + } + let digest = hex::encode(Sha256::digest(fs::read(&binary)?)); + fs::write( + package.join("SHA256SUMS"), + format!( + "{digest} {}\n", + binary.file_name().context("binary name")?.to_string_lossy() + ), + )?; + println!("packaged {}", package.display()); + Ok(()) +} + +fn verify(root: &Path) -> Result<()> { + println!("[1/12] rustfmt"); + run(root, "cargo", &["fmt", "--all", "--", "--check"])?; + run( + root, + "cargo", + &["fmt", "--manifest-path", "fuzz/Cargo.toml", "--", "--check"], + )?; + + println!("[2/12] clippy (warnings denied)"); + run( + root, + "cargo", + &[ + "clippy", + "--workspace", + "--all-targets", + "--all-features", + "--", + "-D", + "warnings", + ], + )?; + + println!("[3/12] workspace build"); + run( + root, + "cargo", + &["build", "--workspace", "--all-features", "--locked"], + )?; + + println!( + "[4/12] unit, integration, compatibility, provider, protocol, persistence, and security tests" + ); + run( + root, + "cargo", + &["test", "--workspace", "--all-features", "--locked"], + )?; + run( + root, + "cargo", + &[ + "check", + "--manifest-path", + "fuzz/Cargo.toml", + "--bins", + "--locked", + ], + )?; + + println!("[5/12] documentation tests and docs"); + run_with_env( + root, + "cargo", + &["test", "--doc", "--workspace", "--locked"], + &[("RUSTDOCFLAGS", "-D warnings")], + )?; + run_with_env( + root, + "cargo", + &[ + "doc", + "--workspace", + "--no-deps", + "--all-features", + "--locked", + ], + &[("RUSTDOCFLAGS", "-D warnings")], + )?; + + println!("[6/12] generated schema and CLI reference consistency"); + verify_generated(root)?; + + println!("[7/12] examples and negative contracts"); + verify_examples(root)?; + + println!("[8/12] dependency sources and license metadata"); + verify_metadata(root)?; + + println!("[9/12] dependency advisories and policy"); + verify_supply_chain(root)?; + + println!("[10/12] secret scan"); + verify_no_secrets(root)?; + + println!("[11/12] source installation smoke"); + verify_install(root)?; + + println!("[12/12] repository production boundary"); + verify_production_boundary(root)?; + + println!("agentctl verification passed"); + Ok(()) +} + +fn generate(root: &Path) -> Result<()> { + run(root, "cargo", &["build", "-p", "agentctl", "--locked"])?; + let binary = binary_path(root); + let schema_path = root.join("schemas/workflow.schema.json"); + let schema_text = generated_schema(&binary)?; + write(&schema_path, &schema_text)?; + let cli_path = root.join("docs/generated/CLI.md"); + let cli_text = generated_cli_reference(&binary)?; + write(&cli_path, &cli_text)?; + println!( + "generated {} and {}", + schema_path.display(), + cli_path.display() + ); + Ok(()) +} + +fn verify_generated(root: &Path) -> Result<()> { + let binary = binary_path(root); + compare_generated( + &root.join("schemas/workflow.schema.json"), + &generated_schema(&binary)?, + "cargo xtask generate", + )?; + compare_generated( + &root.join("docs/generated/CLI.md"), + &generated_cli_reference(&binary)?, + "cargo xtask generate", + ) +} + +fn generated_schema(binary: &Path) -> Result { + let directory = tempfile::tempdir()?; + let path = directory.path().join("workflow.schema.json"); + let output = Command::new(binary) + .args(["schema", "--write"]) + .arg(&path) + .arg("--output") + .arg("json") + .output() + .context("generate schema")?; + ensure_success(&output, "agentctl schema")?; + fs::read_to_string(path).context("read generated schema") +} + +fn generated_cli_reference(binary: &Path) -> Result { + let commands: &[&[&str]] = &[ + &[], + &["check"], + &["plan"], + &["run"], + &["resume"], + &["replay"], + &["fork"], + &["cancel"], + &["inspect"], + &["approvals"], + &["approvals", "list"], + &["approvals", "approve"], + &["approvals", "reject"], + &["providers"], + &["providers", "inspect"], + &["providers", "smoke-openai"], + &["auth"], + &["schema"], + &["migrate"], + &["packs"], + &["db"], + &["memory"], + &["gc"], + &["completion"], + &["version"], + &["update"], + ]; + let mut markdown = String::from( + "# CLI reference\n\nGenerated from the Rust CLI by `cargo xtask generate`. Do not edit by hand.\n\n", + ); + for command in commands { + let output = Command::new(binary) + .args(*command) + .arg("--help") + .output() + .with_context(|| format!("render help for {}", command.join(" ")))?; + ensure_success(&output, "agentctl --help")?; + let title = if command.is_empty() { + "agentctl".to_owned() + } else { + format!("agentctl {}", command.join(" ")) + }; + let help = String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + write!(markdown, "## `{title}`\n\n```text\n{help}\n```\n\n",)?; + } + Ok(format!("{}\n", markdown.trim_end())) +} + +fn verify_examples(root: &Path) -> Result<()> { + let binary = binary_path(root); + let examples = root.join("examples/v1"); + for entry in fs::read_dir(&examples)? { + let path = entry?.path(); + if path.extension() != Some(OsStr::new("yaml")) + || path.file_name() == Some(OsStr::new("example.pack.yaml")) + { + continue; + } + let expected_failure = path.file_name() == Some(OsStr::new("capability-failure.yaml")); + let output = Command::new(&binary) + .arg("check") + .arg(&path) + .args(["--output", "json"]) + .output() + .with_context(|| format!("check example {}", path.display()))?; + if expected_failure { + if output.status.code() != Some(2) { + bail!( + "negative capability fixture returned {:?}: {}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + } + } else { + ensure_success(&output, &format!("check {}", path.display()))?; + } + } + + let directory = tempfile::tempdir()?; + for name in [ + "hello.yaml", + "dataflow.yaml", + "condition.yaml", + "working-memory.yaml", + "long-term-memory.yaml", + "fake-provider.yaml", + "reusable-pack.yaml", + ] { + let db = directory.path().join(format!("{name}.db")); + run_binary( + root, + &binary, + &[ + "run", + examples.join(name).to_str().context("example path")?, + "--db", + db.to_str().context("db path")?, + "--output", + "json", + ], + Some(0), + )?; + } + let check_db = directory.path().join("check.db"); + run_binary( + root, + &binary, + &[ + "run", + examples + .join("check-diff.yaml") + .to_str() + .context("example path")?, + "--db", + check_db.to_str().context("db path")?, + "--check", + "--diff", + "--output", + "json", + ], + Some(0), + )?; + if examples.join("artifacts/report.txt").exists() { + bail!("check mode mutated examples/v1/artifacts/report.txt"); + } + + let denied_db = directory.path().join("denied.db"); + let denied = Command::new(&binary) + .current_dir(root) + .args([ + "run", + examples + .join("policy-denial.yaml") + .to_str() + .context("example path")?, + "--db", + denied_db.to_str().context("db path")?, + "--output", + "json", + ]) + .output()?; + if denied.status.success() { + bail!("policy-denial example unexpectedly succeeded"); + } + if examples.join("artifacts/denied.txt").exists() { + bail!("policy-denial example mutated the workspace"); + } + Ok(()) +} + +fn verify_metadata(root: &Path) -> Result<()> { + let output = Command::new("cargo") + .current_dir(root) + .args(["metadata", "--format-version", "1", "--locked"]) + .output()?; + ensure_success(&output, "cargo metadata")?; + let metadata: Value = serde_json::from_slice(&output.stdout)?; + let packages = metadata["packages"] + .as_array() + .context("cargo metadata packages")?; + let accepted = [ + "Apache-2.0", + "MIT", + "MIT-0", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", + "Zlib", + "MPL-2.0", + "OpenSSL", + "CDLA-Permissive-2.0", + ]; + for package in packages { + let name = package["name"].as_str().unwrap_or("unknown"); + if package["source"] + .as_str() + .is_some_and(|source| source.starts_with("git+")) + { + bail!("git dependency `{name}` is not allowed"); + } + let license = package["license"] + .as_str() + .ok_or_else(|| anyhow!("dependency `{name}` does not declare a license"))?; + if !accepted.iter().any(|accepted| license.contains(accepted)) { + bail!("dependency `{name}` has unreviewed license expression `{license}`"); + } + } + Ok(()) +} + +fn verify_supply_chain(root: &Path) -> Result<()> { + if !command_exists("cargo-deny") { + bail!("cargo-deny is required for the supply-chain verification gate"); + } + run(root, "cargo", &["deny", "check"]) +} + +fn verify_no_secrets(root: &Path) -> Result<()> { + let forbidden = [ + ["sk-", "proj-"].concat(), + ["sk-", "ant-api"].concat(), + ["AI", "zaSy"].concat(), + ["-----BEGIN ", "PRIVATE KEY-----"].concat(), + ]; + let mut files = Vec::new(); + collect_files(root, &mut files)?; + for path in files { + let bytes = fs::read(&path)?; + let Ok(text) = std::str::from_utf8(&bytes) else { + continue; + }; + if let Some(pattern) = forbidden + .iter() + .find(|pattern| text.contains(pattern.as_str())) + { + bail!("possible secret pattern `{pattern}` in {}", path.display()); + } + } + Ok(()) +} + +fn collect_files(directory: &Path, output: &mut Vec) -> Result<()> { + let ignored = [ + ".git", + "target", + "node_modules", + "dist", + ".runtime", + ".agentctl", + ]; + for entry in fs::read_dir(directory)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + if !ignored + .iter() + .any(|name| entry.file_name() == OsStr::new(name)) + { + collect_files(&path, output)?; + } + } else { + output.push(path); + } + } + Ok(()) +} + +fn verify_install(root: &Path) -> Result<()> { + let directory = tempfile::tempdir()?; + run( + root, + "cargo", + &[ + "install", + "--path", + "crates/agentctl-cli", + "--root", + directory.path().to_str().context("install root")?, + "--locked", + "--force", + ], + )?; + let binary = directory.path().join("bin").join(if cfg!(windows) { + "agentctl.exe" + } else { + "agentctl" + }); + let output = Command::new(binary).arg("version").output()?; + ensure_success(&output, "installed agentctl version") +} + +fn verify_production_boundary(root: &Path) -> Result<()> { + let package: Value = serde_json::from_str(&fs::read_to_string(root.join("package.json"))?)?; + if package.get("bin").is_some() || package.get("main").is_some() { + bail!("archived TypeScript package must not expose a production bin or main entry point"); + } + if !root.join("archive/TYPESCRIPT_REFERENCE.md").exists() { + bail!("TypeScript archive marker is missing"); + } + Ok(()) +} + +fn compare_generated(path: &Path, generated: &str, command: &str) -> Result<()> { + let committed = fs::read_to_string(path) + .with_context(|| format!("read committed generated file {}", path.display()))?; + if committed == generated { + Ok(()) + } else { + bail!("{} is stale; run `{command}`", path.display()) + } +} + +fn write(path: &Path, value: &str) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, value)?; + Ok(()) +} + +fn binary_path(root: &Path) -> PathBuf { + root.join("target").join("debug").join(if cfg!(windows) { + "agentctl.exe" + } else { + "agentctl" + }) +} + +fn run(root: &Path, program: &str, args: &[&str]) -> Result<()> { + run_with_env(root, program, args, &[]) +} + +fn run_with_env(root: &Path, program: &str, args: &[&str], vars: &[(&str, &str)]) -> Result<()> { + let mut command = Command::new(program); + command.current_dir(root).args(args); + for (name, value) in vars { + command.env(name, value); + } + let status = command + .status() + .with_context(|| format!("run {program} {}", args.join(" ")))?; + if status.success() { + Ok(()) + } else { + bail!("{program} {} exited with {status}", args.join(" ")) + } +} + +fn run_binary(root: &Path, binary: &Path, args: &[&str], expected_code: Option) -> Result<()> { + let output = Command::new(binary).current_dir(root).args(args).output()?; + if output.status.code() == expected_code { + Ok(()) + } else { + bail!( + "{} {} returned {:?}\nstdout: {}\nstderr: {}", + binary.display(), + args.join(" "), + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + } +} + +fn ensure_success(output: &Output, label: &str) -> Result<()> { + if output.status.success() { + Ok(()) + } else { + bail!( + "{label} failed with {:?}\nstdout: {}\nstderr: {}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + } +} + +fn command_exists(name: &str) -> bool { + env::var_os("PATH").is_some_and(|paths| { + env::split_paths(&paths).any(|directory| { + let candidate = directory.join(name); + candidate.is_file() + || (cfg!(windows) && directory.join(format!("{name}.exe")).is_file()) + }) + }) +} From 0813f4c9cc4e3847c6e49ed67dbc399768c424cf Mon Sep 17 00:00:00 2001 From: Ompragash Date: Wed, 22 Jul 2026 15:42:50 +0530 Subject: [PATCH 02/18] docs: record the adversarial release audit --- README.md | 543 ++---------------- docs/A2A.md | 7 + docs/ARCHITECTURE.md | 36 ++ docs/COMPATIBILITY.md | 23 + docs/CONTAINER.md | 252 ++++++++ docs/CONTRIBUTING.md | 15 + docs/DSL.md | 15 + docs/DURABLE_EXECUTION.md | 20 + docs/LIMITATIONS.md | 48 ++ docs/MCP.md | 7 + docs/MIGRATING_FROM_TYPESCRIPT.md | 15 + docs/OBSERVABILITY.md | 9 + docs/OPERATIONS.md | 66 +++ docs/PACKS.md | 7 + docs/PRODUCT.md | 36 ++ docs/PROVIDERS.md | 29 + docs/SECURITY.md | 24 + docs/TESTING.md | 31 + docs/THREAT_MODEL.md | 25 + docs/TOOLS.md | 11 + ...001-deterministic-core-explicit-effects.md | 7 + ...0002-versioned-strict-workflow-envelope.md | 7 + ...qlite-history-and-conservative-recovery.md | 7 + docs/adr/0004-native-provider-adapters.md | 7 + ...005-narrow-v1-scheduling-and-extensions.md | 7 + ...ble-runtime-and-noninteractive-contract.md | 15 + docs/adr/0007-generic-oci-step-contract.md | 13 + docs/agent-kinds.md | 212 ------- docs/agent-prompts.md | 138 ----- docs/check.md | 75 --- docs/custom-tools.md | 225 -------- docs/execution/BLOCKERS.md | 5 + docs/execution/COMPATIBILITY.md | 32 ++ docs/execution/DECISIONS.md | 13 + docs/execution/DEFINITION_OF_DONE.md | 22 + docs/execution/RELEASE_AUDIT.md | 215 +++++++ docs/execution/STATUS.md | 41 ++ docs/execution/VERIFICATION.md | 44 ++ docs/generated/CLI.md | 481 ++++++++++++++++ docs/long-term-memory.md | 246 -------- docs/memory.md | 306 +--------- docs/policies.md | 207 +------ docs/profiles.md | 182 ------ docs/prompt-cache.md | 265 --------- docs/research/LANDSCAPE.md | 27 + docs/spec.md | 200 ------- docs/typescript.md | 76 --- 47 files changed, 1688 insertions(+), 2606 deletions(-) create mode 100644 docs/A2A.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/COMPATIBILITY.md create mode 100644 docs/CONTAINER.md create mode 100644 docs/CONTRIBUTING.md create mode 100644 docs/DSL.md create mode 100644 docs/DURABLE_EXECUTION.md create mode 100644 docs/LIMITATIONS.md create mode 100644 docs/MCP.md create mode 100644 docs/MIGRATING_FROM_TYPESCRIPT.md create mode 100644 docs/OBSERVABILITY.md create mode 100644 docs/OPERATIONS.md create mode 100644 docs/PACKS.md create mode 100644 docs/PRODUCT.md create mode 100644 docs/PROVIDERS.md create mode 100644 docs/SECURITY.md create mode 100644 docs/TESTING.md create mode 100644 docs/THREAT_MODEL.md create mode 100644 docs/TOOLS.md create mode 100644 docs/adr/0001-deterministic-core-explicit-effects.md create mode 100644 docs/adr/0002-versioned-strict-workflow-envelope.md create mode 100644 docs/adr/0003-sqlite-history-and-conservative-recovery.md create mode 100644 docs/adr/0004-native-provider-adapters.md create mode 100644 docs/adr/0005-narrow-v1-scheduling-and-extensions.md create mode 100644 docs/adr/0006-schedulable-runtime-and-noninteractive-contract.md create mode 100644 docs/adr/0007-generic-oci-step-contract.md delete mode 100644 docs/agent-kinds.md delete mode 100644 docs/agent-prompts.md delete mode 100644 docs/check.md delete mode 100644 docs/custom-tools.md create mode 100644 docs/execution/BLOCKERS.md create mode 100644 docs/execution/COMPATIBILITY.md create mode 100644 docs/execution/DECISIONS.md create mode 100644 docs/execution/DEFINITION_OF_DONE.md create mode 100644 docs/execution/RELEASE_AUDIT.md create mode 100644 docs/execution/STATUS.md create mode 100644 docs/execution/VERIFICATION.md create mode 100644 docs/generated/CLI.md delete mode 100644 docs/long-term-memory.md delete mode 100644 docs/profiles.md delete mode 100644 docs/prompt-cache.md create mode 100644 docs/research/LANDSCAPE.md delete mode 100644 docs/spec.md delete mode 100644 docs/typescript.md diff --git a/README.md b/README.md index 4552517..c57c509 100644 --- a/README.md +++ b/README.md @@ -1,514 +1,83 @@ # agentctl -`agentctl` is a standalone prototype for a declarative autonomous agent runtime. +`agentctl` is a deterministic, declarative control plane for policy-constrained agentic automation. A versioned YAML workflow is compiled into a deterministically ordered task graph; deterministic actions and bounded model agents execute under one policy, effect ledger, SQLite history, and audit model. -It provides: +Rust is the only production implementation. Node.js is not required to build, test, install, or run it. The former TypeScript runtime remains solely as an [archived compatibility reference](archive/TYPESCRIPT_REFERENCE.md). -- YAML playbook parsing and validation -- Internal graph compilation for task dependencies -- Deterministic module execution plus bounded agent steps -- Built-in workspace tools with guardrails: `builtin/read`, `builtin/write`, `builtin/edit`, `builtin/bash`, `builtin/grep`, `builtin/find`, `builtin/ls` -- Provider-backed agent execution with `openai.responses`, including `openai` and `azure-openai-responses` -- Provider-backed agent tools for local and remote MCP servers plus local and remote A2A peers -- Agent tool profiles: `none`, `inspect`, `workspace_write`, `workspace_exec` -- Policy enforcement for workspace roots, writable roots, and approval modes -- SQLite-backed checkpoints for replay and resume -- Audit events and trace spans with optional OpenTelemetry export hooks -- Pack manifests for reusable agents, modules, and policies +## Quickstart -Current protocol support: +The repository pins Rust 1.88, the minimum supported version. -- `mcp:/` routes agent tool calls through a registered MCP server transport or a remote MCP Streamable HTTP endpoint declared in the playbook -- `a2a:` routes agent tool calls through a registered A2A peer transport or a remote A2A HTTP endpoint discovered from an agent card - -Playbooks can either bind concrete in-process transports at runtime or declare remote endpoints directly: - -```yaml -mcpServers: - docs: - url: https://example.com/mcp - bearerTokenEnv: DOCS_TOKEN - -a2aAgents: - helper: - cardUrl: https://example.com/.well-known/agent-card.json -``` - -Runtime-bound transports still override playbook-declared remotes when both are provided. - - -## CLI reference - -### Top-level help - -```text -Usage: - agentctl check [flags] - agentctl run [flags] - agentctl resume [flags] - agentctl replay [flags] - agentctl db stats [flags] - agentctl approvals [flags] - agentctl prompt-cache stats [flags] - agentctl prompt-cache explain [flags] - agentctl memory [flags] - agentctl gc [flags] - agentctl auth check [playbook.yaml] [flags] - agentctl schema - agentctl update - agentctl help - agentctl version - -Use command-specific help for examples and command-specific flags. -Examples: "agentctl run --help", "agentctl memory --help", "agentctl auth check --help". - -Examples: - agentctl run examples/hello.playbook.yaml - agentctl db stats - agentctl approvals list - agentctl prompt-cache stats - agentctl memory stats - agentctl auth check examples/real-autonomy/mission.playbook.yaml - agentctl check examples/prompt-file-vars/mission.playbook.yaml - -Flags: - -h, --help Show help - -v, --verbose Show full structured output - -V, --version Show version - --output yaml|json Structured output format - --color auto|always|never YAML color mode -``` - -### `check` - -```text -Usage: - agentctl check [flags] - -Reports YAML syntax, schema, prompt-file, template-reference, and compile errors with exact file context when available. - -Examples: - agentctl check examples/hello.playbook.yaml - agentctl check examples/prompt-file-vars/mission.playbook.yaml --output json - -Flags: - -h, --help Show help - -v, --verbose Show full structured output - -V, --version Show version - --output yaml|json Structured output format - --color auto|always|never YAML color mode -``` - -### `run` - -```text -Usage: - agentctl run [flags] - -Streams checkpoint events progressively and prints the final run result. -In interactive YAML TTY mode, paused approval-gated runs prompt inline and resume automatically after approval or rejection. - -Examples: - agentctl run examples/hello.playbook.yaml - agentctl run examples/real-autonomy/mission.playbook.yaml --db .runtime/real-autonomy.db - agentctl run examples/hello.playbook.yaml --output json --color never - -Flags: - -h, --help Show help - -v, --verbose Show full structured output - -V, --version Show version - --output yaml|json Structured output format - --color auto|always|never YAML color mode - --db path Runtime database path (default: ~/.agentctl/runtime/runtime.db) - --api-key key Runtime API key override - --provider name Provider for --api-key (default: openai) -``` - -### `resume` - -```text -Usage: - agentctl resume [flags] - -Fails fast for terminal runs and preserves already checkpointed side effects. -In interactive YAML TTY mode, pending approvals can be resolved inline before resuming execution. - -Examples: - agentctl resume examples/hello.playbook.yaml --db ~/.agentctl/runtime/runtime.db - -Flags: - -h, --help Show help - -v, --verbose Show full structured output - -V, --version Show version - --output yaml|json Structured output format - --color auto|always|never YAML color mode - --db path Runtime database path (default: ~/.agentctl/runtime/runtime.db) - --api-key key Runtime API key override - --provider name Provider for --api-key (default: openai) -``` - -### `replay` - -```text -Usage: - agentctl replay [flags] - -Creates a new run id and reuses the selected checkpoint snapshot as the starting state. -In interactive YAML TTY mode, replayed approval gates can be resolved inline as the new run pauses. - -Examples: - agentctl replay examples/hello.playbook.yaml 3 --db ~/.agentctl/runtime/runtime.db - -Flags: - -h, --help Show help - -v, --verbose Show full structured output - -V, --version Show version - --output yaml|json Structured output format - --color auto|always|never YAML color mode - --db path Runtime database path (default: ~/.agentctl/runtime/runtime.db) - --api-key key Runtime API key override - --provider name Provider for --api-key (default: openai) -``` - -### `db` - -```text -Usage: - agentctl db stats [flags] - -Read-only runtime DB inspection. Fails on a missing DB path instead of creating one. - -Examples: - agentctl db stats - agentctl db stats --db .runtime/real-autonomy.db --output json - -Flags: - -h, --help Show help - -v, --verbose Show full structured output - -V, --version Show version - --output yaml|json Structured output format - --color auto|always|never YAML color mode - --db path Runtime database path -``` - -### `prompt-cache` - -```text -Usage: - agentctl prompt-cache stats [flags] - agentctl prompt-cache explain [flags] - -Aggregates prompt-cache hit and token usage from runtime audit events. -Explain reports why prompt cache is enabled or disabled per agent before a run. -This is observability for provider-native caching, not cache content inspection. - -Examples: - agentctl prompt-cache stats - agentctl prompt-cache stats --db .runtime/real-autonomy.db --output json - agentctl prompt-cache stats --run-id --verbose - agentctl prompt-cache explain examples/prompt-cache/mission.playbook.yaml - -Flags: - -h, --help Show help - -v, --verbose Show full structured output - -V, --version Show version - --output yaml|json Structured output format - --color auto|always|never YAML color mode - --db path Runtime database path - --agent-ref ref Filter prompt-cache stats to one agent ref - --run-id id Filter prompt-cache stats to one run - --task-id id Filter prompt-cache stats to one task -``` - -### `memory` - -```text -Usage: - agentctl memory get [flags] - agentctl memory search [flags] - agentctl memory write (--value json | --string text) [flags] - agentctl memory stats [flags] - agentctl memory gc [flags] - -Reads fail on a missing SQLite memory DB path; writes create the DB when needed. -Use "--provider mongodb-atlas" to target the Atlas adapter instead of local SQLite. - -Examples: - agentctl memory get finding --namespace memory-flow - agentctl memory search --query restore --limit 10 - agentctl memory write finding --namespace memory-flow --string restore-drill-missing --tags readiness,audit - agentctl memory gc --older-than-days 30 --keep-entries 100 - -Flags: - -h, --help Show help - -v, --verbose Show full structured output - -V, --version Show version - --output yaml|json Structured output format - --color auto|always|never YAML color mode - --provider sqlite|mongodb-atlas Long-term memory backend - --db path SQLite memory DB path (default: ~/.agentctl/memory/long-term.db) - --connection-string uri Remote memory backend connection string - --database name Remote memory database name - --collection name Remote memory collection name - --namespace name Namespace filter or write target - --limit N Maximum matches to return - --older-than-days N Retention cutoff for memory gc - --keep-entries N Newest entries to keep during memory gc - --value json JSON value for memory write - --string text Plain string value for memory write - --tags a,b Comma-separated tags for memory write -``` - -### `gc` - -```text -Usage: - agentctl gc [flags] - -Only terminal runs are deleted. Running and paused runs are preserved. - -Examples: - agentctl gc - agentctl gc --older-than-days 7 --keep-runs 20 --output json --verbose - -Flags: - -h, --help Show help - -v, --verbose Show full structured output - -V, --version Show version - --output yaml|json Structured output format - --color auto|always|never YAML color mode - --db path Runtime database path - --older-than-days N Delete terminal runs older than N days (default: 30) - --keep-runs N Keep newest terminal runs regardless of age (default: 100) -``` - -### `auth` - -```text -Usage: - agentctl auth check [playbook.yaml] [flags] - -Exits nonzero when any required provider auth is missing. -When a playbook is provided, only provider-backed agents in that playbook are inspected. - -Examples: - agentctl auth check --provider openai - agentctl auth check examples/real-autonomy/mission.playbook.yaml --output json - -Flags: - -h, --help Show help - -v, --verbose Show full structured output - -V, --version Show version - --output yaml|json Structured output format - --color auto|always|never YAML color mode - --api-key key Runtime API key override - --provider name Provider to inspect when no playbook is given -``` - -### `schema` - -```text -Usage: - agentctl schema +```console +cargo build --locked +cargo run -p agentctl -- check examples/v1/hello.yaml +cargo run -p agentctl -- plan examples/v1/hello.yaml +cargo run -p agentctl -- run examples/v1/hello.yaml --db .agentctl/quickstart.db ``` -### `update` - -```text -Usage: - agentctl update -``` - - -## Runtime database - -By default, `agentctl` stores run state in: - -```text -~/.agentctl/runtime/runtime.db -``` - -Use `--db` to override that path for a specific command. - -`run`, `resume`, and `replay` all operate on the same SQLite file unless you point them at different `--db` paths. New runs create new rows inside the same database; they do not create a new database file unless you choose a new path. - -Inspect the current database with: - -```bash -agentctl db stats -agentctl db stats --output json -``` - -`db stats` prints: - -- database path -- current file size in bytes -- run counts by status -- oldest and newest run timestamps -- record counts for checkpoints, task attempts, agent turns, audit events, and trace spans -- latest run metadata - -Clean up old terminal runs with: +The last command is credential-free and deterministic. Install locally with: -```bash -agentctl gc -agentctl gc --older-than-days 7 --keep-runs 20 -agentctl gc --output json --verbose +```console +cargo install --locked --path crates/agentctl-cli ``` -`gc` removes only terminal runs (`succeeded` and `failed`), never `running` runs. By default it: +For a tool-using credential-free journey, copy `examples/acceptance/mock-tool` to a clean directory and run its `workflow.yaml`. The repository acceptance suite executes that exact journey outside the source tree. -- deletes terminal runs older than `30` days -- keeps the newest `100` terminal runs regardless of age -- vacuums the SQLite database after deletion +## Workflow -`gc` prints: - -- the GC policy used (`olderThanDays`, `keepRuns`) -- number of deleted runs -- whether vacuum was performed -- before/after database file size -- before/after run counts -- before/after record counts -- deleted run ids in verbose mode - -## Memory model - -`agentctl` uses four distinct memory modes: - -- `run_memory` - - The runtime/checkpoint state for a single run. - - Stored in the runtime DB, by default `~/.agentctl/runtime/runtime.db`. - - Includes inputs, task state, attempts, agent sessions, checkpoints, trace/audit state, and the current working-memory snapshot. - - This is part of replay/resume correctness and should stay local to the runtime. - -- `working_memory` - - Mutable state for the active run. - - Checkpointed inside the runtime DB and available in templates as `memory.working`. - - Best for facts, intermediate findings, handoff state, and deterministic per-run scratch state. - -- `long_term_memory` - - Cross-run durable knowledge. - - Stored separately from the runtime DB. - - Default local store path: `~/.agentctl/memory/long-term.db`. - - Best for approved facts, indexed artifacts, and reusable operational knowledge. - - Extension point for future external adapters such as SQL, vector, document, and graph stores. - -- `prompt_cache` - - Provider-native optimization for supported model adapters. - - Currently implemented for `openai.responses` with provider `openai`. - - Disabled by default and never required for correctness. - - Custom OpenAI-compatible base URLs are disabled by default unless `promptCache.force: true` is set. - -Recommended usage: - -- Use `working_memory` for state that must survive retries, resume, and replay within the same run. -- Use `long_term_memory` only for cross-run knowledge that you want to keep deliberately. -- Do not treat `run_memory` as a user-facing knowledge store. -- Use `prompt_cache` only for cost and latency optimization on stable prompt prefixes. -- Do not rely on prompt caching for correctness. - -### `vars` compatibility - -`vars` currently remains as a compatibility mirror of `memory.working`. - -That decision is intentional for now: - -- old playbooks and templates that reference `vars.*` continue to work -- the canonical state should now be treated as `memory.working.*` -- new playbooks should prefer `memory.working` - -Long term, `memory.working` should be the canonical surface and `vars` should be treated as compatibility-only. - -### Memory CLI - -Use the first-class memory commands against the standalone long-term memory DB: - -```bash -agentctl memory stats -agentctl memory get finding --namespace memory-flow -agentctl memory search --query restore --limit 10 -agentctl memory write finding --namespace memory-flow --string restore-drill-missing --tags readiness,audit +```yaml +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: + name: hello +spec: + actions: + greeting: + kind: builtin.assign + tasks: + - id: hello + uses: action:greeting + with: + message: hello from agentctl ``` -Command behavior: +Use `check` for strict syntax, references, templates, policy, and provider-capability validation. Use `plan` for deterministic order and predictability, `run --check --diff` for a non-mutating preview, `resume` after interruption, `replay` to reconstruct recorded results without effects, and `fork` when fresh effects are intentional. -- `memory write` creates the memory DB if it does not exist -- `memory get`, `memory search`, and `memory stats` fail on a missing DB path instead of silently creating one -- `--db` defaults to `~/.agentctl/memory/long-term.db` -- `--namespace` filters to a single namespace; omitted namespace searches across all namespaces for CLI reads -- `--value` accepts JSON and `--string` writes a plain string +## Safety boundary -See [docs/memory.md](docs/memory.md) for the detailed memory guide. -See [docs/prompt-cache.md](docs/prompt-cache.md) for prompt-cache support, configuration, sharing modes, and CLI stats. -See [docs/long-term-memory.md](docs/long-term-memory.md) for long-term retention, adapters, MongoDB Atlas, retrieval/promotion, and replay/resume notes. -See [docs/custom-tools.md](docs/custom-tools.md) for pack-defined custom tools, runtime requirements, and host-command wrappers. -See [docs/agent-prompts.md](docs/agent-prompts.md) for inline prompts, prompt files, task-scoped vars, agent default vars, and execution-time prompt rendering. -See [docs/agent-kinds.md](docs/agent-kinds.md) for the supported `agents..kind` values, exact fields, and when to use each one. -See [docs/profiles.md](docs/profiles.md) for the supported agent tool profiles, capability matrix, and selection guidance. -See [docs/policies.md](docs/policies.md) for the supported policy fields, path rules, approval modes, and decision flow. -See [docs/check.md](docs/check.md) for `agentctl check`, YAML syntax validation, schema validation, and prompt-template diagnostics. -See [docs/typescript.md](docs/typescript.md) for the repository TypeScript conventions. +- Secrets are environment references, never inline values or CLI flags. +- Files, processes, providers, MCP servers, and A2A peers require explicit policy grants. +- Every non-pure operation is recorded before execution. A crash after an at-most-once effect starts is reported as uncertain and is never silently repeated. +- Model turns, output tokens, tool calls, retries, and time are bounded. +- Check mode predicts deterministic actions; it does not claim to predict models or remote systems. +- The process policy is an allowlist, not an operating-system sandbox. -## Provider auth +## Providers and protocols -`agentctl` resolves provider auth in this order: +CI uses the scripted fake provider. Native, mock-tested adapters cover OpenAI Responses, Azure OpenAI Responses, Anthropic Messages, and Google Gemini `generateContent`. MCP is pinned to `2025-11-25`; A2A is pinned to `1.0`. Live calls are always opt-in. -1. runtime override from `--api-key` -2. stored API key in `~/.agentctl/auth.json` -3. provider environment variables such as `OPENAI_API_KEY` - -Use `auth check` to diagnose provider configuration before a run: - -```bash -agentctl auth check --provider openai -agentctl auth check examples/real-autonomy/mission.playbook.yaml -``` +## Repository map -`auth check` exits with status `1` if any required provider is missing auth for the inspected playbook. +- `crates/agentctl-core`: DSL, compiler, templates, policy, state, effects, provider/tool contracts +- `crates/agentctl-runtime`: scheduler, actions, agent loop, resume/replay/fork +- `crates/agentctl-store`: versioned SQLite persistence +- `crates/agentctl-providers`: native HTTP provider adapters +- `crates/agentctl-protocols`: MCP and A2A clients +- `crates/agentctl-observability`: audit-safe OpenTelemetry bridge +- `crates/agentctl-cli`: production CLI +- `xtask`: generated artifacts and canonical verification -The first live provider path is `openai.responses`, using the official OpenAI SDK and the Responses API. +Start with [Product](docs/PRODUCT.md), [Architecture](docs/ARCHITECTURE.md), [DSL](docs/DSL.md), [Operations](docs/OPERATIONS.md), [Container contract](docs/CONTAINER.md), [Limitations](docs/LIMITATIONS.md), [Security](docs/SECURITY.md), and the [generated CLI reference](docs/generated/CLI.md). Run the release-readiness layers with: -Supported OpenAI-related auth/config today: - -- `openai` - - `OPENAI_API_KEY` - - `OPENAI_ORG_ID` - - `OPENAI_PROJECT_ID` - - `OPENAI_BASE_URL` -- `azure-openai-responses` - - `AZURE_OPENAI_API_KEY` - - `AZURE_OPENAI_ENDPOINT` - - `OPENAI_API_VERSION` - - optional `OPENAI_ORG_ID`, `OPENAI_PROJECT_ID`, `OPENAI_BASE_URL` - -Stored credentials in `~/.agentctl/auth.json` can be either a legacy string API key or a structured credential object: - -```json -{ - "openai": { - "type": "api_key", - "key": "sk-...", - "organization": "org_...", - "project": "proj_..." - }, - "azure-openai-responses": { - "type": "api_key", - "key": "azure-key", - "endpoint": "https://example-resource.azure.openai.com", - "apiVersion": "2024-10-01-preview" - } -} +```console +cargo xtask verify +cargo xtask acceptance +cargo xtask acceptance-container +cargo xtask package ``` -## Real example - -See [examples/real-autonomy/README.md](examples/real-autonomy/README.md) for a model-backed example that inspects a fixture, writes a report, and verifies the output deterministically. - -See [examples/remote-mcp-autonomy/README.md](examples/remote-mcp-autonomy/README.md) for a second example that crosses a real remote MCP HTTP boundary before persisting and verifying the report. - -See [examples/custom-pack-tools/README.md](examples/custom-pack-tools/README.md) for a pack example that lets an agent call both a wrapped host command and a custom script shipped inside the pack. +`cargo xtask acceptance-live-openai` is the explicit, credentialed live gate and is never part of normal CI. The production image uses `/config`, `/workspace`, `/state`, and `/artifacts` mounts, runs as non-root, and supports a read-only root filesystem. -See [examples/dataflow/README.md](examples/dataflow/README.md) for a deterministic example that proves scalar and structured task outputs can flow across YAML steps without losing shape. +Exit codes are stable: `0` success, `2` usage/validation, `3` policy or approval, `4` run failure, `5` persistence, `6` remote provider/protocol, and `130` cancellation. JSON output always uses the `agentctl.dev/cli/v1` envelope and never includes ANSI color. -See [examples/prompt-file-vars/README.md](examples/prompt-file-vars/README.md) for a deterministic example that proves `instructionsFile`, task-scoped vars, agent default vars, and runtime task-output interpolation. +Licensed under Apache-2.0. diff --git a/docs/A2A.md b/docs/A2A.md new file mode 100644 index 0000000..c5f89d5 --- /dev/null +++ b/docs/A2A.md @@ -0,0 +1,7 @@ +# A2A support + +The client pins [A2A `1.0`](https://a2a-protocol.org/latest/specification/) and discovers an Agent Card. It selects a JSON-RPC interface advertising version `1.0`, then supports `SendMessage`, bounded `GetTask` polling, `CancelTask`, SSE task updates, terminal success/failure/cancel states, messages, structured parts, and artifacts. + +Card and RPC authentication headers are environment secret references. The card URL is subject to network policy, redirects are disabled, and the selected JSON-RPC interface must have the same scheme, host, and effective port as that reviewed card URL. Cards, skills, messages, parts, and artifacts are untrusted data. An A2A delegation is a `remote_agent` effect and may require approval. + +Polling is bounded; request and overall operation timeouts and cancellation are enforced. The client does not claim delivery exactly once or transparently resubmit after an ambiguous response. Mock peers cover discovery, SendMessage/GetTask, artifacts, streaming parsing, cancellation mapping, protocol mismatch, failure, and timeout at the declared maturity. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..6ff5e9c --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,36 @@ +# Architecture + +## Dependency shape + +```text +CLI ───────┬────────> runtime ─────> core + │ │ ▲ + ├────────> providers ──────┤ + ├────────> protocols ─> runtime/core + └────────> store ─────────>┘ +runtime ────────────> observability ─> core contracts +``` + +`agentctl-core` owns deterministic domain behavior: strict parsing, migration, compilation, template resolution, effect identities, state machines, policy, and provider/tool interfaces. It knows no HTTP client, database, or CLI type. `agentctl-store` is the SQLite implementation. `agentctl-runtime` schedules one stable ready task at a time and coordinates injected clocks, IDs, executors, providers, protocols, persistence, and traces. Concrete network adapters and rendering stay at the edges. + +## Execution + +Parsing produces a versioned `Workflow`; compilation resolves references, validates provider capabilities and templates, detects cycles, and emits declaration-order topological tasks plus a digest. The runtime creates durable run/task rows, advances only valid state transitions, evaluates conditions, renders inputs, and executes the selected action or bounded agent. + +An effect request is persisted before any filesystem observation/mutation, process, internal-memory update, long-term-memory operation, tool, model, MCP, or A2A call. Its stable identity covers run, task, task attempt, ordinal, operation, and input digest. A confirmed result can be reused on resume. A started but unconfirmed effect is uncertain and stops recovery rather than being repeated. + +State transitions, checkpoint creation, working-memory replacement, and audit insertion are transactionally coupled where consistency requires it. Provider continuation, function-call correlation, effects, approvals, and redacted trace events are inspectable through the public CLI. Long-term memory is a separate table and never participates in replay correctness. OpenTelemetry export remains optional and is not the audit log. + +## Determinism and concurrency + +Ready tasks are ordered by YAML declaration order after dependencies. `maxConcurrency` currently must be `1`. Parallel execution, loops, matrix/foreach expansion, routers, sub-workflows, handlers, compensation execution, and event triggers are deferred because deterministic merge and recovery semantics are not yet frozen. The DSL carries optional compensation metadata on a tool contract, but the runtime does not execute compensation. + +Clock and identifier generation are injected. Provider responses, tools, and external actions are injected interfaces. Cryptographic digests canonicalize identity; output maps use stable ordering where the public contract requires it. + +## Platform and packaging + +The workspace uses Rust edition 2024, pins Rust 1.88 as the MSRV, forbids unsafe code, and denies clippy warnings. HTTP uses rustls and disables redirects. Subprocesses use direct argv, a cleared environment, explicit allowlists, timeout, and cancellation. SQLite is bundled for predictable installation and creates private files on Unix. SIGINT and SIGTERM converge on durable cancellation. + +The OCI build is multi-stage: only the optimized Rust binary enters a maintained distroless runtime with CA roots and a non-root identity. `/config` is workflow configuration, `/workspace` is the read-only working tree, `/state` holds SQLite, and `/artifacts` receives declared outputs. State must be mounted again for inspect/resume/replay. The root filesystem may be read-only. See [Container contract](CONTAINER.md) and ADR 0007. + +See the [ADRs](adr/) for the decisions and [Durable execution](DURABLE_EXECUTION.md) for failure semantics. diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md new file mode 100644 index 0000000..1d853c0 --- /dev/null +++ b/docs/COMPATIBILITY.md @@ -0,0 +1,23 @@ +# Compatibility policy + +## Preserved + +Declaration-order scheduling among ready tasks, `needs` dataflow, exact typed templates, deterministic assign/assert/file/memory use cases, bounded agent/tool turns, approval concepts, SQLite local persistence, and the useful top-level command names remain. The language-neutral fixture records the legacy assign workflow’s translated model, graph order, and task reference. + +## Migrated + +Unversioned `playbook:` YAML can be translated by `agentctl migrate`; `modules` become `actions`, `module:x` becomes `action:x`, heuristic agents map to the fake provider, and core memory/policy fields are normalized. Rust JSON output is a stable `agentctl.dev/cli/v1` envelope rather than the prototype JSONL/YAML mixture. The production executable and runtime are Rust. + +## Intentionally changed + +`replay` now means no-effect recorded reconstruction. The prototype operation that created a new effectful run is `fork`. Unknown YAML fields, missing references, cycles, unsupported provider capabilities, invalid tool output, path escapes, unsafe processes/networks, and incompatible durable state now fail explicitly. Direct `--api-key` flags are removed; secret references are required. OpenAI uses current Responses concepts, and Anthropic/Google are native adapters rather than names on an OpenAI-compatible route. + +## Deprecated and removed + +Unversioned YAML is compatibility-only and warns. The TypeScript package exposes no `bin` or `main` and is archived. Placeholder memory adapters, provider environment-name-only “support,” YAML output, legacy profiles, automatic endpoint overrides, old prompt-cache fields, and optimistic replay semantics are removed from production. + +Legacy workflows depending on packs, broad built-in tool profiles, remote MCP/A2A shape, MongoDB memory, provider-specific endpoint fields, or embedded credentials require manual conversion. The translator intentionally refuses to guess security-sensitive intent. + +## Deferred product decisions + +Parallel execution, foreach/matrix, loops, routers, sub-workflows, teams/handoffs, compensation execution, a public pack registry/resolver, vector memory, automatic MCP reconnection, general A2A resubmission, and streamed model output are not compatibility promises for v1alpha1. diff --git a/docs/CONTAINER.md b/docs/CONTAINER.md new file mode 100644 index 0000000..eb42499 --- /dev/null +++ b/docs/CONTAINER.md @@ -0,0 +1,252 @@ +# OCI and CI/CD container contract + +The repository `Containerfile` builds the Rust CLI in a pinned Rust 1.88 builder and copies only the optimized binary into a maintained distroless Debian runtime. The runtime has CA roots, version/source/license OCI labels, runs as `nonroot`, has a deterministic `agentctl` entrypoint, and contains no Node.js runtime, TypeScript source, credentials, workflows, or fixtures. + +## Mounts and inputs + +| Path | Contract | +| --- | --- | +| `/config` | read-only reviewed workflow and pack configuration | +| `/workspace` | usually read-only source/fixture workspace | +| `/state` | writable SQLite database and durable recovery state | +| `/artifacts` | writable declared workflow artifacts | + +Pass workflow values with repeated `--input KEY=VALUE`, `--inputs-file`, or `--inputs` JSON. Prefer files for large or sensitive non-provider inputs. Provider credentials are environment references only; never put a key in CLI arguments, YAML, an image layer, or an ordinary input value. Before a bind-mount run, provision `/state` and `/artifacts` host directories so UID/GID 65532 can write them and the runner's artifact collector can read them. Durable state may contain prompts and outputs; protect it like a sensitive build artifact. + +The image emits exactly one versioned JSON result on stdout with `--output json`; failures emit one versioned JSON error on stderr. The document includes exit status semantics, run/trace IDs, final state, and declared outputs. Progress is not mixed into stdout. Persist `/state` for later `inspect`, approval resolution, `resume`, or `replay`. + +## Verified Docker/Podman invocation + +```console +docker run --rm --read-only --user 65532:65532 \ + --tmpfs /tmp:rw,noexec,nosuid,size=16m \ + --mount type=bind,src="$PWD/config",dst=/config,readonly \ + --mount type=bind,src="$PWD/workspace",dst=/workspace,readonly \ + --mount type=bind,src="$PWD/state",dst=/state \ + --mount type=bind,src="$PWD/artifacts",dst=/artifacts \ + --env OPENAI_API_KEY \ + ghcr.io/OWNER/agentctl:0.2.0 \ + run /config/workflow.yaml --workspace /workspace --db /state/runtime.db \ + --input reportPath=/artifacts/report.txt --timeout-seconds 600 \ + --output json --color never +``` + +The value form `--env OPENAI_API_KEY` forwards an already protected host variable without placing its value in the command. The credential-free container acceptance uses the same command with the fake provider and without that environment variable. + +## Pipeline examples + +All examples use the same image/entrypoint contract. Replace the image owner/tag and arrange the four host paths using the platform's storage mechanism. Exit `3` means approval is durably pending: retain the state directory as a protected artifact or persistent volume, resolve the approval in an operator-controlled job, and resume against that same state. Discarding the state directory makes resume impossible. + +### GitHub Actions + +```yaml +jobs: + agentctl: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: mkdir -p .agentctl-state artifacts && chmod 0777 .agentctl-state artifacts + - name: Run agentctl image + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: | + docker run --rm --read-only --user 65532:65532 --tmpfs /tmp:rw,noexec,nosuid,size=16m \ + --mount type=bind,src="$GITHUB_WORKSPACE/config",dst=/config,readonly \ + --mount type=bind,src="$GITHUB_WORKSPACE",dst=/workspace,readonly \ + --mount type=bind,src="$GITHUB_WORKSPACE/.agentctl-state",dst=/state \ + --mount type=bind,src="$GITHUB_WORKSPACE/artifacts",dst=/artifacts \ + --env OPENAI_API_KEY ghcr.io/OWNER/agentctl:0.2.0 \ + run /config/workflow.yaml --workspace /workspace --db /state/runtime.db \ + --input reportPath=/artifacts/report.txt --timeout-seconds 600 \ + --output json --color never + - name: Make mounted outputs collectable + if: always() + run: sudo chown -R "$(id -u):$(id -g)" .agentctl-state artifacts + - uses: actions/upload-artifact@v4 + if: always() + with: + name: agentctl-state-and-artifacts + path: | + .agentctl-state/ + artifacts/ + retention-days: 7 +``` + +### GitLab CI + +This syntax assumes a runner configured with Docker CLI access to the host daemon and a host-visible `$CI_PROJECT_DIR`. + +```yaml +agentctl: + image: docker:27-cli + variables: + AGENTCTL_IMAGE: ghcr.io/OWNER/agentctl:0.2.0 + before_script: + - mkdir -p .agentctl-state artifacts && chmod 0777 .agentctl-state artifacts + script: + - >- + docker run --rm --read-only --user 65532:65532 + --tmpfs /tmp:rw,noexec,nosuid,size=16m + --mount type=bind,src="$CI_PROJECT_DIR/config",dst=/config,readonly + --mount type=bind,src="$CI_PROJECT_DIR",dst=/workspace,readonly + --mount type=bind,src="$CI_PROJECT_DIR/.agentctl-state",dst=/state + --mount type=bind,src="$CI_PROJECT_DIR/artifacts",dst=/artifacts + --env OPENAI_API_KEY "$AGENTCTL_IMAGE" + run /config/workflow.yaml --workspace /workspace --db /state/runtime.db + --input reportPath=/artifacts/report.txt --timeout-seconds 600 + --output json --color never + after_script: + - chown -R "$(id -u):$(id -g)" .agentctl-state artifacts + artifacts: + when: always + expire_in: 7 days + paths: [.agentctl-state/, artifacts/] +``` + +Configure `OPENAI_API_KEY` as a protected, masked GitLab variable. Do not write it in the YAML. + +### Jenkins declarative pipeline + +```groovy +pipeline { + agent any + stages { + stage('agentctl') { + steps { + withCredentials([string(credentialsId: 'openai-api-key', variable: 'OPENAI_API_KEY')]) { + sh ''' + mkdir -p .agentctl-state artifacts + chmod 0777 .agentctl-state artifacts + docker run --rm --read-only --user 65532:65532 \ + --tmpfs /tmp:rw,noexec,nosuid,size=16m \ + --mount type=bind,src="$WORKSPACE/config",dst=/config,readonly \ + --mount type=bind,src="$WORKSPACE",dst=/workspace,readonly \ + --mount type=bind,src="$WORKSPACE/.agentctl-state",dst=/state \ + --mount type=bind,src="$WORKSPACE/artifacts",dst=/artifacts \ + --env OPENAI_API_KEY ghcr.io/OWNER/agentctl:0.2.0 \ + run /config/workflow.yaml --workspace /workspace --db /state/runtime.db \ + --input reportPath=/artifacts/report.txt --timeout-seconds 600 \ + --output json --color never + ''' + } + } + post { + always { + sh 'sudo chown -R "$(id -u):$(id -g)" .agentctl-state artifacts' + archiveArtifacts artifacts: '.agentctl-state/**,artifacts/**', allowEmptyArchive: true + } + } + } + } +} +``` + +### Harness CI Run step + +The runner needs Docker CLI/socket access and four workspace directories. The secret expression is injected as an environment variable and forwarded by name. + +```yaml +- step: + type: Run + name: agentctl + identifier: agentctl + spec: + image: docker:27-cli + shell: Sh + envVariables: + OPENAI_API_KEY: <+secrets.getValue("openai_api_key")> + command: |- + mkdir -p .agentctl-state artifacts + chmod 0777 .agentctl-state artifacts + docker run --rm --read-only --user 65532:65532 \ + --tmpfs /tmp:rw,noexec,nosuid,size=16m \ + --mount type=bind,src=/harness/config,dst=/config,readonly \ + --mount type=bind,src=/harness,dst=/workspace,readonly \ + --mount type=bind,src=/harness/.agentctl-state,dst=/state \ + --mount type=bind,src=/harness/artifacts,dst=/artifacts \ + --env OPENAI_API_KEY ghcr.io/OWNER/agentctl:0.2.0 \ + run /config/workflow.yaml --workspace /workspace --db /state/runtime.db \ + --input reportPath=/artifacts/report.txt --timeout-seconds 600 \ + --output json --color never +``` + +The surrounding Harness stage must publish `/harness/.agentctl-state` and `/harness/artifacts` with its organization-approved artifact step even when this Run step exits nonzero. That vendor-specific publication configuration is intentionally not invented here; the Run step itself was documentation-reviewed, not externally dispatched. + +### Kubernetes Job or CronJob + +Use ConfigMaps for reviewed configuration, a PVC for `/state` when recovery across Pods matters, a PVC or artifact uploader for `/artifacts`, and a Secret environment reference for credentials. The container security context should set `runAsNonRoot`, UID/GID 65532, no privilege escalation, dropped capabilities, and a read-only root filesystem. A CronJob should normally set `concurrencyPolicy: Forbid`; see [Operations](OPERATIONS.md). + +```yaml +apiVersion: batch/v1 +kind: CronJob +metadata: + name: agentctl-report +spec: + schedule: "*/15 * * * *" + timeZone: Etc/UTC + concurrencyPolicy: Forbid + startingDeadlineSeconds: 300 + jobTemplate: + spec: + backoffLimit: 0 + activeDeadlineSeconds: 600 + template: + spec: + restartPolicy: Never + securityContext: + fsGroup: 65532 + containers: + - name: agentctl + image: ghcr.io/OWNER/agentctl:0.2.0 + args: + - run + - /config/workflow.yaml + - --workspace + - /workspace + - --db + - /state/runtime.db + - --inputs-file + - /config/inputs.json + - --timeout-seconds + - "540" + - --output + - json + - --color + - never + env: + - name: OPENAI_API_KEY + valueFrom: + secretKeyRef: + name: agentctl-provider + key: openai-api-key + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: [ALL] } + volumeMounts: + - { name: config, mountPath: /config, readOnly: true } + - { name: workspace, mountPath: /workspace, readOnly: true } + - { name: state, mountPath: /state } + - { name: artifacts, mountPath: /artifacts } + - { name: tmp, mountPath: /tmp } + volumes: + - name: config + configMap: { name: agentctl-report } + - name: workspace + persistentVolumeClaim: { claimName: agentctl-workspace } + - name: state + persistentVolumeClaim: { claimName: agentctl-state } + - name: artifacts + persistentVolumeClaim: { claimName: agentctl-artifacts } + - name: tmp + emptyDir: { sizeLimit: 16Mi } +``` + +For a one-time invocation, use the same Pod template in a `batch/v1` `Job` and omit schedule/concurrency fields. Kubernetes CronJobs can occasionally create duplicate Jobs, so workflow effects still need appropriate idempotency. + +## Validation level + +The native-arm image was executed with Podman as non-root with a read-only root. The final audit exercised a mock tool workflow, artifact and durable inspection, missing-secret and invalid-workflow exit propagation, SIGTERM, and recorded replay under `--network none`. Earlier recorded evidence covers the bounded OpenAI tool workflow, but its source database was not retained for this audit's independent replay. Trivy 0.70.0 found no HIGH/CRITICAL findings, both with and without `--ignore-unfixed`, and generated a CycloneDX JSON SBOM in the ignored verification area. GitHub, GitLab, Jenkins, Harness, and Kubernetes examples were documentation-reviewed but not dispatched to those external platforms. The configured Ubuntu CI container job is the Linux amd64 execution, scan, and SBOM gate when that workflow runs. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..ea83d81 --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,15 @@ +# Contributing + +Use the pinned Rust toolchain and keep changes scoped to the deterministic product. Before editing a public contract, add or update a fixture/test and an ADR when durability, security, compatibility, dependency direction, or protocol version changes. + +```console +cargo fmt --all +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-features --locked +cargo xtask generate +cargo xtask verify +``` + +Generated schema and CLI reference must be committed. No test, example, benchmark, fuzz target, or CI job may require provider credentials. Do not add raw keys, secret CLI flags, redirects, shell-string execution, unbounded retries/turns, or implicit effects. New providers require native mapping, capabilities, normalized errors/usage/cancellation, documentation, example configuration, and mock conformance. New tools require both schemas, risk/effect/idempotency/approval metadata, policy hooks, and malicious-output tests. + +Dependencies must be registry releases with reviewed licenses and no wildcard constraints. Unsafe Rust is forbidden. Cross-platform behavior belongs in the CI matrix. Update `docs/execution` with exact evidence when finishing a release gate. diff --git a/docs/DSL.md b/docs/DSL.md new file mode 100644 index 0000000..d967165 --- /dev/null +++ b/docs/DSL.md @@ -0,0 +1,15 @@ +# Workflow DSL + +The current document version is `agentctl.dev/v1alpha1`, with `kind: Workflow`. The generated, authoritative JSON Schema is [`schemas/workflow.schema.json`](../schemas/workflow.schema.json). YAML documents are limited to 1 MiB and reject unknown fields. + +`metadata` contains the name, description, and labels. `spec` contains typed inputs/outputs; providers; bounded agents; actions; tool contracts; ordered tasks; policy; memory; MCP servers; A2A peers; packs; runtime; and output settings. A task `uses` either `action:` or `agent:`, declares `needs`, an optional `when`, local `vars`, typed `with` input, retry, timeout, and failure behavior. + +Templates use only `${{ inputs.path }}`, `${{ vars.path }}`, `${{ memory.path }}`, and `${{ tasks.task-id.output.path }}`. Conditions additionally allow `not` and equality against a JSON literal or string. Exact templates preserve their JSON type; interpolation into text accepts only scalars. Missing and explicit `null` are different. There is no code execution, function call, indexing, arithmetic, or implicit task dependency. + +Providers, action environments, and protocol headers use `{ env: NAME }` secret references. Secret names are validated and values never become the workflow document. + +The compiler validates missing references, duplicate tasks, cycles, task-aware templates, tool references, provider capabilities, agent limits, and sequential runtime settings before execution. Ready tasks follow declaration order. `maxConcurrency` must be `1` in this version. + +The parser translates a limited unversioned `playbook:` document and emits a migration warning. Use `agentctl migrate old.yaml --write new.yaml`. Legacy pack-backed, MCP, A2A, provider-specific, and broad module configurations need manual migration; see [Migrating from TypeScript](MIGRATING_FROM_TYPESCRIPT.md). + +Not implemented in v1alpha1: `foreach`, matrix expansion, parallel groups, routers, loops, sub-workflows, `finally`, handlers, event triggers, or compensation execution. They remain excluded until their deterministic state, merge, and recovery semantics are specified. diff --git a/docs/DURABLE_EXECUTION.md b/docs/DURABLE_EXECUTION.md new file mode 100644 index 0000000..63d3f30 --- /dev/null +++ b/docs/DURABLE_EXECUTION.md @@ -0,0 +1,20 @@ +# Durable execution + +SQLite is the local history and correctness boundary. Run, task, effect, approval, checkpoint, audit, provider-session, tool-call, and long-term-memory records are schema-versioned. Future database, runtime, plan, effect, or checkpoint versions fail explicitly instead of being ignored. + +## Operations + +- Resume continues the same run from durable task state. Confirmed effects are reused. A requested-but-not-started effect may execute; a started-but-unconfirmed effect fails as uncertain. +- Recorded replay creates a replay record from terminal stored outputs and calls no provider, tool, network, process, or filesystem executor. +- Fork creates a new run linked to the old run and intentionally permits fresh effects. +- Retry creates a new task attempt only within the task’s explicit bound. An unsafe unresolved effect is not retried. + +An effect ID is SHA-256 over run ID, task ID, task attempt, ordinal, operation, and input digest. Each record carries its format version, idempotency key, effect class, risk, status, request/result or error, timestamps, trace correlation, and confirmation flag. The request commits before the executor starts. This supports deterministic reuse of completed results but does not prove exactly-once behavior in an external system. + +Pure operations need no external guarantee. Idempotent and keyed effects may be safely retried only when their implementation contract says so. Model calls and unknown remote mutations are treated at-most-once after start: a crash in the acknowledgement window creates an uncertain effect requiring operator reconciliation or an explicit fork. This is deliberately more conservative than silent at-least-once replay. + +Working-memory replacement, the task transition, checkpoint, and audit event commit in one SQLite transaction. On resume, a confirmed memory-write effect is applied to the reconstructed working-memory value during the succeeding transition. Long-term memory is an external effect and is not rolled back by replay. + +Cancellation is both an injected token and a durable run flag. CLI SIGINT and SIGTERM cancel in-flight async calls and return exit `130`; `agentctl cancel` records a request for another process to observe. An overall CLI deadline can be set with `--timeout-seconds`, in addition to task/tool/provider/protocol bounds. A provider, tool, process, MCP, or A2A timeout/cancellation/transport loss after dispatch marks the effect `uncertain`; resume refuses to guess and requires reconciliation or an explicit fork. + +Clock and ID generation are injected; test providers/tools/protocol handlers are injected. The current scheduler is sequential, so output and memory commit order is task declaration order. diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md new file mode 100644 index 0000000..4344c2f --- /dev/null +++ b/docs/LIMITATIONS.md @@ -0,0 +1,48 @@ +# Limitations and roadmap classification + +This classification is part of the product contract. A deferred feature is not a current capability, but its absence is not automatically a release blocker for the local, externally scheduled, and generic OCI-step journeys. + +## Release blockers + +No known implementation blocker remains for the stated local, scheduled, and OCI journeys. The final independent audit recommends internal review rather than release-candidate designation because the earlier live OpenAI source database was not retained, so its exact durable state could not be replayed again under network denial. Deterministic host replay and OCI `--network none` replay both pass with zero effects or tool calls. + +## Required hardening completed for this release + +- Provider-specific options are allowlisted, type-checked, included in plan capability negotiation, and either mapped or rejected. Streaming and programmatic tool calling are rejected rather than ignored. +- Tool input/output schemas are strict; built-in tool kinds have compiler-checked capability/effect/idempotency contracts. +- Provider calls, function-call IDs/results, continuations, effects, checkpoints, audit events, and redacted trace events are durable and publicly inspectable. +- Timeout/transport ambiguity is not automatically retried; confirmed effects survive resume; call IDs are scoped by run; missing credentials fail before run/database creation. +- Non-interactive approvals durably pause, signals cancel safely, JSON errors include available run/trace correlation, and SQLite uses WAL plus a bounded lock wait. +- The packaged CLI, clean-directory quickstart, cron-like empty environment, and non-root/read-only OCI contract have executable acceptance coverage. + +## Post-v1 features + +These are useful extensions but are not required by the product thesis. They need new deterministic state and compatibility contracts before implementation: + +- parallel task execution; `foreach` and matrix expansion; loops; routers; sub-workflows; compensation execution; +- structured agent teams and handoffs; +- model token streaming into CLI/workflow state; +- opt-in MCP reconnection and A2A resubmission with explicit remote reconciliation; +- pack dependency resolution, pack lockfiles, remote fetching, publisher signatures, process-backed pack tools, and a versioned plugin ABI; +- vector memory; +- encrypted application-level persistence and external secret-manager adapters; +- reliable monetary cost enforcement when providers expose sufficient authoritative metadata. + +## Explicit non-goals + +- Event triggers and calendars: external schedulers trigger `agentctl`. +- MongoDB migration, distributed scheduling, multi-host execution, and distributed storage: the correctness boundary is one local process and SQLite database. +- An in-process OS sandbox or stronger network isolation: allowlists are defense in depth, while containers/VMs, identities, egress policy, and platform sandboxes own isolation. +- Free-form multi-agent conversation control flow: the compiled workflow remains authoritative. + +## Current operational limits + +- The document API is `v1alpha1`; pin the binary/image version and validate before upgrading. +- Scheduling is sequential (`maxConcurrency: 1`). Separate runs may overlap safely in SQLite, but they can still target the same external resource. Use the external scheduler's overlap controls (`flock`, systemd unit serialization, or Kubernetes `concurrencyPolicy: Forbid`) when effects must not overlap. +- SQLite is local durable state, not a secret vault or distributed lease service. Persist `/state` across container invocations and back it up according to the workflow's recovery needs. +- Filesystem/process/network allowlists are not an OS sandbox. Run untrusted workflows in a restricted container/VM with least-privilege credentials and egress. +- At-most-once model/remote calls can become uncertain in the dispatch/acknowledgement window. Inspect and reconcile externally; use `fork` only when fresh effects are knowingly acceptable. +- Tool-using OpenAI/Azure agents require stored-response continuation. `store: false` is rejected until stateless response-item replay is implemented. +- Anthropic, Google, Azure OpenAI, MCP, and A2A are native and mock-tested in this release, not live-tested. Only the OpenAI GPT-5.6 tool path has live end-to-end evidence. +- The local OCI execution evidence is Linux arm64. Linux amd64 is built/tested by the configured Ubuntu CI job when that workflow runs; this local task did not execute the remote CI matrix. +- The native arm64 image had no HIGH/CRITICAL findings in the final Trivy 0.70.0 scan, both with and without `--ignore-unfixed`, and produced a CycloneDX SBOM. The same checks are configured for the Linux amd64 CI image; the external CI job is not represented as executed until its own ledger exists. diff --git a/docs/MCP.md b/docs/MCP.md new file mode 100644 index 0000000..523e64d --- /dev/null +++ b/docs/MCP.md @@ -0,0 +1,7 @@ +# MCP support + +The client pins the stable MCP protocol version `2025-11-25` from the [official specification](https://modelcontextprotocol.io/specification/2025-11-25). It uses Streamable HTTP JSON-RPC and implements initialization/version negotiation, initialized notification, session IDs, protocol headers, tool listing, input/output schemas, tool calls, structured content, error mapping, SSE response parsing, timeout, best-effort cancellation notification, and explicit session-expiry failure. + +Authentication headers are environment secret references. The endpoint must pass network policy. Redirects are disabled and an `Origin` header is sent. Remote descriptions, schemas, content, and annotations are untrusted; annotations are exposed only as metadata and never authorize an effect. + +The client initializes lazily and does not automatically reconnect after session expiry because repeating a remote operation could be unsafe. The caller must reconcile and resume or fork. Streaming transport is parsed, but tool results are delivered to the runtime only when complete. Deterministic local mock-server tests cover negotiation, sessions, listing/call, structured results, version mismatch, and timeout. diff --git a/docs/MIGRATING_FROM_TYPESCRIPT.md b/docs/MIGRATING_FROM_TYPESCRIPT.md new file mode 100644 index 0000000..0df677b --- /dev/null +++ b/docs/MIGRATING_FROM_TYPESCRIPT.md @@ -0,0 +1,15 @@ +# Migrating from the TypeScript prototype + +1. Preserve a copy of the old workflow and run the archived test suite if its behavior matters: `NODE_OPTIONS=--no-deprecation npm test`. +2. Run `cargo run -p agentctl -- migrate old.yaml --write workflow.yaml`. +3. Run `agentctl check workflow.yaml` and address every diagnostic; the new schema is strict. +4. Replace `module:name` with `action:name`, and define typed provider entries referenced by agents. +5. Move credentials to `{ env: NAME }`; remove API-key arguments and inline tokens. Add environment, provider, host, process, readable workspace, and writable-root policy grants explicitly. +6. Replace the old effectful meaning of replay with `fork`. Use `replay` only when no current external observation is desired. +7. Review model settings: OpenAI is Responses-native, `reasoning.effort` uses current values, tools require strict input/output schemas, and token/tool/turn/time bounds are mandatory/defaulted. +8. Convert MCP to `mcpServers` version `2025-11-25` and A2A to `a2aPeers` version `1.0`; secrets belong in header references. +9. Verify with the fake provider and local mocks. Run any `*-live.yaml` example only as an explicit external test. + +The automatic translator covers simple top-level metadata, modules/actions, tasks, heuristic agents, common approval mode, and initial working memory. It discards unsupported legacy provider endpoint/cache/profile fields with a migration warning rather than preserving unsafe or obsolete semantics. Pack-backed actions, remote transports, MongoDB/vector memory, arbitrary profiles, and custom TypeScript executors must be rewritten against the Rust contracts. + +Use `fixtures/compat/v0/assign.playbook.yaml` as the minimum preserved contract and compare changes against [Compatibility](COMPATIBILITY.md). The old source is non-production reference material; do not add new behavior to it. diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md new file mode 100644 index 0000000..2e07351 --- /dev/null +++ b/docs/OBSERVABILITY.md @@ -0,0 +1,9 @@ +# Observability + +The runtime emits versioned typed events for runs, tasks, attempts, agent turns, provider/model responses, tool/effect calls, approvals, MCP/A2A operations, retries, checkpoints, state transitions, and useful database boundaries. Events carry run/task/effect and trace correlation plus phase and timestamp. + +`agentctl-observability` provides a no-op sink, buffered test sink, and an OpenTelemetry-compatible global tracer bridge. Tracing is optional and has no role in scheduling or replay. Structured audit events are persisted separately in SQLite and ordered per run. + +Sensitive field names and registered secret values are redacted before trace attributes leave the runtime. Provider response content is not printed by the live smoke. Operators must still treat trace backends and the local database as sensitive because prompts, file content, tool output, and remote artifacts may contain confidential non-secret data. + +Usage maps input/output/reasoning/cache-read/cache-write tokens where providers expose them. Duration, attempts, provider errors, retries, approval waits, tool counts, and action change status are available from trace and audit events. Price calculation is not fabricated when no reliable price metadata exists. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..40db127 --- /dev/null +++ b/docs/OPERATIONS.md @@ -0,0 +1,66 @@ +# Scheduled and unattended operation + +Scheduling belongs to the external platform. `agentctl` owns deterministic execution, SQLite history, overlap-safe database access, effects, outputs, recovery, and diagnostics; it does not own clocks, calendars, leader election, log rotation, or distributed leases. + +## Non-interactive contract + +- Do not pass `--interactive` from cron or CI. +- Use `--output json --color never` for one parseable final document on stdout. Errors use the same versioned envelope on stderr. +- Set explicit `--workspace`, `--db`, artifact inputs, and `--timeout-seconds`. +- A pending approval is persisted and exits `3`; it never waits on stdin. Use `approvals list`, an operator-controlled `approve` or `reject`, and then `resume` with the same database and workspace. +- Success is `0`, validation is `2`, policy/approval is `3`, run failure is `4`, persistence is `5`, provider/protocol failure is `6`, and cancellation is `130`. +- Output/error correlation includes a run ID and trace ID whenever a run exists. + +## Cron + +Use absolute paths and an external overlap lock when two schedules must not affect the same resource: + +```cron +*/15 * * * * /usr/bin/flock -n /var/lib/agentctl/report.lock /usr/local/bin/agentctl run /etc/agentctl/report.yaml --workspace /srv/app --db /var/lib/agentctl/runtime.db --inputs-file /etc/agentctl/inputs.json --timeout-seconds 600 --output json --color never >>/var/log/agentctl/report.jsonl 2>>/var/log/agentctl/report.err +``` + +The administrator owns log rotation and restrictive file permissions. Provider keys belong in the scheduler's protected environment, never in the crontab command line. + +## systemd timer + +```ini +# /etc/systemd/system/agentctl-report.service +[Unit] +Description=Run the reviewed agentctl report workflow + +[Service] +Type=oneshot +User=agentctl +EnvironmentFile=/etc/agentctl/provider.env +ExecStart=/usr/local/bin/agentctl run /etc/agentctl/report.yaml --workspace /srv/app --db /var/lib/agentctl/runtime.db --inputs-file /etc/agentctl/inputs.json --timeout-seconds 600 --output json --color never +ReadWritePaths=/var/lib/agentctl /srv/agentctl-artifacts +NoNewPrivileges=true +PrivateTmp=true +``` + +```ini +# /etc/systemd/system/agentctl-report.timer +[Unit] +Description=Schedule the agentctl report workflow + +[Timer] +OnCalendar=*:0/15 +Persistent=true +Unit=agentctl-report.service + +[Install] +WantedBy=timers.target +``` + +A oneshot service has one active invocation at a time. Use distinct databases only when independent histories are intended. + +## Recovery + +1. Capture the final JSON/error envelope and run/trace IDs. +2. Run `agentctl inspect RUN_ID --db PATH --output json`. +3. Resolve a pending approval, then `resume`; never use `fork` as an implicit retry. +4. Use `replay` for a no-effect reconstruction of a terminal run. +5. Use `fork` for a new run that may execute fresh effects. +6. For an uncertain effect, reconcile the remote system first. The runtime intentionally refuses unsafe resume. + +Use `agentctl gc --db PATH --older-than-days N` for expired memory and old terminal histories after the organization's retention/backup requirements are satisfied. SQLite WAL files belong with the database during backup. A future schedule-run key may improve deduplication; today the external scheduler owns overlap prevention. diff --git a/docs/PACKS.md b/docs/PACKS.md new file mode 100644 index 0000000..661e89d --- /dev/null +++ b/docs/PACKS.md @@ -0,0 +1,7 @@ +# Packs + +A pack is reviewed reusable YAML content, not executable plugin code. Its manifest API is `agentctl.dev/pack/v1alpha1` and declares a fully qualified dotted name, semantic version, agentctl semver constraint, actions, agents, tool contracts, capabilities, provider requirements, and optional policy defaults. + +`agentctl packs inspect` strictly parses the manifest, validates the API version, name, versions, and compatibility with the running binary. `agentctl packs verify` compares a `sha256:` integrity digest for a local manifest or archive. Workflow pack references carry name, version, local path, and integrity. The CLI verifies a referenced manifest, keeps it beneath the workflow directory, and loads actions, agents, and tools as `.` before compilation. + +Dependency resolution, transitive lockfile generation, Git fetching, reusable sub-workflows, policy-default merging, a hosted registry, native dynamic libraries, and pack processes are not implemented. A checked-in pack reference is therefore an integrity/provenance contract for local content, not a package manager. Manifest policy defaults are inspectable metadata and never weaken the invoking workflow’s policy. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md new file mode 100644 index 0000000..ec7271f --- /dev/null +++ b/docs/PRODUCT.md @@ -0,0 +1,36 @@ +# Product definition + +## Thesis and boundaries + +`agentctl` is a local-first control plane for automation that needs ordinary deterministic work and narrowly bounded model reasoning in the same durable run. The graph, policy, persistence, and replay are authoritative; a model is a replaceable executor for one task. + +Primary users are application and platform engineers authoring reviewed automation, security-conscious teams introducing model calls into existing operations, CI maintainers needing credential-free validation, and Rust applications embedding the runtime. Their jobs are to validate before acting, understand an exact plan, constrain effects, recover from interruption, prove what happened, and reuse reviewed content. + +Core use cases are local repository automation, approval-gated changes, structured model enrichment, provider-portable agent tasks, MCP tool calls, A2A delegation, cron-invoked runs, and generic containerized CI steps. `agentctl` is a schedulable runtime, not a scheduler: cron, systemd, Kubernetes, and CI own triggers and overlap policy. Hosted orchestration, a visual builder, chat, distributed scheduling, a public registry, arbitrary configuration management, secret storage, and unbounded autonomy are non-goals. + +## Journeys + +- Local: author strict YAML, run `check`, inspect `plan`, preview with `run --check --diff`, execute, approve if required, and inspect the audit history. +- Scheduled: invoke the CLI without a TTY, use explicit database/workspace/artifact paths and an overall timeout, receive exit `3` for a durable pending approval, and resume through an operator-controlled invocation. +- CI: mount config/workspace/state/artifacts into the generic OCI image, inject secrets only as environment variables, pass inputs by `--inputs-file` or repeated `--input`, and consume one versioned final JSON envelope on stdout. +- Embedded: construct core workflow and plan values, inject a store, providers, tools, clock, IDs, and tracing, then invoke the runtime with a cancellation token. + +Provider portability means the internal message, tool, continuation, usage, and capability contracts do not expose provider SDK types. It does not mean every provider has identical features. Compilation rejects a requested feature absent from the chosen provider. + +Reusable packs have a versioned manifest, fully qualified name, semantic version, agentctl constraint, capability/provider declarations, and file integrity verification. This release deliberately has no remote registry or executable plugin ABI. + +## Trust model + +Workflow and pack authors are trusted to request work, but their requests remain policy constrained. Model output, tool output, remote descriptions, file content, MCP annotations, A2A cards, and network responses are untrusted. Environment variables may contain secrets and are read only at adapter boundaries after allowlist checks. Primary provider credentials are loaded immediately before dispatch; configured header references are loaded during adapter construction, before run creation. SQLite is local durable state, not a secret vault. + +## Compatibility and maturity + +The current document API is `agentctl.dev/v1alpha1`; breaking changes may occur with explicit diagnostics and migration support. Machine output, plan, effects, runtime state, checkpoints, database schema, audit events, and protocol continuation all carry independent versions. Deprecations are documented for at least one compatibility window; incompatible durable state fails explicitly. + +Version 0.2 is a production-oriented alpha with executable evidence for the stated local, scheduled, and generic-container journeys. The workflow schema remains `v1alpha1`, so callers must pin the binary/image version. A stable release requires a frozen v1 workflow schema, accumulated cross-platform CI history, documented long-horizon database upgrade support, expanded compatibility fixtures, and a security review of any newly added executor. + +## Differentiation + +This is not a chat-agent or multi-agent conversation framework: workflows, not conversations, own control flow. It is not CI/CD: it can run inside CI but does not manage runners or deployment environments. It borrows idempotence and check/diff vocabulary from Ansible without becoming configuration management. It borrows plan/effect separation from Terraform without owning infrastructure state. It is not a hosted orchestrator or general scripting language: one local process, SQLite, constrained templates, typed actions, and explicit remote effects are intentional boundaries. + +The differentiator is the combination of deterministic compilation, honest predictability, durable effect identity, recorded no-effect replay, native provider portability, and policy decisions made outside the model. diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md new file mode 100644 index 0000000..aecec52 --- /dev/null +++ b/docs/PROVIDERS.md @@ -0,0 +1,29 @@ +# Providers + +The core defines provider-neutral messages, text/reasoning/tool content, strict tool schemas, tool calls/results, finish reasons, usage, and opaque continuation. The compiler compares each agent’s requested structured output, tools, reasoning, cache, and continuation needs with typed provider capabilities. + +| Kind | Native API | Implemented behavior | Credential default | +| --- | --- | --- | --- | +| `fake` | in-process scripted provider | deterministic echo/script, tool path, usage | none | +| `openai` | Responses API | GPT-5.6; strict function tools and structured output; multiple call IDs; `previous_response_id`; reasoning effort/mode/context; response storage; prompt-cache mode/TTL; input/output/reasoning/cache metrics | `OPENAI_API_KEY` | +| `azure_openai` | Azure `/openai/v1/responses?api-version=v1` | OpenAI mapping with Azure `api-key`; explicit endpoint required | `AZURE_OPENAI_API_KEY` | +| `anthropic` | Messages API | native content/tool blocks, structured output instruction, usage and stop mapping | `ANTHROPIC_API_KEY` | +| `google` | Gemini `generateContent` | native contents/function declarations/calls, response schema, token usage | `GEMINI_API_KEY` | + +Endpoints must pass the workflow network allowlist. Redirects are disabled. Credentials and configured headers are resolved from environment references only when building an adapter; standard authentication headers override custom headers. Errors are normalized without response bodies or secret values, and calls honor timeout and cancellation. + +`agentctl providers inspect ` reports declared capabilities without calling a service. OpenAI has the broadest mock request/response/tool/usage/error coverage. Azure OpenAI, Anthropic, and Google have native mapping and focused mock-protocol coverage at the maturity shown below; normal tests have no credentials. Live provider workflow examples end in `-live.yaml` and are opt-in. + +| Provider | Validation level in this tree | +| --- | --- | +| Fake | deterministic in-process runtime and acceptance tested | +| OpenAI | native adapter mock-protocol tested; prior bounded GPT-5.6 tool workflow live-tested | +| Azure OpenAI | native adapter request/auth/response mapping mock-tested; not live-tested | +| Anthropic | native text/tool/usage mapping mock-tested; not live-tested | +| Google | native text/function/usage mapping mock-tested; not live-tested | + +`agentctl providers smoke-openai --live --model gpt-5.6` remains a provider-only diagnostic; it is not runtime acceptance. The repository-owned live gate is `cargo xtask acceptance-live-openai`. It runs a YAML workflow through compilation, SQLite, a real strict function call, built-in tool policy/schema validation, `previous_response_id` continuation, deterministic assertion/artifact creation, public inspection, and replay with the credential removed. It repeats the journey inside the production OCI image and never runs in normal CI. Anthropic, Google, and Azure are implemented and mock-tested but are not live-tested in this release. + +OpenAI provider options are an allowlisted map (`store`, `reasoningContext`, `promptCacheMode`, `promptCacheTtl`, `parallelToolCalls`, and `safetyIdentifier`). Unknown options or invalid values fail compilation. Tool-using OpenAI and Azure OpenAI agents may not set `store: false`: stateless continuation would require replaying returned response/reasoning/function items, which this release does not implement. One-turn agents without tools may disable storage. Programmatic tool calling and model streaming are explicitly unsupported in the workflow runtime and fail rather than being ignored. Parallel function calls are parsed and correlated, but executors run them serially in response order because v1 scheduling is sequential. + +Cost is not inferred when a provider returns no reliable cost metadata. A workflow requesting `maxCostUsd` therefore fails capability negotiation; input/output token limits are enforced from native usage. Retry is limited to explicit task bounds and definitive retryable HTTP responses. Timeout, cancellation, or a transport loss after dispatch is considered ambiguous and is not automatically reissued. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..4878606 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,24 @@ +# Security + +## Controls + +- Workflow parsing is strict, bounded to 1 MiB, source-aware, and has no executable expression language. +- Environment-backed primary credentials are resolved immediately before provider dispatch; custom header references are resolved while constructing the adapter, before a run or database is created. There are no API-key flags. Authentication values are not persisted or traced, and error text is redacted. +- Canonical read/write roots reject `..` and symlink escape. Writes use temporary files and rename. +- Processes require an allowed executable basename, direct argv, cleared environment, selected variables, timeout, and cancellation. +- Network destinations require an exact/wildcard host grant. Provider and protocol clients disable redirects and use rustls. +- Tool input and output JSON Schemas are enforced. Models, MCP annotations, A2A cards, remote schemas, and results cannot grant capabilities. +- Requests are ledgered before effects. Approval is durable; non-interactive mode pauses with exit `3` or uses an explicitly stricter deny/fail mode, never a prompt or implicit approval. +- SQLite uses foreign keys, WAL/busy timeout, version checks, checksummed checkpoints, and mode `0600` on Unix. +- Packs require a supported manifest/version and can be checked against SHA-256 integrity. +- The workspace forbids unsafe Rust, denies warnings, locks dependencies, checks licenses/sources/advisories, scans secret patterns, and keeps live tests outside CI. + +## Limitations + +Path and executable allowlists are not a sandbox. A permitted program can access anything the operating-system identity can access. Host allowlists do not defend against every DNS rebinding, proxy, local-service, or compromised endpoint scenario; use network isolation for hostile workflows. SHA-256 integrity establishes sameness, not author identity. SQLite protects local correctness but is not encrypted and is not a secret store. + +Prompts, file content, model output, remote artifacts, and tool output may be confidential or malicious. Treat them as data, validate before mutation, minimize trace export, and isolate untrusted automation. Approval is a decision point, not proof that an operation is safe. At-most-once recovery may leave an uncertain external outcome for human reconciliation. + +MCP reconnection and A2A resubmission are intentionally not automatic. Streaming is bounded but completed results, not token deltas, enter workflow state. Windows cannot express Unix database mode bits; rely on the user profile ACL and CI tests. + +Report vulnerabilities privately to the repository maintainer. Do not include credentials, database contents, or production prompts in a report. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..3423b3e --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,31 @@ +# Testing and verification + +The canonical command is: + +```console +cargo xtask verify +``` + +User-journey layers are separate: + +```console +cargo xtask acceptance +cargo xtask acceptance-container +cargo xtask acceptance-live-openai # explicit credentialed gate only +cargo xtask package +``` + +It checks rustfmt; clippy with all targets/features and warnings denied; locked build; unit, integration, compatibility, provider, protocol, persistence, runtime, and security tests; rustdoc; generated schema/CLI consistency; all workflow validation and deterministic examples; negative capability/policy/no-mutation cases; dependency sources/licenses/advisories; a repository secret-pattern scan; `cargo install`; and the Rust-only production boundary. + +Unit tests cover parser diagnostics, strictness, compiler order/cycles/capabilities, templates, tool schemas, policy traversal/network/redaction, state transitions, effect recovery, store migration/corruption/checkpoints, runtime dataflow/check/diff/approval/cancellation/replay/fork, provider mappings, protocols, and traces. `proptest` exercises arbitrary templates and typed preservation. Language-neutral fixtures in `fixtures/compat` preserve the TypeScript oracle’s external graph/dataflow contract. + +`fuzz/` contains `cargo-fuzz` targets for workflow YAML/templates, provider responses, MCP/A2A payload shapes, persisted state, and tool schemas/inputs. They use no network or credentials. Example: + +```console +cargo install cargo-fuzz +cargo fuzz run workflow_yaml -- -max_total_time=60 +``` + +CI runs the canonical suite on Linux, macOS, and Windows, stable and Rust 1.88, plus credential-free acceptance, a Linux amd64 container gate, and strict supply-chain checks. Provider/protocol conformance uses local mock HTTP servers. Normal examples are deterministic; MCP/A2A runtime behavior is covered by mocks rather than requiring a background service. + +The only full live gate is the separately invoked OpenAI acceptance described in [Providers](PROVIDERS.md). It performs two bounded Responses API requests locally and two in the OCI image for one tool-call/continuation journey each, then performs keyless replays. Never run it for debugging loops, fuzzing, load, or normal CI. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md new file mode 100644 index 0000000..8771c16 --- /dev/null +++ b/docs/THREAT_MODEL.md @@ -0,0 +1,25 @@ +# Threat model + +## Assets and boundaries + +Assets are workspace files, allowed environment secrets, provider accounts, external systems reached by tools, workflow history, prompts/results, approvals, and the integrity of deterministic scheduling. Boundaries are the YAML/pack parser, filesystem/process/network executors, provider APIs, MCP servers, A2A peers, SQLite, trace exporters, and dependencies. + +The local operator and reviewed binary are trusted. Workflow authors are only as trusted as policy grants. Models, file content, remote descriptions/results, pack content without independent provenance, and all network peers are untrusted. The host OS, CA store, and Rust dependency supply chain are assumed but monitored dependencies. + +| Threat | Control | Residual risk | +| --- | --- | --- | +| Malicious YAML/template causes code execution or resource exhaustion | strict fields, constrained paths/equality, 1 MiB bound, fuzzing | deeply nested valid data remains bounded mainly by parser behavior | +| Path traversal or symlink escape | canonical roots and focused tests | TOCTOU is possible if another process swaps paths; isolate hostile workspaces | +| Secret exfiltration through CLI/log/database/trace | env references, no key flags, allowlists, redaction, secret scan | authorized tools can deliberately transmit permitted data | +| Command injection | direct argv, no shell, cleared env, executable allowlist | an allowed executable may interpret malicious arguments | +| SSRF/redirect bypass | URL parse, host allowlist, disabled redirects, tests | DNS/proxy behavior needs external network containment for hostile inputs | +| Prompt injection grants tool authority | policy outside model, visible tool set, schema validation, approvals | an operator may approve deceptive content | +| MCP annotation or A2A card claims safety | always treated as untrusted metadata | compromised authorized peer can return malicious but schema-valid data | +| Crash duplicates an external mutation | request-before-start ledger, uncertain state, no silent retry | external action may have happened without acknowledgement | +| Replay reissues effects | recorded replay uses stored terminal output only | replayed data may no longer reflect current reality, by design | +| Approval bypass in CI | non-interactive durable pause or explicit deny/fail; operator resolution | stolen database write access is outside application trust boundary | +| Pack substitution | SHA-256 verification and semver/API checks | digest source/signature trust is manual | +| Corrupt or future state misexecutes | schema/version/checksum/deserialization failures | SQLite file deletion or rollback by an attacker is not prevented | +| Dependency compromise | locked registry-only deps, cargo-deny, license/source checks | registry compromise and zero-days remain possible | + +No unresolved critical or high-severity defect is knowingly accepted for the implemented boundary. Deferred sandboxing, signature verification, distributed concurrency, and encrypted storage are explicit product limitations, not implied controls. diff --git a/docs/TOOLS.md b/docs/TOOLS.md new file mode 100644 index 0000000..cbb3eb8 --- /dev/null +++ b/docs/TOOLS.md @@ -0,0 +1,11 @@ +# Tools and effects + +A tool contract has a stable ID, description, input and output JSON Schema, capability, risk, effect class, idempotency, retry-safety flag, timeout, secret and network requirements, approval mode, and optional compensation metadata. Inputs are validated before an executor is called; outputs are validated before entering messages or task state. Executor errors remain errors. + +Effect classes are `pure`, `internal_state`, `observe`, `workspace_mutate`, `external_mutate`, `process_execution`, `network`, `model`, and `remote_agent`. Idempotency is `pure`, `idempotent`, `keyed`, `at_most_once`, or `unknown`. These values drive durable recovery and policy; model-provided MCP annotations never override them. + +Built-in model-callable tools are workspace read, workspace write, and echo. Their declared kind must match compiler-enforced capability/effect/idempotency semantics; the packaged CLI registers each declared built-in executor. Function-call IDs, input/output digests, status, and effect correlation are stored per run. + +Built-in actions are assign, assert, file read/write, direct process execution, run working-memory read/write, SQLite long-term-memory read/write, MCP call, and A2A delegation. File writes use a temporary file plus rename and return before/after/diff. Shell execution uses a direct executable and argv, never an implicit shell, clears inherited environment, applies allowlisted variables, and is not an OS sandbox. + +Check mode executes pure/internal simulation and observation needed for dataflow but never filesystem, process, remote, or model mutation. Results say fully predictable, partially predictable, or requires execution; unknown external work is never reported as predicted. diff --git a/docs/adr/0001-deterministic-core-explicit-effects.md b/docs/adr/0001-deterministic-core-explicit-effects.md new file mode 100644 index 0000000..8fa09a2 --- /dev/null +++ b/docs/adr/0001-deterministic-core-explicit-effects.md @@ -0,0 +1,7 @@ +# ADR 0001: Deterministic core and explicit effects + +Status: accepted, 2026-07-22. + +The graph, policy, state machine, persistence decisions, and recorded replay remain model-independent. Provider, tool, filesystem, process, MCP, A2A, internal-state mutation, clock, and ID behavior cross injected interfaces and receive durable effect identity when externally observable. + +This keeps models replaceable and tests credential-free. It requires more records and conservative uncertain states, but avoids hidden calls during replay. Dynamic model-owned orchestration is rejected. diff --git a/docs/adr/0002-versioned-strict-workflow-envelope.md b/docs/adr/0002-versioned-strict-workflow-envelope.md new file mode 100644 index 0000000..b2a5de8 --- /dev/null +++ b/docs/adr/0002-versioned-strict-workflow-envelope.md @@ -0,0 +1,7 @@ +# ADR 0002: Versioned strict workflow envelope + +Status: accepted, 2026-07-22. + +Workflows use `apiVersion`, `kind`, `metadata`, and `spec`. The envelope is adopted because identity/version and evolution need unambiguous locations, not because another system uses it. All typed objects deny unknown fields and a generated JSON Schema is checked in. + +A narrow legacy translator provides actionable migration. General aliases and silent coercion are rejected because they make security review and durable reproduction ambiguous. diff --git a/docs/adr/0003-sqlite-history-and-conservative-recovery.md b/docs/adr/0003-sqlite-history-and-conservative-recovery.md new file mode 100644 index 0000000..17897e4 --- /dev/null +++ b/docs/adr/0003-sqlite-history-and-conservative-recovery.md @@ -0,0 +1,7 @@ +# ADR 0003: SQLite history and conservative recovery + +Status: accepted, 2026-07-22. + +One versioned SQLite database is the local correctness store. Transactional task transitions/checkpoints/audits and a request-before-start effect ledger support resume and no-effect replay. Confirmed results are reused; started unconfirmed work becomes uncertain. + +No exactly-once claim is made. Automatic retry of ambiguous external effects is rejected. Fork is the explicit operation for fresh effects. Distributed history services are outside this release. diff --git a/docs/adr/0004-native-provider-adapters.md b/docs/adr/0004-native-provider-adapters.md new file mode 100644 index 0000000..bbd6f57 --- /dev/null +++ b/docs/adr/0004-native-provider-adapters.md @@ -0,0 +1,7 @@ +# ADR 0004: Native provider adapters behind neutral contracts + +Status: accepted, 2026-07-22. + +OpenAI Responses, Azure OpenAI Responses, Anthropic Messages, Google Gemini generateContent, and a scripted fake implement one provider-neutral internal interface. Capabilities are negotiated before execution; provider SDK/HTTP shapes never enter durable core state. + +“OpenAI-compatible” shims are rejected as a support claim because they hide native tool, continuation, reasoning, error, and usage differences. Every provider requires mock protocol coverage; live credentials are optional evidence only. diff --git a/docs/adr/0005-narrow-v1-scheduling-and-extensions.md b/docs/adr/0005-narrow-v1-scheduling-and-extensions.md new file mode 100644 index 0000000..c256359 --- /dev/null +++ b/docs/adr/0005-narrow-v1-scheduling-and-extensions.md @@ -0,0 +1,7 @@ +# ADR 0005: Narrow v1 scheduling and extension surface + +Status: accepted, 2026-07-22. + +V1alpha1 schedules a sequential DAG in declaration order and integrates typed actions/tools, local packs, MCP 2025-11-25, and A2A 1.0. `maxConcurrency` greater than one is rejected. + +Parallel groups, loops, routing, sub-workflows, teams/handoffs, automatic reconnection/resubmission, executable plugin ABIs, and registries are deferred. Each needs deterministic merge, cancellation, policy, version, and recovery semantics before it can enter the stable contract. diff --git a/docs/adr/0006-schedulable-runtime-and-noninteractive-contract.md b/docs/adr/0006-schedulable-runtime-and-noninteractive-contract.md new file mode 100644 index 0000000..e573d9f --- /dev/null +++ b/docs/adr/0006-schedulable-runtime-and-noninteractive-contract.md @@ -0,0 +1,15 @@ +# ADR 0006: Schedulable runtime and non-interactive contract + +Status: accepted + +## Decision + +`agentctl` is a schedulable runtime, not a scheduler. External platforms own triggers, calendars, leader election, overlap policy, log retention, and job lifecycle. The CLI owns one bounded run, durable SQLite history, effect safety, cancellation, outputs, and recovery. + +Non-interactive execution never prompts or auto-approves. The default approval behavior persists the request, pauses the run, emits run/trace correlation, and exits `3`. An operator resolves the approval and invokes `resume`. `deny_approval` and `fail` are stricter explicit modes. + +Machine output is one `agentctl.dev/cli/v1` final envelope. Inputs come from JSON, an input file, or repeated `KEY=VALUE` arguments; provider secrets remain environment references. Separate runs can share a SQLite database, but external schedulers must prevent overlapping effects when the target resource requires serialization. + +## Consequences + +Cron, systemd, Kubernetes, and CI can use normal process semantics without hidden terminal waits. The product avoids an eventing/distributed-control-plane surface. A future schedule-run key may improve deduplication, but it cannot replace external overlap controls or effect idempotency. diff --git a/docs/adr/0007-generic-oci-step-contract.md b/docs/adr/0007-generic-oci-step-contract.md new file mode 100644 index 0000000..3b31c6c --- /dev/null +++ b/docs/adr/0007-generic-oci-step-contract.md @@ -0,0 +1,13 @@ +# ADR 0007: Generic OCI step contract + +Status: accepted + +## Decision + +One minimal OCI image serves Docker, Kubernetes, and container-step CI systems. It contains only the Rust CLI on a maintained distroless base, runs as UID/GID 65532, and supports a read-only root filesystem. The stable mount contract is `/config` (read-only configuration), `/workspace` (usually read-only source), `/state` (SQLite durability), and `/artifacts` (declared output). + +The image entrypoint is `agentctl`. Callers supply ordinary CLI arguments and one final JSON result is written to stdout. Credentials are injected as environment variables or platform secret mounts, never ordinary CLI values. `/state` must persist between execution, inspect, approval, resume, and replay invocations. + +## Consequences + +No vendor-specific plugin API is required. Platforms without direct entrypoint/argument support can invoke `docker run` from their normal shell step. Distroless reduces runtime surface but deliberately has no shell; debugging uses the public CLI or a separate diagnostic image, not mutation of production images. diff --git a/docs/agent-kinds.md b/docs/agent-kinds.md deleted file mode 100644 index e9df11e..0000000 --- a/docs/agent-kinds.md +++ /dev/null @@ -1,212 +0,0 @@ -# Agent Kinds - -`agentctl` currently supports exactly two `agents..kind` values: - -- `builtin.heuristic` -- `openai.responses` - -Those are enforced by the playbook schema and the runtime registry. - -## Shared agent fields - -All agent kinds support these shared fields: - -```yaml -agents: - name: - kind: builtin.heuristic | openai.responses - description: optional string - instructions: optional string - instructionsFile: optional path - vars: optional object - promptCache: optional object - maxTurns: optional integer - profile: optional none|inspect|workspace_write|workspace_exec - tools: optional array -``` - -Rules: - -- exactly one of `instructions` or `instructionsFile` must be set -- `vars` are agent-level defaults -- task-scoped `tasks[].vars` override agent defaults at execution time -- `promptCache` is a runtime-owned optimization layer, not a memory store -- `tools` defines the tools the agent is allowed to attempt to call -- `profile` applies the agent tool policy profile - -## `builtin.heuristic` - -Use `builtin.heuristic` when you want: - -- deterministic local behavior -- no external model provider -- simple bounded tool sequences -- reliable smoke tests and examples - -Behavior: - -- if no tools are configured, the agent returns the rendered instructions as `finalText` -- if tools are configured, it calls them in listed order, one per turn -- after collecting observations, it finishes using the last observation - -Example: - -```yaml -agents: - reviewer: - kind: builtin.heuristic - instructionsFile: ./prompts/review.md - vars: - severity: medium - maxTurns: 4 - profile: workspace_exec - tools: - - tool: builtin/read - - tool: builtin/find -``` - -Kind-specific expectations: - -- `provider` is not used -- `promptCache` is not supported -- `model` is not used -- `temperature` is not used -- `maxOutputTokens` is not used -- `reasoningEffort` is not used -- `organization`, `project`, `baseUrl`, `endpoint`, `apiVersion`, `deployment` are not used - -Recommended use cases: - -- local examples -- deterministic regressions -- pack and tool integration tests -- environments where no provider auth should be required - -## `openai.responses` - -Use `openai.responses` when you want: - -- provider-backed autonomous behavior -- model-chosen tool use -- multi-turn tool execution with final synthesis -- OpenAI Responses API or Azure OpenAI Responses API - -Required fields: - -```yaml -agents: - auditor: - kind: openai.responses - provider: openai | azure-openai-responses - model: gpt-5 - instructionsFile: ./prompts/audit.md -``` - -Supported provider-related fields: - -- `provider` -- `model` -- `promptCache` -- `baseUrl` -- `organization` -- `project` -- `endpoint` -- `apiVersion` -- `deployment` -- `temperature` -- `maxOutputTokens` -- `reasoningEffort` - -Provider notes: - -- `provider: openai` - - uses the OpenAI Responses API - - auth comes from `--api-key`, `~/.agentctl/auth.json`, or `OPENAI_API_KEY` - - prompt cache is supported here -- `provider: azure-openai-responses` - - uses the Azure OpenAI Responses API path - - typically needs `endpoint` and `apiVersion` - - prompt cache is not currently supported here - -Behavior: - -- renders instructions after merging agent default vars and task-scoped vars -- sends the task input and rendered instructions to the provider -- optionally sends `prompt_cache_key` and retention metadata when prompt cache is enabled -- advertises configured tools to the model -- executes requested tools through the same runtime policy, tracing, and checkpoint flow -- continues with `previous_response_id` / tool outputs until the model finishes or `maxTurns` is exceeded - -Prompt cache shape: - -```yaml -promptCache: - enabled: true - force: true - retention: in_memory | 24h - keyScope: agent | run | playbook | provider | custom - shareMode: isolated | group - group: optional group name - keyTemplate: optional custom template when keyScope=custom -``` - -Use prompt cache when: - -- the agent has a stable prompt prefix -- the same provider/model path will be reused across turns or tasks -- you want lower repeated prompt cost and latency - -Do not use prompt cache as if it were memory: - -- agents do not read or write cache contents -- cache reuse is provider-native and opaque -- stats are available through `agentctl prompt-cache stats` -- custom OpenAI-compatible base URLs require `force: true` or prompt cache stays disabled - -Example: - -```yaml -agents: - ops_auditor: - kind: openai.responses - provider: openai - model: gpt-5 - instructionsFile: ./prompts/audit.md - maxTurns: 6 - profile: workspace_exec - tools: - - tool: builtin/find - - tool: builtin/read - - tool: builtin/write -``` - -Recommended use cases: - -- model-backed audits -- tool-using autonomous runs -- cases where deterministic heuristics are too limited - -## Validation and runtime behavior - -Validation happens in two layers: - -1. schema validation - - only `builtin.heuristic` and `openai.responses` are accepted -2. runtime model registry - - the runtime must have a registered model implementation for the selected kind - -So a playbook with an unsupported kind fails early, and a runtime missing a model implementation fails with a concrete registry error. - -## How to choose - -Use `builtin.heuristic` when: - -- you want repeatable local behavior -- the agent should just run a fixed tool sequence -- you are testing framework mechanics rather than model quality - -Use `openai.responses` when: - -- the model must decide which tools to use -- the final answer must be synthesized from multiple tool observations -- you are building real provider-backed autonomy rather than a deterministic harness diff --git a/docs/agent-prompts.md b/docs/agent-prompts.md deleted file mode 100644 index 3a4625e..0000000 --- a/docs/agent-prompts.md +++ /dev/null @@ -1,138 +0,0 @@ -# Agent Prompt Files and Task Vars - -`agentctl` agents can define instructions in two ways: - -- inline with `instructions` -- from disk with `instructionsFile` - -Exactly one must be set. - -## Inline instructions with agent defaults - -```yaml -agents: - audit: - kind: builtin.heuristic - instructions: | - Inspect {{ service }} and report the finding. - vars: - severity: medium -``` - -## Prompt files with task-scoped vars - -```yaml -agents: - audit: - kind: builtin.heuristic - instructionsFile: ./prompts/audit.md - vars: - severity: medium - -tasks: - - id: audit_checkout - uses: agent:audit - vars: - service: checkout - finding: "{{ tasks.prepare.output.values.finding }}" -``` - -Prompt file: - -```md -Service: {{ service }} -Finding: {{ finding }} -Severity: {{ vars.severity }} -``` - -## Resolution rules and precedence - -Var precedence is: - -1. task-scoped `tasks[].vars` -2. agent default `agents..vars` - -That precedence applies to both: - -- bare references such as `{{ service }}` -- alias references such as `{{ vars.service }}` - -1. `instructionsFile` is resolved relative to the playbook or pack file that defines the agent. -2. The prompt file is loaded during playbook loading. -3. Agent-level `vars` are reusable defaults. -4. Task-level `vars` are the primary invocation-time values. -5. Task vars override agent defaults on key collision. -6. Var resolution happens at execution time, not parse time. -7. Prompt rendering happens after var resolution. - -Merged vars are available in both forms: - -- bare: `{{ service }}` -- namespaced alias: `{{ vars.service }}` - -Bare vars only resolve from the merged var bag. They do not implicitly fall back to: - -- `inputs` -- `memory` -- `tasks` -- `run` - -Use explicit namespaces for those: - -That means dynamic values from runtime state are supported: - -- `{{ inputs.foo }}` -- `{{ tasks.prepare.output.values.finding }}` -- `{{ memory.working.finding }}` -- `{{ vars.service }}` - -Task vars and agent default vars can depend on other vars in the merged bag: - -```yaml -vars: - service: checkout - heading: "Service: {{ service }}" -``` - -`agentctl` resolves vars iteratively until all referenced values are available or resolution fails. - -## Module tasks use the same vars model - -Task vars are not limited to agents. Module tasks can use the same merged var bag: - -```yaml -tasks: - - id: project - uses: module:builtin.assign - vars: - service: checkout - finding: "{{ tasks.prepare.output.values.finding }}" - with: - values: - rendered: "{{ service }}:{{ vars.finding }}" -``` - -## Failure behavior - -`agentctl` fails hard when: - -- both `instructions` and `instructionsFile` are set -- neither is set -- the prompt file does not exist -- a prompt references an undefined merged var such as `{{ finding }}` -- a prompt references `{{ vars.finding }}` with no matching merged var -- a task var or agent default var cannot be resolved at execution time - -There is no silent empty-string substitution for prompt files. - -## Recommended usage - -- Use `instructionsFile` when the prompt is long, reused, or edited frequently. -- Put invocation-specific values under `tasks[].vars`. -- Use `agents..vars` only for reusable defaults. -- Prefer bare names like `{{ service }}` in prompts for readability. -- Use `{{ vars.service }}` when you want to make the var namespace explicit. -- Use explicit namespaces for runtime state such as `{{ inputs.service }}` and `{{ memory.working.finding }}`. -- Keep prompt templates simple. `agentctl` supports placeholder lookups, not a full template language. - -See [examples/prompt-file-vars/README.md](../examples/prompt-file-vars/README.md) for a runnable example. diff --git a/docs/check.md b/docs/check.md deleted file mode 100644 index 4228d99..0000000 --- a/docs/check.md +++ /dev/null @@ -1,75 +0,0 @@ -# `agentctl check` - -`agentctl check` validates a playbook without running it. - -```bash -agentctl check examples/prompt-file-vars/mission.playbook.yaml -agentctl check examples/prompt-file-vars/mission.playbook.yaml --output json -``` - -## What it validates - -`check` covers these phases: - -1. YAML syntax -2. playbook schema -3. pack schema -4. prompt-file loading -5. template-reference sanity -6. graph/reference compilation - -## Error reporting - -For YAML syntax errors, `agentctl check` reports: - -- file -- phase: `yaml_syntax` -- line -- column -- parser detail - -For schema, prompt-file, template, and compile errors, it reports: - -- file -- phase -- optional field path -- error detail - -## Output contract - -Success: - -```yaml -type: check -ok: true -playbook: /absolute/path/to/playbook.yaml -packs: [] -compiled: true -``` - -Failure: - -```yaml -type: check -ok: false -playbook: /absolute/path/to/playbook.yaml -packs: [] -diagnostics: - - file: /absolute/path/to/playbook.yaml - phase: yaml_syntax - line: 12 - column: 7 - detail: Nested mappings are not allowed in compact mappings -``` - -## Current template checks - -`check` validates obvious prompt-template mistakes before execution: - -- prompt references a merged var that is not defined by agent defaults plus task-scoped vars for that specific task invocation -- prompt or var references `tasks.` where the task does not exist -- task `with` references a merged var that is not defined for that task - -That validation is task-aware. If the same agent is used by two tasks with different `vars`, `check` validates each task separately. - -It does not attempt full runtime-data validation. Dynamic values may still fail at execution time if the required task output or working-memory key is absent when the agent runs. diff --git a/docs/custom-tools.md b/docs/custom-tools.md deleted file mode 100644 index 6b7c622..0000000 --- a/docs/custom-tools.md +++ /dev/null @@ -1,225 +0,0 @@ -# Custom Pack Tools - -This document defines how packs can expose custom tools to `agentctl` agents. - -## Scope - -`agentctl` has three tool sources: - -- builtin tools -- pack-defined tools -- external tools wrapped by packs - -Builtin tools remain first-party and runtime-native. - -Pack-defined tools are the extension point for: - -- scripts shipped inside the pack -- existing host commands already installed on the machine -- future remote wrappers, if the pack chooses to wrap them behind a process or service boundary - -## Current Runtime Contract - -The current supported custom tool kind is: - -- `pack.process` - -This is a process-backed module definition that can be used: - -- directly in tasks -- indirectly as an agent tool by referencing the module in `tools:` - -The runtime contract is the same in both cases: - -- inputs are resolved through the normal templating path -- policy is enforced before execution -- execution is traced and audited -- task checkpoints still govern retry, resume, and replay behavior - -That policy check includes the effective working directory. If a `pack.process` module resolves `cwd` outside `workspaceRoot`, the call is denied before the process starts. - -## Why one process-backed kind first - -This is the smallest correct abstraction. - -It covers both: - -- “I ship a script in my pack” -- “I want to wrap an existing host command” - -without introducing a second tool runtime or speculative plugin model. - -## Module Shape - -Example wrapping an existing host command: - -```yaml -modules: - node_version: - kind: pack.process - command: node - args: - - --version - runtime: - requires: - - command: node - version: ">=22" - policy: - label: node.version - capability: observe - risk: low -``` - -Example running a script shipped inside the pack: - -```yaml -modules: - fixture_audit: - kind: pack.process - command: node - args: - - ./tools/audit-service.mjs - - ./fixtures/service - cwd: . - runtime: - requires: - - command: node - version: ">=22" - policy: - label: fixture.audit - capability: observe - risk: low -``` - -## Fields - -- `command` - - required - - executable name or path -- `args` - - optional string array - - passed directly to the executable -- `cwd` - - optional working directory - - resolved relative to the pack file when declared in a pack - - policy-checked against `workspaceRoot` before execution -- `env` - - optional string-to-string environment overrides -- `with` - - optional default input values -- `runtime.requires` - - optional list of runtime requirements -- `policy` - - optional tool metadata used by the policy engine - -## Pack-relative resolution - -When a `pack.process` module is loaded from a pack file: - -- path-like `command` values such as `./bin/tool` are resolved relative to the pack file -- `cwd` is resolved relative to the pack file -- plain executable names such as `node`, `python3`, or `terraform` are left unchanged - -This lets a pack support both shipped tools and existing host tools. - -## Runtime Requirements - -`runtime.requires` is the preflight declaration. - -It is checked before `run`, `resume`, and `replay` start executing. - -Current requirement fields: - -- `command` - - required executable to find -- `version` - - optional minimum version constraint - - currently supports `>=x.y.z` -- `versionArgs` - - optional custom version command arguments - - default is `--version` - -Example: - -```yaml -runtime: - requires: - - command: node - version: ">=22" - - command: terraform - version: ">=1.8.0" -``` - -Failure behavior: - -- if a required executable is missing, the run fails before a run record is created -- if a version constraint is not satisfied, the run fails before execution starts - -That is intentional. Missing runtime dependencies are environment errors, not in-run task failures. - -## Agent Usage - -Agents do not automatically get custom tools. - -The same rule applies as with builtin tools: - -- define the module in the pack or playbook -- expose it in the agent’s `tools:` list -- let policy/profile decide whether the agent may call it - -Example: - -```yaml -agents: - auditor: - kind: builtin.heuristic - profile: inspect - tools: - - tool: custom/node_version - - tool: custom/fixture_audit -``` - -## Policy Model - -Custom tools do not bypass policy. - -`policy` metadata on the module defines: - -- `label` -- `capability` - - `internal` - - `observe` - - `mutate` - - `act` -- `risk` - - `low` - - `medium` - - `high` - -Defaults for `pack.process`: - -- `capability: act` -- `risk: high` -- `label: basename(command)` - -That means pack tools are treated conservatively unless the pack author narrows them deliberately. - -When a `pack.process` module is exposed to an agent through `tools:`, `agentctl` also requires approval before execution. Subprocess launches are not sandboxed by command content, so agent-origin process tools are treated as approval-gated operations even when `approvalMode: never`. - -## What this supports today - -- wrapping existing installed host commands -- shipping custom scripts inside a pack -- using custom tools from tasks -- using custom tools from agents -- preflighting declared runtime dependencies -- preserving checkpoint/replay behavior for custom-tool tasks - -## What this does not support yet - -- container-backed custom tools -- HTTP-backed custom tool definitions -- custom output schema validation -- automatic runtime installation - -Those can be added later without changing the current process-tool contract. diff --git a/docs/execution/BLOCKERS.md b/docs/execution/BLOCKERS.md new file mode 100644 index 0000000..c4094e3 --- /dev/null +++ b/docs/execution/BLOCKERS.md @@ -0,0 +1,5 @@ +# Blockers + +There are no known P0/P1 implementation blockers as of 2026-07-22. The independently repeated local, scheduled, and OCI acceptance journeys pass. Release status remains **ready for internal review**, not release candidate, because the prior live OpenAI durable state was not retained for the required independent network-disabled replay. + +Only blockers that prevent safe progress under the mission's definition are recorded here. Missing non-OpenAI live credentials will not be treated as blockers for native implementations with deterministic mock coverage. diff --git a/docs/execution/COMPATIBILITY.md b/docs/execution/COMPATIBILITY.md new file mode 100644 index 0000000..30fc97f --- /dev/null +++ b/docs/execution/COMPATIBILITY.md @@ -0,0 +1,32 @@ +# Compatibility ledger + +The TypeScript oracle is commit `be9d0ae`; the detailed public policy is [docs/COMPATIBILITY.md](../COMPATIBILITY.md). + +## Preserved + +- Declaration-order DAG scheduling, dependencies, typed exact templates, deterministic dataflow, bounded agents, approvals, and SQLite local state. +- Simple legacy assign behavior is captured in `fixtures/compat/v0` and dual-mode language-neutral assertions. + +## Migrated + +- `playbook` to versioned `apiVersion`/`kind`/`metadata`/`spec`; `modules` to `actions`; `module:x` to `action:x`. +- Rust versioned JSON output, generated JSON Schema/CLI reference, native providers/protocols, and namespaced dotted packs. + +## Intentionally changed + +- Recorded `replay` cannot call effects; legacy effectful replay is now `fork`. +- Strict unknown fields, capability/schema validation, formal run/task states, request-before-start effects, migration failures, safe environment references, and denied redirects replace permissive prototype behavior. +- Anthropic and Google use their native APIs; current OpenAI uses Responses concepts. + +## Deprecated + +- Unversioned YAML is warning-only compatibility input for simple workflows. +- TypeScript source/tests are archived and expose no production entry point. + +## Removed + +- Direct API-key flags, YAML machine output, legacy profiles, placeholder provider/memory support, optimistic replay, implicit full environment inheritance, and obsolete provider/cache fields. + +## Manual migration/deferred + +Legacy pack-backed workflows, custom TypeScript executors, MongoDB/vector memory, and old MCP/A2A shapes require manual conversion. Parallel/dynamic workflows, sub-workflows, teams/handoffs, compensation execution, registry resolution, and automatic remote resubmission are deferred product decisions. diff --git a/docs/execution/DECISIONS.md b/docs/execution/DECISIONS.md new file mode 100644 index 0000000..cea8e70 --- /dev/null +++ b/docs/execution/DECISIONS.md @@ -0,0 +1,13 @@ +# Decision index + +| ADR | Decision | Status | Consequence | +| --- | --- | --- | --- | +| [0001](../adr/0001-deterministic-core-explicit-effects.md) | Deterministic core, explicit effects | accepted | Models never own graph/policy/history; uncertain effects stop recovery. | +| [0002](../adr/0002-versioned-strict-workflow-envelope.md) | Versioned strict envelope | accepted | Generated schema, strict fields, explicit migration. | +| [0003](../adr/0003-sqlite-history-and-conservative-recovery.md) | SQLite history, conservative recovery | accepted | Resume reuses confirmed effects; replay performs none; fork is fresh. | +| [0004](../adr/0004-native-provider-adapters.md) | Native providers behind neutral contracts | accepted | Native request/response tests and early capability rejection. | +| [0005](../adr/0005-narrow-v1-scheduling-and-extensions.md) | Sequential v1 and narrow extensions | accepted | Deterministic commits now; parallel/dynamic constructs deferred. | +| [0006](../adr/0006-schedulable-runtime-and-noninteractive-contract.md) | Schedulable runtime, durable non-interactive pause | accepted | External platforms schedule; CLI never prompts or auto-approves in CI. | +| [0007](../adr/0007-generic-oci-step-contract.md) | Generic OCI step contract | accepted | Non-root/read-only image; mounted config/workspace/state/artifacts. | + +These decisions resolve the researched patterns in [LANDSCAPE.md](../research/LANDSCAPE.md). No unsafe code or distributed control plane ADR is required because neither exists. diff --git a/docs/execution/DEFINITION_OF_DONE.md b/docs/execution/DEFINITION_OF_DONE.md new file mode 100644 index 0000000..3106538 --- /dev/null +++ b/docs/execution/DEFINITION_OF_DONE.md @@ -0,0 +1,22 @@ +# Definition of done evidence map + +Status values distinguish **deterministically tested**, **mock-provider tested**, **live OpenAI tested**, **operationally tested**, **syntax-validated only**, and **deferred/non-goal**. + +| Area | Status | Evidence | +| --- | --- | --- | +| Strict YAML, diagnostics, compiled plan, inputs/outputs | deterministically tested | core tests; generated schema; acceptance validation/plan/input scenarios | +| Rust-only CLI, clean install/package, no Node dependency | operationally tested | source install; packaged/copy isolation; quickstart; production boundary gate | +| Deterministic actions/state/effects/checkpoints/audit/traces | deterministically tested | runtime/store tests and public inspect acceptance | +| Fake agent and strict tool continuation | mock-provider tested | tool-using acceptance with artifact and durable evidence | +| GPT-5.6 tool-using runtime | prior live OpenAI evidence reviewed | packaged local and OCI live acceptance was recorded by the preceding run; not called again in the final audit | +| OpenAI reasoning/context/storage/cache/strict schemas/multiple calls/usage mapping | deterministic mapping tests plus prior live evidence | provider mapping tests, compiler rejection tests, prior live continuation | +| Replay without credentials or network | deterministically and operationally tested | provider/tool-executor regression; host replay; OCI `--network none` replay with identical output and zero effects/tool calls | +| Resume/reject/uncertainty/fork/retry/auth/rate-limit/malformed/cancellation semantics | deterministically tested | focused provider/runtime/store tests and acceptance scenarios | +| Non-interactive approvals, cron, inputs, timeout, SIGTERM | operationally tested | empty-environment and signal acceptance; operations guide | +| OCI non-root/read-only/mount/JSON/artifact/state contract | operationally tested; prior OpenAI live evidence | final native arm64 mock, failure, signal, and offline-replay cases; prior OpenAI image run | +| Image high/critical scan and SBOM | operationally tested on arm64 | Trivy result and CycloneDX artifact recorded in verification ledger | +| Linux amd64 image and external CI/vendor pipelines | syntax/configuration validated only | GitHub job and pipeline examples; not remotely dispatched here | +| Anthropic/Google/Azure adapters; MCP/A2A | mock-provider/protocol tested | native mapping/protocol tests; not live-tested | +| Advisories/licenses/sources/secrets | deterministically tested | cargo-deny, metadata, source, and secret gates | +| Parallel/dynamic orchestration, pack ecosystem, vector/encrypted/distributed additions | deferred or non-goal | `docs/LIMITATIONS.md`, ADR 0005/0006/0007 | +| No known P0/P1 correctness/security defect in implemented boundary | verified for internal review | canonical gates, clean-room acceptance audit, image scan, conservative documented limits | diff --git a/docs/execution/RELEASE_AUDIT.md b/docs/execution/RELEASE_AUDIT.md new file mode 100644 index 0000000..bd6bb6b --- /dev/null +++ b/docs/execution/RELEASE_AUDIT.md @@ -0,0 +1,215 @@ +# Final adversarial release audit + +Audit date: 2026-07-22 (Asia/Kolkata) + +Recommendation: **Ready for internal review**. This is not a stable-v1 recommendation and not yet a `v1alpha1` release-candidate recommendation. + +## Scope and tree identity + +The audit began on branch `main` at base commit `be9d0aee0b71e31aeaa386429bb8d7907e01404b` (`push`). The initial working tree contained the intended uncommitted TypeScript-to-Rust migration: 107 tracked paths, 115 intended untracked source paths, and local ignored build/runtime directories. The tracked-diff patch ID was `1e8c3fcff65f7edd8624d6d72f1d379e066941bb`. This base commit plus the initial status inventory is the reviewed pre-commit tree identity. + +Initial local-only data included `target` (about 5.5 GiB), `.runtime` (about 1.2 GiB), `node_modules` (about 109 MiB), and `dist` (about 16 MiB). They were ignored and excluded from clean-room source. A tracked legacy runtime database, `examples/memory-flow/state/long-term.db`, was removed; it remains recoverable from Git history. An accidental generated Node-version-only artifact change was restored. + +The ledger was changed to `release audit in progress` before verification. No release-candidate status from the preceding run was assumed. + +## Initial claims reviewed + +| Claim from the preceding run | Audit conclusion | +| --- | --- | +| Rust-only local build, install, deterministic examples, and scheduled execution pass | Confirmed independently from a clean copy with Node/npm/npx/tsc poisoned to fail. | +| Credential-free public-CLI acceptance passes | Confirmed; 25 scenarios pass. | +| Native arm64 OCI execution passes | Confirmed and strengthened with failure exits, SIGTERM, durable inspect, and network-disabled replay. | +| OpenAI GPT-5.6 tool workflow passed live | Prior ledger and bounded usage metadata reviewed; not called again. The implementation path remains mock-tested. | +| Live OpenAI durable state replayed without credentials | Corrected: prior databases were not retained, so the exact live state could not be independently replayed. Deterministic host replay and OCI `--network none` replay pass. | +| Ambiguous effects safely block resume | Partially false before fixes: several paths remained `started` or were marked `failed`; one subprocess timeout returned before uncertainty recording. Fixed and regression-tested. | +| JSON mode is always parseable | False for Clap parse failures before fixes. Unknown command, missing argument, and invalid value now return the versioned JSON error envelope. | +| Supply-chain verification cannot be silently skipped | False before fixes: `cargo-deny` could be absent while `verify` still succeeded. It is now a required gate and CI installs it. | +| No fixable HIGH/CRITICAL image vulnerabilities | Reworded and strengthened: final Trivy 0.70.0 scans found no HIGH/CRITICAL findings with or without `--ignore-unfixed`. | +| External CI/CD examples were validated | Corrected to documentation-reviewed/YAML-parsed where applicable; none was externally dispatched. | + +## Clean-room environment + +The final clean source copy was `/private/tmp/agentctl-release-audit.2pCaK5/source`. It was created with `rsync` while excluding `.git`, `target`, `fuzz/target`, `.agentctl`, `.runtime`, `dist`, `node_modules`, databases/WAL files, and logs. It included every intended tracked and untracked source change. + +`OPENAI_API_KEY` was removed from every clean-room command. A directory placed first on `PATH` contained `node`, `npm`, `npx`, and `tsc` symlinked to `/usr/bin/false`; all release commands still passed. This proves the production build, installation, tests, examples, package, image build, and workflow execution do not invoke Node or the archived TypeScript runtime. + +Environment: + +- host: macOS arm64; +- `rustc`/Cargo 1.88.0, host `aarch64-apple-darwin`; +- Podman 5.8.2, native Linux arm64 VM; +- `cargo-deny` available and mandatory; +- Trivy container image 0.70.0 with a local vulnerability database; +- `cargo-llvm-cov`, `syft`, `actionlint`, `kubectl`, `kubeconform`, `hadolint`, and `shellcheck` unavailable; no new host tooling was installed. + +Two early clean-room attempts failed as intended: the first caught unformatted new tests, and the second caught a warnings-denied Clippy `needless_borrow`. The current-tree gate then exposed a real subprocess-timeout path that returned before marking uncertainty. All three findings were corrected before the successful clean-room run. The gates propagated nonzero exit status; they did not mask failure. + +## Exact release commands and results + +The following commands ran from the final clean copy with `OPENAI_API_KEY` absent and the no-Node `PATH` prefix: + +| Command | Exit | Safe result | +| --- | ---: | --- | +| `cargo xtask verify` | 0 | All 12 gates passed: rustfmt, warnings-denied Clippy, workspace build, 66 tests, fuzz-target checks, doc tests/docs, generated schema/CLI consistency, examples/negative contracts, source/license/advisory checks, secret scan, source installation, and Rust-only boundary. | +| `cargo xtask acceptance` | 0 | All 25 public-CLI scenarios passed, including approval/resume, replay/fork, uncertainty, JSON parse errors, cron-like empty environment, concurrency, and SIGTERM. | +| `cargo xtask acceptance-container` | 0 | Native arm64 OCI success/artifact/inspect, offline replay, missing secret, invalid workflow, SIGTERM, non-root, read-only root, and mount cases passed. | +| `cargo xtask package` | 0 | Produced the optimized macOS arm64 package and four shell completions. | +| `shasum -a 256 -c SHA256SUMS` | 0 | `agentctl: OK`. | +| `git diff --check` | 0 | No whitespace errors. | + +`cargo xtask acceptance-live-openai` was deliberately not run. No OpenAI request was made during this audit because the provider execution path was not changed; the `store: false` fix is compile-time validation and is covered by deterministic mapping/compiler tests. + +GitHub workflow YAML was parsed locally with Ruby's YAML parser. GitLab, Jenkins, Harness, Docker, Kubernetes Job, and Kubernetes CronJob examples were documentation-reviewed but not dispatched or vendor-validated. + +## Findings and remediation + +No P0 finding was identified. + +### P1 — ambiguous effects were not explicitly uncertain + +- Symptom: provider timeout/transport could remain `started`; subprocess timeout/cancellation/I/O and MCP/A2A errors could be recorded as `failed`; tool timeout/cancellation could mark the tool call failed or leave the effect started. A `?` in subprocess timeout selection also bypassed the later uncertainty handler. +- Root cause: the store lacked explicit uncertainty transitions and dispatch layers collapsed transport ambiguity into ordinary execution errors. +- Affected journeys: resume after interruption, scheduled cancellation, remote/provider/tool/process effects. +- Change: added durable effect/tool-call uncertainty transitions; classified provider, process, tool, MCP, and A2A ambiguous outcomes conservatively; enriched uncertainty errors with run/trace/effect correlation. +- Regression: provider timeout acceptance; tool timeout/cancellation runtime test; subprocess timeout runtime test; protocol ambiguity classification test; resume assertion. +- Result: uncertain effects are visible through `inspect`, are never automatically repeated, and resume exits `3` with run/trace correlation. + +### P1 — unsupported OpenAI stateless tool continuation + +- Symptom: a tool-using OpenAI/Azure agent could set `store: false`, while continuation still used `previous_response_id` and did not replay stateless response/reasoning/function items. +- Root cause: provider-option validation did not account for runtime continuation semantics. +- Affected journey: multi-turn OpenAI/Azure function calling. +- Change: compilation now rejects `store: false` for tool-using OpenAI/Azure agents; one-turn non-tool use remains supported. +- Regression: compiler unit test and credential-free acceptance negative contract. +- Result: no unsupported continuation is silently emitted. This matches the official [function-calling continuation contract](https://developers.openai.com/api/docs/guides/function-calling) and [reasoning-item context guidance](https://developers.openai.com/api/docs/guides/reasoning#keeping-reasoning-items-in-context). + +### P1 — JSON parse failures bypassed the machine contract + +- Symptom: unknown commands, missing arguments, and invalid values used Clap's human error even when `--output json` was requested. +- Root cause: `Cli::parse()` exited before application rendering. +- Affected journey: CI/scheduled callers consuming the versioned machine interface. +- Change: pre-detect JSON mode, use fallible parsing, preserve help/version behavior, and emit a versioned exit-2 JSON error. +- Regression: CLI unit tests and three public-binary acceptance cases. +- Result: each tested parse failure is valid `agentctl.dev/cli/v1` JSON on stderr. + +### P2 — replay could create partial state for a nonterminal source + +- Symptom: a replay row could be created before source task terminality was validated. +- Root cause: validation occurred inside the copy loop. +- Change: validate and map source run/tasks before replay creation. +- Regression: paused-source replay test asserts rejection and unchanged run count. +- Result: invalid replay attempts leave no partial replay run. + +### P2 — release gates and container acceptance were incomplete + +- Symptom: `cargo-deny` could be skipped; OCI acceptance covered only a successful mock run; CI examples omitted bounded timeouts and recoverable approval-state collection. +- Root cause: optimistic prerequisite handling and narrow happy-path acceptance. +- Change: require `cargo-deny`, install it in verification/release workflows, and add OCI missing-secret, invalid-input, artifact/inspect, `--network none` replay, and SIGTERM cases. CI/CD examples now use bounded timeouts and document protected state retention/recovery. +- Regression: the canonical gates themselves. +- Result: missing prerequisites/failures are nonzero; container exit codes and PID1 signal handling are exercised. + +### P2 — repository hygiene and claims + +- Removed the tracked SQLite runtime database and retained database/build/runtime ignores. +- Removed dead `xtask` code and its `allow(dead_code)`. +- Replaced “safe”/production-readiness wording with policy-constrained, production-oriented `v1alpha1` language. +- Corrected secret-loading timing, provider maturity, live-evidence, architecture, external-CI, and scan claims. + +## Durable execution review + +| Failure window | Durable behavior | +| --- | --- | +| Before request record | No external dispatch occurs. | +| After request record, before dispatch | Status remains `requested`; resume may dispatch it after policy/approval checks. | +| During dispatch | Status is `started`; timeout, cancellation, transport loss, or ambiguous I/O changes it to `uncertain`. | +| External commit before local acknowledgement | Local state remains `uncertain`; automatic resume is refused. | +| Confirmed result before task-state commit | The confirmed effect result is reused; it is not dispatched again. | +| Task-state transition before checkpoint | Task transition and checkpoint write share one SQLite transaction, preventing that partial local state. | + +Effect identity includes run, task, task attempt, ordinal, operation, and input digest. Attempts and trace IDs are inspectable. `fork` is the explicit operation that permits fresh effects. No exactly-once external-execution claim is made. + +## Offline replay proof + +The deterministic runtime regression starts a tool-calling provider workflow, replays it, asserts identical structured output, and proves the replay invokes neither provider nor tool executor and records zero replay effects/tool calls. + +The public OCI journey then: + +1. ran the mock tool workflow and retained `/state/runtime.db`; +2. replayed with no credential forwarding and `--network none`; +3. compared the complete structured `/data/output` value; +4. asserted a distinct replay run ID; +5. inspected the replay and found zero effects and zero tool calls. + +The exact prior live OpenAI database was unavailable. That evidence gap is why this audit stops at **Ready for internal review**. A future release-candidate gate should retain a sanitized encrypted/protected state artifact long enough to perform the same `--network none` public replay, then destroy it under the release evidence retention policy. + +## Provider and protocol support + +No adapter except OpenAI is represented as live-tested, and no external provider conformance suite was run. + +| Kind | Implementation | Audit validation | Release wording | +| --- | --- | --- | --- | +| Fake | in-process text/tool/usage/continuation | deterministic unit/runtime/public acceptance | Deterministically tested | +| OpenAI Responses | native auth/request/response, strict tools, multiple call IDs, continuation, usage, reasoning/cache options | mock-protocol mapping plus prior bounded GPT-5.6 live tool evidence; no audit live call | Prior live-tested and mock-protocol tested | +| Azure OpenAI Responses | native Azure auth/path plus OpenAI mapping | focused mock request/auth/response test | Mock-mapping tested; not live-tested | +| Anthropic Messages | native content/tool/usage mapping | focused mock native tool test | Mock-mapping tested; not live-tested | +| Google Gemini | native content/function/usage mapping | focused mock native response test | Mock-mapping tested; not live-tested | +| MCP 2025-11-25 | native initialization/session/list/call/SSE/cancel/timeout | local mock protocol server tests | Mock-protocol tested; not live-tested | +| A2A 1.0 | native discovery/send/poll/stream/cancel | local mock peer tests | Mock-protocol tested; not live-tested | + +## Platform and delivery support + +| Platform | Validation | +| --- | --- | +| macOS arm64 host | Native build, 66 tests, public acceptance, installation, package, checksum: executed | +| Linux arm64 OCI | Native Podman build/run, non-root/read-only, signals, failures, offline replay, scan/SBOM: executed | +| Linux amd64 OCI | CI-configured only. A local `--platform linux/amd64` build was attempted because Podman advertised emulation, but emulated `rustc` terminated with SIGSEGV; the emulator was not reliable, so no local build/run claim is made. | +| macOS x86_64 | Not tested locally; CI-configured through hosted macOS only when dispatched. | +| Windows x86_64 | Not tested locally; CI-configured only when dispatched. | +| GitHub Actions | Workflow YAML parsed locally; not dispatched. | +| GitLab CI, Jenkins, Harness | Documentation-reviewed examples; not externally dispatched. | +| Kubernetes Job/CronJob | Documentation-reviewed manifest; not submitted to a cluster. | + +## Container and security evidence + +- Final image: Linux arm64, version label `0.2.0`, user `nonroot:nonroot`, entrypoint `/usr/local/bin/agentctl`. +- Environment defaults contain only PATH and CA-certificate location; no provider credential defaults. +- Image history contains the runtime base, labels, and copied Rust binary; no credential-bearing command. +- Exported root filesystem contains no Node/npm/npx, Rust compiler/Cargo, TypeScript source, workflow, fixture, or build tree. CA certificates are present through the distroless base. +- The image runs with `--read-only`, UID/GID 65532, and only `/state` and `/artifacts` writable. +- Trivy 0.70.0 reported zero HIGH/CRITICAL findings both with and without `--ignore-unfixed`. A 20 KiB CycloneDX JSON SBOM was generated at ignored local evidence path `.runtime/scan/agentctl-final.cdx.json`. +- `cargo deny check`: advisories, bans, licenses, and sources passed. Duplicate dependency versions are warnings, not denied findings. +- Repository secret scan and manual credential-pattern scan found no committed token/private key. No intended source database or live response body remains. `OPENAI_API_KEY` was present in the host environment but its value was never printed, passed as an argument, persisted, or copied into clean-room/container state. +- Production Rust contains no `unsafe`, production `panic!`, ignored test, `allow(dead_code)`, or `allow(unused)`. `expect`/`panic!` occurrences are test assertions. `allow(clippy::too_many_arguments)` is limited to explicit effect/transition/store data-flow signatures where named parameters preserve audit meaning. `serde_json::Value` serialization uses an infallible-in-practice fallback for digest construction; malformed external JSON is parsed before reaching that value type. +- Filesystem/process/network controls are policy checks, not an OS sandbox. Untrusted workflows require a restricted OS/container identity and egress controls. + +## Critical-path test map + +| Guarantee | Evidence | +| --- | --- | +| Parser/schema/compiler/templates | strict/unknown-field, source diagnostic, cycle, deterministic order, property, capability, stateless-tool negative tests | +| State/persistence/migrations/corruption | state transition, transactional checkpoint, schema upgrade/future version, corruption, lock wait, GC tests | +| Effects/approval/resume/fork | store/runtime tests plus public scenarios 9–16 | +| Replay no dispatch | provider+tool executor regression and host/OCI public replay inspection | +| Provider/tool continuation | native mapping mocks, call-ID test, schema failures, fake tool acceptance, prior live OpenAI evidence | +| Policy/path/redaction | traversal, symlink, host allowlist, secret redaction, invalid UTF-8/read-only artifact tests | +| Cancellation/uncertainty | provider/tool/process/protocol tests and host/container SIGTERM acceptance | +| CLI machine contract | parse errors, validation/auth/policy/run/cancel outputs, run/trace correlation | +| Cron/container | empty-environment non-TTY acceptance and native arm64 OCI acceptance | + +Coverage percentage was not invented; `cargo-llvm-cov` was unavailable. Timing-sensitive tool/process/signal tests were run through both the focused suite and repeated canonical acceptance during the audit without a flaky failure. + +## Deferred items and residual risks + +- Retain a completed live OpenAI durable-state artifact for one independent credential-free, network-disabled replay before release-candidate designation. +- Dispatch the configured Linux amd64, macOS, Windows, scan/SBOM, and external pipeline gates; until then they remain CI-configured or documentation-reviewed only. +- Expand Azure/Anthropic/Google adapter negative/error/cancellation/tool-continuation coverage before raising their maturity beyond focused mock mapping. +- Single-host SQLite, sequential scheduling, manual uncertain-effect reconciliation, alpha schema evolution, and policy-not-sandbox limitations remain intentional. +- Tool-using OpenAI/Azure `store: false` remains unsupported until full stateless response-item replay is implemented. +- Container bind mounts require deliberate UID/GID 65532 provisioning and protected collection of state, which may contain prompts and outputs. +- Formal external MCP/A2A/provider conformance suites and long-horizon upgrade fixtures are deferred. + +Files requiring closest human review are `crates/agentctl-runtime/src/lib.rs` (effect windows and replay), `crates/agentctl-store/src/lib.rs` (uncertainty persistence), `crates/agentctl-providers/src/lib.rs` (OpenAI continuation mapping), `crates/agentctl-protocols/src/lib.rs` (ambiguity classification), `crates/agentctl-cli/src/main.rs` (machine errors/correlation), `xtask/src/acceptance.rs` (release claims), `Containerfile`, and `docs/CONTAINER.md`. + +## Final gate decision + +There is no known P0/P1 implementation defect in the defined local, externally scheduled, or generic OCI boundary after remediation. Clean-room deterministic and OCI evidence is green. The missing retained live state prevents completion of one specifically requested independent proof, so the honest recommendation is **Ready for internal review**, not yet **Ready as a `v1alpha1` release candidate**. diff --git a/docs/execution/STATUS.md b/docs/execution/STATUS.md new file mode 100644 index 0000000..8239a12 --- /dev/null +++ b/docs/execution/STATUS.md @@ -0,0 +1,41 @@ +# Execution status + +Last updated: 2026-07-22 + +## Current phase + +ready for internal review + +The adversarial audit passed the defined local, scheduled, and native-arm64 OCI implementation gates. Release-candidate status is deliberately withheld because the prior live OpenAI database was not retained for the required independent `--network none` replay. + +## Accepted evidence + +- The independently audited Rust implementation passes all 12 `cargo xtask verify` gates (66 tests) and the 25-scenario credential-free public-CLI acceptance suite from a clean copy with Node tools poisoned. +- The preceding run recorded a packaged GPT-5.6 strict function-call workflow; this audit reviewed that evidence but made no additional OpenAI calls. +- Deterministic host replay invokes neither provider nor tool executor. Native-arm64 OCI replay passes under `--network none` with identical output, a distinct replay ID, and zero effects/tool calls. +- Confirmed effects survive resume; fork is distinct and fresh; timeout/transport uncertainty blocks unsafe repetition. +- Clean copied/source-installed/package layouts, empty-environment cron invocation, concurrency, SIGTERM, approvals, machine output, and recovery paths passed. +- The actual OCI image passed mock-tool, failure-exit, SIGTERM, and offline-replay cases as non-root with a read-only root and mounted durable state/artifacts. Trivy 0.70.0 found no HIGH/CRITICAL findings with or without `--ignore-unfixed`; a CycloneDX SBOM was generated. + +## Product boundary + +`agentctl` is a schedulable local runtime, not a scheduler or distributed control plane. The workflow API remains alpha and scheduling is sequential. Provider, filesystem, process, and network policy is not an OS sandbox. At-most-once external work can require manual reconciliation. See [Limitations](../LIMITATIONS.md) for the complete release-blocker/hardening/post-v1/non-goal classification. + +## External evidence not claimed + +The local environment executed macOS arm64 packaging and Linux arm64 OCI tests. The configured GitHub Linux amd64, macOS, Windows, vendor-pipeline, Trivy, and SBOM jobs were not remotely dispatched in this task; GitHub YAML was parsed locally and the remaining examples were documentation-reviewed only. Anthropic, Google, Azure OpenAI, MCP, and A2A remain native mock-tested rather than live-tested. + +## Hard blockers + +No known P0/P1 implementation blocker. The live-state evidence gap blocks only a release-candidate recommendation. See [BLOCKERS.md](BLOCKERS.md) and [RELEASE_AUDIT.md](RELEASE_AUDIT.md). + +## Exact commands + +```console +cargo xtask verify +cargo xtask acceptance +cargo xtask acceptance-container +cargo xtask package +``` + +`cargo xtask acceptance-live-openai` was not run during this audit. See [RELEASE_AUDIT.md](RELEASE_AUDIT.md) for the independent results and [VERIFICATION.md](VERIFICATION.md) for the preceding run's safe live usage metadata. diff --git a/docs/execution/VERIFICATION.md b/docs/execution/VERIFICATION.md new file mode 100644 index 0000000..8624669 --- /dev/null +++ b/docs/execution/VERIFICATION.md @@ -0,0 +1,44 @@ +# Verification record + +Date: 2026-07-22, Asia/Kolkata. Secret values were never printed, passed as arguments, placed in YAML, or included in retained evidence. + +This file records the preceding implementation run. The independent final audit, including corrections to these claims, is authoritative in [RELEASE_AUDIT.md](RELEASE_AUDIT.md). In particular, the prior live databases were not retained, so the final audit could not replay those exact runs under network denial and did not make new OpenAI requests. + +## Independent audit corrections + +The reopened audit found that the earlier provider-only smoke did not substantiate runtime production readiness. It also found concrete implementation gaps: packaged YAML tools were not registered; declared outputs could not read workflow inputs; traces and provider/tool continuation evidence were not durable/publicly inspectable; non-interactive approvals did not durably pause; SIGTERM and in-flight provider cancellation were misclassified; resume/fork lost the original workspace; missing credentials created partial database state; provider options could be ignored; ambiguous transport failures could be retried; function-call IDs were treated as globally unique; and the repository had no user-journey, cron, or OCI acceptance layer. + +All release-blocking gaps above were fixed and covered by focused regression or public-CLI acceptance scenarios. Unsupported OpenAI streaming and programmatic tool calling now fail compilation rather than implying support. + +## Repository-owned gates + +| Command | Result | +| --- | --- | +| `cargo xtask verify` | passed all 12 gates; 60 unit/integration/compatibility tests, doc tests, six fuzz-target builds, denied-warning clippy, generated artifacts, examples, source install, supply-chain/secret/Rust-only boundaries | +| `cargo xtask acceptance` | passed 25 credential-free public-binary scenarios covering the required deterministic/mock/tool/schema/policy/approval/resume/replay/fork/timeout/retry/auth/output/input/artifact/concurrency/SIGTERM/package-style/cron/quickstart journeys | +| `cargo xtask acceptance-container` | passed on Linux arm64 through Podman: non-root UID/GID, read-only root, mounted config/workspace/state/artifacts, strict tool continuation, parseable JSON, public inspect, expected artifact | +| `cargo xtask acceptance-live-openai` | passed from the packaged macOS arm64 CLI and production Linux arm64 image; each journey used a real tool call and continuation, then replayed with the credential removed | +| `cargo xtask package` | passed; optimized binary, Bash/Zsh/Fish/PowerShell completions, README, license, and SHA-256 manifest at `dist/agentctl-0.2.0-aarch64-apple-darwin` | + +The final canonical `verify`, credential-free acceptance, container acceptance, and packaging runs used the final tree. Live acceptance used the same successful execution path before the final tool-cancellation-only branch hardening; that later branch has focused deterministic tests and does not change normal provider/tool continuation. Normal verification/CI remains credential-free. + +## Live OpenAI evidence + +Scenario: `examples/openai-live/workflow.yaml`, model alias `gpt-5.6` (GPT-5.6 Sol), Responses API, low reasoning, stored response, current-turn reasoning context, implicit 30-minute cache mode, parallel tool calls disabled. + +The public path was YAML parse/schema → compiler/plan → capability and policy checks → SQLite run/effect creation → Responses API → strict `read_fixture` function call → tool input validation → workspace policy → real read → output validation → `previous_response_id` continuation → exact final token assertion → atomic report write → checkpoints/audit/traces → CLI result/inspect. The same path ran inside the OCI image. Both source runs replayed successfully in processes where `OPENAI_API_KEY` was removed, with zero replay effects. + +The final recorded invocation used four API requests: two packaged-local and two OCI. Aggregate usage was 987 input tokens, 66 output tokens, 0 reasoning tokens, 0 cache-read tokens, and 0 cache-write tokens. At the current documented GPT-5.6 Sol standard text rates, that invocation is approximately USD 0.0069; provider billing metadata was not returned. The live gate was invoked twice during the reopened task—eight requests total—because the first successful four-request run exposed that the harness did not emit aggregate usage; that reporting defect was fixed before the second run. The first invocation's exact aggregate tokens were not retained, but it used the same bounded workflow and remained comfortably below the USD 3 task target. No response text or fixture content was emitted by the harness. + +Official feature/pricing references used for the audit: [GPT-5.6 model catalog](https://developers.openai.com/api/docs/models), [model guidance](https://developers.openai.com/api/docs/guides/model-guidance?model=gpt-5.6), [function calling](https://developers.openai.com/api/docs/guides/function-calling), [reasoning](https://developers.openai.com/api/docs/guides/reasoning), and [prompt caching](https://developers.openai.com/api/docs/guides/prompt-caching). + +## Operational and supply-chain evidence + +- A copied binary ran help, version, schema, provider diagnostics, completion generation, and the canonical quickstart outside the source tree. `cargo install --path` also passed in a clean temporary root. +- An empty-environment, non-TTY cron-equivalent run passed with stable JSON and explicit paths. Approval pause, overall timeout, SIGTERM exit `130`, concurrent SQLite use, and recovery paths passed. +- Mock tests cover redacted non-retryable authentication errors, explicit 429 retryability, malformed success responses, provider cancellation, tool timeout/cancellation, invalid UTF-8, the 1 MiB workspace-read bound, read-only artifact failure, traversal/symlink escape, database lock/corruption, and protocol malformed/version/origin/timeout cases. +- The OCI image inspection reported `linux arm64`, `nonroot:nonroot`, and version label `0.2.0`. The acceptance invocation used `--read-only` plus only mounted writable state/artifact paths. +- The preceding Trivy run used `--ignore-unfixed`; the final audit repeated Trivy 0.70.0 both with and without that filter and found no HIGH/CRITICAL findings. A CycloneDX JSON SBOM was generated under the ignored `.runtime/scan` verification area. +- GitHub Actions, GitLab CI, Jenkins, Harness CI, Docker, and Kubernetes examples are syntax/documentation-validated only; they were not dispatched to external vendor platforms. Ubuntu CI is configured to execute the Linux amd64 container, scan, and SBOM gates. + +`cargo deny check` passed advisories, bans, licenses, and sources. Duplicate-version reports remain reviewed non-blocking warnings. diff --git a/docs/generated/CLI.md b/docs/generated/CLI.md new file mode 100644 index 0000000..a0b7c41 --- /dev/null +++ b/docs/generated/CLI.md @@ -0,0 +1,481 @@ +# CLI reference + +Generated from the Rust CLI by `cargo xtask generate`. Do not edit by hand. + +## `agentctl` + +```text +Deterministic, declarative control plane for policy-constrained agentic automation + +Usage: agentctl [OPTIONS] + +Commands: + check Validate syntax, schema, references, capabilities, policy, and templates + plan Print the deterministic compiled plan + run Execute a workflow, or predict it with --check + resume Continue an interrupted or approval-paused run + replay Reconstruct a terminal run only from recorded state and results + fork Create a new run from a prior workflow with fresh effects + cancel Durably request cancellation + inspect Inspect durable run, task, and audit state + approvals List or resolve durable approval requests + providers Inspect provider capabilities or run the opt-in OpenAI smoke + auth Check configured secret references without revealing values + schema Print or write the generated workflow JSON Schema + migrate Translate an unversioned TypeScript-era workflow into v1alpha1 + packs Inspect and verify a local reusable pack + db Inspect the runtime database + memory Read or write namespaced long-term memory + gc Garbage-collect expired memory and old terminal runs + completion Generate completion for a supported shell + version Print the exact build version + update Explain safe update options without modifying the installation + +Options: + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --verbose + -h, --help Print help + -V, --version Print version +``` + +## `agentctl check` + +```text +Validate syntax, schema, references, capabilities, policy, and templates + +Usage: agentctl check [OPTIONS] + +Arguments: + + +Options: + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --verbose + -h, --help Print help +``` + +## `agentctl plan` + +```text +Print the deterministic compiled plan + +Usage: agentctl plan [OPTIONS] + +Arguments: + + +Options: + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --verbose + -h, --help Print help +``` + +## `agentctl run` + +```text +Execute a workflow, or predict it with --check + +Usage: agentctl run [OPTIONS] + +Arguments: + + +Options: + --db [default: .agentctl/runtime.db] + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --inputs + --inputs-file + --verbose + --input + --workspace + --timeout-seconds + --check + --diff + --interactive + -h, --help Print help +``` + +## `agentctl resume` + +```text +Continue an interrupted or approval-paused run + +Usage: agentctl resume [OPTIONS] + +Arguments: + + +Options: + --db [default: .agentctl/runtime.db] + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --diff + --interactive + --verbose + --workspace + --timeout-seconds + -h, --help Print help +``` + +## `agentctl replay` + +```text +Reconstruct a terminal run only from recorded state and results + +Usage: agentctl replay [OPTIONS] + +Arguments: + + +Options: + --db [default: .agentctl/runtime.db] + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --verbose + -h, --help Print help +``` + +## `agentctl fork` + +```text +Create a new run from a prior workflow with fresh effects + +Usage: agentctl fork [OPTIONS] + +Arguments: + + +Options: + --db [default: .agentctl/runtime.db] + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --interactive + --diff + --verbose + --workspace + --timeout-seconds + -h, --help Print help +``` + +## `agentctl cancel` + +```text +Durably request cancellation + +Usage: agentctl cancel [OPTIONS] + +Arguments: + + +Options: + --db [default: .agentctl/runtime.db] + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --verbose + -h, --help Print help +``` + +## `agentctl inspect` + +```text +Inspect durable run, task, and audit state + +Usage: agentctl inspect [OPTIONS] + +Arguments: + + +Options: + --db [default: .agentctl/runtime.db] + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --verbose + -h, --help Print help +``` + +## `agentctl approvals` + +```text +List or resolve durable approval requests + +Usage: agentctl approvals [OPTIONS] + +Commands: + list + approve + reject + +Options: + --db [default: .agentctl/runtime.db] + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --verbose + -h, --help Print help +``` + +## `agentctl approvals list` + +```text +Usage: agentctl approvals list [OPTIONS] + +Arguments: + + +Options: + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --verbose + -h, --help Print help +``` + +## `agentctl approvals approve` + +```text +Usage: agentctl approvals approve [OPTIONS] --reason + +Arguments: + + +Options: + --actor [default: cli-user] + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --reason + --verbose + -h, --help Print help +``` + +## `agentctl approvals reject` + +```text +Usage: agentctl approvals reject [OPTIONS] --reason + +Arguments: + + +Options: + --actor [default: cli-user] + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --reason + --verbose + -h, --help Print help +``` + +## `agentctl providers` + +```text +Inspect provider capabilities or run the opt-in OpenAI smoke + +Usage: agentctl providers [OPTIONS] + +Commands: + inspect + smoke-openai + +Options: + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --verbose + -h, --help Print help +``` + +## `agentctl providers inspect` + +```text +Usage: agentctl providers inspect [OPTIONS] + +Arguments: + + +Options: + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --verbose + -h, --help Print help +``` + +## `agentctl providers smoke-openai` + +```text +Usage: agentctl providers smoke-openai [OPTIONS] --live + +Options: + --live Required acknowledgement that this performs one bounded live request + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --model [default: gpt-5.6] + --verbose + -h, --help Print help +``` + +## `agentctl auth` + +```text +Check configured secret references without revealing values + +Usage: agentctl auth [OPTIONS] + +Commands: + check + +Options: + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --verbose + -h, --help Print help +``` + +## `agentctl schema` + +```text +Print or write the generated workflow JSON Schema + +Usage: agentctl schema [OPTIONS] + +Options: + --output [default: human] [possible values: human, json] + --write + --color [default: auto] [possible values: auto, always, never] + --verbose + -h, --help Print help +``` + +## `agentctl migrate` + +```text +Translate an unversioned TypeScript-era workflow into v1alpha1 + +Usage: agentctl migrate [OPTIONS] + +Arguments: + + +Options: + --output [default: human] [possible values: human, json] + --write + --color [default: auto] [possible values: auto, always, never] + --verbose + -h, --help Print help +``` + +## `agentctl packs` + +```text +Inspect and verify a local reusable pack + +Usage: agentctl packs [OPTIONS] + +Commands: + inspect + verify + +Options: + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --verbose + -h, --help Print help +``` + +## `agentctl db` + +```text +Inspect the runtime database + +Usage: agentctl db [OPTIONS] + +Commands: + stats + migrate + +Options: + --db [default: .agentctl/runtime.db] + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --verbose + -h, --help Print help +``` + +## `agentctl memory` + +```text +Read or write namespaced long-term memory + +Usage: agentctl memory [OPTIONS] + +Commands: + get + put + +Options: + --db [default: .agentctl/runtime.db] + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --verbose + -h, --help Print help +``` + +## `agentctl gc` + +```text +Garbage-collect expired memory and old terminal runs + +Usage: agentctl gc [OPTIONS] + +Options: + --db [default: .agentctl/runtime.db] + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --older-than-days [default: 30] + --verbose + -h, --help Print help +``` + +## `agentctl completion` + +```text +Generate completion for a supported shell + +Usage: agentctl completion [OPTIONS] + +Arguments: + [possible values: bash, elvish, fish, powershell, zsh] + +Options: + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --verbose + -h, --help Print help +``` + +## `agentctl version` + +```text +Print the exact build version + +Usage: agentctl version [OPTIONS] + +Options: + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --verbose + -h, --help Print help +``` + +## `agentctl update` + +```text +Explain safe update options without modifying the installation + +Usage: agentctl update [OPTIONS] + +Options: + --output [default: human] [possible values: human, json] + --color [default: auto] [possible values: auto, always, never] + --verbose + -h, --help Print help +``` diff --git a/docs/long-term-memory.md b/docs/long-term-memory.md deleted file mode 100644 index 375f471..0000000 --- a/docs/long-term-memory.md +++ /dev/null @@ -1,246 +0,0 @@ -# Long-Term Memory Operations - -This document covers the operational surface of `long_term_memory` in `agentctl`: - -- retention and garbage collection -- adapter selection -- MongoDB Atlas support -- agent-facing retrieval and promotion patterns -- replay/resume behavior for memory-heavy agent flows - -For the broader memory model, see [memory.md](memory.md). - -## Scope - -`long_term_memory` is the cross-run durable knowledge layer. - -It is the correct place for: - -- approved findings -- reusable facts -- retained operational context -- external memory backends such as SQL, vector, document, and graph stores - -It is not the place for: - -- per-run checkpoint correctness -- transient scratch state -- provider prompt cache material - -## Retention and GC - -`agentctl` now supports first-class garbage collection for long-term memory: - -```bash -agentctl memory gc -agentctl memory gc --older-than-days 7 --keep-entries 50 -agentctl memory gc --namespace service-audit --output json --verbose -``` - -Behavior: - -- deletes entries older than the configured cutoff -- keeps the newest `N` entries even if they are older than the cutoff -- supports optional namespace scoping -- for SQLite, runs `VACUUM` after deletions -- for MongoDB Atlas, no vacuum step exists, so `vacuumed` remains `false` - -Output fields: - -- `provider` -- `olderThanDays` -- `keepEntries` -- `deletedEntries` -- `vacuumed` -- `before` -- `after` -- `deletedKeys` in verbose mode - -## Adapter Selection - -Current supported runtime providers: - -- `sqlite` -- `mongodb-atlas` - -Current scaffold-only placeholders: - -- `postgres` -- `pgvector` -- `elasticsearch` -- `qdrant` -- `weaviate` -- `pinecone` -- `document` -- `graph` - -Only `sqlite` and `mongodb-atlas` are functional today. - -## SQLite - -SQLite remains the local default. - -Playbook config: - -```yaml -memory: - longTerm: - provider: sqlite - dbPath: ./state/long-term.db - namespace: service-audit -``` - -CLI examples: - -```bash -agentctl memory write finding --db ./state/long-term.db --namespace service-audit --string restore-drill-missing -agentctl memory get finding --db ./state/long-term.db --namespace service-audit -agentctl memory gc --db ./state/long-term.db --older-than-days 30 --keep-entries 100 -``` - -## MongoDB Atlas - -MongoDB Atlas is now supported as a real long-term memory adapter. - -Playbook config: - -```yaml -memory: - longTerm: - provider: mongodb-atlas - connectionStringEnv: AGENTCTL_MONGODB_URI - database: agentctl - collection: long_term_memories - namespace: service-audit -``` - -CLI examples: - -```bash -agentctl memory write finding \ - --provider mongodb-atlas \ - --connection-string "$AGENTCTL_MONGODB_URI" \ - --database agentctl \ - --collection long_term_memories \ - --namespace service-audit \ - --string restore-drill-missing - -agentctl memory search \ - --provider mongodb-atlas \ - --connection-string "$AGENTCTL_MONGODB_URI" \ - --database agentctl \ - --collection long_term_memories \ - --query restore -``` - -Implementation notes: - -- connection string comes from `connectionString` or `connectionStringEnv` -- database default: `agentctl` -- collection default: `long_term_memories` -- indexes are created on: - - `{ namespace: 1, key: 1 }` unique - - `{ namespace: 1, updatedAt: -1 }` - -## Agent-Facing Retrieval Patterns - -The framework now supports a higher-level retrieval-and-promotion module: - -- `builtin.long_term_memory.retrieve` - -Purpose: - -- search long-term memory -- select one or more results -- promote selected data into `working_memory` - -This is more useful for agents than a raw search followed by a separate working-memory write. - -### Inputs - -- `namespace` -- `query` -- `key` -- `limit` -- `select` - - `first` - - `all` -- `promoteKey` -- `promoteMode` - - `value` - - `entry` - - `matches` - - `values` -- `includeMetadata` - -### Behavior - -- `select: first` - - uses the first matching entry -- `select: all` - - uses all matching entries -- `promoteKey` - - required - - target key in `memory.working` -- `promoteMode: value` - - promotes the entry value -- `promoteMode: values` - - promotes an array of values -- `promoteMode: entry` - - promotes a single full entry -- `promoteMode: matches` - - promotes the selected entry or entries with metadata - -### Example - -```yaml -tasks: - - id: recall_incident_owner - uses: module:builtin.long_term_memory.retrieve - with: - key: incident-owner - promoteKey: recalled_owner - select: first - promoteMode: value -``` - -After this runs successfully: - -- `memory.working.recalled_owner` is available to later tasks and agents -- `vars.recalled_owner` mirrors it for compatibility - -## Replay and Resume Semantics - -Memory-heavy agent flows are now covered by targeted regressions: - -- resume after a mid-agent working-memory turn -- replay from a checkpoint inside a multi-turn memory agent - -What is guaranteed: - -- `working_memory` survives checkpoints -- agent session turns survive checkpoints -- replay from an agent checkpoint resumes from that stored agent/session state -- promoted working-memory values remain available after resume/replay - -What is not guaranteed: - -- `long_term_memory` side effects are not “rolled back” -- replaying from a checkpoint after an external long-term write continues from the stored checkpoint state, which is correct for durable side effects - -## Community Extensions - -The long-term adapter surface is intentionally separated under: - -- [src/long-term-memory-adapters/](../src/long-term-memory-adapters/) - -The contract includes: - -- `write` -- `get` -- `search` -- `getStats` -- `garbageCollect` -- `close` - -That is the extension point for future built-in and community adapters. diff --git a/docs/memory.md b/docs/memory.md index 0e22d69..205c1c7 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -1,302 +1,10 @@ -# Memory Model +# State and memory -This document defines the memory model for `agentctl` and the intended operational behavior of each memory mode. +Four mechanisms remain intentionally separate: -## Overview +- Run state is authoritative lifecycle data: inputs, task states, attempts, outputs, cancellations, effects, approvals, and checkpoints. +- Working memory is a JSON object owned by one run. Writes are explicit keyed internal-state effects and the updated object commits with the task transition and checkpoint. Sequential scheduling is its merge rule. +- Long-term memory is namespaced SQLite data across runs with optional expiry. Reads/writes are explicit actions; `memory get/put` and `gc` provide administration. Replay never rolls it back or treats it as history. +- Provider prompt cache is an optional performance optimization. Cache keys/options and usage counts are provider metadata, never correctness or memory. -`agentctl` separates runtime durability from cross-run knowledge. - -That split is deliberate: - -- replay/resume correctness depends on local checkpointed state -- cross-run knowledge has different retention, query, and policy needs -- provider prompt caching is optimization, not correctness - -The framework currently uses four memory modes conceptually: - -1. `run_memory` -2. `working_memory` -3. `long_term_memory` -4. `prompt_cache` - -## 1. Run Memory - -Run memory is the execution state for one run. - -It includes: - -- playbook inputs -- task states -- task attempts -- agent sessions and turns -- checkpoints -- audit events -- trace spans -- the current working-memory snapshot - -Storage: - -- runtime DB -- default path: `~/.agentctl/runtime/runtime.db` - -Requirements: - -- deterministic -- replay-safe -- resume-safe -- local-first - -Run memory should not depend on external stores. - -## 2. Working Memory - -Working memory is the mutable state for the active run. - -It is intended for: - -- facts discovered during execution -- intermediate conclusions -- handoff state between tasks or agents -- structured scratch state that must survive retries and resume - -Storage: - -- checkpointed inside the runtime DB as part of the run snapshot - -Current template surface: - -- canonical: `memory.working.*` -- compatibility mirror: `vars.*` - -Example: - -```yaml -memory: - working: - initial: - service: checkout -``` - -And later: - -```yaml -tasks: - - id: remember - uses: module:builtin.memory.write - with: - key: finding - value: restore-drill-missing -``` - -## 3. Long-Term Memory - -Long-term memory is cross-run durable knowledge. - -Use it for: - -- approved facts -- reusable operational knowledge -- indexed reports -- retained findings that should survive independent runs - -Do not use it for: - -- transient per-run scratch state -- checkpoint/replay correctness -- provider cache material - -Current implementation: - -- local SQLite store -- default path: `~/.agentctl/memory/long-term.db` -- adapter extension point scaffolded under `src/long-term-memory-adapters/` - -Current access paths: - -- playbook modules - - `builtin.long_term_memory.write` - - `builtin.long_term_memory.search` -- CLI - - `agentctl memory get` - - `agentctl memory search` - - `agentctl memory write` - - `agentctl memory stats` - -### Namespace model - -Long-term memory is namespaced. - -That prevents unrelated playbooks or environments from writing into the same logical key space by accident. - -If a playbook omits a namespace, compilation defaults it to the playbook name. - -For the CLI: - -- `memory write` defaults to namespace `default` if `--namespace` is omitted -- `memory get` and `memory search` work across all namespaces when `--namespace` is omitted -- `memory stats` reports all namespaces when `--namespace` is omitted - -### Why external adapters belong here - -If `agentctl` later connects to external SQL, document, vector, or graph backends, `long_term_memory` is the correct integration point. - -Reason: - -- cross-run retrieval belongs here -- semantic search belongs here -- retention/governance belongs here -- runtime correctness does not depend on it - -### Adapter extension surface - -`agentctl` now includes a placeholder adapter surface for future built-in and community backends. - -Current adapter files: - -- `sqlite` -- `postgres` -- `pgvector` -- `elasticsearch` -- `qdrant` -- `weaviate` -- `pinecone` -- `document` -- `graph` - -Only `sqlite` is implemented today. - -The others are placeholders with a stable contract: - -- `write(namespace, key, value, tags?)` -- `get(namespace, key)` -- `search(namespace, query, key, limit)` -- `getStats(namespace?)` -- `close()` - -Community adapters can implement the same interface and later be wired into runtime/CLI configuration without changing the core memory semantics. - -## 4. Prompt Cache - -Prompt cache is not a memory-of-record. - -It is a provider-native optimization layer for: - -- repeated prompt prefixes -- tool schema reuse -- lower repeated input-token cost -- lower repeated prompt latency - -Current implementation: - -- supported for `openai.responses` with provider `openai` -- disabled by default -- configured at playbook or agent level -- observed through runtime audit events and `agentctl prompt-cache stats` - -Prompt cache must remain optional and disposable. - -It should never be required for correctness. - -## CLI Reference - -### `agentctl memory stats` - -Inspect the long-term memory DB. - -```bash -agentctl memory stats -agentctl memory stats --namespace memory-flow -agentctl memory stats --output json -``` - -Output fields: - -- `dbPath` -- `fileSizeBytes` -- `totalEntries` -- `totalNamespaces` -- `oldestCreatedAt` -- `newestUpdatedAt` -- optional filtered `namespace` -- `namespaces[]` - - `namespace` - - `entries` - - `oldestCreatedAt` - - `newestUpdatedAt` - -### `agentctl memory get` - -Exact-key lookup. - -```bash -agentctl memory get finding -agentctl memory get finding --namespace memory-flow -``` - -Behavior: - -- with `--namespace`, returns exact matches within that namespace -- without `--namespace`, returns exact-key matches across all namespaces - -Output fields: - -- `dbPath` -- `namespace` or `null` -- `key` -- `limit` -- `found` -- `matchCount` -- `matches[]` - -### `agentctl memory search` - -Search by text query or exact key. - -```bash -agentctl memory search --query restore -agentctl memory search --namespace memory-flow --query readiness -agentctl memory search --key finding -``` - -Behavior: - -- `--query` searches key, serialized value, and serialized tags -- `--key` performs exact-key matching -- if both are omitted, returns entries up to `--limit` - -### `agentctl memory write` - -Write a long-term memory entry. - -```bash -agentctl memory write finding --namespace memory-flow --string restore-drill-missing -agentctl memory write finding --namespace memory-flow --value '{"status":"missing"}' -agentctl memory write finding --tags readiness,audit --string restore-drill-missing -``` - -Behavior: - -- creates the DB if it does not exist -- requires exactly one of: - - `--value` for JSON - - `--string` for plain text -- tags are optional and comma-separated - -## `vars` Decision - -`vars` is currently retained as a compatibility mirror of `memory.working`. - -This is the right short-term tradeoff because it avoids breaking: - -- older playbooks -- older templates -- tests and examples that still reference `vars` - -But the framework direction is: - -- canonical state: `memory.working` -- compatibility-only mirror: `vars` - -New playbooks should use `memory.working`. - -Future work can de-emphasize `vars` in output and documentation before eventually removing it, but correctness should continue to rely on `memory.working`. +Long-term retrieval is exact namespace/key lookup in this release. Vector search and automatic promotion are not implemented. A workflow promotes a value explicitly by reading long-term memory and then writing working memory. Retention is applied by expiration/GC, not by replay. diff --git a/docs/policies.md b/docs/policies.md index 588916b..2f10aa5 100644 --- a/docs/policies.md +++ b/docs/policies.md @@ -1,206 +1,11 @@ -# Policies +# Policies and approvals -`agentctl` policies are runtime guardrails for tool execution. +Policy is evaluated by the runtime, never by a model. A policy defines a canonical workspace root, writable roots, allowed environment names, network host patterns, process basenames, providers, tool allow/deny lists, approval mode, and non-interactive behavior. -They do not decide which tools an agent sees. Tool declaration and agent profiles handle that. Policies decide whether a requested tool call is allowed, denied, or requires approval. +Read paths must canonicalize under the workspace. Write paths canonicalize the nearest existing parent and must remain under a writable root. Parent traversal and symlink escape fail. Network rules match an exact hostname or `*.suffix` subdomains; suffix lookalikes and the wildcard apex do not match. HTTP redirects are disabled. Process allowlisting checks the executable basename and then launches direct argv with a cleared environment. -Subprocess-backed tools are a special case. `builtin.shell.exec` and `pack.process` can launch arbitrary commands, so path checks on `cwd` alone are not enough to make them safe. When an agent tries to call one of those tools, `agentctl` requires approval even if `approvalMode: never`. +Tool visibility, tool/capability authorization, resource checks, effect risk, and approval are distinct decisions. `never`, `mutations`, `high_risk`, and `always` are available approval modes. A tool may say `never`, `policy`, or `always`. The default non-interactive behavior is a durable pause and exit code `3`; explicit `deny_approval` and `fail` modes fail closed. Non-interactive execution never prompts or auto-approves. -## Supported policy fields +An approval stores the run/trace/task/agent, tool, capability, risk, redacted input, expected effect, reason, and resolution actor/reason. The associated task waits durably. Use `approvals list`, `approve`, or `reject`, then `resume`. Resolution and effect status are auditable. -Playbooks currently support these policy fields: - -```yaml -policy: - workspaceRoot: ./relative-or-absolute-path - writableRoots: - - ./path-a - - ./path-b - approvalMode: never | on-mutate | on-act | always -``` - -That is the full supported policy surface today. - -## Field behavior - -### `workspaceRoot` - -`workspaceRoot` defines the root boundary for path-based observation and shell working directories. - -The runtime canonicalizes it at startup. - -It is used for: - -- resolving relative file paths -- preventing observe tools from escaping the allowed workspace -- preventing subprocess tools from using a `cwd` outside the workspace root -- preventing symlink traversal from escaping the allowed workspace - -### `writableRoots` - -`writableRoots` defines where mutate-capability tools may write. - -The runtime canonicalizes every entry at startup. - -It is used for: - -- `builtin.write` -- `builtin.edit` -- any other tool whose capability is `mutate` - -If a target path is outside all writable roots, the tool call is denied. - -### `approvalMode` - -`approvalMode` defines when a tool call should return `require_approval` instead of `allow`. - -Supported values: - -- `never` - - no approval requirement from policy -- `on-mutate` - - approval required for `mutate` and `act` -- `on-act` - - approval required for `act` only -- `always` - - approval required for every non-`internal` capability - -`internal` capability never requires approval from this policy rule. - -When approval is required, `agentctl` does not fail the run immediately. The run is paused, the blocked task moves to `waiting_approval`, and an approval record is persisted in the runtime DB. - -You can then: - -- inspect the pending request with `agentctl approvals list` or `agentctl approvals show ` -- resolve it with `agentctl approvals approve ` or `agentctl approvals reject ` -- continue the run with `agentctl resume ` - -In interactive YAML TTY mode, `agentctl run`, `resume`, and `replay` can prompt inline for approval and continue automatically. - -## Decision flow - -For a tool call, `agentctl` evaluates policy in this order: - -1. agent profile capability check, when the origin is an agent tool call -2. path guardrails: - - any tool input `cwd` must stay inside `workspaceRoot` - - observe tool `path` must stay inside `workspaceRoot` - - mutate tool `path` must stay inside one of `writableRoots` -3. subprocess guardrail: - - agent-origin `builtin.shell.exec` and `pack.process` calls require approval -4. approval mode -5. final decision: - - `allow` - - `deny` - - `require_approval` - -## Current path rules by capability - -### Observe tools - -If the tool input includes a string `path`, the resolved canonical target path must remain inside `workspaceRoot`. - -This applies to tools such as: - -- `builtin.read` -- `builtin.find` -- `builtin.grep` -- `builtin.ls` -- any custom or remote tool classified as `observe` and taking a `path` - -### Mutate tools - -If the tool input includes a string `path`, the resolved canonical target path must remain inside one of the configured `writableRoots`. - -This applies to tools such as: - -- `builtin.write` -- `builtin.edit` -- `builtin.long_term_memory.write` is `mutate` by capability, but it is not path-based, so writable-root checks do not apply to it - -### Act tools - -For subprocess-backed tools, the runtime validates the `cwd` when one is supplied: - -- the resolved `cwd` must remain inside `workspaceRoot` - -For agent-origin subprocess calls, the runtime also requires approval regardless of `approvalMode`, because `cwd` checks do not restrict what the command itself can do. - -Other `act` tools are governed mainly by profile and approval mode unless they also expose a `path` input and use the generic path checks. - -## What policies do not currently cover - -The current policy engine does not yet implement: - -- network allowlists -- environment variable allowlists -- per-tool explicit deny/allow lists in YAML -- separate MCP/A2A-specific auth policies -- sandbox mode policy - -Those may be added later, but they are not part of the current supported surface. - -## How policy interacts with profiles - -Profiles and policies are separate: - -- profiles say whether an agent is allowed to use a capability at all -- policies say whether the specific call is safe in the current workspace and whether approval is required - -So this is possible: - -- profile allows `mutate` -- policy still denies the write because the target path is outside `writableRoots` - -## Examples - -Read-only workspace policy: - -```yaml -policy: - workspaceRoot: . - writableRoots: [] - approvalMode: never -``` - -This allows observe tools inside the repo but denies path-based mutations. - -Editable workspace policy: - -```yaml -policy: - workspaceRoot: . - writableRoots: - - . - approvalMode: on-act -``` - -This allows writes inside the repo, but `act` tools still require approval. - -Restricted writable subtree: - -```yaml -policy: - workspaceRoot: . - writableRoots: - - ./artifacts - - ./reports - approvalMode: on-mutate -``` - -This allows writes only under `artifacts` and `reports`, and requires approval for both mutate and act tool calls. - -## Failure behavior - -Typical denial reasons are: - -- `path "/abs/path" escapes workspaceRoot` -- `path "/abs/path" is not inside writableRoots` -- `bash cwd "/abs/path" escapes workspaceRoot` -- `custom-tool cwd "/abs/path" escapes workspaceRoot` -- `... requires approval under approvalMode=...` -- `... launches a subprocess and requires approval` - -Denials are deliberate hard failures so policy mistakes are visible immediately. - -Approval requirements are not denials. They pause the run until the approval is resolved. +Provider, MCP, A2A, filesystem, process, and environment allowlists are necessary controls, not a containment boundary. Run untrusted executors inside an external OS/container sandbox. diff --git a/docs/profiles.md b/docs/profiles.md deleted file mode 100644 index 9268769..0000000 --- a/docs/profiles.md +++ /dev/null @@ -1,182 +0,0 @@ -# Agent Profiles - -`agentctl` profiles control which tool capabilities an agent is allowed to use when it calls tools. - -Profiles apply only to agent tool calls. They do not change what a normal module task may do when the playbook itself directly uses a module. - -## Supported profiles - -`agentctl` currently supports exactly these profile names: - -- `none` -- `inspect` -- `workspace_write` -- `workspace_exec` - -## Capability model - -Each tool has one of these capabilities: - -- `internal` -- `observe` -- `mutate` -- `act` - -Current built-in capability mapping: - -- `internal` - - `builtin.assign` - - `builtin.assert` - - `builtin.memory.read` - - `builtin.memory.write` - - `builtin.long_term_memory.retrieve` -- `observe` - - `builtin.read` - - `builtin.grep` - - `builtin.find` - - `builtin.ls` - - `builtin.long_term_memory.search` -- `mutate` - - `builtin.write` - - `builtin.edit` - - `builtin.long_term_memory.write` -- `act` - - `builtin.shell.exec` - - `pack.process` by default, unless the pack overrides the capability in its module `policy` - -Remote MCP and A2A tools are also assigned a capability through their policy spec before profile checks run. - -## Profile matrix - -Profile behavior is a straight capability allowlist: - -- `none` - - allows: `internal` - - denies: `observe`, `mutate`, `act` -- `inspect` - - allows: `internal`, `observe` - - denies: `mutate`, `act` -- `workspace_write` - - allows: `internal`, `observe`, `mutate` - - denies: `act` -- `workspace_exec` - - allows: `internal`, `observe`, `mutate`, `act` - - denies: nothing in the current capability model - -## How profiles are selected - -You can set a default profile for agents: - -```yaml -defaults: - agentProfile: inspect -``` - -You can also set a profile per agent: - -```yaml -agents: - reviewer: - kind: builtin.heuristic - profile: workspace_write -``` - -Agent-level `profile` overrides `defaults.agentProfile`. - -If no profile is set anywhere, the runtime uses `none`. - -## What profiles do not do - -Profiles do not: - -- grant tools automatically -- bypass policy checks -- bypass path restrictions -- bypass approval mode - -An agent must still have the tool explicitly listed under `tools:`. - -The runtime then applies profile checks after tool declaration and before execution. - -## Recommended usage - -Use `none` when: - -- the agent should only do internal bookkeeping -- the agent should not observe or mutate the workspace - -Use `inspect` when: - -- the agent should read/search/list only -- you want a safe analysis-only agent - -Use `workspace_write` when: - -- the agent may inspect and edit files -- shell execution should still be blocked - -Use `workspace_exec` when: - -- the agent needs to run commands -- the agent needs the full workspace tool surface -- you accept the higher risk of `act` tools - -## Examples - -Read-only audit agent: - -```yaml -defaults: - agentProfile: inspect - -agents: - auditor: - kind: openai.responses - provider: openai - model: gpt-5 - instructionsFile: ./prompts/audit.md - tools: - - tool: builtin/find - - tool: builtin/read -``` - -Editable but non-exec agent: - -```yaml -agents: - fixer: - kind: openai.responses - provider: openai - model: gpt-5 - profile: workspace_write - instructionsFile: ./prompts/fix.md - tools: - - tool: builtin/read - - tool: builtin/edit - - tool: builtin/write -``` - -Command-running agent: - -```yaml -agents: - builder: - kind: openai.responses - provider: openai - model: gpt-5 - profile: workspace_exec - instructionsFile: ./prompts/build.md - tools: - - tool: builtin/read - - tool: builtin/bash -``` - -## Failure behavior - -If an agent tool call violates the profile, the runtime denies it with a concrete error such as: - -```text -Agent profile "inspect" does not allow write -``` - -That denial happens before the tool executes. diff --git a/docs/prompt-cache.md b/docs/prompt-cache.md deleted file mode 100644 index 86f0e7b..0000000 --- a/docs/prompt-cache.md +++ /dev/null @@ -1,265 +0,0 @@ -# Prompt Cache - -`agentctl` treats prompt cache as a provider-native optimization layer. - -It is not: - -- run memory -- working memory -- long-term memory -- a correctness dependency - -It is currently implemented for: - -- `agents..kind: openai.responses` -- `provider: openai` - -It is not currently supported for: - -- `builtin.heuristic` -- `provider: azure-openai-responses` - -## What prompt cache does - -When enabled, the OpenAI adapter sends a stable `prompt_cache_key` with each response request and records cache-usage metrics from the provider response. - -`agentctl` then exposes those metrics through: - -- runtime audit events -- `agentctl prompt-cache stats` - -Prompt cache is runtime-owned. Agents do not read or write cache contents directly. - -## Default behavior - -Prompt cache is disabled by default. - -That is deliberate: - -- it is optimization, not correctness -- provider semantics differ -- hidden cache reuse can make debugging harder -- some deployments will treat cached prompt material as sensitive - -## Configuration - -Prompt cache can be configured at: - -- playbook level: `promptCache` -- agent level: `agents..promptCache` - -Agent-level config overrides playbook-level defaults. - -Example: - -```yaml -promptCache: - enabled: true - retention: in_memory - keyScope: agent - -agents: - reviewer: - kind: openai.responses - provider: openai - model: gpt-5-mini - instructionsFile: ./prompts/review.md -``` - -## Supported fields - -```yaml -promptCache: - enabled: true | false - force: true | false - retention: in_memory | 24h - keyScope: agent | run | playbook | provider | custom - shareMode: isolated | group - group: optional string - keyTemplate: optional string -``` - -Rules: - -- `shareMode: group` requires `group` -- `keyScope: custom` requires `keyTemplate` -- custom OpenAI-compatible `baseUrl` values disable prompt cache unless `force: true` - -## Key generation - -`agentctl` generates a stable cache key from: - -- provider identity -- configured sharing subject -- a static fingerprint of the agent prompt prefix - -That prefix fingerprint includes: - -- agent kind -- provider -- model -- instruction template content -- tool names and tool input-key shape - -This keeps grouped sharing safer by only aligning agents that actually have the same stable prompt prefix. - -## Scope and sharing - -The effective sharing subject works like this: - -### `shareMode: isolated` - -`keyScope` determines the sharing boundary: - -- `agent` - - isolated per agent - - best default -- `run` - - shared within one run -- `playbook` - - shared across runs of the same playbook -- `provider` - - shared broadly for the provider path -- `custom` - - uses `keyTemplate` - -### `shareMode: group` - -When `shareMode: group` is used, the `group` name becomes the sharing subject. - -Use this only when multiple agents intentionally share the same prompt prefix. - -Example: - -```yaml -agents: - planner: - kind: openai.responses - provider: openai - model: gpt-5-mini - instructions: shared review prefix - promptCache: - enabled: true - shareMode: group - group: review-shared - - reviewer: - kind: openai.responses - provider: openai - model: gpt-5-mini - instructions: shared review prefix - promptCache: - enabled: true - shareMode: group - group: review-shared -``` - -## Retention - -Supported values: - -- `in_memory` -- `24h` - -For OpenAI-native caching: - -- `in_memory` is the baseline retention -- `24h` is only requested where the provider path supports it - -On non-direct OpenAI base URLs: - -- prompt cache is disabled by default -- `force: true` is required to opt in -- when forced, `24h` retention is downgraded to in-memory retention - -## Single-agent example - -```yaml -playbook: prompt-cache-single - -promptCache: - enabled: true - force: true - retention: in_memory - keyScope: agent - -agents: - review: - kind: openai.responses - provider: openai - model: gpt-5-mini - instructionsFile: ./prompts/review.md - -tasks: - - id: run_review - uses: agent:review -``` - -## Multi-agent grouped example - -```yaml -playbook: prompt-cache-group - -agents: - first: - kind: openai.responses - provider: openai - model: gpt-5-mini - instructions: shared cache prefix - promptCache: - enabled: true - shareMode: group - group: shared-reviewers - - second: - kind: openai.responses - provider: openai - model: gpt-5-mini - instructions: shared cache prefix - promptCache: - enabled: true - shareMode: group - group: shared-reviewers - -tasks: - - id: one - uses: agent:first - - - id: two - needs: [one] - uses: agent:second -``` - -## Observability - -Inspect recorded cache metrics with: - -```bash -agentctl prompt-cache stats -agentctl prompt-cache stats --db .runtime/runtime.db --output json -agentctl prompt-cache stats --run-id --verbose -agentctl prompt-cache stats --task-id review -``` - -The stats output includes: - -- total responses -- hit responses -- cached input tokens -- uncached input tokens -- total input tokens -- total output tokens -- latest response timestamp -- provider breakdown -- per-response rows in verbose mode - -## What prompt cache is not - -Prompt cache is not a substitute for: - -- `memory.working` -- long-term memory -- explicit retrieval and promotion - -Use prompt cache to optimize repeated prompt prefixes. -Use memory to store facts, state, and durable knowledge. diff --git a/docs/research/LANDSCAPE.md b/docs/research/LANDSCAPE.md new file mode 100644 index 0000000..020ec99 --- /dev/null +++ b/docs/research/LANDSCAPE.md @@ -0,0 +1,27 @@ +# Workflow and agent landscape + +Research was performed against official documentation and stable specifications on 2026-07-22. “Adopt” means implemented here; “adapt” means the idea is narrowed to agentctl’s local deterministic product. + +| Pattern | Source | Problem solved | Existing agentctl equivalent | Decision | Product rationale | Technical consequence | Compatibility impact | Security impact | Testing implication | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Typed workflows separate from agents | [Agno workflows](https://docs.agno.com/workflows2/overview) | Keeps orchestration outside model reasoning | Workflow/task/agent types | Adapt | The graph must remain authoritative | Compiler schedules bounded agent nodes | Clarifies legacy agent steps | Removes model control of scheduler | Compiler and bounded-loop tests | +| Sequential, conditional, parallel steps | [Agno workflow patterns](https://docs.agno.com/workflows2) | Express common control flow | Dependencies and `when` | Adapt; defer parallel | Sequential DAG and safe conditions suffice until merge rules exist | Reject `maxConcurrency > 1` | Legacy sequential behavior preserved | Avoids race-based policy/state bugs | Stable-order and condition tests | +| Idempotent modules and check/diff | [Ansible playbooks](https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_intro.html) | Predict changes before mutation | Action results and check mode | Adapt | Honest changed/unchanged is useful developer feedback | Typed action result and predictability | Makes old dry-run semantics explicit | Preview cannot authorize an effect | No-mutation and diff tests | +| Fully qualified reusable content | [Ansible collections](https://docs.ansible.com/ansible/latest/collections_guide/index.html) | Avoid name collision and package automation | Pack dotted names | Adopt | Local packs need stable identity | Manifest validation and integrity digest | Old slash pack refs need migration | Enables provenance policy | Tamper and semver tests | +| Plan before apply | [Terraform plan](https://developer.hashicorp.com/terraform/cli/commands/plan) | Separate validation/prediction from mutation | `check`, `plan`, `run --check` | Adapt | Remote/model effects are not predictable | Plan carries predictability class | Replaces optimistic legacy preview | Prevents false safety claims | Predictability golden tests | +| Reconciliation loops | [Kubernetes controllers](https://kubernetes.io/docs/concepts/architecture/controller/) | Converge actual to desired state | Idempotent actions | Defer | General reconciliation is outside local-run scope | No background controller | None | Avoids unattended repeated effects | Future action conformance | +| Workflow history and activity effects | [Temporal durable execution](https://docs.temporal.io/workflows) | Recover without losing decisions | SQLite history/effect ledger | Adapt | Local durability needs the invariant, not a distributed service | Persist request before execution; replay recorded results | Legacy replay renamed fork | Uncertain effects stop instead of duplicate | Crash/resume/replay tests | +| Content-addressed execution | [Dagger engine](https://docs.dagger.io/) | Reuse deterministic computation | Plan/input/effect digests | Adapt | Digests stabilize identities without building a container engine | SHA-256 plan/effect IDs | New durable identity contract | Detects mismatch, not authenticity | Digest stability tests | +| Declarative event orchestration | [Kestra documentation](https://kestra.io/docs) | Schedule triggered workflows | None | Reject for milestone | No hosted/event scheduler in product boundary | Local CLI only | None | Reduces exposed remote surface | N/A | +| Python flow/task retries | [Prefect concepts](https://docs.prefect.io/v3/concepts/flows) | Operationally friendly flow state | Task state/retry | Adapt | Explicit durable states are useful; Python execution is not | Typed retry bounds and states | More explicit than legacy | Bounded retries | State/retry tests | +| Asset/data lineage | [Dagster assets](https://docs.dagster.io/guides/build/assets) | Model data-product dependencies | Task outputs/audit | Defer | Full asset catalog is outside scope | Keep task dataflow only | None | Avoids new metadata trust surface | N/A | +| DAG jobs and expressions | [GitHub Actions workflows](https://docs.github.com/actions/writing-workflows/workflow-syntax-for-github-actions) | Familiar YAML dependency UX | `needs`, inputs, outputs | Adapt | Simple DAG authoring is legible | Strict constrained templates; no general expression VM | Safer than legacy interpolation | No arbitrary expression execution | Malicious-template fuzzing | +| Container DAGs | [Argo Workflows](https://argo-workflows.readthedocs.io/en/latest/) | Kubernetes-native parallel jobs | None | Reject | Kubernetes is an explicit non-goal | No cluster scheduler | None | Avoids cluster credentials | N/A | +| Responses continuation and reasoning items | [OpenAI Responses](https://platform.openai.com/docs/guides/migrate-to-responses) | Preserve model context across turns | Provider-neutral continuation | Adopt | Current native API behavior avoids lossy emulation | `previous_response_id`, opaque reasoning content, usage mapping | Old request parameters migrate | Continuations are durable metadata, not trusted state | Mock mapping and bounded live smoke | +| Strict function tools and structured outputs | [OpenAI function calling](https://platform.openai.com/docs/guides/function-calling) | Make tool I/O machine-validatable | Tool contracts | Adopt | Validation belongs outside the model | Strict JSON schemas before/after tools | Invalid legacy outputs now fail | Blocks success-shaped malicious output | Contract and provider mock tests | +| Session memory and tracing | [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) | Preserve conversation and diagnose handoffs | Run memory, continuations, OTel | Adapt; defer handoffs | Sessions/traces help; free-form handoffs undermine deterministic scheduling | Separate memory layers and typed spans | Teams/handoffs not preserved | Redacted telemetry | Trace and replay tests | +| Checkpointed graph state | [LangGraph persistence](https://docs.langchain.com/oss/python/langgraph/persistence) | Resume graph workflows | Transactional checkpoints | Adapt | Checkpoints are valuable without dynamic graph mutation | Checksummed versioned snapshots | Stronger crash semantics | Corruption is explicit | Persistence corruption tests | +| Stable MCP lifecycle and tools | [MCP 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25) | Standard remote tool access | MCP external action | Adopt | Interoperability should use a pinned stable protocol | Initialize/version/session/list/call/cancel/timeout | Replaces prototype subset | Annotations remain untrusted hints | Mock server contract tests | +| Agent-card task delegation | [A2A 1.0](https://a2a-protocol.org/latest/specification/) | Delegate long-running remote agent work | A2A external action | Adopt narrowly | Remote delegation is an explicit effect, not local scheduling | Card discovery, SendMessage/GetTask/CancelTask, streaming/artifacts | Replaces ad-hoc legacy transport | Card/auth/parts untrusted | Mock peer lifecycle tests | + +Rejected systems are not judged generally unsuitable; they simply do not solve a first-release agentctl problem without expanding the trust or operational boundary. diff --git a/docs/spec.md b/docs/spec.md deleted file mode 100644 index b9b7c02..0000000 --- a/docs/spec.md +++ /dev/null @@ -1,200 +0,0 @@ -# agentctl v0.1 - -## YAML schema - -### Playbook - -```yaml -playbook: -version: 0.1.0 -description: -packs: - - ./relative-pack.yaml -inputs: - key: value -defaults: - agentProfile: none | inspect | workspace_write | workspace_exec -policy: - workspaceRoot: - writableRoots: - - - approvalMode: never | on-mutate | on-act | always -mcpServers: - server_name: - description: - url: - headers: - X-Header: value - bearerTokenEnv: OPTIONAL_ENV_NAME -a2aAgents: - agent_name: - description: - url: - cardUrl: - headers: - X-Header: value - bearerTokenEnv: OPTIONAL_ENV_NAME -modules: - local/name: - kind: builtin.assign | builtin.assert | builtin.shell.exec | builtin.read | builtin.write | builtin.edit | builtin.grep | builtin.find | builtin.ls - with: {} -agents: - local/name: - kind: builtin.heuristic | openai.responses - instructions: - maxTurns: 4 - profile: none | inspect | workspace_write | workspace_exec - provider: openai - model: gpt-5-mini - baseUrl: - organization: - project: - endpoint: - apiVersion: - deployment: - temperature: 0 - maxOutputTokens: 4096 - reasoningEffort: minimal | low | medium | high - tools: - - tool: builtin/bash | builtin/read | builtin/write | builtin/edit | builtin/grep | builtin/find | builtin/ls | mcp:/ | a2a: | - name: optional-name - with: {} -tasks: - - id: unique_id - uses: module: | agent: - needs: [other_task] - with: {} - retry: - maxAttempts: 1 - backoffMs: 0 -``` - -### Pack - -```yaml -pack: namespace -version: 0.1.0 -modules: {} -agents: {} -``` - -Pack members are imported as `/`. - -## Runtime object model - -- `CompiledPlaybook`: validated task graph plus resolved module/agent registries. -- `CompiledPlaybook.defaults`: default agent profile for agent tool authorization. -- `CompiledPlaybook.policy`: resolved workspace guardrails and approval mode. -- `CompiledPlaybook.mcpServers`: declared MCP servers plus optional remote transport configuration. -- `CompiledPlaybook.a2aAgents`: declared A2A peers plus optional remote transport configuration. -- `ModelRegistry`: resolves provider model configuration and auth for provider-backed agents. -- `AuthStorage`: resolves runtime overrides, persisted credentials, and environment variables. -- `RuntimeSnapshot`: checkpointable execution state containing: - - `inputs` - - `vars` - - `tasks` - - `agents` -- `TaskState`: `pending | running | succeeded | failed`, attempts, output, error. -- `AgentSessionState`: current attempt, resolved input, and persisted turn history. -- `CheckpointRecord`: immutable snapshot for replay and resume. -- `RunRecord`: mutable head pointer to the latest snapshot. - -## SQLite schema - -Tables: - -- `runs`: latest execution head for each run -- `checkpoints`: immutable snapshots keyed by `(run_id, seq)` -- `task_attempts`: per-task attempt history -- `agent_turns`: persisted turn-by-turn agent decisions and observations -- `audit_events`: user-facing operational events -- `trace_spans`: internal span model compatible with OpenTelemetry export - -The store uses WAL mode for durability and concurrent readers. - -## Task and agent execution semantics - -1. Compile the playbook into a DAG. -2. Create an initial snapshot with all tasks `pending`. -3. Select the next runnable task when all dependencies succeeded. -4. Checkpoint before the first execution of a task attempt. -5. Execute: - - module task: deterministic module executor or side-effecting shell module - - agent task: bounded loop with persisted turn history and policy-gated tool execution - - `builtin.heuristic`: deterministic local heuristic model - - `openai.responses`: provider-backed loop using the OpenAI Responses API and persisted `previous_response_id` - - provider tools: - - local builtin/module tools - - MCP tools via `mcp:/`, using either injected transports or remote Streamable HTTP sessions - - A2A delegation via `a2a:`, using either injected transports or remote HTTP discovery plus task polling -6. Checkpoint after every agent turn and after task completion/failure. -7. On resume: - - completed tasks remain completed - - interrupted module tasks are reset to `pending` - - interrupted agent tasks continue from persisted turn history -8. On replay: create a new run seeded from an earlier checkpoint snapshot. - -## Built-in tool profiles - -- `none`: internal-only tools such as `builtin.assign` and `builtin.assert` -- `inspect`: `builtin/read`, `builtin/grep`, `builtin/find`, `builtin/ls` -- `workspace_write`: `inspect` plus `builtin/write` and `builtin/edit` -- `workspace_exec`: `workspace_write` plus `builtin/bash` - -Built-in tools are not auto-injected into agents. Agents must still list the tools they intend to use. Profiles decide whether those tool calls are allowed. - -## MCP and A2A - -- MCP supports: - - injected in-process transports for tests or embedded runtimes - - remote Streamable HTTP endpoints declared with `mcpServers..url` -- Remote MCP behavior: - - sends `initialize` - - sends `notifications/initialized` - - caches `MCP-Session-Id` - - reuses `MCP-Protocol-Version` - - lists tools before the first remote call -- Remote MCP tools default to high-risk `act` capability unless the server advertises read-only hints. -- A2A supports: - - injected in-process peer transports - - remote endpoints declared directly with `a2aAgents..url` - - remote discovery from `a2aAgents..cardUrl` -- Remote A2A behavior: - - fetches the agent card when needed - - sends `message/send` - - falls back to `tasks/send` for older peers - - polls `tasks/get` until a terminal task state is reached -- A2A delegated calls produce task/context identifiers and return structured task output into the calling agent turn. - -## Provider auth resolution - -- runtime CLI override via `--api-key` -- persisted provider key in `~/.agentctl/auth.json` -- provider environment variable lookup -- preflight inspection via `agentctl auth check [playbook.yaml] [--provider name]` - -`auth check` inspects either: - -- the provider-backed agents referenced by a playbook -- or the explicit `--provider` value when no playbook is supplied - -The command emits JSON and exits nonzero when any inspected provider is missing auth, so deployments can gate execution before `run`. - -The current live provider implementation is `openai.responses`, with two provider configurations behind the same agent kind: - -- `openai` - - API key plus optional organization, project, and base URL -- `azure-openai-responses` - - API key plus Azure endpoint and API version, using the official `AzureOpenAI` client path - -Stored credentials can be legacy strings or structured `api_key` objects with provider metadata such as `organization`, `project`, `endpoint`, and `apiVersion`. The auth/model registry is intentionally generic so additional providers can be added without changing the playbook runtime contract. - -## Pack packaging format - -`packs` are distribution units. They package reusable: - -- modules -- agents -- policies - -The v0.1 runtime resolves pack files directly from local paths listed in a playbook. A future registry can keep the same manifest contract. diff --git a/docs/typescript.md b/docs/typescript.md deleted file mode 100644 index eb48baa..0000000 --- a/docs/typescript.md +++ /dev/null @@ -1,76 +0,0 @@ -# TypeScript Conventions - -`agentctl` uses strict TypeScript as part of the runtime contract, not just as editor help. - -## Core Rules - -- Prefer `unknown` to `any`. -- Prefer explicit `JsonObject` and `JsonValue` types for runtime payloads. -- Keep mutable state narrow and local. -- Make public interfaces explicit. -- Use type guards instead of broad assertions where runtime data crosses process or network boundaries. - -## Runtime Boundaries - -These parts of the codebase are treated as untrusted-input boundaries and should always normalize or validate data before use: - -- CLI argument parsing -- YAML playbook parsing -- provider responses -- MCP and A2A transport responses -- long-term memory adapters - -Do not pass raw `JSON.parse(...)` results or remote payloads deeper into the runtime without narrowing them first. - -## JSON Payload Model - -The canonical JSON types live in [src/types.ts](../src/types.ts): - -- `JsonPrimitive` -- `JsonArray` -- `JsonObject` -- `JsonValue` - -Use: - -- `JsonObject` for structured runtime inputs, task inputs, memory updates, and provider state -- `JsonValue` for generic serialized payloads - -Avoid introducing ad hoc `Record` payloads when the data is already part of the runtime JSON model. - -## Memory and Task State - -- `memory.working` is the canonical mutable per-run memory surface. -- `vars` remains a compatibility mirror only. -- long-term memory adapters should return plain JSON-shaped entries that can be promoted into working memory without unsafe casting. - -## Module Design - -Builtin modules should: - -- validate inputs up front -- return structured `TaskOutput` -- return `stateUpdates` only when they intentionally mutate working memory -- avoid side-effecting shared state outside the returned `stateUpdates` - -## Agent Design - -Agent models should: - -- keep provider-specific state isolated in `providerState` -- treat tool arguments as validated `JsonObject` -- avoid relying on implicit truthy/falsy casting for control flow when the shape is known - -## Refactor Notes - -Recent refactors in this repo tightened: - -- JSON object typing in module and agent execution paths -- long-term memory retrieval promotion behavior -- MongoDB Atlas adapter aggregation typing - -When extending these areas, preserve the same pattern: - -1. narrow the input -2. keep the runtime payload JSON-shaped -3. add a regression test for the exact boundary you changed From 41a611f9ddcc3367ccb8d97b214a712698392937 Mon Sep 17 00:00:00 2001 From: Ompragash Date: Wed, 22 Jul 2026 15:54:54 +0530 Subject: [PATCH 03/18] fix: pin example pack to committed content --- examples/v1/reusable-pack.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/v1/reusable-pack.yaml b/examples/v1/reusable-pack.yaml index 9d72920..cad43c4 100644 --- a/examples/v1/reusable-pack.yaml +++ b/examples/v1/reusable-pack.yaml @@ -7,7 +7,7 @@ spec: - name: example.utility version: 1.0.0 path: example.pack.yaml - integrity: sha256:1996dafe44c3b1ceec5f3afe39eae566e35155eec298f148377c5b85ee964ea5 + integrity: sha256:a010bcf3aca472351a2a63f097a6972423d98da007ef93a52ea47d284808a91a tasks: - id: packed uses: action:example.utility.assign From f4913dce937d89216f446add153b33d63014ed83 Mon Sep 17 00:00:00 2001 From: Ompragash Date: Wed, 22 Jul 2026 16:54:50 +0530 Subject: [PATCH 04/18] docs: finalize committed-tree audit evidence --- docs/execution/RELEASE_AUDIT.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/execution/RELEASE_AUDIT.md b/docs/execution/RELEASE_AUDIT.md index bd6bb6b..131f839 100644 --- a/docs/execution/RELEASE_AUDIT.md +++ b/docs/execution/RELEASE_AUDIT.md @@ -29,7 +29,7 @@ The ledger was changed to `release audit in progress` before verification. No re ## Clean-room environment -The final clean source copy was `/private/tmp/agentctl-release-audit.2pCaK5/source`. It was created with `rsync` while excluding `.git`, `target`, `fuzz/target`, `.agentctl`, `.runtime`, `dist`, `node_modules`, databases/WAL files, and logs. It included every intended tracked and untracked source change. +The final committed-tree source copy was `/private/tmp/agentctl-final-committed.ARlcZj/source`. It was created with `git archive HEAD`, so it contained only committed release source and no `.git`, Rust build output, runtime state, generated test artifacts, credentials, editor state, or existing container state. An earlier pre-commit clean copy used `rsync` with the same source exclusions so intended untracked migration files were audited before commit. `OPENAI_API_KEY` was removed from every clean-room command. A directory placed first on `PATH` contained `node`, `npm`, `npx`, and `tsc` symlinked to `/usr/bin/false`; all release commands still passed. This proves the production build, installation, tests, examples, package, image build, and workflow execution do not invoke Node or the archived TypeScript runtime. @@ -42,7 +42,9 @@ Environment: - Trivy container image 0.70.0 with a local vulnerability database; - `cargo-llvm-cov`, `syft`, `actionlint`, `kubectl`, `kubeconform`, `hadolint`, and `shellcheck` unavailable; no new host tooling was installed. -Two early clean-room attempts failed as intended: the first caught unformatted new tests, and the second caught a warnings-denied Clippy `needless_borrow`. The current-tree gate then exposed a real subprocess-timeout path that returned before marking uncertainty. All three findings were corrected before the successful clean-room run. The gates propagated nonzero exit status; they did not mask failure. +Two early clean-room attempts failed as intended: the first caught unformatted new tests, and the second caught a warnings-denied Clippy `needless_borrow`. The current-tree gate then exposed a real subprocess-timeout path that returned before marking uncertainty. A later `git archive HEAD` gate caught an incorrect reusable-pack digest that local pre-commit state had masked. All findings were corrected before the successful committed-tree run. The gates propagated nonzero exit status; they did not mask failure. + +The local Podman VM sits behind an enterprise TLS-interception root trusted by macOS but not by the stock Rust builder image. For a no-cache container rebuild, the already-trusted public root certificate was mounted read-only over the builder's CA bundle. It was never added to source, copied into an image layer, or retained after the build. The unmodified `cargo xtask acceptance-container` command then rebuilt the content-addressed image and executed the complete OCI suite. ## Exact release commands and results @@ -92,6 +94,15 @@ No P0 finding was identified. - Regression: CLI unit tests and three public-binary acceptance cases. - Result: each tested parse failure is valid `agentctl.dev/cli/v1` JSON on stderr. +### P1 — committed reusable-pack example failed integrity verification + +- Symptom: `cargo xtask verify` from `git archive HEAD` rejected `examples/v1/reusable-pack.yaml` because its pinned digest did not match the committed pack manifest. +- Root cause: the reference retained a digest from earlier local content, and the pre-commit verification environment did not expose the committed-tree mismatch. +- Affected journey: source checkout verification and the documented reusable-pack example. +- Change: recomputed and pinned the SHA-256 digest of the committed `example.pack.yaml` content. +- Regression: the canonical example/negative-contract gate checks the reference by running the public `agentctl check` command. +- Result: the committed-tree 12-gate verification and reusable-pack execution pass. + ### P2 — replay could create partial state for a nonterminal source - Symptom: a replay row could be created before source task terminality was validated. @@ -172,6 +183,7 @@ No adapter except OpenAI is represented as live-tested, and no external provider ## Container and security evidence - Final image: Linux arm64, version label `0.2.0`, user `nonroot:nonroot`, entrypoint `/usr/local/bin/agentctl`. +- A no-cache builder run used a temporary read-only enterprise CA mount solely for dependency download in this network; the certificate is absent from source, build layers, runtime filesystem, image history, labels, and environment defaults. - Environment defaults contain only PATH and CA-certificate location; no provider credential defaults. - Image history contains the runtime base, labels, and copied Rust binary; no credential-bearing command. - Exported root filesystem contains no Node/npm/npx, Rust compiler/Cargo, TypeScript source, workflow, fixture, or build tree. CA certificates are present through the distroless base. From b4e96dbebd81b1f3eb844d6c0668952c691677d9 Mon Sep 17 00:00:00 2001 From: Ompragash Date: Wed, 22 Jul 2026 18:52:09 +0530 Subject: [PATCH 05/18] test: prove live OpenAI workflow replays offline --- .gitignore | 1 + crates/agentctl-runtime/src/lib.rs | 98 ++++++++++++++- crates/agentctl-store/src/lib.rs | 52 ++++++++ docs/CONTAINER.md | 2 +- docs/LIMITATIONS.md | 2 +- docs/execution/BLOCKERS.md | 2 +- docs/execution/DEFINITION_OF_DONE.md | 10 +- docs/execution/LIVE_OPENAI_REPLAY_EVIDENCE.md | 113 ++++++++++++++++++ docs/execution/RELEASE_AUDIT.md | 31 +++-- docs/execution/STATUS.md | 13 +- docs/execution/VERIFICATION.md | 14 +-- examples/openai-live/workflow.yaml | 1 - 12 files changed, 302 insertions(+), 37 deletions(-) create mode 100644 docs/execution/LIVE_OPENAI_REPLAY_EVIDENCE.md diff --git a/.gitignore b/.gitignore index 79797d6..498624e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /fuzz/target/ /.agentctl/ /.runtime/ +/.release-evidence/ /dist/ /node_modules/ *.db diff --git a/crates/agentctl-runtime/src/lib.rs b/crates/agentctl-runtime/src/lib.rs index 5af213d..0b12e57 100644 --- a/crates/agentctl-runtime/src/lib.rs +++ b/crates/agentctl-runtime/src/lib.rs @@ -453,6 +453,8 @@ impl Runtime { pub async fn replay(&self, source_run_id: &str) -> Result { let source = self.store.load_run(source_run_id)?; let source_tasks = self.store.list_tasks(source_run_id)?; + let source_effects = self.store.list_effects(source_run_id)?; + let source_tool_calls = self.store.tool_calls(source_run_id)?; if !source.state.is_terminal() { return Err(RuntimeError::InvalidState(format!( "source run `{source_run_id}` is not terminal ({:?})", @@ -492,6 +494,14 @@ impl Runtime { self.clock.now(), &trace_id, )?; + self.store.record_replay_effects_reused( + &replay_id, + source_run_id, + &source_effects, + &source_tool_calls, + self.clock.now(), + &trace_id, + )?; for (task, terminal) in source_tasks { self.store.transition_task( &replay_id, @@ -2493,6 +2503,23 @@ mod tests { } } + struct PanicProvider; + + #[async_trait] + impl ModelProvider for PanicProvider { + fn name(&self) -> &'static str { + "fake" + } + + async fn complete( + &self, + _request: &ProviderRequest, + _cancellation: &CancellationToken, + ) -> Result { + panic!("provider executor must not run during recorded replay") + } + } + struct FixtureTool { contract: ToolContract, malformed: bool, @@ -2574,6 +2601,25 @@ mod tests { } } + struct PanicTool { + contract: ToolContract, + } + + #[async_trait] + impl ToolExecutor for PanicTool { + fn contract(&self) -> &ToolContract { + &self.contract + } + + async fn execute( + &self, + _input: Value, + _cancellation: &CancellationToken, + ) -> Result { + panic!("tool executor must not run during recorded replay") + } + } + fn compile_fixture(source: &str) -> (Workflow, CompiledPlan) { let workflow = parse_workflow(source, "fixture.yaml") .expect("parse fixture") @@ -2844,12 +2890,12 @@ spec: let (workflow, plan) = compile_fixture(source); let provider = Arc::new(ToolCallingProvider::default()); let store = SqliteStore::open_memory().expect("store"); - let runtime = runtime(store.clone(), directory.path()).with_registry( + let execution_runtime = runtime(store.clone(), directory.path()).with_registry( RuntimeRegistry::default() .with_provider("fake", provider.clone()) .with_tool("echo", Arc::new(FixtureTool::new(false))), ); - let first = runtime + let first = execution_runtime .start( &workflow, &plan, @@ -2859,7 +2905,17 @@ spec: ) .await .expect("first run"); - let replay = runtime.replay(&first.run_id).await.expect("replay"); + let replay_runtime = runtime(store.clone(), directory.path()).with_registry( + RuntimeRegistry::default() + .with_provider("fake", Arc::new(PanicProvider)) + .with_tool( + "echo", + Arc::new(PanicTool { + contract: FixtureTool::new(false).contract, + }), + ), + ); + let replay = replay_runtime.replay(&first.run_id).await.expect("replay"); assert_eq!(provider.0.load(Ordering::SeqCst), 2); assert_eq!(replay.output, first.output); assert!( @@ -2869,6 +2925,42 @@ spec: .is_empty() ); assert!(store.tool_calls(&replay.run_id).expect("calls").is_empty()); + let source_effect_ids = store + .list_effects(&first.run_id) + .expect("source effects") + .into_iter() + .map(|effect| effect.request.id) + .collect::>(); + let source_tool_call_ids = store + .tool_calls(&first.run_id) + .expect("source tool calls") + .into_iter() + .map(|call| call.call_id) + .collect::>(); + let replay_audit = store.audit_events(&replay.run_id).expect("replay audit"); + let reused = replay_audit + .iter() + .find(|event| event.event_type == "replay.effects_reused") + .expect("reused-effect audit event"); + assert_eq!(reused.payload["sourceRunId"], first.run_id); + assert_eq!( + reused.payload["effects"] + .as_array() + .expect("effect references") + .iter() + .map(|effect| effect["effectId"].as_str().expect("effect id").to_owned()) + .collect::>(), + source_effect_ids + ); + assert_eq!( + reused.payload["toolCalls"] + .as_array() + .expect("tool-call references") + .iter() + .map(|call| call["callId"].as_str().expect("call id").to_owned()) + .collect::>(), + source_tool_call_ids + ); } #[tokio::test] diff --git a/crates/agentctl-store/src/lib.rs b/crates/agentctl-store/src/lib.rs index f6f036c..12f5b2c 100644 --- a/crates/agentctl-store/src/lib.rs +++ b/crates/agentctl-store/src/lib.rs @@ -523,6 +523,58 @@ impl SqliteStore { }) } + pub fn record_replay_effects_reused( + &self, + replay_run_id: &str, + source_run_id: &str, + effects: &[EffectRecord], + tool_calls: &[ToolCallRecord], + now: DateTime, + trace_id: &str, + ) -> Result<(), StoreError> { + let mut connection = self.connection.lock(); + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let effects = effects + .iter() + .map(|effect| { + serde_json::json!({ + "effectId": effect.request.id, + "taskId": effect.request.task_id, + "effectClass": effect.request.effect_class, + "status": effect.status, + "confirmed": effect.confirmed, + }) + }) + .collect::>(); + let tool_calls = tool_calls + .iter() + .map(|call| { + serde_json::json!({ + "callId": call.call_id, + "effectId": call.effect_id, + "taskId": call.task_id, + "toolId": call.tool_id, + "status": call.status, + }) + }) + .collect::>(); + append_audit_tx( + &transaction, + replay_run_id, + "replay.effects_reused", + None, + trace_id, + &serde_json::json!({ + "sourceRunId": source_run_id, + "effects": effects, + "toolCalls": tool_calls, + }), + now, + )?; + transaction.commit()?; + Ok(()) + } + pub fn list_tasks(&self, run_id: &str) -> Result, StoreError> { let connection = self.connection.lock(); let mut statement = connection.prepare( diff --git a/docs/CONTAINER.md b/docs/CONTAINER.md index eb42499..7a690dd 100644 --- a/docs/CONTAINER.md +++ b/docs/CONTAINER.md @@ -249,4 +249,4 @@ For a one-time invocation, use the same Pod template in a `batch/v1` `Job` and o ## Validation level -The native-arm image was executed with Podman as non-root with a read-only root. The final audit exercised a mock tool workflow, artifact and durable inspection, missing-secret and invalid-workflow exit propagation, SIGTERM, and recorded replay under `--network none`. Earlier recorded evidence covers the bounded OpenAI tool workflow, but its source database was not retained for this audit's independent replay. Trivy 0.70.0 found no HIGH/CRITICAL findings, both with and without `--ignore-unfixed`, and generated a CycloneDX JSON SBOM in the ignored verification area. GitHub, GitLab, Jenkins, Harness, and Kubernetes examples were documentation-reviewed but not dispatched to those external platforms. The configured Ubuntu CI container job is the Linux amd64 execution, scan, and SBOM gate when that workflow runs. +The native-arm image was executed with Podman as non-root with a read-only root. The final audit exercised a mock tool workflow, artifact and durable inspection, missing-secret and invalid-workflow exit propagation, SIGTERM, and recorded replay under `--network none`. The exact retained GPT-5.6 live database also replayed with no credential and `--network none`, identical declared output, an unchanged artifact digest, zero fresh effects/tool calls/provider sessions, and explicit source-effect audit links. Trivy 0.70.0 found no HIGH/CRITICAL findings, both with and without `--ignore-unfixed`, and generated a CycloneDX JSON SBOM in the ignored verification area. GitHub, GitLab, Jenkins, Harness, and Kubernetes examples were documentation-reviewed but not dispatched to those external platforms. The configured Ubuntu CI container job is the Linux amd64 execution, scan, and SBOM gate when that workflow runs. diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md index 4344c2f..8f8caa4 100644 --- a/docs/LIMITATIONS.md +++ b/docs/LIMITATIONS.md @@ -4,7 +4,7 @@ This classification is part of the product contract. A deferred feature is not a ## Release blockers -No known implementation blocker remains for the stated local, scheduled, and OCI journeys. The final independent audit recommends internal review rather than release-candidate designation because the earlier live OpenAI source database was not retained, so its exact durable state could not be replayed again under network denial. Deterministic host replay and OCI `--network none` replay both pass with zero effects or tool calls. +No known implementation or evidence blocker remains for the stated local, scheduled, and OCI journeys. The exact final live OpenAI database passed credential-free OCI replay under `--network none` with identical output, unchanged artifact digest, zero fresh effects/tool calls/provider sessions, and explicit source-effect provenance. This supports a `v1alpha1` release-candidate recommendation, not stable v1.0. ## Required hardening completed for this release diff --git a/docs/execution/BLOCKERS.md b/docs/execution/BLOCKERS.md index c4094e3..aab3c79 100644 --- a/docs/execution/BLOCKERS.md +++ b/docs/execution/BLOCKERS.md @@ -1,5 +1,5 @@ # Blockers -There are no known P0/P1 implementation blockers as of 2026-07-22. The independently repeated local, scheduled, and OCI acceptance journeys pass. Release status remains **ready for internal review**, not release candidate, because the prior live OpenAI durable state was not retained for the required independent network-disabled replay. +There are no known P0/P1 implementation or evidence blockers as of 2026-07-22. The independently repeated local, scheduled, and OCI acceptance journeys pass, and the exact retained live OpenAI state passed credential-free network-disabled replay. Release status is **ready as a `v1alpha1` release candidate**, not stable v1.0. Only blockers that prevent safe progress under the mission's definition are recorded here. Missing non-OpenAI live credentials will not be treated as blockers for native implementations with deterministic mock coverage. diff --git a/docs/execution/DEFINITION_OF_DONE.md b/docs/execution/DEFINITION_OF_DONE.md index 3106538..2ddc8b6 100644 --- a/docs/execution/DEFINITION_OF_DONE.md +++ b/docs/execution/DEFINITION_OF_DONE.md @@ -8,15 +8,15 @@ Status values distinguish **deterministically tested**, **mock-provider tested** | Rust-only CLI, clean install/package, no Node dependency | operationally tested | source install; packaged/copy isolation; quickstart; production boundary gate | | Deterministic actions/state/effects/checkpoints/audit/traces | deterministically tested | runtime/store tests and public inspect acceptance | | Fake agent and strict tool continuation | mock-provider tested | tool-using acceptance with artifact and durable evidence | -| GPT-5.6 tool-using runtime | prior live OpenAI evidence reviewed | packaged local and OCI live acceptance was recorded by the preceding run; not called again in the final audit | -| OpenAI reasoning/context/storage/cache/strict schemas/multiple calls/usage mapping | deterministic mapping tests plus prior live evidence | provider mapping tests, compiler rejection tests, prior live continuation | -| Replay without credentials or network | deterministically and operationally tested | provider/tool-executor regression; host replay; OCI `--network none` replay with identical output and zero effects/tool calls | +| GPT-5.6 tool-using runtime | live OpenAI tested | final packaged workflow used a real model-selected tool call and stored-response continuation | +| OpenAI reasoning/context/storage/cache/strict schemas/multiple calls/usage mapping | deterministic mapping tests plus final live evidence | provider mapping tests, compiler rejection tests, final live continuation and usage | +| Replay without credentials or network | deterministically and operationally tested on exact live state | panic-on-call provider/tool regression; OCI `--network none` replay with identical output/artifact digest, zero fresh effects/tool calls, and source-effect audit links | | Resume/reject/uncertainty/fork/retry/auth/rate-limit/malformed/cancellation semantics | deterministically tested | focused provider/runtime/store tests and acceptance scenarios | | Non-interactive approvals, cron, inputs, timeout, SIGTERM | operationally tested | empty-environment and signal acceptance; operations guide | -| OCI non-root/read-only/mount/JSON/artifact/state contract | operationally tested; prior OpenAI live evidence | final native arm64 mock, failure, signal, and offline-replay cases; prior OpenAI image run | +| OCI non-root/read-only/mount/JSON/artifact/state contract | operationally tested | native arm64 mock/failure/signal cases and exact live-state offline replay as UID/GID 65532 | | Image high/critical scan and SBOM | operationally tested on arm64 | Trivy result and CycloneDX artifact recorded in verification ledger | | Linux amd64 image and external CI/vendor pipelines | syntax/configuration validated only | GitHub job and pipeline examples; not remotely dispatched here | | Anthropic/Google/Azure adapters; MCP/A2A | mock-provider/protocol tested | native mapping/protocol tests; not live-tested | | Advisories/licenses/sources/secrets | deterministically tested | cargo-deny, metadata, source, and secret gates | | Parallel/dynamic orchestration, pack ecosystem, vector/encrypted/distributed additions | deferred or non-goal | `docs/LIMITATIONS.md`, ADR 0005/0006/0007 | -| No known P0/P1 correctness/security defect in implemented boundary | verified for internal review | canonical gates, clean-room acceptance audit, image scan, conservative documented limits | +| No known P0/P1 correctness/security defect in implemented boundary | verified for `v1alpha1` release candidate | canonical gates, clean-room acceptance audit, image scan, exact live durable replay, conservative documented limits | diff --git a/docs/execution/LIVE_OPENAI_REPLAY_EVIDENCE.md b/docs/execution/LIVE_OPENAI_REPLAY_EVIDENCE.md new file mode 100644 index 0000000..ee360ae --- /dev/null +++ b/docs/execution/LIVE_OPENAI_REPLAY_EVIDENCE.md @@ -0,0 +1,113 @@ +# Live OpenAI durable-replay evidence + +Evidence date: 2026-07-22 (Asia/Kolkata) + +Status: **passed**. A packaged macOS arm64 `agentctl` executed the canonical GPT-5.6 tool workflow, retained its exact SQLite state, and the updated production Linux arm64 image replayed a byte-identical copy of that state as UID/GID 65532 with no credential and `--network none`. + +The local evidence is ignored at `.release-evidence/openai-live/`. It contains the exact pre-replay database, the post-replay database, the generated artifact, machine-readable public-CLI output, safe metadata, a manifest, and sanitized commands. It is deliberately not committed because normalized prompts, provider results, tool output, and workspace content are durable runtime data. + +## Live execution + +The canonical workflow is `examples/openai-live/workflow.yaml`. It uses GPT-5.6, one strict model-selected `builtin.workspace.read` call, a concrete fixture marker, an exact final verdict, a deterministic assertion, and a deterministic artifact write. OpenAI tool continuation uses the documented `previous_response_id` plus function-call output flow described by the official [function-calling guide](https://developers.openai.com/api/docs/guides/function-calling). + +The final retained invocation was: + +```console +dist/agentctl-0.2.0-aarch64-apple-darwin/agentctl run \ + .release-evidence/openai-live/workspace/workflow.yaml \ + --workspace .release-evidence/openai-live/workspace \ + --db .release-evidence/openai-live/state-final/runtime.db \ + --output json --color never --timeout-seconds 120 +``` + +After the compliant run succeeded, `state-final` was promoted to the retained canonical `state` directory without changing the database bytes. + +The credential was inherited only by the process. Its value was not a command argument, YAML value, output field, database value, container environment, or committed artifact. + +| Field | Final retained value | +| --- | --- | +| Run ID | `run-019f89f5-bdc4-70a1-83bf-b3327388b4eb` | +| Trace ID | `trace-019f89f5-bdc4-70a1-83bf-b347e8def8a1` | +| State | `succeeded` | +| Model | `gpt-5.6` | +| Provider requests | 2 | +| Model-selected tool calls | 1 (`read_fixture`) | +| Usage | 530 input, 33 output, 0 reasoning, 0 cache-read, 0 cache-write tokens | +| Provider-reported cost | unavailable (`costMicrousd: null`) | +| Checkpoints / audit / trace records | 11 / 15 / 12 | +| Workflow digest | `aea4a9441129de8a3e0c7c7ad8061af58727816d1c11cd2799b63a97e14553a6` | +| Plan digest | `aef56e9a102a711ecc7d8e35f85185bedc878db8983acf118caf28f7b7c63bb8` | + +Public `inspect` reported four confirmed successful effects: two `model` effects, one `observe` tool effect, and one `workspace_mutate` artifact effect. It also reported one successful tool call and one persisted OpenAI continuation session. + +This task made two bounded live workflow executions. The first exposed that the canonical YAML's redundant explicit credential-environment reference was persisted with the workflow. The reference contained no key value, but it violated this gate's stricter database rule, so the example now relies on the CLI's built-in OpenAI credential default. The final compliant run used two more provider requests. Task total: **4 provider requests**, 1,060 input tokens, 66 output tokens, and no reasoning/cache tokens. No further live call was made. + +## Credential-free network-disabled replay + +Before replay, an empty-environment `auth check` reported the OpenAI credential as absent. The container received no credential file or environment variable; image defaults contain only `PATH` and the CA-certificate location. + +The host-created SQLite file was copied byte-for-byte into a container-managed volume because macOS bind-mount ownership cannot represent container UID 65532 on that existing file. The pre-replay host and volume digests matched. The public replay command was: + +```console +podman run --rm --network none --read-only --user 65532:65532 \ + --tmpfs /tmp:rw,noexec,nosuid,size=16m \ + --mount type=volume,source=agentctl-openai-live-replay-final-019f89f5,target=/state \ + agentctl-acceptance:local replay \ + run-019f89f5-bdc4-70a1-83bf-b3327388b4eb \ + --db /state/runtime.db --output json --color never +``` + +| Field | Replay value | +| --- | --- | +| Replay run ID | `replay-019f89f6-5daa-73e0-bea0-ccd55b3ee5ac` | +| Replay trace ID | `trace-019f89f6-5daa-73e0-bea0-cce2288704a4` | +| Source link | `parentRunId = run-019f89f5-bdc4-70a1-83bf-b3327388b4eb` | +| State / mode | `succeeded` / `replay` | +| Fresh effects / model effects / tool calls / provider sessions | 0 / 0 / 0 / 0 | +| Network | `none` | +| Credential forwarded | no | +| Exit / stderr bytes | 0 / 0 | + +Replay public inspection contains a `replay.effects_reused` audit event on the replay trace. It links the source run, all four original effect IDs and statuses, and the original successful tool-call/effect pair. The replay itself creates no effect or tool-call rows. + +The deterministic regression `recorded_replay_never_calls_provider_or_tool_executor` now replays against provider and tool executors that panic if invoked. It proves identical output, zero replay effects/tool calls, and exact source-effect/tool-call audit references. + +## Digests and comparisons + +| Artifact | SHA-256 | +| --- | --- | +| Runtime database before replay | `8920ab40656e0b4258e89bfdacef0d243c64cb5bf496ab686c9633df861ff621` | +| Runtime database after replay | `e4a6c68892bfb992e02a48c46e0bc2b4601ee1e414b928517f890690d202d2c3` | +| Artifact before replay | `e8d13b658dad59fdf7914765dfc79541bf1765a78cab522967b183b3038e9667` | +| Artifact after replay | `e8d13b658dad59fdf7914765dfc79541bf1765a78cab522967b183b3038e9667` | +| Live stdout | `343cd889cdba2a8108b4f8cc1ffe55fcb4ba8e1782911aae2d2e446b0505652d` | +| Replay stdout | `291dfccbabea59fe97f9e20914ac2d6cd3992f019adc17b9ad48428db3b60f37` | +| Canonical semantic `/data/output` | `670d0705bfe2a81c6c6cdbb4c7ca91428c28ac51e5de5fff2a407fa55a0f9f5b` | + +The database digest changes because replay durably records a distinct run, task transitions, checkpoints, and audit records. Live and replay envelopes have different operational IDs, but their canonical declared outputs have the same digest. Recorded replay intentionally does not execute the artifact write again; the workspace was not mounted into the replay container, and the original declared artifact digest remained unchanged before and after replay. + +## Security inspection and local review + +- Exact key and every 16-byte key fragment: zero matches across retained evidence. +- Database credential names, authorization markers, and bearer markers: zero matches in both databases. +- Generic key/authorization patterns: zero matches. +- Unexpected host paths: zero; only the explicit ignored workspace base path is present. +- Image configuration/history exact-key or fragment matches: zero. +- Static, live, replay, and inspect stderr: zero bytes for the final successful journey. +- Durable provider content is the normalized effect input/result required for inspection and replay, not a raw HTTP response, header set, or environment dump. + +Start local review with `.release-evidence/openai-live/manifest.json` and `.release-evidence/openai-live/commands.txt`, then use the packaged public CLI: + +```console +dist/agentctl-0.2.0-aarch64-apple-darwin/agentctl inspect \ + run-019f89f5-bdc4-70a1-83bf-b3327388b4eb \ + --db .release-evidence/openai-live/state/runtime.db \ + --output json --color never + +dist/agentctl-0.2.0-aarch64-apple-darwin/agentctl inspect \ + replay-019f89f6-5daa-73e0-bea0-ccd55b3ee5ac \ + --db .release-evidence/openai-live/state/runtime-after-replay.db \ + --output json --color never +``` + +Protect the ignored directory like runtime state and remove it under the team's evidence-retention policy after review. diff --git a/docs/execution/RELEASE_AUDIT.md b/docs/execution/RELEASE_AUDIT.md index 131f839..e7782f7 100644 --- a/docs/execution/RELEASE_AUDIT.md +++ b/docs/execution/RELEASE_AUDIT.md @@ -2,7 +2,15 @@ Audit date: 2026-07-22 (Asia/Kolkata) -Recommendation: **Ready for internal review**. This is not a stable-v1 recommendation and not yet a `v1alpha1` release-candidate recommendation. +Recommendation: **Ready as a `v1alpha1` release candidate**. This is not a stable-v1 recommendation. + +## Final live durable-replay gate + +The previously missing proof is now complete. A packaged macOS arm64 CLI executed the canonical GPT-5.6 YAML workflow with a real model-selected read-only tool call and stored-response continuation. The exact completed SQLite database was retained locally, scanned, copied byte-for-byte into the updated production image's state volume, and replayed as non-root with no credential and `--network none`. + +The final source run used two provider requests and one tool call (530 input and 33 output tokens). Replay returned the same declared output and unchanged artifact digest while public inspection reported zero fresh effects, tool calls, or provider sessions. A new `replay.effects_reused` audit event links the replay trace to the original run's four effect IDs and tool-call record. The deterministic regression uses provider/tool executors that panic if replay invokes them. Full sanitized evidence is in [LIVE_OPENAI_REPLAY_EVIDENCE.md](LIVE_OPENAI_REPLAY_EVIDENCE.md); exact databases and machine output remain ignored locally. + +Two bounded live executions were required: the first revealed that the canonical example's redundant credential-environment reference was serialized into durable workflow state. No key value was present. The reference was removed in favor of the existing provider default, and the final retained database has zero credential-name, authorization, exact-key, or key-fragment matches. Task total was four OpenAI requests, within the authorized maximum. ## Scope and tree identity @@ -20,7 +28,7 @@ The ledger was changed to `release audit in progress` before verification. No re | Credential-free public-CLI acceptance passes | Confirmed; 25 scenarios pass. | | Native arm64 OCI execution passes | Confirmed and strengthened with failure exits, SIGTERM, durable inspect, and network-disabled replay. | | OpenAI GPT-5.6 tool workflow passed live | Prior ledger and bounded usage metadata reviewed; not called again. The implementation path remains mock-tested. | -| Live OpenAI durable state replayed without credentials | Corrected: prior databases were not retained, so the exact live state could not be independently replayed. Deterministic host replay and OCI `--network none` replay pass. | +| Live OpenAI durable state replayed without credentials | Corrected during the original audit because its prior databases were unavailable. The final closure gate above now supplies the missing exact live-state proof. | | Ambiguous effects safely block resume | Partially false before fixes: several paths remained `started` or were marked `failed`; one subprocess timeout returned before uncertainty recording. Fixed and regression-tested. | | JSON mode is always parseable | False for Clap parse failures before fixes. Unknown command, missing argument, and invalid value now return the versioned JSON error envelope. | | Supply-chain verification cannot be silently skipped | False before fixes: `cargo-deny` could be absent while `verify` still succeeded. It is now a required gate and CI installs it. | @@ -59,7 +67,7 @@ The following commands ran from the final clean copy with `OPENAI_API_KEY` absen | `shasum -a 256 -c SHA256SUMS` | 0 | `agentctl: OK`. | | `git diff --check` | 0 | No whitespace errors. | -`cargo xtask acceptance-live-openai` was deliberately not run. No OpenAI request was made during this audit because the provider execution path was not changed; the `store: false` fix is compile-time validation and is covered by deterministic mapping/compiler tests. +The final durable-replay closure used the packaged production CLI directly rather than the four-request host-plus-container live harness. It made four total OpenAI requests across two bounded executions and then performed the exact final replay without credentials or network. See [LIVE_OPENAI_REPLAY_EVIDENCE.md](LIVE_OPENAI_REPLAY_EVIDENCE.md). GitHub workflow YAML was parsed locally with Ruby's YAML parser. GitLab, Jenkins, Harness, Docker, Kubernetes Job, and Kubernetes CronJob examples were documentation-reviewed but not dispatched or vendor-validated. @@ -141,7 +149,7 @@ Effect identity includes run, task, task attempt, ordinal, operation, and input ## Offline replay proof -The deterministic runtime regression starts a tool-calling provider workflow, replays it, asserts identical structured output, and proves the replay invokes neither provider nor tool executor and records zero replay effects/tool calls. +The deterministic runtime regression starts a tool-calling provider workflow, replays it against provider/tool executors that panic if invoked, asserts identical structured output, proves zero replay effects/tool calls, and verifies the replay audit's exact source effect/tool-call references. The public OCI journey then: @@ -151,7 +159,7 @@ The public OCI journey then: 4. asserted a distinct replay run ID; 5. inspected the replay and found zero effects and zero tool calls. -The exact prior live OpenAI database was unavailable. That evidence gap is why this audit stops at **Ready for internal review**. A future release-candidate gate should retain a sanitized encrypted/protected state artifact long enough to perform the same `--network none` public replay, then destroy it under the release evidence retention policy. +The same public OCI journey was then repeated with the exact final live OpenAI database. Credential-free auth inspection reported the OpenAI credential absent; the image received no credential or workspace mount and ran with `--network none`. Replay succeeded with a distinct run/trace ID, identical declared output, an unchanged artifact digest, zero fresh effects/tool calls/provider sessions, and explicit source-effect provenance in audit output. ## Provider and protocol support @@ -160,7 +168,7 @@ No adapter except OpenAI is represented as live-tested, and no external provider | Kind | Implementation | Audit validation | Release wording | | --- | --- | --- | --- | | Fake | in-process text/tool/usage/continuation | deterministic unit/runtime/public acceptance | Deterministically tested | -| OpenAI Responses | native auth/request/response, strict tools, multiple call IDs, continuation, usage, reasoning/cache options | mock-protocol mapping plus prior bounded GPT-5.6 live tool evidence; no audit live call | Prior live-tested and mock-protocol tested | +| OpenAI Responses | native auth/request/response, strict tools, multiple call IDs, continuation, usage, reasoning/cache options | mock-protocol mapping plus final bounded GPT-5.6 live tool run and exact offline durable replay | Live-tested and mock-protocol tested | | Azure OpenAI Responses | native Azure auth/path plus OpenAI mapping | focused mock request/auth/response test | Mock-mapping tested; not live-tested | | Anthropic Messages | native content/tool/usage mapping | focused mock native tool test | Mock-mapping tested; not live-tested | | Google Gemini | native content/function/usage mapping | focused mock native response test | Mock-mapping tested; not live-tested | @@ -171,7 +179,7 @@ No adapter except OpenAI is represented as live-tested, and no external provider | Platform | Validation | | --- | --- | -| macOS arm64 host | Native build, 66 tests, public acceptance, installation, package, checksum: executed | +| macOS arm64 host | Native build, 66 tests, public acceptance, installation, package, checksum, packaged GPT-5.6 tool workflow: executed | | Linux arm64 OCI | Native Podman build/run, non-root/read-only, signals, failures, offline replay, scan/SBOM: executed | | Linux amd64 OCI | CI-configured only. A local `--platform linux/amd64` build was attempted because Podman advertised emulation, but emulated `rustc` terminated with SIGSEGV; the emulator was not reliable, so no local build/run claim is made. | | macOS x86_64 | Not tested locally; CI-configured through hosted macOS only when dispatched. | @@ -190,7 +198,7 @@ No adapter except OpenAI is represented as live-tested, and no external provider - The image runs with `--read-only`, UID/GID 65532, and only `/state` and `/artifacts` writable. - Trivy 0.70.0 reported zero HIGH/CRITICAL findings both with and without `--ignore-unfixed`. A 20 KiB CycloneDX JSON SBOM was generated at ignored local evidence path `.runtime/scan/agentctl-final.cdx.json`. - `cargo deny check`: advisories, bans, licenses, and sources passed. Duplicate dependency versions are warnings, not denied findings. -- Repository secret scan and manual credential-pattern scan found no committed token/private key. No intended source database or live response body remains. `OPENAI_API_KEY` was present in the host environment but its value was never printed, passed as an argument, persisted, or copied into clean-room/container state. +- Repository and retained-evidence scans found no committed token/private key or exact/fragment key match. The ignored final database contains no provider credential name, authorization header, bearer marker, or environment dump. The configured key value was never printed, passed as an argument, persisted, or forwarded into replay/container state. - Production Rust contains no `unsafe`, production `panic!`, ignored test, `allow(dead_code)`, or `allow(unused)`. `expect`/`panic!` occurrences are test assertions. `allow(clippy::too_many_arguments)` is limited to explicit effect/transition/store data-flow signatures where named parameters preserve audit meaning. `serde_json::Value` serialization uses an infallible-in-practice fallback for digest construction; malformed external JSON is parsed before reaching that value type. - Filesystem/process/network controls are policy checks, not an OS sandbox. Untrusted workflows require a restricted OS/container identity and egress controls. @@ -201,8 +209,8 @@ No adapter except OpenAI is represented as live-tested, and no external provider | Parser/schema/compiler/templates | strict/unknown-field, source diagnostic, cycle, deterministic order, property, capability, stateless-tool negative tests | | State/persistence/migrations/corruption | state transition, transactional checkpoint, schema upgrade/future version, corruption, lock wait, GC tests | | Effects/approval/resume/fork | store/runtime tests plus public scenarios 9–16 | -| Replay no dispatch | provider+tool executor regression and host/OCI public replay inspection | -| Provider/tool continuation | native mapping mocks, call-ID test, schema failures, fake tool acceptance, prior live OpenAI evidence | +| Replay no dispatch | panic-on-call provider+tool regression and exact live-state OCI public replay inspection | +| Provider/tool continuation | native mapping mocks, call-ID test, schema failures, fake tool acceptance, final live OpenAI continuation | | Policy/path/redaction | traversal, symlink, host allowlist, secret redaction, invalid UTF-8/read-only artifact tests | | Cancellation/uncertainty | provider/tool/process/protocol tests and host/container SIGTERM acceptance | | CLI machine contract | parse errors, validation/auth/policy/run/cancel outputs, run/trace correlation | @@ -212,7 +220,6 @@ Coverage percentage was not invented; `cargo-llvm-cov` was unavailable. Timing-s ## Deferred items and residual risks -- Retain a completed live OpenAI durable-state artifact for one independent credential-free, network-disabled replay before release-candidate designation. - Dispatch the configured Linux amd64, macOS, Windows, scan/SBOM, and external pipeline gates; until then they remain CI-configured or documentation-reviewed only. - Expand Azure/Anthropic/Google adapter negative/error/cancellation/tool-continuation coverage before raising their maturity beyond focused mock mapping. - Single-host SQLite, sequential scheduling, manual uncertain-effect reconciliation, alpha schema evolution, and policy-not-sandbox limitations remain intentional. @@ -224,4 +231,4 @@ Files requiring closest human review are `crates/agentctl-runtime/src/lib.rs` (e ## Final gate decision -There is no known P0/P1 implementation defect in the defined local, externally scheduled, or generic OCI boundary after remediation. Clean-room deterministic and OCI evidence is green. The missing retained live state prevents completion of one specifically requested independent proof, so the honest recommendation is **Ready for internal review**, not yet **Ready as a `v1alpha1` release candidate**. +There is no known P0/P1 implementation defect in the defined local, externally scheduled, or generic OCI boundary after remediation. Clean-room deterministic and OCI evidence is green, and the exact retained live OpenAI state now passes independent credential-free, network-disabled replay. The honest recommendation is **Ready as a `v1alpha1` release candidate**, not stable v1.0. diff --git a/docs/execution/STATUS.md b/docs/execution/STATUS.md index 8239a12..d075dd0 100644 --- a/docs/execution/STATUS.md +++ b/docs/execution/STATUS.md @@ -4,15 +4,16 @@ Last updated: 2026-07-22 ## Current phase -ready for internal review +ready as a `v1alpha1` release candidate -The adversarial audit passed the defined local, scheduled, and native-arm64 OCI implementation gates. Release-candidate status is deliberately withheld because the prior live OpenAI database was not retained for the required independent `--network none` replay. +The adversarial audit and final live durable-replay gate passed the defined local, scheduled, and native-arm64 OCI implementation boundary. This is not a stable-v1 recommendation. ## Accepted evidence - The independently audited Rust implementation passes all 12 `cargo xtask verify` gates (66 tests) and the 25-scenario credential-free public-CLI acceptance suite from a clean copy with Node tools poisoned. -- The preceding run recorded a packaged GPT-5.6 strict function-call workflow; this audit reviewed that evidence but made no additional OpenAI calls. -- Deterministic host replay invokes neither provider nor tool executor. Native-arm64 OCI replay passes under `--network none` with identical output, a distinct replay ID, and zero effects/tool calls. +- A packaged GPT-5.6 workflow made one real model-selected read-only tool call and continued through stored-response function output; the final run used two provider requests, 530 input tokens, and 33 output tokens. +- The exact completed live database is retained locally and replays in the native-arm64 image with no credential and `--network none`. Replay has a distinct run/trace ID, identical output, unchanged artifact digest, zero fresh effects/tool calls/provider sessions, and explicit source-effect audit links. +- The deterministic replay regression uses provider and tool executors that panic if called. - Confirmed effects survive resume; fork is distinct and fresh; timeout/transport uncertainty blocks unsafe repetition. - Clean copied/source-installed/package layouts, empty-environment cron invocation, concurrency, SIGTERM, approvals, machine output, and recovery paths passed. - The actual OCI image passed mock-tool, failure-exit, SIGTERM, and offline-replay cases as non-root with a read-only root and mounted durable state/artifacts. Trivy 0.70.0 found no HIGH/CRITICAL findings with or without `--ignore-unfixed`; a CycloneDX SBOM was generated. @@ -27,7 +28,7 @@ The local environment executed macOS arm64 packaging and Linux arm64 OCI tests. ## Hard blockers -No known P0/P1 implementation blocker. The live-state evidence gap blocks only a release-candidate recommendation. See [BLOCKERS.md](BLOCKERS.md) and [RELEASE_AUDIT.md](RELEASE_AUDIT.md). +No known P0/P1 implementation or evidence blocker remains for the stated boundary. See [BLOCKERS.md](BLOCKERS.md), [RELEASE_AUDIT.md](RELEASE_AUDIT.md), and [LIVE_OPENAI_REPLAY_EVIDENCE.md](LIVE_OPENAI_REPLAY_EVIDENCE.md). ## Exact commands @@ -38,4 +39,4 @@ cargo xtask acceptance-container cargo xtask package ``` -`cargo xtask acceptance-live-openai` was not run during this audit. See [RELEASE_AUDIT.md](RELEASE_AUDIT.md) for the independent results and [VERIFICATION.md](VERIFICATION.md) for the preceding run's safe live usage metadata. +The final live journey used the packaged CLI directly to stay within the four-request authorization; normal repository verification remains credential-free. diff --git a/docs/execution/VERIFICATION.md b/docs/execution/VERIFICATION.md index 8624669..a24c8d6 100644 --- a/docs/execution/VERIFICATION.md +++ b/docs/execution/VERIFICATION.md @@ -2,7 +2,7 @@ Date: 2026-07-22, Asia/Kolkata. Secret values were never printed, passed as arguments, placed in YAML, or included in retained evidence. -This file records the preceding implementation run. The independent final audit, including corrections to these claims, is authoritative in [RELEASE_AUDIT.md](RELEASE_AUDIT.md). In particular, the prior live databases were not retained, so the final audit could not replay those exact runs under network denial and did not make new OpenAI requests. +The independent final audit and the completed exact live-state replay are authoritative in [RELEASE_AUDIT.md](RELEASE_AUDIT.md) and [LIVE_OPENAI_REPLAY_EVIDENCE.md](LIVE_OPENAI_REPLAY_EVIDENCE.md). ## Independent audit corrections @@ -14,21 +14,21 @@ All release-blocking gaps above were fixed and covered by focused regression or | Command | Result | | --- | --- | -| `cargo xtask verify` | passed all 12 gates; 60 unit/integration/compatibility tests, doc tests, six fuzz-target builds, denied-warning clippy, generated artifacts, examples, source install, supply-chain/secret/Rust-only boundaries | +| `cargo xtask verify` | passed all 12 gates; 66 unit/integration/compatibility tests, doc tests, six fuzz-target builds, denied-warning clippy, generated artifacts, examples, source install, supply-chain/secret/Rust-only boundaries | | `cargo xtask acceptance` | passed 25 credential-free public-binary scenarios covering the required deterministic/mock/tool/schema/policy/approval/resume/replay/fork/timeout/retry/auth/output/input/artifact/concurrency/SIGTERM/package-style/cron/quickstart journeys | | `cargo xtask acceptance-container` | passed on Linux arm64 through Podman: non-root UID/GID, read-only root, mounted config/workspace/state/artifacts, strict tool continuation, parseable JSON, public inspect, expected artifact | -| `cargo xtask acceptance-live-openai` | passed from the packaged macOS arm64 CLI and production Linux arm64 image; each journey used a real tool call and continuation, then replayed with the credential removed | +| Manual final live/replay gate | packaged macOS arm64 GPT-5.6 tool workflow passed; its exact retained state replayed in the production Linux arm64 image with no credential, `--network none`, identical output/artifact digest, and zero fresh effects/tool calls | | `cargo xtask package` | passed; optimized binary, Bash/Zsh/Fish/PowerShell completions, README, license, and SHA-256 manifest at `dist/agentctl-0.2.0-aarch64-apple-darwin` | -The final canonical `verify`, credential-free acceptance, container acceptance, and packaging runs used the final tree. Live acceptance used the same successful execution path before the final tool-cancellation-only branch hardening; that later branch has focused deterministic tests and does not change normal provider/tool continuation. Normal verification/CI remains credential-free. +The canonical `verify`, credential-free acceptance, container acceptance, and packaging gates use no provider credentials. The final live gate was separately bounded and authorized. ## Live OpenAI evidence -Scenario: `examples/openai-live/workflow.yaml`, model alias `gpt-5.6` (GPT-5.6 Sol), Responses API, low reasoning, stored response, current-turn reasoning context, implicit 30-minute cache mode, parallel tool calls disabled. +Scenario: `examples/openai-live/workflow.yaml`, model `gpt-5.6`, Responses API, low reasoning, stored response, current-turn reasoning context, implicit 30-minute cache mode, parallel tool calls disabled. -The public path was YAML parse/schema → compiler/plan → capability and policy checks → SQLite run/effect creation → Responses API → strict `read_fixture` function call → tool input validation → workspace policy → real read → output validation → `previous_response_id` continuation → exact final token assertion → atomic report write → checkpoints/audit/traces → CLI result/inspect. The same path ran inside the OCI image. Both source runs replayed successfully in processes where `OPENAI_API_KEY` was removed, with zero replay effects. +The public path was YAML parse/schema → compiler/plan → capability and policy checks → SQLite run/effect creation → Responses API → strict `read_fixture` function call → tool input validation → workspace policy → real read → output validation → `previous_response_id` continuation → exact final token assertion → atomic report write → checkpoints/audit/traces → CLI result/inspect. -The final recorded invocation used four API requests: two packaged-local and two OCI. Aggregate usage was 987 input tokens, 66 output tokens, 0 reasoning tokens, 0 cache-read tokens, and 0 cache-write tokens. At the current documented GPT-5.6 Sol standard text rates, that invocation is approximately USD 0.0069; provider billing metadata was not returned. The live gate was invoked twice during the reopened task—eight requests total—because the first successful four-request run exposed that the harness did not emit aggregate usage; that reporting defect was fixed before the second run. The first invocation's exact aggregate tokens were not retained, but it used the same bounded workflow and remained comfortably below the USD 3 task target. No response text or fixture content was emitted by the harness. +The final retained run used two provider requests, one actual model-selected tool call, 530 input tokens, and 33 output tokens. Its exact database replayed in the image with no credential or network, and public inspection found zero replay effects, tool calls, and provider sessions. Two bounded live executions were needed because the first exposed a persisted credential-environment reference in the canonical YAML; after removing that redundant reference, the compliant final run used the remaining two requests. Task total was four requests, 1,060 input tokens, and 66 output tokens. Provider billing metadata was not returned, so cost was not fabricated. Official feature/pricing references used for the audit: [GPT-5.6 model catalog](https://developers.openai.com/api/docs/models), [model guidance](https://developers.openai.com/api/docs/guides/model-guidance?model=gpt-5.6), [function calling](https://developers.openai.com/api/docs/guides/function-calling), [reasoning](https://developers.openai.com/api/docs/guides/reasoning), and [prompt caching](https://developers.openai.com/api/docs/guides/prompt-caching). diff --git a/examples/openai-live/workflow.yaml b/examples/openai-live/workflow.yaml index b3c7cf7..b7b9544 100644 --- a/examples/openai-live/workflow.yaml +++ b/examples/openai-live/workflow.yaml @@ -11,7 +11,6 @@ spec: providers: openai: kind: openai - credential: { env: OPENAI_API_KEY } policy: workspaceRoot: . writableRoots: [artifacts] From 2e461e913b364400d228aab9d4f0c8604428655b Mon Sep 17 00:00:00 2001 From: Ompragash Date: Wed, 22 Jul 2026 19:43:27 +0530 Subject: [PATCH 06/18] fix: harden provider continuations and secret handling --- crates/agentctl-core/src/provider.rs | 2 + crates/agentctl-protocols/src/lib.rs | 93 ++++++++-- crates/agentctl-providers/src/lib.rs | 253 ++++++++++++++++++++++++--- 3 files changed, 307 insertions(+), 41 deletions(-) diff --git a/crates/agentctl-core/src/provider.rs b/crates/agentctl-core/src/provider.rs index 2d92ecc..9401ac6 100644 --- a/crates/agentctl-core/src/provider.rs +++ b/crates/agentctl-core/src/provider.rs @@ -42,6 +42,8 @@ pub enum ContentBlock { id: String, name: String, input: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_metadata: Option, }, ToolResult { id: String, diff --git a/crates/agentctl-protocols/src/lib.rs b/crates/agentctl-protocols/src/lib.rs index e99a6a8..37aa0e8 100644 --- a/crates/agentctl-protocols/src/lib.rs +++ b/crates/agentctl-protocols/src/lib.rs @@ -219,7 +219,13 @@ impl McpClient { ProtocolError::Transport("MCP session lock was poisoned".to_owned()) })? = Some(session.to_owned()); } - let value = response_value(response, self.config.timeout, cancellation).await?; + let value = response_value( + response, + self.config.timeout, + cancellation, + &self.config.headers, + ) + .await?; json_rpc_result(value) } @@ -349,8 +355,13 @@ impl A2aClient { ); let response = execute_request(request, self.card_config.timeout, cancellation, None).await?; - let card: AgentCard = - response_json(response, self.card_config.timeout, cancellation).await?; + let card: AgentCard = response_json( + response, + self.card_config.timeout, + cancellation, + &self.card_config.headers, + ) + .await?; if card.name.trim().is_empty() || card.supported_interfaces.is_empty() { return Err(ProtocolError::Malformed( "Agent Card requires a name and supportedInterfaces".to_owned(), @@ -489,7 +500,13 @@ impl A2aClient { let request = self.request(&interface)?.json(&body); let response = execute_request(request, self.card_config.timeout, cancellation, None).await?; - let values = response_values(response, self.card_config.timeout, cancellation).await?; + let values = response_values( + response, + self.card_config.timeout, + cancellation, + &self.card_config.headers, + ) + .await?; values.into_iter().map(json_rpc_result).collect() } @@ -510,7 +527,15 @@ impl A2aClient { None, ) .await?; - json_rpc_result(response_value(response, self.card_config.timeout, cancellation).await?) + json_rpc_result( + response_value( + response, + self.card_config.timeout, + cancellation, + &self.card_config.headers, + ) + .await?, + ) } fn selected_interface(&self) -> Result { @@ -658,20 +683,25 @@ async fn response_json Deserialize<'de>>( response: Response, timeout: Duration, cancellation: &CancellationToken, + headers: &BTreeMap, ) -> Result { if !response.status().is_success() { return Err(http_error(response).await); } let bytes = bounded_response(response, timeout, cancellation).await?; - serde_json::from_slice(&bytes).map_err(|error| ProtocolError::Malformed(error.to_string())) + let mut value: Value = serde_json::from_slice(&bytes) + .map_err(|error| ProtocolError::Malformed(error.to_string()))?; + redact_header_secrets(&mut value, headers); + serde_json::from_value(value).map_err(|error| ProtocolError::Malformed(error.to_string())) } async fn response_value( response: Response, timeout: Duration, cancellation: &CancellationToken, + headers: &BTreeMap, ) -> Result { - response_values(response, timeout, cancellation) + response_values(response, timeout, cancellation, headers) .await? .into_iter() .last() @@ -682,6 +712,7 @@ async fn response_values( response: Response, timeout: Duration, cancellation: &CancellationToken, + headers: &BTreeMap, ) -> Result, ProtocolError> { if !response.status().is_success() { return Err(http_error(response).await); @@ -695,18 +726,51 @@ async fn response_values( let bytes = bounded_response(response, timeout, cancellation).await?; let text = String::from_utf8(bytes) .map_err(|error| ProtocolError::Malformed(format!("SSE encoding: {error}")))?; - text.lines() + let mut values = text + .lines() .filter_map(|line| line.strip_prefix("data:")) .map(|data| { serde_json::from_str(data.trim()) .map_err(|error| ProtocolError::Malformed(format!("SSE data: {error}"))) }) - .collect() + .collect::, _>>()?; + for value in &mut values { + redact_header_secrets(value, headers); + } + Ok(values) } else { let bytes = bounded_response(response, timeout, cancellation).await?; - serde_json::from_slice(&bytes) - .map(|value| vec![value]) - .map_err(|error| ProtocolError::Malformed(error.to_string())) + let mut value = serde_json::from_slice(&bytes) + .map_err(|error| ProtocolError::Malformed(error.to_string()))?; + redact_header_secrets(&mut value, headers); + Ok(vec![value]) + } +} + +fn redact_header_secrets(value: &mut Value, headers: &BTreeMap) { + match value { + Value::String(text) => { + for secret in headers.values().filter(|secret| !secret.is_empty()) { + *text = text.replace(secret, "[REDACTED]"); + } + } + Value::Array(values) => { + for value in values { + redact_header_secrets(value, headers); + } + } + Value::Object(values) => { + let entries = std::mem::take(values); + for (name, mut value) in entries { + redact_header_secrets(&mut value, headers); + let name = headers + .values() + .filter(|secret| !secret.is_empty()) + .fold(name, |name, secret| name.replace(secret, "[REDACTED]")); + values.insert(name, value); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} } } @@ -838,12 +902,13 @@ mod tests { .await; Mock::given(method("POST")) .and(path("/mcp")) + .and(header("authorization", "Bearer fixture")) .and(body_partial_json( serde_json::json!({"method": "tools/call"}), )) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "jsonrpc": "2.0", "id": 3, - "result": {"structuredContent": {"echo": "ok"}, "isError": false} + "result": {"structuredContent": {"Bearer fixture": "Bearer fixture"}, "isError": false} }))) .mount(&server) .await; @@ -863,7 +928,7 @@ mod tests { .call_tool("echo", serde_json::json!({"text": "ok"}), &cancellation) .await .expect("call"); - assert_eq!(result, serde_json::json!({"echo": "ok"})); + assert_eq!(result, serde_json::json!({"[REDACTED]": "[REDACTED]"})); } #[tokio::test] diff --git a/crates/agentctl-providers/src/lib.rs b/crates/agentctl-providers/src/lib.rs index 4beac8b..a2a6d6c 100644 --- a/crates/agentctl-providers/src/lib.rs +++ b/crates/agentctl-providers/src/lib.rs @@ -145,7 +145,8 @@ impl ModelProvider for OpenAiProvider { if let Some(project) = &self.config.project { http = http.header("OpenAI-Project", project); } - let response = send(http, cancellation, &credential).await?; + let secrets = configured_secrets(&credential, &self.config.headers); + let response = send(http, cancellation, &secrets).await?; parse_openai(response) } } @@ -307,7 +308,9 @@ fn openai_input(request: &ProviderRequest) -> Result, ProviderError> "type": "output_text", "text": value })), - ContentBlock::ToolCall { id, name, input } => output.push(serde_json::json!({ + ContentBlock::ToolCall { + id, name, input, .. + } => output.push(serde_json::json!({ "type": "function_call", "call_id": id, "name": name, @@ -382,7 +385,12 @@ fn parse_openai(value: Value) -> Result { name: name.clone(), input: input.clone(), }); - assistant_content.push(ContentBlock::ToolCall { id, name, input }); + assistant_content.push(ContentBlock::ToolCall { + id, + name, + input, + provider_metadata: None, + }); } Some("reasoning") => assistant_content.push(ContentBlock::OpaqueReasoning { value: item.clone(), @@ -463,7 +471,8 @@ impl ModelProvider for AnthropicProvider { let http = http .header("x-api-key", &credential) .header("anthropic-version", ANTHROPIC_VERSION); - let response = send(http, cancellation, &credential).await?; + let secrets = configured_secrets(&credential, &self.config.headers); + let response = send(http, cancellation, &secrets).await?; parse_anthropic(response, request) } } @@ -532,7 +541,9 @@ fn anthropic_messages(messages: &[Message]) -> Result, ProviderError> ContentBlock::Text { text } => { Some(serde_json::json!({"type": "text", "text": text})) } - ContentBlock::ToolCall { id, name, input } => Some(serde_json::json!({ + ContentBlock::ToolCall { + id, name, input, .. + } => Some(serde_json::json!({ "type": "tool_use", "id": id, "name": name, "input": input })), ContentBlock::ToolResult { @@ -545,7 +556,7 @@ fn anthropic_messages(messages: &[Message]) -> Result, ProviderError> "content": serde_json::to_string(output).ok()?, "is_error": is_error, })), - ContentBlock::OpaqueReasoning { .. } => None, + ContentBlock::OpaqueReasoning { value } => Some(value.clone()), }) .collect(); Some(serde_json::json!({"role": role, "content": content})) @@ -585,9 +596,15 @@ fn parse_anthropic( id: call.id.clone(), name: call.name.clone(), input: call.input.clone(), + provider_metadata: None, }); tool_calls.push(call); } + Some("thinking" | "redacted_thinking") => { + assistant_content.push(ContentBlock::OpaqueReasoning { + value: block.clone(), + }); + } _ => {} } } @@ -660,7 +677,8 @@ impl ModelProvider for GoogleProvider { .iter() .fold(http, |request, (name, value)| request.header(name, value)); let http = http.header("x-goog-api-key", &credential); - let response = send(http, cancellation, &credential).await?; + let secrets = configured_secrets(&credential, &self.config.headers); + let response = send(http, cancellation, &secrets).await?; parse_google(response, request) } } @@ -706,6 +724,16 @@ fn google_request(request: &ProviderRequest) -> Result { } fn google_contents(messages: &[Message]) -> Result, ProviderError> { + let tool_names = messages + .iter() + .flat_map(|message| match message { + Message::User(blocks) | Message::Assistant(blocks) => blocks, + }) + .filter_map(|block| match block { + ContentBlock::ToolCall { id, name, .. } => Some((id.clone(), name.clone())), + _ => None, + }) + .collect::>(); messages .iter() .map(|message| { @@ -715,17 +743,40 @@ fn google_contents(messages: &[Message]) -> Result, ProviderError> { }; let parts = blocks .iter() - .filter_map(|block| match block { - ContentBlock::Text { text } => Some(serde_json::json!({"text": text})), - ContentBlock::ToolCall { id, name, input } => Some(serde_json::json!({ - "functionCall": {"id": id, "name": name, "args": input} - })), - ContentBlock::ToolResult { id, output, .. } => Some(serde_json::json!({ - "functionResponse": {"id": id, "name": "agentctl_tool", "response": output} - })), - ContentBlock::OpaqueReasoning { .. } => None, + .map(|block| match block { + ContentBlock::Text { text } => Ok(serde_json::json!({"text": text})), + ContentBlock::ToolCall { + id, + name, + input, + provider_metadata, + } => { + let mut part = serde_json::json!({ + "functionCall": {"id": id, "name": name, "args": input} + }); + if let Some(signature) = provider_metadata + .as_ref() + .and_then(|metadata| metadata.get("thoughtSignature")) + { + part["thoughtSignature"] = signature.clone(); + } + Ok(part) + } + ContentBlock::ToolResult { id, output, .. } => tool_names.get(id).map_or_else( + || { + Err(ProviderError::Malformed(format!( + "Gemini tool result `{id}` has no matching function name" + ))) + }, + |name| { + Ok(serde_json::json!({ + "functionResponse": {"id": id, "name": name, "response": output} + })) + }, + ), + ContentBlock::OpaqueReasoning { value } => Ok(value.clone()), }) - .collect::>(); + .collect::, _>>()?; Ok(serde_json::json!({"role": role, "parts": parts})) }) .collect() @@ -743,6 +794,10 @@ fn parse_google( let mut text = String::new(); let mut tool_calls = Vec::new(); let mut assistant_content = Vec::new(); + let response_scope = value + .get("responseId") + .and_then(Value::as_str) + .unwrap_or("response"); for part in candidate .pointer("/content/parts") .and_then(Value::as_array) @@ -758,7 +813,7 @@ fn parse_google( if let Some(call) = part.get("functionCall") { let tool_call = ToolCall { id: call.get("id").and_then(Value::as_str).map_or_else( - || format!("gemini-call-{}", tool_calls.len()), + || format!("gemini-{response_scope}-call-{}", tool_calls.len()), ToOwned::to_owned, ), name: required_field(call, "name")?, @@ -771,6 +826,9 @@ fn parse_google( id: tool_call.id.clone(), name: tool_call.name.clone(), input: tool_call.input.clone(), + provider_metadata: part + .get("thoughtSignature") + .map(|signature| serde_json::json!({"thoughtSignature": signature})), }); tool_calls.push(tool_call); } @@ -905,6 +963,7 @@ impl ModelProvider for FakeProvider { id: "fake-call-1".to_owned(), name: tool.id.clone(), input, + provider_metadata: None, }], continuation: None, usage: Usage { @@ -951,7 +1010,7 @@ impl ModelProvider for FakeProvider { async fn send( request: reqwest::RequestBuilder, cancellation: &CancellationToken, - credential: &str, + secrets: &[&str], ) -> Result { let response = tokio::select! { response = request.send() => response.map_err(normalize_transport)?, @@ -963,8 +1022,9 @@ async fn send( .get("x-request-id") .or_else(|| response.headers().get("request-id")) .and_then(|value| value.to_str().ok()) - .unwrap_or("unavailable") - .to_owned(); + .unwrap_or("unavailable"); + let mut request_id = redact_text(request_id, secrets); + request_id.truncate(512); let mut stream = response.bytes_stream(); let mut bytes = Vec::new(); loop { @@ -981,8 +1041,9 @@ async fn send( } bytes.extend_from_slice(&chunk); } - let body: Value = serde_json::from_slice(&bytes) + let mut body: Value = serde_json::from_slice(&bytes) .map_err(|error| ProviderError::Malformed(error.to_string()))?; + redact_value(&mut body, secrets); if status.is_success() { Ok(body) } else { @@ -991,7 +1052,7 @@ async fn send( .or_else(|| body.get("message")) .and_then(Value::as_str) .unwrap_or("provider request failed"); - let mut safe = message.replace(credential, "[REDACTED]"); + let mut safe = redact_text(message, secrets); safe.truncate(512); Err(ProviderError::Http { status: status.as_u16(), @@ -1002,6 +1063,44 @@ async fn send( } } +fn configured_secrets<'a>( + credential: &'a str, + headers: &'a BTreeMap, +) -> Vec<&'a str> { + std::iter::once(credential) + .chain(headers.values().map(String::as_str)) + .filter(|secret| !secret.is_empty()) + .collect() +} + +fn redact_value(value: &mut Value, secrets: &[&str]) { + match value { + Value::String(text) => *text = redact_text(text, secrets), + Value::Array(values) => { + for value in values { + redact_value(value, secrets); + } + } + Value::Object(values) => { + let entries = std::mem::take(values); + for (name, mut value) in entries { + redact_value(&mut value, secrets); + values.insert(redact_text(&name, secrets), value); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } +} + +fn redact_text(value: &str, secrets: &[&str]) -> String { + secrets + .iter() + .filter(|secret| !secret.is_empty()) + .fold(value.to_owned(), |text, secret| { + text.replace(secret, "[REDACTED]") + }) +} + fn normalize_transport(error: reqwest::Error) -> ProviderError { if error.is_timeout() { ProviderError::Timeout @@ -1260,6 +1359,71 @@ mod tests { assert_eq!(response.usage.cache_read_tokens, 4); } + #[test] + fn google_preserves_function_identity_and_thought_signature_on_continuation() { + let response = parse_google( + serde_json::json!({ + "responseId": "gemini_tools", + "candidates": [{ + "content": {"parts": [{ + "functionCall": {"id": "call-7", "name": "echo", "args": {"text": "hello"}}, + "thoughtSignature": "encrypted-signature" + }]}, + "finishReason": "STOP" + }] + }), + &request(), + ) + .expect("Gemini tool response"); + let mut messages = request().messages; + messages.push(Message::Assistant(response.assistant_content)); + messages.push(Message::User(vec![ContentBlock::ToolResult { + id: "call-7".to_owned(), + output: serde_json::json!({"text": "hello"}), + is_error: false, + }])); + + let contents = google_contents(&messages).expect("Gemini continuation"); + assert_eq!( + contents[1].pointer("/parts/0/thoughtSignature"), + Some(&Value::String("encrypted-signature".to_owned())) + ); + assert_eq!( + contents[2].pointer("/parts/0/functionResponse/id"), + Some(&Value::String("call-7".to_owned())) + ); + assert_eq!( + contents[2].pointer("/parts/0/functionResponse/name"), + Some(&Value::String("echo".to_owned())) + ); + } + + #[test] + fn anthropic_preserves_thinking_blocks_on_continuation() { + let response = parse_anthropic( + serde_json::json!({ + "id": "msg-thinking", + "content": [ + {"type": "thinking", "thinking": "hidden", "signature": "signed"}, + {"type": "tool_use", "id": "toolu-1", "name": "echo", "input": {"text": "hello"}} + ], + "stop_reason": "tool_use" + }), + &request(), + ) + .expect("Anthropic tool response"); + let message = Message::Assistant(response.assistant_content); + let mapped = anthropic_messages(&[message]).expect("Anthropic continuation"); + assert_eq!( + mapped[0].pointer("/content/0/type"), + Some(&Value::String("thinking".to_owned())) + ); + assert_eq!( + mapped[0].pointer("/content/0/signature"), + Some(&Value::String("signed".to_owned())) + ); + } + #[tokio::test] async fn azure_uses_api_key_and_v1_responses_path() { let server = MockServer::start().await; @@ -1299,15 +1463,18 @@ mod tests { .and(path("/v1/responses")) .respond_with( ResponseTemplate::new(401) - .insert_header("x-request-id", "request-auth") + .insert_header("x-request-id", "request-header-secret") .set_body_json(serde_json::json!({ - "error": {"message": "invalid credential test-key"} + "error": {"message": "invalid credentials test-key and header-secret"} })), ) .mount(&server) .await; let mut config = HttpProviderConfig::openai("AGENTCTL_PROVIDER_TEST_KEY"); config.endpoint = format!("{}/v1/responses", server.uri()); + config + .headers + .insert("x-custom-auth".to_owned(), "header-secret".to_owned()); let error = OpenAiProvider::new(config) .expect("provider") .complete(&request(), &CancellationToken::new()) @@ -1321,14 +1488,46 @@ mod tests { retryable, } => { assert_eq!(status, 401); - assert_eq!(message, "invalid credential [REDACTED]"); - assert_eq!(request_id, "request-auth"); + assert_eq!(message, "invalid credentials [REDACTED] and [REDACTED]"); + assert_eq!(request_id, "request-[REDACTED]"); assert!(!retryable); } other => panic!("unexpected error: {other}"), } } + #[tokio::test] + async fn provider_success_payloads_cannot_echo_configured_header_secrets() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/responses")) + .and(header("x-custom-auth", "header-secret")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "resp_header-secret", + "status": "completed", + "output": [ + {"type": "reasoning", "header-secret": "test-key"}, + {"type": "message", "content": [{"type": "output_text", "text": "echo header-secret and test-key"}]} + ] + }))) + .mount(&server) + .await; + let mut config = HttpProviderConfig::openai("AGENTCTL_PROVIDER_TEST_KEY"); + config.endpoint = format!("{}/v1/responses", server.uri()); + config + .headers + .insert("x-custom-auth".to_owned(), "header-secret".to_owned()); + let response = OpenAiProvider::new(config) + .expect("provider") + .complete(&request(), &CancellationToken::new()) + .await + .expect("response"); + let serialized = serde_json::to_string(&response).expect("serialized response"); + assert!(!serialized.contains("header-secret")); + assert!(!serialized.contains("test-key")); + assert_eq!(response.text, "echo [REDACTED] and [REDACTED]"); + } + #[tokio::test] async fn rate_limits_are_explicitly_retryable() { let server = MockServer::start().await; From 10ce285c5534e886e415443e9d88a97d05a9c00b Mon Sep 17 00:00:00 2001 From: Ompragash Date: Wed, 22 Jul 2026 19:43:35 +0530 Subject: [PATCH 07/18] fix: close policy and durable execution gaps --- crates/agentctl-cli/src/main.rs | 40 ++- crates/agentctl-core/src/policy.rs | 190 ++++++++++++--- crates/agentctl-runtime/src/lib.rs | 377 ++++++++++++++++++++++++----- crates/agentctl-store/src/lib.rs | 137 +++++++++-- xtask/src/acceptance.rs | 17 +- 5 files changed, 652 insertions(+), 109 deletions(-) diff --git a/crates/agentctl-cli/src/main.rs b/crates/agentctl-cli/src/main.rs index ce7f4d0..dc09c2b 100644 --- a/crates/agentctl-cli/src/main.rs +++ b/crates/agentctl-cli/src/main.rs @@ -34,6 +34,7 @@ const EXIT_RUN_FAILED: u8 = 4; const EXIT_PERSISTENCE: u8 = 5; const EXIT_REMOTE: u8 = 6; const EXIT_CANCELLED: u8 = 130; +const MAX_TEXT_FILE_BYTES: u64 = 1024 * 1024; static VERBOSE_OUTPUT: AtomicBool = AtomicBool::new(false); static COLOR_OUTPUT: AtomicBool = AtomicBool::new(false); @@ -475,7 +476,7 @@ async fn execute(cli: Cli) -> Result { outcome.run_id, outcome.state ), )?; - Ok(EXIT_OK) + Ok(outcome_exit_code(outcome.state)) } Command::Fork(args) => { validate_interactive(args.interactive)?; @@ -719,13 +720,17 @@ fn print_outcome( outcome.run_id, outcome.state, outcome.trace_id ), )?; - Ok(match outcome.state { + Ok(outcome_exit_code(outcome.state)) +} + +const fn outcome_exit_code(state: agentctl_core::state::RunState) -> u8 { + match state { agentctl_core::state::RunState::Succeeded => EXIT_OK, agentctl_core::state::RunState::Paused => EXIT_POLICY, agentctl_core::state::RunState::Cancelled => EXIT_CANCELLED, agentctl_core::state::RunState::Failed => EXIT_RUN_FAILED, agentctl_core::state::RunState::Running => EXIT_RUN_FAILED, - }) + } } fn approval_command(output: OutputFormat, args: ApprovalArgs) -> Result { @@ -1442,8 +1447,22 @@ fn parse_inputs(raw: &str, source: &str) -> Result Result { - std::fs::read_to_string(path) - .map_err(|error| CliError::validation(format!("{}: {error}", path.display()))) + use std::io::Read as _; + + let file = std::fs::File::open(path) + .map_err(|error| CliError::validation(format!("{}: {error}", path.display())))?; + let mut reader = file.take(MAX_TEXT_FILE_BYTES + 1); + let mut content = String::new(); + reader + .read_to_string(&mut content) + .map_err(|error| CliError::validation(format!("{}: {error}", path.display())))?; + if content.len() as u64 > MAX_TEXT_FILE_BYTES { + return Err(CliError::validation(format!( + "{} exceeds {MAX_TEXT_FILE_BYTES} bytes", + path.display() + ))); + } + Ok(content) } fn write_text(path: &Path, content: &str) -> Result<(), CliError> { @@ -1686,4 +1705,15 @@ mod tests { .expect("valid flags"); assert_eq!(cli.output, OutputFormat::Json); } + + #[test] + fn text_inputs_are_read_with_a_hard_size_limit() { + let directory = tempfile::tempdir().expect("tempdir"); + let path = directory.path().join("oversized.yaml"); + std::fs::write(&path, vec![b'x'; MAX_TEXT_FILE_BYTES as usize + 1]) + .expect("oversized fixture"); + let error = read_text(&path).expect_err("oversized input must fail"); + assert_eq!(error.code, EXIT_VALIDATION); + assert!(error.message.contains("exceeds 1048576 bytes")); + } } diff --git a/crates/agentctl-core/src/policy.rs b/crates/agentctl-core/src/policy.rs index 1e5a2fa..bbd0e09 100644 --- a/crates/agentctl-core/src/policy.rs +++ b/crates/agentctl-core/src/policy.rs @@ -7,7 +7,9 @@ use serde_json::Value; use thiserror::Error; use url::Url; -use crate::dsl::{ApprovalMode, EffectClass, NonInteractiveMode, PolicyDefinition, Risk}; +use crate::dsl::{ + ApprovalMode, ApprovalRequirement, EffectClass, NonInteractiveMode, PolicyDefinition, Risk, +}; #[derive(Debug, Clone)] pub struct PolicyEngine { @@ -83,26 +85,28 @@ impl PolicyEngine { #[must_use] pub fn decide(&self, context: &PolicyContext) -> PolicyDecision { - if self - .policy - .tools_deny - .iter() - .any(|tool| tool == &context.tool) - { - return PolicyDecision::Deny { - reason: "tool is explicitly denied".to_owned(), - }; - } - if !self.policy.tools_allow.is_empty() - && !self + if context.provider.is_none() { + if self .policy - .tools_allow + .tools_deny .iter() .any(|tool| tool == &context.tool) - { - return PolicyDecision::Deny { - reason: "tool is not in the allowlist".to_owned(), - }; + { + return PolicyDecision::Deny { + reason: "tool is explicitly denied".to_owned(), + }; + } + if !self.policy.tools_allow.is_empty() + && !self + .policy + .tools_allow + .iter() + .any(|tool| tool == &context.tool) + { + return PolicyDecision::Deny { + reason: "tool is not in the allowlist".to_owned(), + }; + } } if let Some(provider) = &context.provider && !self.policy.providers.is_empty() @@ -152,6 +156,43 @@ impl PolicyEngine { } } + #[must_use] + pub fn decide_with_approval( + &self, + context: &PolicyContext, + approval: ApprovalRequirement, + ) -> PolicyDecision { + match self.decide(context) { + denied @ PolicyDecision::Deny { .. } => denied, + required @ PolicyDecision::RequireApproval { .. } => required, + allowed @ PolicyDecision::Allow { .. } + if approval != ApprovalRequirement::Always => + { + allowed + } + PolicyDecision::Allow { .. } if context.interactive => { + PolicyDecision::RequireApproval { + reason: "tool contract always requires approval".to_owned(), + } + } + PolicyDecision::Allow { .. } => match self.policy.non_interactive { + NonInteractiveMode::Pause => PolicyDecision::RequireApproval { + reason: "tool contract requires approval; the non-interactive run will pause durably" + .to_owned(), + }, + NonInteractiveMode::DenyApproval => PolicyDecision::Deny { + reason: + "tool contract requires approval and non-interactive policy denies approval" + .to_owned(), + }, + NonInteractiveMode::Fail => PolicyDecision::Deny { + reason: "tool contract requires approval and non-interactive policy is fail" + .to_owned(), + }, + }, + } + } + pub fn resolve_read_path(&self, requested: &str) -> Result { let candidate = self.join_workspace(requested)?; let canonical = fs::canonicalize(&candidate) @@ -260,19 +301,23 @@ impl PolicyEngine { } fn canonicalize_existing_or_parent(path: &Path) -> Result { - if path.exists() { - return fs::canonicalize(path) - .map_err(|error| PolicyError::Workspace(format!("{}: {error}", path.display()))); + let mut existing = path; + let mut missing = Vec::new(); + while !existing.exists() { + let name = existing + .file_name() + .ok_or_else(|| PolicyError::Workspace(path.display().to_string()))?; + missing.push(name.to_os_string()); + existing = existing + .parent() + .ok_or_else(|| PolicyError::Workspace(path.display().to_string()))?; } - let parent = path - .parent() - .ok_or_else(|| PolicyError::Workspace(path.display().to_string()))?; - let canonical_parent = fs::canonicalize(parent) - .map_err(|error| PolicyError::Workspace(format!("{}: {error}", parent.display())))?; - let name = path - .file_name() - .ok_or_else(|| PolicyError::Workspace(path.display().to_string()))?; - Ok(canonical_parent.join(name)) + let mut canonical = fs::canonicalize(existing) + .map_err(|error| PolicyError::Workspace(format!("{}: {error}", existing.display())))?; + for component in missing.into_iter().rev() { + canonical.push(component); + } + Ok(canonical) } fn host_matches(host: &str, rule: &str) -> bool { @@ -347,6 +392,20 @@ mod tests { )); } + #[test] + fn permits_nested_missing_paths_below_a_writable_root() { + let root = tempdir().expect("temp dir"); + fs::create_dir(root.path().join("safe")).expect("safe dir"); + assert_eq!( + policy(root.path()) + .resolve_write_path("safe/new/nested/output.txt") + .expect("nested path"), + fs::canonicalize(root.path()) + .expect("canonical root") + .join("safe/new/nested/output.txt") + ); + } + #[cfg(unix)] #[test] fn rejects_symlink_escape() { @@ -384,6 +443,77 @@ mod tests { ); } + #[test] + fn provider_and_tool_allowlists_are_evaluated_independently() { + let root = tempdir().expect("temp dir"); + let engine = PolicyEngine::new( + PolicyDefinition { + workspace_root: root.path().display().to_string(), + providers: vec!["primary".to_owned()], + tools_allow: vec!["echo".to_owned()], + approval: ApprovalMode::Never, + ..PolicyDefinition::default() + }, + root.path(), + ) + .expect("policy"); + let provider = PolicyContext { + run_id: "run".to_owned(), + trace_id: "trace".to_owned(), + task_id: "task".to_owned(), + agent: Some("worker".to_owned()), + tool: "provider.openai".to_owned(), + capability: "model".to_owned(), + effect_class: EffectClass::Model, + risk: Risk::Medium, + resource: None, + provider: Some("primary".to_owned()), + input: Value::Null, + interactive: false, + }; + assert!(matches!( + engine.decide(&provider), + PolicyDecision::Allow { .. } + )); + let mut tool = provider; + tool.provider = None; + tool.tool = "other".to_owned(); + assert!(matches!(engine.decide(&tool), PolicyDecision::Deny { .. })); + } + + #[test] + fn contract_approval_honors_non_interactive_deny_policy() { + let root = tempdir().expect("temp dir"); + let engine = PolicyEngine::new( + PolicyDefinition { + workspace_root: root.path().display().to_string(), + approval: ApprovalMode::Never, + non_interactive: NonInteractiveMode::DenyApproval, + ..PolicyDefinition::default() + }, + root.path(), + ) + .expect("policy"); + let context = PolicyContext { + run_id: "run".to_owned(), + trace_id: "trace".to_owned(), + task_id: "task".to_owned(), + agent: Some("worker".to_owned()), + tool: "echo".to_owned(), + capability: "internal".to_owned(), + effect_class: EffectClass::Pure, + risk: Risk::Low, + resource: None, + provider: None, + input: Value::Null, + interactive: false, + }; + assert!(matches!( + engine.decide_with_approval(&context, ApprovalRequirement::Always), + PolicyDecision::Deny { .. } + )); + } + #[test] fn redacts_keys_and_embedded_secret_values() { let value = serde_json::json!({ diff --git a/crates/agentctl-runtime/src/lib.rs b/crates/agentctl-runtime/src/lib.rs index 0b12e57..77fe83a 100644 --- a/crates/agentctl-runtime/src/lib.rs +++ b/crates/agentctl-runtime/src/lib.rs @@ -26,7 +26,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; use thiserror::Error; -use tokio::io::AsyncWriteExt; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::process::Command; use tokio_util::sync::CancellationToken; use url::Url; @@ -68,7 +68,7 @@ pub trait ExternalActionHandler: Send + Sync { ) -> Result; } -const MAX_WORKSPACE_TOOL_BYTES: u64 = 1024 * 1024; +const MAX_WORKSPACE_FILE_BYTES: u64 = 1024 * 1024; pub struct BuiltinToolExecutor { contract: ToolContract, @@ -127,15 +127,7 @@ impl ToolExecutor for BuiltinToolExecutor { .policy .resolve_read_path(path) .map_err(|error| ToolContractError::Execution(error.to_string()))?; - let metadata = tokio::fs::metadata(&resolved) - .await - .map_err(|error| ToolContractError::Execution(error.to_string()))?; - if metadata.len() > MAX_WORKSPACE_TOOL_BYTES { - return Err(ToolContractError::Execution(format!( - "workspace read exceeds {MAX_WORKSPACE_TOOL_BYTES} bytes" - ))); - } - let content = tokio::fs::read_to_string(&resolved) + let content = read_bounded_text(&resolved) .await .map_err(|error| ToolContractError::Execution(error.to_string()))?; let bytes = content.len(); @@ -828,7 +820,15 @@ impl Runtime { )?; tokio::select! { () = tokio::time::sleep(Duration::from_millis(task.retry.backoff_ms)) => {} - () = cancellation.cancelled() => return Err(RuntimeError::Cancelled), + () = cancellation.cancelled() => { + self.cancel_non_terminal(run_id, trace_id)?; + return Ok(RunOutcome { + run_id: run_id.to_owned(), + trace_id: trace_id.to_owned(), + state: RunState::Cancelled, + output: None, + }); + }, } self.store.transition_task( run_id, @@ -897,7 +897,12 @@ impl Runtime { cancellation: &CancellationToken, ) -> Result { let tasks = self.store.list_tasks(&run.run_id)?; - let context = context_for(run, &tasks)?; + let mut context = context_for(run, &tasks)?; + context.vars = task + .vars + .iter() + .map(|(name, value)| render(value, &context).map(|value| (name.clone(), value))) + .collect::, _>>()?; let raw_input = serde_json::to_value(&task.input)?; let input = render(&raw_input, &context)?; match &task.uses { @@ -1088,7 +1093,7 @@ impl Runtime { PreparedEffect::Execute => { self.store .mark_effect_started(&request.id, self.clock.now())?; - let content = tokio::fs::read_to_string(resolved).await; + let content = read_bounded_text(&resolved).await; match content { Ok(content) => { let output = serde_json::json!({"status": "unchanged", "changed": false, "content": content}); @@ -1118,7 +1123,11 @@ impl Runtime { let path = required_string(&input, "path")?; let content = required_string(&input, "content")?; let resolved = policy.resolve_write_path(&path)?; - let before = tokio::fs::read_to_string(&resolved).await.ok(); + let before = match read_bounded_text(&resolved).await { + Ok(content) => Some(content), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(RuntimeError::Io(error)), + }; let changed = before.as_deref() != Some(&content); let diff = options .diff @@ -1627,7 +1636,7 @@ impl Runtime { PreparedEffect::Execute => { self.store .mark_effect_started(&request.id, self.clock.now())?; - match tokio::fs::read_to_string(resolved).await { + match read_bounded_text(&resolved).await { Ok(content) => { let output = serde_json::json!({"content": content}); self.store.complete_effect( @@ -1845,74 +1854,54 @@ impl Runtime { match result { Ok(Ok(result)) => { if let Err(error) = contract.validate_output(&result.output) { - self.store.complete_effect( + self.store.complete_tool_effect( &tool_effect.id, - Err(&error.to_string()), - self.clock.now(), - )?; - self.store.complete_tool_call( &run.run_id, &call_id, + Err(&error.to_string()), None, - false, self.clock.now(), )?; return Err(RuntimeError::Tool(error)); } - self.store.complete_effect( + self.store.complete_tool_effect( &tool_effect.id, - Ok(&result.output), - self.clock.now(), - )?; - let output_digest = - digest(&serde_json::to_vec(&result.output)?); - self.store.complete_tool_call( &run.run_id, &call_id, - Some(&output_digest), - true, + Ok(&result.output), + Some(&digest(&serde_json::to_vec(&result.output)?)), self.clock.now(), )?; result.output } Ok(Err(ToolContractError::Cancelled)) | Err(ToolContractError::Cancelled) => { - self.store.mark_effect_uncertain( + self.store.mark_tool_effect_uncertain( &tool_effect.id, - "tool execution was cancelled after dispatch", - self.clock.now(), - )?; - self.store.mark_tool_call_uncertain( &run.run_id, &call_id, + "tool execution was cancelled after dispatch", self.clock.now(), )?; return Err(RuntimeError::Cancelled); } Err(error) => { - self.store.mark_effect_uncertain( + self.store.mark_tool_effect_uncertain( &tool_effect.id, - &error.to_string(), - self.clock.now(), - )?; - self.store.mark_tool_call_uncertain( &run.run_id, &call_id, + &error.to_string(), self.clock.now(), )?; return Err(RuntimeError::Tool(error)); } Ok(Err(error)) => { - self.store.complete_effect( + self.store.complete_tool_effect( &tool_effect.id, - Err(&error.to_string()), - self.clock.now(), - )?; - self.store.complete_tool_call( &run.run_id, &call_id, + Err(&error.to_string()), None, - false, self.clock.now(), )?; return Err(RuntimeError::Tool(error)); @@ -2065,19 +2054,12 @@ impl Runtime { effect_class: request.effect_class, risk: request.risk, resource: None, - provider: (request.effect_class == EffectClass::Model).then(|| tool.to_owned()), + provider: (request.effect_class == EffectClass::Model) + .then(|| request.operation.clone()), input: request.input.clone(), interactive, }; - let decision = match approval { - ApprovalRequirement::Never => PolicyDecision::Allow { - reason: "tool contract does not require approval".to_owned(), - }, - ApprovalRequirement::Always => PolicyDecision::RequireApproval { - reason: "tool contract always requires approval".to_owned(), - }, - ApprovalRequirement::Policy => policy.decide(&context), - }; + let decision = policy.decide_with_approval(&context, approval); match decision { PolicyDecision::Allow { .. } => Ok(PreparedEffect::Execute), PolicyDecision::Deny { reason } => Err(RuntimeError::Task { @@ -2304,6 +2286,20 @@ async fn write_atomic(path: &Path, content: &[u8]) -> Result<(), std::io::Error> tokio::fs::rename(temporary, path).await } +async fn read_bounded_text(path: &Path) -> Result { + let file = tokio::fs::File::open(path).await?; + let mut reader = file.take(MAX_WORKSPACE_FILE_BYTES + 1); + let mut content = String::new(); + reader.read_to_string(&mut content).await?; + if content.len() as u64 > MAX_WORKSPACE_FILE_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("file exceeds {MAX_WORKSPACE_FILE_BYTES} bytes"), + )); + } + Ok(content) +} + fn add_usage(total: &mut Usage, current: &Usage) { total.input_tokens = total.input_tokens.saturating_add(current.input_tokens); total.output_tokens = total.output_tokens.saturating_add(current.output_tokens); @@ -2455,6 +2451,64 @@ mod tests { } } + struct PromptEchoProvider; + + #[async_trait] + impl ModelProvider for PromptEchoProvider { + fn name(&self) -> &'static str { + "fake" + } + + async fn complete( + &self, + request: &ProviderRequest, + _cancellation: &CancellationToken, + ) -> Result { + let text = match request.messages.first() { + Some(Message::User(content)) => content + .first() + .and_then(|block| match block { + ContentBlock::Text { text } => Some(text.clone()), + _ => None, + }) + .unwrap_or_default(), + _ => String::new(), + }; + Ok(ProviderResponse { + response_id: Some("prompt-echo".to_owned()), + text: text.clone(), + tool_calls: Vec::new(), + assistant_content: vec![ContentBlock::Text { text }], + continuation: None, + usage: Usage::default(), + finish_reason: FinishReason::Complete, + }) + } + } + + struct RetryableThenCancelProvider; + + #[async_trait] + impl ModelProvider for RetryableThenCancelProvider { + fn name(&self) -> &'static str { + "fake" + } + + async fn complete( + &self, + _request: &ProviderRequest, + cancellation: &CancellationToken, + ) -> Result { + cancellation.cancel(); + Err(ProviderError::Http { + status: 503, + message: "retry later".to_owned(), + request_id: "retry-cancel".to_owned(), + retryable: true, + }) + } + } + #[derive(Default)] struct ToolCallingProvider(AtomicU64); @@ -2482,6 +2536,7 @@ mod tests { id: "call-1".to_owned(), name: "echo".to_owned(), input: serde_json::json!({"text": "hello"}), + provider_metadata: None, }], continuation: None, usage: Usage::default(), @@ -2678,6 +2733,111 @@ spec: ); } + #[tokio::test] + async fn task_vars_render_agent_defaults_overrides_and_dependency_outputs() { + let directory = tempdir().expect("tempdir"); + let store = SqliteStore::open_memory().expect("store"); + let registry = + RuntimeRegistry::default().with_provider("fake", Arc::new(PromptEchoProvider)); + let (workflow, plan) = compile_fixture( + r#" +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: task-vars } +spec: + inputs: { service: checkout } + providers: { fake: { kind: fake } } + agents: + reviewer: + provider: fake + model: scripted + instructions: review + vars: { severity: medium, service: default } + maxTurns: 1 + actions: { assign: { kind: builtin.assign } } + tasks: + - id: prepare + uses: action:assign + with: { finding: restore-drill-missing } + - id: review + uses: agent:reviewer + needs: [prepare] + vars: + service: "${{ inputs.service }}" + finding: "${{ tasks.prepare.output.output.finding }}" + with: + prompt: "${{ vars.service }}:${{ vars.finding }}:${{ vars.severity }}" +"#, + ); + let outcome = runtime(store.clone(), directory.path()) + .with_registry(registry) + .start( + &workflow, + &plan, + serde_json::json!({"service": "checkout"}), + RunOptions::default(), + &CancellationToken::new(), + ) + .await + .expect("run succeeds"); + assert_eq!(outcome.state, RunState::Succeeded); + let review = store + .list_tasks(&outcome.run_id) + .expect("tasks") + .into_iter() + .find(|task| task.task_id == "review") + .expect("review task"); + assert_eq!( + review.output.expect("review output")["text"], + "checkout:restore-drill-missing:medium" + ); + } + + #[tokio::test] + async fn direct_read_rejects_files_over_the_workspace_limit() { + let directory = tempdir().expect("tempdir"); + std::fs::write( + directory.path().join("oversized.txt"), + vec![b'x'; MAX_WORKSPACE_FILE_BYTES as usize + 1], + ) + .expect("oversized fixture"); + let (workflow, plan) = compile_fixture( + r#" +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: bounded-read } +spec: + policy: { workspaceRoot: ., approval: never } + actions: { read: { kind: builtin.read } } + tasks: + - id: read + uses: action:read + with: { path: oversized.txt } +"#, + ); + let store = SqliteStore::open_memory().expect("store"); + let result = runtime(store.clone(), directory.path()) + .start( + &workflow, + &plan, + serde_json::json!({}), + RunOptions::default(), + &CancellationToken::new(), + ) + .await; + + let run_id = match result { + Err(RuntimeError::RunFailed { run_id, .. }) => run_id, + other => panic!("expected failed run, got {other:?}"), + }; + let run = store.load_run(&run_id).expect("run"); + assert_eq!(run.state, RunState::Failed); + assert_eq!( + store.list_effects(&run.run_id).expect("effects")[0].status, + EffectStatus::Failed + ); + } + #[tokio::test] async fn check_diff_does_not_mutate_and_interactive_approval_resumes() { let directory = tempdir().expect("tempdir"); @@ -3031,6 +3191,54 @@ spec: ); } + #[tokio::test] + async fn cancellation_during_retry_backoff_is_durable() { + let directory = tempdir().expect("tempdir"); + let store = SqliteStore::open_memory().expect("store"); + let (workflow, plan) = compile_fixture( + r#" +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: retry-cancel } +spec: + providers: { fake: { kind: fake } } + agents: + worker: + provider: fake + model: scripted + instructions: test + tasks: + - id: work + uses: agent:worker + retry: { maxAttempts: 2, backoffMs: 1000 } + with: { prompt: hello } +"#, + ); + let registry = + RuntimeRegistry::default().with_provider("fake", Arc::new(RetryableThenCancelProvider)); + let outcome = runtime(store.clone(), directory.path()) + .with_registry(registry) + .start( + &workflow, + &plan, + serde_json::json!({}), + RunOptions::default(), + &CancellationToken::new(), + ) + .await + .expect("cancelled outcome"); + + assert_eq!(outcome.state, RunState::Cancelled); + assert_eq!( + store.load_run(&outcome.run_id).expect("run").state, + RunState::Cancelled + ); + assert_eq!( + store.list_tasks(&outcome.run_id).expect("tasks")[0].state, + TaskState::Cancelled + ); + } + #[tokio::test] async fn agent_tool_loop_validates_tool_output_before_model_continuation() { let directory = tempdir().expect("tempdir"); @@ -3039,6 +3247,7 @@ apiVersion: agentctl.dev/v1alpha1 kind: Workflow metadata: { name: tools } spec: + policy: { providers: [fake] } providers: { fake: { kind: fake } } tools: echo: @@ -3105,6 +3314,60 @@ spec: assert!(matches!(result, Err(RuntimeError::RunFailed { .. }))); } + #[tokio::test] + async fn tool_approval_override_cannot_bypass_global_policy_denial() { + let directory = tempdir().expect("tempdir"); + let (workflow, plan) = compile_fixture( + r#" +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: denied-tool } +spec: + policy: { toolsDeny: [echo], approval: never } + providers: { fake: { kind: fake } } + tools: + echo: + kind: builtin.echo + description: echo + inputSchema: { type: object, properties: { text: { type: string } }, required: [text], additionalProperties: false } + outputSchema: { type: object, properties: { text: { type: string } }, required: [text], additionalProperties: false } + capability: internal + risk: low + effectClass: pure + idempotency: pure + retrySafe: true + timeoutSeconds: 5 + approval: never + agents: + worker: + provider: fake + model: scripted + instructions: use the tool + tools: [echo] + maxTurns: 2 + maxToolCalls: 1 + tasks: [{ id: work, uses: "agent:worker", with: { prompt: hello } }] +"#, + ); + let registry = RuntimeRegistry::default() + .with_provider("fake", Arc::new(ToolCallingProvider::default())) + .with_tool("echo", Arc::new(FixtureTool::new(false))); + let store = SqliteStore::open_memory().expect("store"); + let result = runtime(store.clone(), directory.path()) + .with_registry(registry) + .start( + &workflow, + &plan, + serde_json::json!({}), + RunOptions::default(), + &CancellationToken::new(), + ) + .await; + + assert!(matches!(result, Err(RuntimeError::RunFailed { .. }))); + assert_eq!(store.stats().expect("stats").tool_calls, 0); + } + #[tokio::test] async fn tool_timeout_and_cancellation_are_bounded_and_durable() { let directory = tempdir().expect("tempdir"); diff --git a/crates/agentctl-store/src/lib.rs b/crates/agentctl-store/src/lib.rs index 12f5b2c..e499b89 100644 --- a/crates/agentctl-store/src/lib.rs +++ b/crates/agentctl-store/src/lib.rs @@ -1305,44 +1305,75 @@ impl SqliteStore { Ok(()) } - pub fn complete_tool_call( + pub fn complete_tool_effect( &self, + effect_id: &str, run_id: &str, call_id: &str, + result: Result<&Value, &str>, output_digest: Option<&str>, - succeeded: bool, now: DateTime, ) -> Result<(), StoreError> { - let changed = self.connection.lock().execute( + let (effect_status, output, error, confirmed, call_status) = match result { + Ok(output) => ( + EffectStatus::Succeeded, + Some(encode(output)?), + None, + true, + "succeeded", + ), + Err(error) => (EffectStatus::Failed, None, Some(error), false, "failed"), + }; + let mut connection = self.connection.lock(); + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let effect_changed = transaction.execute( + "UPDATE effects SET status = ?2, result_json = ?3, error = ?4, confirmed = ?5, completed_at = ?6 WHERE effect_id = ?1 AND status = ?7", + params![effect_id, encode_enum(effect_status)?, output, error, confirmed, now.to_rfc3339(), encode_enum(EffectStatus::Started)?], + )?; + if effect_changed != 1 { + return Err(StoreError::EffectNotFound(effect_id.to_owned())); + } + let call_changed = transaction.execute( "UPDATE tool_calls SET output_digest = ?3, status = ?4, completed_at = ?5 WHERE run_id = ?1 AND call_id = ?2 AND status = 'started'", - params![run_id, call_id, output_digest, if succeeded { "succeeded" } else { "failed" }, now.to_rfc3339()], + params![run_id, call_id, output_digest, call_status, now.to_rfc3339()], )?; - if changed == 1 { - Ok(()) - } else { - Err(StoreError::Incompatible(format!( + if call_changed != 1 { + return Err(StoreError::Incompatible(format!( "tool call `{call_id}` in run `{run_id}` is missing or terminal" - ))) + ))); } + transaction.commit()?; + Ok(()) } - pub fn mark_tool_call_uncertain( + pub fn mark_tool_effect_uncertain( &self, + effect_id: &str, run_id: &str, call_id: &str, + error: &str, now: DateTime, ) -> Result<(), StoreError> { - let changed = self.connection.lock().execute( + let mut connection = self.connection.lock(); + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let effect_changed = transaction.execute( + "UPDATE effects SET status = ?2, error = ?3, completed_at = ?4, confirmed = 0 WHERE effect_id = ?1 AND status = ?5", + params![effect_id, encode_enum(EffectStatus::Uncertain)?, error, now.to_rfc3339(), encode_enum(EffectStatus::Started)?], + )?; + if effect_changed != 1 { + return Err(StoreError::EffectNotFound(effect_id.to_owned())); + } + let call_changed = transaction.execute( "UPDATE tool_calls SET status = 'uncertain', completed_at = ?3 WHERE run_id = ?1 AND call_id = ?2 AND status = 'started'", params![run_id, call_id, now.to_rfc3339()], )?; - if changed == 1 { - Ok(()) - } else { - Err(StoreError::Incompatible(format!( + if call_changed != 1 { + return Err(StoreError::Incompatible(format!( "tool call `{call_id}` in run `{run_id}` is missing or terminal" - ))) + ))); } + transaction.commit()?; + Ok(()) } pub fn get_long_term_memory( @@ -1730,6 +1761,80 @@ spec: ); } + #[test] + fn tool_effect_and_call_completion_commit_atomically() { + let store = SqliteStore::open_memory().expect("store"); + create(&store, "run"); + let request = EffectRequest::new( + "run", + "one", + 1, + 1, + "tool.echo", + EffectClass::Pure, + Risk::Low, + Idempotency::Pure, + serde_json::json!({"text": "hello"}), + "execute echo", + "trace", + ); + let now = Utc::now(); + store + .record_effect_request(&request, now) + .expect("record effect"); + store + .mark_effect_started(&request.id, now) + .expect("start effect"); + store + .start_tool_call( + "call-1", + "run", + "one", + &request.id, + "echo", + &request.input_digest, + now, + ) + .expect("start call"); + let output = serde_json::json!({"text": "hello"}); + + assert!( + store + .complete_tool_effect( + &request.id, + "run", + "missing-call", + Ok(&output), + Some("digest"), + now, + ) + .is_err() + ); + assert_eq!( + store.load_effect(&request.id).expect("effect").status, + EffectStatus::Started + ); + assert_eq!(store.tool_calls("run").expect("calls")[0].status, "started"); + + store + .complete_tool_effect( + &request.id, + "run", + "call-1", + Ok(&output), + Some("digest"), + now, + ) + .expect("complete atomically"); + let effect = store.load_effect(&request.id).expect("effect"); + assert_eq!(effect.status, EffectStatus::Succeeded); + assert!(effect.confirmed); + assert_eq!( + store.tool_calls("run").expect("calls")[0].status, + "succeeded" + ); + } + #[test] fn audit_sequence_continues_across_resume() { let store = SqliteStore::open_memory().expect("store"); diff --git a/xtask/src/acceptance.rs b/xtask/src/acceptance.rs index 0dea3de..52d181a 100644 --- a/xtask/src/acceptance.rs +++ b/xtask/src/acceptance.rs @@ -420,6 +420,20 @@ pub fn run(root: &Path) -> Result<()> { ensure!(replay_id != mock_id); let replay_inspect = inspect(&binary, &mock_workspace, &mock_db, replay_id)?; ensure!(array_len(&replay_inspect, "/data/effects")? == 0); + let failed_replay = json_with_code( + &binary, + &workspace, + &strings([ + "replay", + rejected_id, + "--db", + path(&reject_db)?, + "--output", + "json", + ]), + 4, + )?; + ensure_eq(&failed_replay, "/data/state", "failed")?; scenario(14, "fork creates a distinct run with fresh effects"); let fork = successful_json( @@ -1416,7 +1430,8 @@ fn build_image(root: &Path, engine: &Path) -> Result<()> { Ok(()) } else { bail!( - "OCI image build failed:\n{}", + "OCI image build failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ) } From 51700278b8e1f7dd2f31a2a3d8fbdc6312245c24 Mon Sep 17 00:00:00 2001 From: Ompragash Date: Wed, 22 Jul 2026 19:43:42 +0530 Subject: [PATCH 08/18] docs: record independent release candidate review --- Containerfile | 3 +- docs/DURABLE_EXECUTION.md | 2 +- docs/LIMITATIONS.md | 6 +- docs/PROVIDERS.md | 6 +- docs/SECURITY.md | 6 +- docs/TESTING.md | 2 +- docs/execution/BLOCKERS.md | 2 +- docs/execution/DEFINITION_OF_DONE.md | 6 +- docs/execution/INDEPENDENT_RC_REVIEW.md | 109 ++++++++++++++++++++++++ docs/execution/RELEASE_AUDIT.md | 2 +- docs/execution/STATUS.md | 14 +-- 11 files changed, 134 insertions(+), 24 deletions(-) create mode 100644 docs/execution/INDEPENDENT_RC_REVIEW.md diff --git a/Containerfile b/Containerfile index 269e961..189502a 100644 --- a/Containerfile +++ b/Containerfile @@ -1,5 +1,6 @@ # syntax=docker/dockerfile:1.7 FROM rust:1.88.0-bookworm AS build +ENV RUSTUP_TOOLCHAIN=1.88.0 WORKDIR /source COPY Cargo.toml Cargo.lock rust-toolchain.toml rustfmt.toml ./ @@ -13,7 +14,7 @@ LABEL org.opencontainers.image.title="agentctl" \ org.opencontainers.image.description="Deterministic control plane for policy-constrained agentic automation" \ org.opencontainers.image.version="${AGENTCTL_VERSION}" \ org.opencontainers.image.licenses="Apache-2.0" \ - org.opencontainers.image.source="https://github.com/ompragash/agentctl" + org.opencontainers.image.source="https://github.com/opensourceops/agentctl" COPY --from=build --chown=nonroot:nonroot /source/target/release/agentctl /usr/local/bin/agentctl USER nonroot:nonroot WORKDIR /workspace diff --git a/docs/DURABLE_EXECUTION.md b/docs/DURABLE_EXECUTION.md index 63d3f30..99bfa04 100644 --- a/docs/DURABLE_EXECUTION.md +++ b/docs/DURABLE_EXECUTION.md @@ -13,7 +13,7 @@ An effect ID is SHA-256 over run ID, task ID, task attempt, ordinal, operation, Pure operations need no external guarantee. Idempotent and keyed effects may be safely retried only when their implementation contract says so. Model calls and unknown remote mutations are treated at-most-once after start: a crash in the acknowledgement window creates an uncertain effect requiring operator reconciliation or an explicit fork. This is deliberately more conservative than silent at-least-once replay. -Working-memory replacement, the task transition, checkpoint, and audit event commit in one SQLite transaction. On resume, a confirmed memory-write effect is applied to the reconstructed working-memory value during the succeeding transition. Long-term memory is an external effect and is not rolled back by replay. +Working-memory replacement, the task transition, checkpoint, and audit event commit in one SQLite transaction. Tool-effect and tool-call terminal status also commit together, so inspection cannot observe one as completed while the other remains started. On resume, a confirmed memory-write effect is applied to the reconstructed working-memory value during the succeeding transition. Long-term memory is an external effect and is not rolled back by replay. Cancellation is both an injected token and a durable run flag. CLI SIGINT and SIGTERM cancel in-flight async calls and return exit `130`; `agentctl cancel` records a request for another process to observe. An overall CLI deadline can be set with `--timeout-seconds`, in addition to task/tool/provider/protocol bounds. A provider, tool, process, MCP, or A2A timeout/cancellation/transport loss after dispatch marks the effect `uncertain`; resume refuses to guess and requires reconciliation or an explicit fork. diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md index 8f8caa4..13deaa3 100644 --- a/docs/LIMITATIONS.md +++ b/docs/LIMITATIONS.md @@ -4,7 +4,7 @@ This classification is part of the product contract. A deferred feature is not a ## Release blockers -No known implementation or evidence blocker remains for the stated local, scheduled, and OCI journeys. The exact final live OpenAI database passed credential-free OCI replay under `--network none` with identical output, unchanged artifact digest, zero fresh effects/tool calls/provider sessions, and explicit source-effect provenance. This supports a `v1alpha1` release-candidate recommendation, not stable v1.0. +No known P0/P1 implementation defect remains for the stated local, scheduled, and OCI journeys after the independent review. The exact final live OpenAI database passed another credential-free replay with identical output, zero fresh effects/tool calls/provider sessions, and explicit source-effect provenance. Hosted cross-platform CI has never run for the Rust branch, and the current default image build was blocked by this host's container CA before a current Trivy/SBOM run. These are release-candidate evidence blockers, so the current recommendation is internal review, not `v1alpha1` RC or stable v1.0. ## Required hardening completed for this release @@ -44,5 +44,5 @@ These are useful extensions but are not required by the product thesis. They nee - At-most-once model/remote calls can become uncertain in the dispatch/acknowledgement window. Inspect and reconcile externally; use `fork` only when fresh effects are knowingly acceptable. - Tool-using OpenAI/Azure agents require stored-response continuation. `store: false` is rejected until stateless response-item replay is implemented. - Anthropic, Google, Azure OpenAI, MCP, and A2A are native and mock-tested in this release, not live-tested. Only the OpenAI GPT-5.6 tool path has live end-to-end evidence. -- The local OCI execution evidence is Linux arm64. Linux amd64 is built/tested by the configured Ubuntu CI job when that workflow runs; this local task did not execute the remote CI matrix. -- The native arm64 image had no HIGH/CRITICAL findings in the final Trivy 0.70.0 scan, both with and without `--ignore-unfixed`, and produced a CycloneDX SBOM. The same checks are configured for the Linux amd64 CI image; the external CI job is not represented as executed until its own ledger exists. +- The current local OCI runtime evidence is Linux arm64. Linux amd64 is configured in the unpushed Ubuntu CI workflow but has not executed. +- The earlier native arm64 image scan reported no HIGH/CRITICAL findings and produced a CycloneDX SBOM. The current source changes have no fresh completed image-build/scan/SBOM record because this host's container CA blocked dependency retrieval. diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index aecec52..e832bdb 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -7,10 +7,10 @@ The core defines provider-neutral messages, text/reasoning/tool content, strict | `fake` | in-process scripted provider | deterministic echo/script, tool path, usage | none | | `openai` | Responses API | GPT-5.6; strict function tools and structured output; multiple call IDs; `previous_response_id`; reasoning effort/mode/context; response storage; prompt-cache mode/TTL; input/output/reasoning/cache metrics | `OPENAI_API_KEY` | | `azure_openai` | Azure `/openai/v1/responses?api-version=v1` | OpenAI mapping with Azure `api-key`; explicit endpoint required | `AZURE_OPENAI_API_KEY` | -| `anthropic` | Messages API | native content/tool blocks, structured output instruction, usage and stop mapping | `ANTHROPIC_API_KEY` | -| `google` | Gemini `generateContent` | native contents/function declarations/calls, response schema, token usage | `GEMINI_API_KEY` | +| `anthropic` | Messages API | native content/tool/thinking blocks, structured output instruction, usage and stop mapping | `ANTHROPIC_API_KEY` | +| `google` | Gemini `generateContent` | native contents/function declarations/calls/results, thought-signature continuation, response schema, token usage | `GEMINI_API_KEY` | -Endpoints must pass the workflow network allowlist. Redirects are disabled. Credentials and configured headers are resolved from environment references only when building an adapter; standard authentication headers override custom headers. Errors are normalized without response bodies or secret values, and calls honor timeout and cancellation. +Endpoints must pass the workflow network allowlist. Redirects are disabled. Credentials and configured headers are resolved from environment references only when building an adapter; standard authentication headers override custom headers. Successful/error response JSON keys and values plus provider request IDs are scrubbed of configured secrets before parsing or persistence. Calls honor timeout and cancellation. `agentctl providers inspect ` reports declared capabilities without calling a service. OpenAI has the broadest mock request/response/tool/usage/error coverage. Azure OpenAI, Anthropic, and Google have native mapping and focused mock-protocol coverage at the maturity shown below; normal tests have no credentials. Live provider workflow examples end in `-live.yaml` and are opt-in. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 4878606..c48a2de 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -3,12 +3,12 @@ ## Controls - Workflow parsing is strict, bounded to 1 MiB, source-aware, and has no executable expression language. -- Environment-backed primary credentials are resolved immediately before provider dispatch; custom header references are resolved while constructing the adapter, before a run or database is created. There are no API-key flags. Authentication values are not persisted or traced, and error text is redacted. +- Environment-backed primary credentials are resolved immediately before provider dispatch; custom header references are resolved while constructing the adapter, before a run or database is created. There are no API-key flags. Provider/protocol response JSON keys and values, provider request IDs, errors, subprocess output, and traces redact every known configured secret value before persistence or output. - Canonical read/write roots reject `..` and symlink escape. Writes use temporary files and rename. - Processes require an allowed executable basename, direct argv, cleared environment, selected variables, timeout, and cancellation. - Network destinations require an exact/wildcard host grant. Provider and protocol clients disable redirects and use rustls. - Tool input and output JSON Schemas are enforced. Models, MCP annotations, A2A cards, remote schemas, and results cannot grant capabilities. -- Requests are ledgered before effects. Approval is durable; non-interactive mode pauses with exit `3` or uses an explicitly stricter deny/fail mode, never a prompt or implicit approval. +- Requests are ledgered before effects. Global denial or approval cannot be weakened by a tool contract. Approval is durable; non-interactive mode pauses with exit `3` or uses an explicitly stricter deny/fail mode, never a prompt or implicit approval. - SQLite uses foreign keys, WAL/busy timeout, version checks, checksummed checkpoints, and mode `0600` on Unix. - Packs require a supported manifest/version and can be checked against SHA-256 integrity. - The workspace forbids unsafe Rust, denies warnings, locks dependencies, checks licenses/sources/advisories, scans secret patterns, and keeps live tests outside CI. @@ -17,7 +17,7 @@ Path and executable allowlists are not a sandbox. A permitted program can access anything the operating-system identity can access. Host allowlists do not defend against every DNS rebinding, proxy, local-service, or compromised endpoint scenario; use network isolation for hostile workflows. SHA-256 integrity establishes sameness, not author identity. SQLite protects local correctness but is not encrypted and is not a secret store. -Prompts, file content, model output, remote artifacts, and tool output may be confidential or malicious. Treat them as data, validate before mutation, minimize trace export, and isolate untrusted automation. Approval is a decision point, not proof that an operation is safe. At-most-once recovery may leave an uncertain external outcome for human reconciliation. +Prompts, file content, model output, remote artifacts, and tool output may be confidential or malicious. Treat them as data, validate before mutation, minimize trace export, and isolate untrusted automation. Workflow, input, pack, direct-read, existing-write-target, and instruction files are capped at 1 MiB. Approval is a decision point, not proof that an operation is safe. At-most-once recovery may leave an uncertain external outcome for human reconciliation. MCP reconnection and A2A resubmission are intentionally not automatic. Streaming is bounded but completed results, not token deltas, enter workflow state. Windows cannot express Unix database mode bits; rely on the user profile ACL and CI tests. diff --git a/docs/TESTING.md b/docs/TESTING.md index 3423b3e..c2f60da 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -26,6 +26,6 @@ cargo install cargo-fuzz cargo fuzz run workflow_yaml -- -max_total_time=60 ``` -CI runs the canonical suite on Linux, macOS, and Windows, stable and Rust 1.88, plus credential-free acceptance, a Linux amd64 container gate, and strict supply-chain checks. Provider/protocol conformance uses local mock HTTP servers. Normal examples are deterministic; MCP/A2A runtime behavior is covered by mocks rather than requiring a background service. +The local CI configuration would run the canonical suite on Linux, macOS, and Windows, stable and Rust 1.88, plus credential-free acceptance, a Linux amd64 container gate, and strict supply-chain checks. It has not yet been pushed or dispatched, so it is configured evidence rather than validated platform support. Provider/protocol conformance uses local mock HTTP servers. Normal examples are deterministic; MCP/A2A runtime behavior is covered by mocks rather than requiring a background service. The only full live gate is the separately invoked OpenAI acceptance described in [Providers](PROVIDERS.md). It performs two bounded Responses API requests locally and two in the OCI image for one tool-call/continuation journey each, then performs keyless replays. Never run it for debugging loops, fuzzing, load, or normal CI. diff --git a/docs/execution/BLOCKERS.md b/docs/execution/BLOCKERS.md index aab3c79..3e4e9df 100644 --- a/docs/execution/BLOCKERS.md +++ b/docs/execution/BLOCKERS.md @@ -1,5 +1,5 @@ # Blockers -There are no known P0/P1 implementation or evidence blockers as of 2026-07-22. The independently repeated local, scheduled, and OCI acceptance journeys pass, and the exact retained live OpenAI state passed credential-free network-disabled replay. Release status is **ready as a `v1alpha1` release candidate**, not stable v1.0. +There are no known P0/P1 implementation defects as of 2026-07-22. Release-candidate evidence is still blocked on an actual hosted Linux amd64/macOS/Windows CI run and a green committed image build with current Trivy/SBOM outputs. The exact retained live OpenAI state passed another credential-free replay with the current packaged CLI, and a current-source Linux arm64 binary passed the OCI runtime cases, but those results do not replace hosted evidence. Status is **ready for internal review**. Only blockers that prevent safe progress under the mission's definition are recorded here. Missing non-OpenAI live credentials will not be treated as blockers for native implementations with deterministic mock coverage. diff --git a/docs/execution/DEFINITION_OF_DONE.md b/docs/execution/DEFINITION_OF_DONE.md index 2ddc8b6..eb87ac0 100644 --- a/docs/execution/DEFINITION_OF_DONE.md +++ b/docs/execution/DEFINITION_OF_DONE.md @@ -13,10 +13,10 @@ Status values distinguish **deterministically tested**, **mock-provider tested** | Replay without credentials or network | deterministically and operationally tested on exact live state | panic-on-call provider/tool regression; OCI `--network none` replay with identical output/artifact digest, zero fresh effects/tool calls, and source-effect audit links | | Resume/reject/uncertainty/fork/retry/auth/rate-limit/malformed/cancellation semantics | deterministically tested | focused provider/runtime/store tests and acceptance scenarios | | Non-interactive approvals, cron, inputs, timeout, SIGTERM | operationally tested | empty-environment and signal acceptance; operations guide | -| OCI non-root/read-only/mount/JSON/artifact/state contract | operationally tested | native arm64 mock/failure/signal cases and exact live-state offline replay as UID/GID 65532 | -| Image high/critical scan and SBOM | operationally tested on arm64 | Trivy result and CycloneDX artifact recorded in verification ledger | +| OCI non-root/read-only/mount/JSON/artifact/state contract | operationally tested | current-source Linux arm64 binary passed mock/failure/signal cases; earlier exact live-state replay ran as UID/GID 65532 | +| Image high/critical scan and SBOM | historical arm64 evidence only | earlier Trivy result and CycloneDX artifact recorded in verification ledger; current rebuild/scan pending | | Linux amd64 image and external CI/vendor pipelines | syntax/configuration validated only | GitHub job and pipeline examples; not remotely dispatched here | | Anthropic/Google/Azure adapters; MCP/A2A | mock-provider/protocol tested | native mapping/protocol tests; not live-tested | | Advisories/licenses/sources/secrets | deterministically tested | cargo-deny, metadata, source, and secret gates | | Parallel/dynamic orchestration, pack ecosystem, vector/encrypted/distributed additions | deferred or non-goal | `docs/LIMITATIONS.md`, ADR 0005/0006/0007 | -| No known P0/P1 correctness/security defect in implemented boundary | verified for `v1alpha1` release candidate | canonical gates, clean-room acceptance audit, image scan, exact live durable replay, conservative documented limits | +| No known P0/P1 correctness/security defect in implemented boundary | verified for internal review | independent RC review and regressions; hosted CI and current image build/scan remain RC evidence blockers | diff --git a/docs/execution/INDEPENDENT_RC_REVIEW.md b/docs/execution/INDEPENDENT_RC_REVIEW.md new file mode 100644 index 0000000..8f3de9b --- /dev/null +++ b/docs/execution/INDEPENDENT_RC_REVIEW.md @@ -0,0 +1,109 @@ +# Independent release-candidate review + +Review date: 2026-07-22 (Asia/Kolkata) + +Recommendation: **Ready for internal review**. + +The reviewed baseline was `b4e96dbebd81b1f3eb844d6c0668952c691677d9`, five commits ahead of `origin/main` (`be9d0ae`). The tracked tree was initially clean. This review found and remediated one P0, five P1s, and five scoped P2s. No known P0/P1 implementation defect remains in the declared local, scheduled, or generic OCI runtime boundary. + +The branch is not yet a `v1alpha1` release candidate because none of its Rust CI workflows or release-prep jobs exists on the remote default branch, no PR exists, and GitHub reports zero workflow runs. The default local OCI build also could not complete after source changes because this host's container trust store rejects the intercepted certificates for Rust/crates.io. A current Linux arm64 binary was independently built from the reviewed source with networking disabled and passed every OCI runtime scenario in the production distroless image, but that is not equivalent to a green committed image-build job. + +## Hosted CI + +| Evidence | Actual state | +| --- | --- | +| GitHub workflows | Local YAML only; configured but not pushed/dispatched | +| PR checks | No PR and no checks | +| Linux amd64 | Not executed | +| macOS | Not executed on GitHub; local arm64 verification passed | +| Windows | Not executed | +| Formatting / Clippy / tests | Passed locally; not executed on GitHub | +| Acceptance / packaging | Passed locally; not executed on GitHub | +| Container / Trivy / SBOM | Runtime scenarios passed locally on Linux arm64; current default build and current scan/SBOM not completed | +| Branch protection | GitHub reports `main` is unprotected | + +## Findings and remediation + +| ID | Severity | Finding, impact, and root cause | Remediation and regression | +| --- | --- | --- | --- | +| RC-001 | P0 | Provider and MCP/A2A endpoints could echo environment-backed custom-header credentials into successful JSON, reasoning/tool payloads, or provider request-ID/error fields. Only the primary provider credential was scrubbed, and arbitrary JSON keys were not covered. This could persist a credential in effects, task output, audit-visible state, or CLI output. | Provider and protocol responses now recursively redact every configured credential/header value from JSON keys and values; provider error messages and request IDs are also bounded and redacted. Mock tests cover successful and failed provider responses plus MCP structured output. | +| RC-002 | P1 | A tool contract's `approval: never` or `always` replaced the global policy decision, so it could bypass a global deny or required approval. Provider allowlists compared the adapter kind instead of the workflow provider name, while tool allowlists were also applied to model calls. | Global denial/approval now wins before contract-specific approval. Provider and tool allowlists are independent and provider policy uses the compiled provider key. Unit/runtime tests prove denial cannot be bypassed and a named provider allowlist succeeds. | +| RC-003 | P1 | Gemini tool continuation used a hardcoded function-response name, generated weak fallback IDs, and discarded Gemini 3 thought signatures. The advertised Google tool path could therefore fail or correlate the wrong call. | Function results map to the originating name, fallback IDs are response-scoped, and `thoughtSignature` is stored as provider metadata and returned unchanged. The mock continuation regression follows Google's required function identity and thought-signature flow. | +| RC-004 | P1 | The compiler merged agent/task `vars`, but the runtime always evaluated inputs with an empty variable map. Documented `${{ vars.* }}` expressions failed at execution. | Task variables are rendered from input/memory/dependency context before task inputs. A runtime regression covers agent defaults, task overrides, inputs, and dependency outputs. | +| RC-005 | P1 | Cancellation during retry backoff returned cancellation without terminalizing the durable run and tasks. The CLI could exit while SQLite still said `running`. | The backoff cancellation branch now uses the same durable cancellation transition as in-flight cancellation. A regression asserts both run and task are `cancelled`. | +| RC-006 | P1 | Recorded replay always exited `0`, even when the terminal source was `failed` or `cancelled`. Schedulers could accept a reconstructed failure as success. | Replay now uses the common outcome-to-exit mapping (`0`, `3`, `4`, `130`). Public acceptance replays a failed run and requires exit `4`. | +| RC-007 | P2 | Tool effect completion and tool-call completion were separate SQLite transactions. A crash could expose conflicting terminal ledger records. | Both success/failure and uncertainty updates now commit effect and tool-call rows atomically. The regression deliberately fails the second update and proves the first rolls back. | +| RC-008 | P2 | Anthropic thinking/redacted-thinking blocks were discarded before a tool continuation. | Opaque reasoning blocks are preserved and returned; a native mock regression verifies the signed thinking block round trip. | +| RC-009 | P2 | CLI text files, direct read actions, existing write targets, and agent instruction files used unbounded reads despite the stated 1 MiB parser/tool limit. | All those paths now use bounded readers and fail before retaining oversized content. CLI and runtime regressions use 1 MiB + 1 fixtures and confirm validation/durable failure. | +| RC-010 | P2 | Write-path canonicalization checked only the immediate parent, so safe nested new paths below a writable root were rejected even though atomic write creates their parents. | Canonicalization now walks to the nearest existing ancestor while preserving symlink containment. Unit coverage includes nested missing paths and symlink escape. | +| RC-011 | P2 | The OCI source label named the wrong repository, the builder unnecessarily re-synced its installed Rust toolchain, and build failures hid Podman's stdout. | The source label is corrected, `RUSTUP_TOOLCHAIN=1.88.0` selects the image's installed toolchain, and both build output streams are reported. The remaining crates.io certificate failure is environmental and explicitly unresolved. | + +Google's own documentation requires a function response to carry the matching function name/ID and requires Gemini 3 thought signatures on subsequent function-call turns: [function calling](https://ai.google.dev/gemini-api/docs/generate-content/function-calling) and [thought signatures](https://ai.google.dev/gemini-api/docs/generate-content/thought-signatures). + +## Independently confirmed guarantees + +| Guarantee | Production path | Executable evidence | User journey / documentation | +| --- | --- | --- | --- | +| Stable graph order | compiler topological order with declaration-order tie break | compiler unit tests | acceptance 1; DSL docs | +| Explicit terminal state machine | core run/task transition tables | exhaustive state tests | acceptance failure/cancel/approval cases | +| Transactional state/checkpoint/audit | SQLite immediate transactions | store transition/checkpoint tests | inspect across acceptance runs | +| Effect recorded before dispatch | runtime `prepare_effect` then `mark_effect_started` | effect identity/recovery tests | approval/resume and timeout scenarios | +| No duplicate confirmed effects on resume | durable effect identity and confirmed-result reuse | runtime/store tests | acceptance 10 and 12 | +| Uncertain effects block resume | started/uncertain handling | timeout and cancellation tests | acceptance 15 | +| Recorded replay dispatches nothing | terminal-output reconstruction path | panic-on-provider/tool regression | acceptance 13 and retained OpenAI database replay | +| Fork creates fresh effects | fork creates a linked execute run | counting-provider test | acceptance 14 | +| Approval persistence | SQLite approvals and policy engine | policy/runtime tests | acceptance 9–12 | +| Cancellation durability | token/flag plus terminal transitions | in-flight and retry-backoff tests | acceptance 22 and OCI SIGTERM runtime case | +| Strict tool schemas | compiler and runtime contract validators | malformed input/output tests | acceptance 6–7 | +| Secret redaction | provider/protocol/runtime recursive redaction | provider, MCP, subprocess, trace tests | security docs; no live secret retained | +| Capability negotiation | compiler provider capability sets | compiler tests | acceptance 3–4 and `providers inspect` | +| CLI machine contract | versioned envelope and outcome exit mapping | CLI tests | acceptance 13 and 18 | +| Pack integrity | canonical containment plus SHA-256 verification | pack tests | reusable-pack example | + +The retained GPT-5.6 database was independently hash-checked and replayed again with the newly packaged CLI under `env -i`. It produced the same declared output, a distinct replay run, and zero effects, tool calls, or provider sessions. No new OpenAI request was made: the changed provider continuation/redaction paths have direct mock coverage, while recorded replay was exercised keylessly. + +## Provider and protocol support + +| Adapter | Evidence | Review classification | +| --- | --- | --- | +| Fake | runtime and public acceptance | Executed and passed | +| OpenAI Responses | native mock mapping plus retained prior GPT-5.6 tool run; current keyless replay | Live evidence retained; no new live call | +| Azure OpenAI | request/auth/path/response mocks | Mock-tested only | +| Anthropic | native text/tool/usage/thinking continuation mocks | Mock-tested only | +| Google Gemini | native content/function/usage/signature continuation mocks | Mock-tested only | +| MCP | initialize/session/list/call/version/timeout/redaction mocks | Mock-tested only | +| A2A | discovery/interface/origin/send/poll/cancel mocks | Mock-tested only | + +## Platform support + +| Platform | Result | +| --- | --- | +| macOS arm64 | Local verify, 25-scenario acceptance, package, and retained-state replay passed | +| Linux arm64 | Current source built offline in Linux; complete non-root/read-only OCI runtime suite passed in distroless | +| Linux amd64 | Configured in CI, not dispatched | +| GitHub macOS | Configured in CI, not dispatched | +| Windows | Configured in CI, not dispatched | +| Kubernetes / vendor examples | Documentation-reviewed only; not submitted | + +## Verification outcomes + +| Command | Outcome | +| --- | --- | +| `cargo xtask verify` | Passed all 12 stages after remediation | +| `cargo xtask acceptance` | Passed all 25 public-CLI scenarios | +| `cargo xtask package` | Passed; macOS arm64 package created | +| `cargo xtask acceptance-container` | Default build failed because the local container CA does not trust Rust/crates.io; failure was not skipped | +| Linux offline build + OCI runtime acceptance | Passed with networking disabled during build and all runtime cases exercised | +| Retained OpenAI database replay under `env -i` | Passed; same output and zero fresh effects/tool calls/provider sessions | + +## Residual risks + +- The Rust branch and its CI configuration are still local. Linux amd64, hosted macOS, Windows, release packaging, Trivy, SBOM, and secret-scan jobs have no hosted execution record. +- GitHub Action dependencies use movable tags rather than commit SHA pins. +- The repository secret scan is pattern-based and does not replace a dedicated history/binary secret scanner. +- Subprocess output is collected in memory without a byte ceiling; subprocesses are explicitly allowlisted, bounded by time, and run with a cleared environment, but this remains P2 hardening. +- Only OpenAI has retained live provider evidence. Azure OpenAI, Anthropic, Google, MCP, and A2A remain mock-tested. +- SQLite is unencrypted local state and policy allowlists are controls, not an OS sandbox. +- At-most-once external calls can remain uncertain after a dispatch/acknowledgement crash window; this is a documented reconciliation boundary. + +Closest human review should focus on `crates/agentctl-runtime/src/lib.rs`, `crates/agentctl-store/src/lib.rs`, `crates/agentctl-providers/src/lib.rs`, `crates/agentctl-protocols/src/lib.rs`, `crates/agentctl-core/src/policy.rs`, `crates/agentctl-cli/src/main.rs`, `xtask/src/acceptance.rs`, `Containerfile`, and `.github/workflows/ci.yml`. diff --git a/docs/execution/RELEASE_AUDIT.md b/docs/execution/RELEASE_AUDIT.md index e7782f7..ed6e888 100644 --- a/docs/execution/RELEASE_AUDIT.md +++ b/docs/execution/RELEASE_AUDIT.md @@ -2,7 +2,7 @@ Audit date: 2026-07-22 (Asia/Kolkata) -Recommendation: **Ready as a `v1alpha1` release candidate**. This is not a stable-v1 recommendation. +Historical recommendation: **Ready as a `v1alpha1` release candidate**. This conclusion is superseded by the later [independent RC review](INDEPENDENT_RC_REVIEW.md), which found additional P0/P1 defects and changed current status to **Ready for internal review** pending hosted CI and current image-build evidence. The retained live-run facts below remain historical evidence. ## Final live durable-replay gate diff --git a/docs/execution/STATUS.md b/docs/execution/STATUS.md index d075dd0..f40bdb2 100644 --- a/docs/execution/STATUS.md +++ b/docs/execution/STATUS.md @@ -4,19 +4,19 @@ Last updated: 2026-07-22 ## Current phase -ready as a `v1alpha1` release candidate +ready for internal review -The adversarial audit and final live durable-replay gate passed the defined local, scheduled, and native-arm64 OCI implementation boundary. This is not a stable-v1 recommendation. +The independent release-candidate review found and remediated one P0, five P1s, and scoped journey P2s. Local deterministic verification, public CLI acceptance, packaging, current-source Linux arm64 compilation, OCI runtime cases, and keyless replay of retained live state pass. The recommendation is not yet release-candidate status because the Rust CI/release workflows have never run on GitHub and the default current-source image build was blocked by this host's container certificate trust. ## Accepted evidence -- The independently audited Rust implementation passes all 12 `cargo xtask verify` gates (66 tests) and the 25-scenario credential-free public-CLI acceptance suite from a clean copy with Node tools poisoned. +- The independently audited Rust implementation passes all 12 `cargo xtask verify` gates and the 25-scenario credential-free public-CLI acceptance suite. - A packaged GPT-5.6 workflow made one real model-selected read-only tool call and continued through stored-response function output; the final run used two provider requests, 530 input tokens, and 33 output tokens. - The exact completed live database is retained locally and replays in the native-arm64 image with no credential and `--network none`. Replay has a distinct run/trace ID, identical output, unchanged artifact digest, zero fresh effects/tool calls/provider sessions, and explicit source-effect audit links. - The deterministic replay regression uses provider and tool executors that panic if called. - Confirmed effects survive resume; fork is distinct and fresh; timeout/transport uncertainty blocks unsafe repetition. - Clean copied/source-installed/package layouts, empty-environment cron invocation, concurrency, SIGTERM, approvals, machine output, and recovery paths passed. -- The actual OCI image passed mock-tool, failure-exit, SIGTERM, and offline-replay cases as non-root with a read-only root and mounted durable state/artifacts. Trivy 0.70.0 found no HIGH/CRITICAL findings with or without `--ignore-unfixed`; a CycloneDX SBOM was generated. +- A current-source Linux arm64 binary built offline and passed mock-tool, failure-exit, SIGTERM, and offline-replay cases in the production distroless image as non-root with a read-only root and mounted durable state/artifacts. ## Product boundary @@ -24,11 +24,11 @@ The adversarial audit and final live durable-replay gate passed the defined loca ## External evidence not claimed -The local environment executed macOS arm64 packaging and Linux arm64 OCI tests. The configured GitHub Linux amd64, macOS, Windows, vendor-pipeline, Trivy, and SBOM jobs were not remotely dispatched in this task; GitHub YAML was parsed locally and the remaining examples were documentation-reviewed only. Anthropic, Google, Azure OpenAI, MCP, and A2A remain native mock-tested rather than live-tested. +The local environment executed macOS arm64 packaging and Linux arm64 OCI runtime tests. The committed default OCI build did not complete because the container trust store rejected Rust/crates.io certificates. The configured GitHub Linux amd64, macOS, Windows, Trivy, and SBOM jobs do not exist on the remote default branch and were not dispatched. Anthropic, Google, Azure OpenAI, MCP, and A2A remain native mock-tested rather than live-tested. -## Hard blockers +## Release-candidate blockers -No known P0/P1 implementation or evidence blocker remains for the stated boundary. See [BLOCKERS.md](BLOCKERS.md), [RELEASE_AUDIT.md](RELEASE_AUDIT.md), and [LIVE_OPENAI_REPLAY_EVIDENCE.md](LIVE_OPENAI_REPLAY_EVIDENCE.md). +No known P0/P1 implementation defect remains for the stated boundary. Hosted cross-platform CI and a green committed image-build/scan/SBOM record are still missing. See [INDEPENDENT_RC_REVIEW.md](INDEPENDENT_RC_REVIEW.md), [BLOCKERS.md](BLOCKERS.md), and [LIVE_OPENAI_REPLAY_EVIDENCE.md](LIVE_OPENAI_REPLAY_EVIDENCE.md). ## Exact commands From 5248d89e8496cb692083ff8f5619a97add2bff1c Mon Sep 17 00:00:00 2001 From: Ompragash Date: Wed, 22 Jul 2026 20:52:55 +0530 Subject: [PATCH 09/18] feat: harden process execution and hosted RC gates --- .github/workflows/ci.yml | 166 +++++---- .github/workflows/container.yml | 92 +++++ .github/workflows/release-prep.yml | 59 +++- .github/workflows/security.yml | 101 ++++++ Cargo.lock | 14 + Cargo.toml | 1 + Containerfile | 11 +- crates/agentctl-core/src/dsl.rs | 128 +++++++ crates/agentctl-core/src/pack.rs | 16 + crates/agentctl-runtime/Cargo.toml | 3 + crates/agentctl-runtime/src/lib.rs | 325 +++++++++++++++++- crates/agentctl-runtime/src/process.rs | 456 +++++++++++++++++++++++++ fuzz/Cargo.lock | 13 + schemas/workflow.schema.json | 24 ++ xtask/Cargo.toml | 3 + xtask/src/acceptance.rs | 103 +++--- xtask/src/main.rs | 180 +++++++--- xtask/src/process.rs | 273 +++++++++++++++ 18 files changed, 1787 insertions(+), 181 deletions(-) create mode 100644 .github/workflows/container.yml create mode 100644 .github/workflows/security.yml create mode 100644 crates/agentctl-runtime/src/process.rs create mode 100644 xtask/src/process.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb68893..094beb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,83 +1,121 @@ -name: ci +name: credential-free-ci on: push: pull_request: + workflow_dispatch: permissions: contents: read +concurrency: + group: credential-free-ci-${{ github.ref }} + cancel-in-progress: true + +env: + RUST_TOOLCHAIN: "1.88.0" + CARGO_DENY_VERSION: "0.20.2" + jobs: - verify: - env: - RUSTUP_TOOLCHAIN: ${{ matrix.rust }} + gates: + name: gates (${{ matrix.target }}) strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - rust: [stable, "1.88.0"] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@master - with: - toolchain: ${{ matrix.rust }} - components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 - - uses: taiki-e/install-action@cargo-deny - - run: cargo xtask verify - - acceptance: - env: - RUSTUP_TOOLCHAIN: "1.88.0" - runs-on: ubuntu-latest + include: + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + - runner: macos-14 + target: aarch64-apple-darwin + - runner: windows-2022 + target: x86_64-pc-windows-msvc + runs-on: ${{ matrix.runner }} + defaults: + run: + shell: bash steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@master + - name: Checkout exact revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install pinned Rust toolchain + run: | + set -euo pipefail + rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal --component clippy,rustfmt + rustup default "$RUST_TOOLCHAIN" + host="$(rustc -vV | sed -n 's/^host: //p' | tr -d '\r')" + test "$host" = "${{ matrix.target }}" + + - name: Install pinned cargo-deny + run: cargo install cargo-deny --version "$CARGO_DENY_VERSION" --locked + + - name: Run deterministic verification gate + run: cargo xtask verify + + - name: Run credential-free acceptance gate + run: cargo xtask acceptance + + - name: Build production package + run: cargo xtask package + + - name: Upload production package + id: package + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - toolchain: "1.88.0" - - uses: Swatinem/rust-cache@v2 - - run: cargo xtask acceptance - - container: - env: - RUSTUP_TOOLCHAIN: "1.88.0" - runs-on: ubuntu-latest + name: agentctl-${{ matrix.target }} + path: dist/ + if-no-files-found: error + retention-days: 14 + + - name: Record package artifact digest + run: echo "agentctl-${{ matrix.target }} artifact digest ${{ steps.package.outputs.artifact-digest }}" >> "$GITHUB_STEP_SUMMARY" + + production-sbom: + name: production SBOM + needs: gates + runs-on: ubuntu-24.04 + defaults: + run: + shell: bash steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@master - with: - toolchain: "1.88.0" - - uses: Swatinem/rust-cache@v2 - - run: cargo xtask acceptance-container - - name: Reject fixed critical/high image vulnerabilities - uses: aquasecurity/trivy-action@v0.36.0 - with: - image-ref: agentctl-acceptance:local - format: table - severity: CRITICAL,HIGH - ignore-unfixed: true - exit-code: "1" - - name: Generate CycloneDX image SBOM - uses: aquasecurity/trivy-action@v0.36.0 + - name: Checkout exact revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install pinned Rust toolchain + run: | + set -euo pipefail + rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal + rustup default "$RUST_TOOLCHAIN" + + - name: Build production package for SBOM input + run: cargo xtask package + + - name: Generate CycloneDX production SBOM + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 with: - image-ref: agentctl-acceptance:local - format: cyclonedx - output: agentctl-image.cdx.json - exit-code: "0" - - uses: actions/upload-artifact@v4 + path: dist/ + format: cyclonedx-json + output-file: agentctl-production.cdx.json + syft-version: v1.49.0 + upload-artifact: false + upload-release-assets: false + + - name: Validate and digest production SBOM + id: sbom_file + run: | + set -euo pipefail + jq -e '.bomFormat == "CycloneDX" and (.components | type == "array")' agentctl-production.cdx.json >/dev/null + digest="$(sha256sum agentctl-production.cdx.json | cut -d ' ' -f 1)" + echo "sha256=$digest" >> "$GITHUB_OUTPUT" + echo "agentctl-production.cdx.json SHA-256 $digest" >> "$GITHUB_STEP_SUMMARY" + + - name: Upload production SBOM + id: sbom + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: agentctl-image-sbom - path: agentctl-image.cdx.json + name: agentctl-production-sbom-cyclonedx + path: agentctl-production.cdx.json if-no-files-found: error + retention-days: 14 - supply-chain: - env: - RUSTUP_TOOLCHAIN: stable - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: taiki-e/install-action@cargo-deny - - run: cargo deny check - - run: cargo xtask verify + - name: Record SBOM artifact digest + run: echo "agentctl-production-sbom-cyclonedx artifact digest ${{ steps.sbom.outputs.artifact-digest }}" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml new file mode 100644 index 0000000..54f73b7 --- /dev/null +++ b/.github/workflows/container.yml @@ -0,0 +1,92 @@ +name: container-security + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: container-security-${{ github.ref }} + cancel-in-progress: true + +env: + RUST_TOOLCHAIN: "1.88.0" + +jobs: + container: + runs-on: ubuntu-24.04 + defaults: + run: + shell: bash + steps: + - name: Checkout exact revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install pinned Rust toolchain + run: | + set -euo pipefail + rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal + rustup default "$RUST_TOOLCHAIN" + + - name: Prepare optional build CA secret + env: + BUILD_CA_PEM: ${{ secrets.AGENTCTL_BUILD_CA_PEM }} + run: | + set -euo pipefail + if [[ -n "${BUILD_CA_PEM:-}" ]]; then + ca_file="$RUNNER_TEMP/agentctl-build-ca.pem" + umask 077 + printf '%s' "$BUILD_CA_PEM" > "$ca_file" + echo "AGENTCTL_BUILD_CA_FILE=$ca_file" >> "$GITHUB_ENV" + fi + + - name: Build and run hardened container acceptance + run: cargo xtask acceptance-container + + - name: Remove optional build CA secret + if: always() + run: rm -f "${AGENTCTL_BUILD_CA_FILE:-$RUNNER_TEMP/agentctl-build-ca.pem}" + + - name: Record local image digest + run: | + image_id="$(docker image inspect agentctl-acceptance:local --format '{{.Id}}')" + test -n "$image_id" + echo "agentctl-acceptance:local image digest $image_id" >> "$GITHUB_STEP_SUMMARY" + + - name: Reject fixed critical or high image vulnerabilities + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: agentctl-acceptance:local + version: v0.72.0 + scanners: vuln + format: table + severity: CRITICAL,HIGH + ignore-unfixed: true + exit-code: "1" + + - name: Generate CycloneDX image SBOM + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: agentctl-acceptance:local + version: v0.72.0 + format: cyclonedx + output: agentctl-image.cdx.json + exit-code: "1" + + - name: Validate image SBOM + run: jq -e '.bomFormat == "CycloneDX" and (.components | type == "array")' agentctl-image.cdx.json >/dev/null + + - name: Upload image SBOM + id: image_sbom + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: agentctl-image-sbom-cyclonedx + path: agentctl-image.cdx.json + if-no-files-found: error + retention-days: 14 + + - name: Record image SBOM artifact digest + run: echo "agentctl-image-sbom-cyclonedx artifact digest ${{ steps.image_sbom.outputs.artifact-digest }}" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-prep.yml b/.github/workflows/release-prep.yml index c5e2cb8..19d157e 100644 --- a/.github/workflows/release-prep.yml +++ b/.github/workflows/release-prep.yml @@ -1,4 +1,4 @@ -name: release-prep +name: rc-release-preparation on: workflow_dispatch: @@ -6,25 +6,52 @@ on: permissions: contents: read +env: + RUST_TOOLCHAIN: "1.88.0" + CARGO_DENY_VERSION: "0.20.2" + jobs: - package: - env: - RUSTUP_TOOLCHAIN: "1.88.0" + release-candidate: + name: RC (${{ matrix.target }}) strategy: + fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} + include: + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + - runner: macos-14 + target: aarch64-apple-darwin + - runner: windows-2022 + target: x86_64-pc-windows-msvc + runs-on: ${{ matrix.runner }} + defaults: + run: + shell: bash steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@master - with: - toolchain: "1.88.0" - components: rustfmt, clippy - - uses: taiki-e/install-action@cargo-deny - - run: cargo xtask verify - - run: cargo xtask package - - uses: actions/upload-artifact@v4 + - name: Checkout exact revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install pinned release toolchain + run: | + set -euo pipefail + rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal --component clippy,rustfmt + rustup default "$RUST_TOOLCHAIN" + cargo install cargo-deny --version "$CARGO_DENY_VERSION" --locked + + - name: Run full credential-free RC gate + run: | + cargo xtask verify + cargo xtask acceptance + cargo xtask package + + - name: Upload RC package + id: package + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: agentctl-${{ runner.os }} + name: agentctl-rc-${{ matrix.target }} path: dist/ if-no-files-found: error + retention-days: 14 + + - name: Record RC artifact digest + run: echo "agentctl-rc-${{ matrix.target }} artifact digest ${{ steps.package.outputs.artifact-digest }}" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..f8db016 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,101 @@ +name: supply-chain-security + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: supply-chain-security-${{ github.ref }} + cancel-in-progress: true + +env: + RUST_TOOLCHAIN: "1.88.0" + CARGO_DENY_VERSION: "0.20.2" + GITLEAKS_VERSION: "8.30.1" + GITLEAKS_LINUX_X64_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" + ACTIONLINT_VERSION: "1.7.12" + ACTIONLINT_LINUX_X64_SHA256: "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" + +jobs: + security: + runs-on: ubuntu-24.04 + defaults: + run: + shell: bash + steps: + - name: Checkout complete history + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Prove checkout is complete + run: test "$(git rev-parse --is-shallow-repository)" = "false" + + - name: Install checksum-pinned Gitleaks + run: | + set -euo pipefail + archive="$RUNNER_TEMP/gitleaks.tar.gz" + install_dir="$RUNNER_TEMP/gitleaks" + curl --fail --silent --show-error --location \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + --output "$archive" + echo "${GITLEAKS_LINUX_X64_SHA256} $archive" | sha256sum --check --strict + mkdir -p "$install_dir" + tar -xzf "$archive" -C "$install_dir" gitleaks + echo "$install_dir" >> "$GITHUB_PATH" + + - name: Scan complete Git history + run: gitleaks git --redact=100 --no-banner --timeout 300 --log-opts="--all" . + + - name: Scan checked-out tree + run: | + set -euo pipefail + tree="$(mktemp -d)" + trap 'rm -rf "$tree"' EXIT + git ls-files -z | tar --null -T - -cf - | tar -xf - -C "$tree" + gitleaks dir --redact=100 --no-banner --timeout 300 "$tree" + + - name: Prove scanner detects a synthetic credential + run: | + set -euo pipefail + fixture="$(mktemp -d)" + trap 'rm -rf "$fixture"' EXIT + token="$(printf 'ghp_%s%s' '7f9Q2wE4rT6yU8iO0pA3' 'sD5fG7hJ9kL1zX2cV')" + printf 'token=%s\n' "$token" > "$fixture/credential.txt" + if gitleaks dir --redact=100 --no-banner "$fixture"; then + echo "Gitleaks failed to reject the synthetic credential" >&2 + exit 1 + fi + + - name: Install pinned Rust and dependency audit toolchain + run: | + set -euo pipefail + rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal + rustup default "$RUST_TOOLCHAIN" + cargo install cargo-deny --version "$CARGO_DENY_VERSION" --locked + + - name: Run dependency and deterministic repository security gates + run: | + cargo deny check + cargo xtask secret-scan + + - name: Install checksum-pinned actionlint + run: | + set -euo pipefail + archive="$RUNNER_TEMP/actionlint.tar.gz" + install_dir="$RUNNER_TEMP/actionlint" + curl --fail --silent --show-error --location \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ + --output "$archive" + echo "${ACTIONLINT_LINUX_X64_SHA256} $archive" | sha256sum --check --strict + mkdir -p "$install_dir" + tar -xzf "$archive" -C "$install_dir" actionlint + "$install_dir/actionlint" -version + echo "$install_dir" >> "$GITHUB_PATH" + + - name: Lint all hosted workflows + run: actionlint -color diff --git a/Cargo.lock b/Cargo.lock index 2582174..f9c0239 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -103,6 +103,7 @@ dependencies = [ "async-trait", "chrono", "hex", + "nix", "serde", "serde_json", "sha2", @@ -1180,6 +1181,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "num" version = "0.4.3" @@ -2611,6 +2624,7 @@ version = "0.2.0" dependencies = [ "anyhow", "hex", + "nix", "serde_json", "sha2", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 7465cf9..c91f939 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ futures-util = "0.3.31" hex = "0.4.3" http = "1.4.0" jsonschema = { version = "0.37.1", default-features = false } +nix = { version = "0.31.3", default-features = false, features = ["signal"] } opentelemetry = "0.31.0" parking_lot = "0.12.5" proptest = "1.9.0" diff --git a/Containerfile b/Containerfile index 189502a..ac096dd 100644 --- a/Containerfile +++ b/Containerfile @@ -6,7 +6,16 @@ WORKDIR /source COPY Cargo.toml Cargo.lock rust-toolchain.toml rustfmt.toml ./ COPY crates ./crates COPY xtask ./xtask -RUN cargo build --release --locked -p agentctl +RUN --mount=type=secret,id=agentctl_ca,required=false \ + --mount=type=tmpfs,target=/tmp/agentctl-ca \ + set -eu; \ + if [ -s /run/secrets/agentctl_ca ]; then \ + cat /etc/ssl/certs/ca-certificates.crt /run/secrets/agentctl_ca \ + > /tmp/agentctl-ca/combined-ca.pem; \ + export CARGO_HTTP_CAINFO=/tmp/agentctl-ca/combined-ca.pem; \ + export SSL_CERT_FILE=/tmp/agentctl-ca/combined-ca.pem; \ + fi; \ + cargo build --release --locked -p agentctl FROM gcr.io/distroless/cc-debian12:nonroot ARG AGENTCTL_VERSION=0.2.0 diff --git a/crates/agentctl-core/src/dsl.rs b/crates/agentctl-core/src/dsl.rs index ec4f38f..b282b39 100644 --- a/crates/agentctl-core/src/dsl.rs +++ b/crates/agentctl-core/src/dsl.rs @@ -192,6 +192,77 @@ pub struct ActionDefinition { pub env: BTreeMap, #[serde(default)] pub timeout_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stdout_limit_bytes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stderr_limit_bytes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub combined_output_limit_bytes: Option, +} + +pub const DEFAULT_PROCESS_STREAM_LIMIT_BYTES: u64 = 1024 * 1024; +pub const DEFAULT_PROCESS_COMBINED_LIMIT_BYTES: u64 = 2 * 1024 * 1024; +pub const MAX_PROCESS_OUTPUT_LIMIT_BYTES: u64 = 16 * 1024 * 1024; +pub const MAX_PROCESS_TIMEOUT_SECONDS: u64 = 24 * 60 * 60; + +impl ActionDefinition { + #[must_use] + pub fn stdout_limit_bytes(&self) -> u64 { + self.stdout_limit_bytes + .unwrap_or(DEFAULT_PROCESS_STREAM_LIMIT_BYTES) + } + + #[must_use] + pub fn stderr_limit_bytes(&self) -> u64 { + self.stderr_limit_bytes + .unwrap_or(DEFAULT_PROCESS_STREAM_LIMIT_BYTES) + } + + #[must_use] + pub fn combined_output_limit_bytes(&self) -> u64 { + self.combined_output_limit_bytes + .unwrap_or(DEFAULT_PROCESS_COMBINED_LIMIT_BYTES) + } + + pub fn validate_process_bounds(&self) -> Result<(), &'static str> { + let has_output_limit = self.stdout_limit_bytes.is_some() + || self.stderr_limit_bytes.is_some() + || self.combined_output_limit_bytes.is_some(); + if self.kind != ActionKind::ShellExec && has_output_limit { + return Err("process output limits are only valid for builtin.shell.exec actions"); + } + if self.kind != ActionKind::ShellExec { + return Ok(()); + } + if self.timeout_seconds.is_some_and(|value| value == 0) { + return Err("timeoutSeconds must be greater than zero"); + } + if self + .timeout_seconds + .is_some_and(|value| value > MAX_PROCESS_TIMEOUT_SECONDS) + { + return Err("timeoutSeconds must not exceed 86400"); + } + for (value, message) in [ + ( + self.stdout_limit_bytes, + "stdoutLimitBytes must be between 1 and 16777216", + ), + ( + self.stderr_limit_bytes, + "stderrLimitBytes must be between 1 and 16777216", + ), + ( + self.combined_output_limit_bytes, + "combinedOutputLimitBytes must be between 1 and 16777216", + ), + ] { + if value.is_some_and(|value| value == 0 || value > MAX_PROCESS_OUTPUT_LIMIT_BYTES) { + return Err(message); + } + } + Ok(()) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -777,6 +848,26 @@ fn validate_document(workflow: &Workflow, file: &str) -> Vec { ), ); } + if workflow.spec.runtime.default_timeout_seconds == 0 + || workflow.spec.runtime.default_timeout_seconds > MAX_PROCESS_TIMEOUT_SECONDS + { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + "runtime.defaultTimeoutSeconds must be between 1 and 86400", + ) + .with_path("spec.runtime.defaultTimeoutSeconds"), + ); + } + for (name, action) in &workflow.spec.actions { + if let Err(message) = action.validate_process_bounds() { + diagnostics.push( + Diagnostic::error(DiagnosticCode::SchemaViolation, file, message) + .with_path(format!("spec.actions.{name}")), + ); + } + } for (name, provider) in &workflow.spec.providers { if let Some(secret) = &provider.credential && !valid_env_name(&secret.env) @@ -841,6 +932,19 @@ fn validate_document(workflow: &Workflow, file: &str) -> Vec { } } for (position, task) in workflow.spec.tasks.iter().enumerate() { + if task + .timeout_seconds + .is_some_and(|value| value == 0 || value > MAX_PROCESS_TIMEOUT_SECONDS) + { + diagnostics.push( + Diagnostic::error( + DiagnosticCode::SchemaViolation, + file, + "task timeoutSeconds must be between 1 and 86400", + ) + .with_path(format!("spec.tasks[{position}].timeoutSeconds")), + ); + } if task.retry.max_attempts == 0 || task.retry.max_attempts > 20 { diagnostics.push( Diagnostic::error( @@ -961,4 +1065,28 @@ spec: let diagnostics = parse_workflow(&source, "bad.yaml").expect_err("bad env name"); assert_eq!(diagnostics[0].code, DiagnosticCode::InvalidSecretReference); } + + #[test] + fn rejects_process_limits_on_non_process_actions() { + let source = MINIMAL.replace( + " kind: builtin.assign", + " kind: builtin.assign\n stdoutLimitBytes: 64", + ); + let diagnostics = parse_workflow(&source, "bad.yaml").expect_err("invalid bound"); + assert!( + diagnostics[0] + .message + .contains("only valid for builtin.shell.exec") + ); + } + + #[test] + fn rejects_zero_and_unreasonably_large_process_bounds() { + let source = MINIMAL.replace( + " kind: builtin.assign", + " kind: builtin.shell.exec\n command: sh\n stdoutLimitBytes: 0", + ); + let diagnostics = parse_workflow(&source, "bad.yaml").expect_err("invalid bound"); + assert!(diagnostics[0].message.contains("between 1 and 16777216")); + } } diff --git a/crates/agentctl-core/src/pack.rs b/crates/agentctl-core/src/pack.rs index 1d3101f..377915a 100644 --- a/crates/agentctl-core/src/pack.rs +++ b/crates/agentctl-core/src/pack.rs @@ -59,6 +59,13 @@ impl PackManifest { "agentctl {current} does not satisfy `{requirement}`" ))); } + for (name, action) in &self.actions { + action.validate_process_bounds().map_err(|message| { + PackError::Invalid(format!( + "action `{name}` has invalid process bounds: {message}" + )) + })?; + } Ok(()) } } @@ -134,4 +141,13 @@ mod tests { invalid.name = "local".to_owned(); assert!(matches!(invalid.validate(), Err(PackError::Invalid(_)))); } + + #[test] + fn rejects_unreasonable_pack_process_output_limit() { + let manifest: PackManifest = serde_yaml_ng::from_str( + "apiVersion: agentctl.dev/pack/v1alpha1\nname: example.utility\nversion: 1.0.0\nagentctl: '>=0.2.0, <1.0.0'\nactions:\n noisy:\n kind: builtin.shell.exec\n command: sh\n stdoutLimitBytes: 16777217\n", + ) + .expect("manifest"); + assert!(matches!(manifest.validate(), Err(PackError::Invalid(_)))); + } } diff --git a/crates/agentctl-runtime/Cargo.toml b/crates/agentctl-runtime/Cargo.toml index be2409c..6e551c6 100644 --- a/crates/agentctl-runtime/Cargo.toml +++ b/crates/agentctl-runtime/Cargo.toml @@ -23,6 +23,9 @@ tokio-util.workspace = true uuid.workspace = true url.workspace = true +[target.'cfg(unix)'.dependencies] +nix.workspace = true + [dev-dependencies] tempfile.workspace = true diff --git a/crates/agentctl-runtime/src/lib.rs b/crates/agentctl-runtime/src/lib.rs index 77fe83a..95e650d 100644 --- a/crates/agentctl-runtime/src/lib.rs +++ b/crates/agentctl-runtime/src/lib.rs @@ -32,6 +32,10 @@ use tokio_util::sync::CancellationToken; use url::Url; use uuid::Uuid; +mod process; + +use process::{ProcessOutputLimits, ProcessRunError, run_bounded_process}; + pub trait Clock: Send + Sync { fn now(&self) -> DateTime; } @@ -1252,6 +1256,9 @@ impl Runtime { "args": action.args, "cwd": action.cwd, "environment": environment_digests, + "stdoutLimitBytes": action.stdout_limit_bytes(), + "stderrLimitBytes": action.stderr_limit_bytes(), + "combinedOutputLimitBytes": action.combined_output_limit_bytes(), }), "execute an allowlisted subprocess", trace_id, @@ -1286,18 +1293,13 @@ impl Runtime { .timeout_seconds .unwrap_or(task_timeout(workflow, &task.task_id)), ); - let result = tokio::select! { - result = tokio::time::timeout(timeout, process.output()) => { - match result { - Ok(result) => result.map_err(RuntimeError::Io), - Err(_) => Err(RuntimeError::Task { - task: task.task_id.clone(), - message: "subprocess timed out".to_owned(), - }), - } - } - () = cancellation.cancelled() => Err(RuntimeError::Cancelled), + let limits = ProcessOutputLimits { + stdout_bytes: action.stdout_limit_bytes(), + stderr_bytes: action.stderr_limit_bytes(), + combined_bytes: action.combined_output_limit_bytes(), }; + let result = + run_bounded_process(process, limits, timeout, cancellation).await; match result { Ok(result) => { let secrets = resolved_environment @@ -1341,7 +1343,56 @@ impl Runtime { }) } } + Err(ProcessRunError::OutputLimitExceeded { + stream, + limit_bytes, + stdout, + stderr, + }) => { + let secrets = resolved_environment + .values() + .map(String::as_str) + .collect::>(); + let diagnostic = serde_json::json!({ + "code": "subprocess_output_limit_exceeded", + "stream": stream, + "limitBytes": limit_bytes, + "stdoutPrefix": redacted_process_diagnostic(&stdout, &secrets), + "stderrPrefix": redacted_process_diagnostic(&stderr, &secrets), + "remediation": "reduce subprocess output or raise the action output limit within the 16777216-byte maximum", + }) + .to_string(); + self.store.complete_effect( + &request.id, + Err(&diagnostic), + self.clock.now(), + )?; + Err(RuntimeError::Task { + task: task.task_id.clone(), + message: diagnostic, + }) + } Err(error) => { + let error = match error { + ProcessRunError::Timeout { seconds } => RuntimeError::Task { + task: task.task_id.clone(), + message: format!( + "subprocess timed out after {seconds} seconds and was terminated" + ), + }, + ProcessRunError::Cancelled => RuntimeError::Cancelled, + ProcessRunError::Spawn(error) + | ProcessRunError::Wait(error) => RuntimeError::Io(error), + ProcessRunError::Read { stream, message } => { + RuntimeError::Task { + task: task.task_id.clone(), + message: format!( + "failed to capture subprocess {stream}: {message}" + ), + } + } + ProcessRunError::OutputLimitExceeded { .. } => unreachable!(), + }; self.store.mark_effect_uncertain( &request.id, &error.to_string(), @@ -2258,6 +2309,16 @@ fn redact_text(value: &str, secrets: &[&str]) -> String { }) } +fn redacted_process_diagnostic(value: &[u8], secrets: &[&str]) -> String { + const DIAGNOSTIC_PREFIX_BYTES: usize = 4 * 1024; + if !secrets.is_empty() { + return "[REDACTED: subprocess output omitted because secret environment values were present]" + .to_owned(); + } + let prefix = &value[..value.len().min(DIAGNOSTIC_PREFIX_BYTES)]; + redact_text(&String::from_utf8_lossy(prefix), secrets) +} + fn unified_diff(before: Option<&str>, after: &str) -> String { let before = before.unwrap_or(""); if before == after { @@ -3545,6 +3606,248 @@ spec: )); } + #[cfg(unix)] + #[tokio::test] + async fn subprocess_output_limit_is_structured_durable_and_secret_safe() { + let directory = tempdir().expect("tempdir"); + let secret = std::env::var("PATH").expect("PATH"); + let (workflow, plan) = compile_fixture( + r#" +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: process-output-limit } +spec: + policy: + workspaceRoot: . + processAllowlist: [sh] + environmentAllowlist: [SECRET, PATH] + approval: never + actions: + noisy: + kind: builtin.shell.exec + command: /bin/sh + args: [-c, 'while :; do printf "%s" "$SECRET"; done'] + env: + SECRET: { env: PATH } + stdoutLimitBytes: 64 + stderrLimitBytes: 64 + combinedOutputLimitBytes: 128 + tasks: [{ id: noisy, uses: "action:noisy" }] +"#, + ); + let store = SqliteStore::open_memory().expect("store"); + let run_id = match runtime(store.clone(), directory.path()) + .start( + &workflow, + &plan, + serde_json::json!({}), + RunOptions::default(), + &CancellationToken::new(), + ) + .await + { + Err(RuntimeError::RunFailed { run_id, .. }) => run_id, + other => panic!("expected failed run, got {other:?}"), + }; + let effect = &store.list_effects(&run_id).expect("effects")[0]; + assert_eq!(effect.status, EffectStatus::Failed); + let diagnostic: Value = serde_json::from_str(effect.error.as_deref().expect("error")) + .expect("structured diagnostic"); + assert_eq!(diagnostic["code"], "subprocess_output_limit_exceeded"); + assert_eq!(diagnostic["stream"], "stdout"); + assert_eq!(diagnostic["limitBytes"], 64); + assert_eq!( + diagnostic["stdoutPrefix"], + "[REDACTED: subprocess output omitted because secret environment values were present]" + ); + assert!(!effect.error.as_deref().expect("error").contains(&secret)); + assert_eq!( + store.load_run(&run_id).expect("run").state, + RunState::Failed + ); + assert!( + store.list_tasks(&run_id).expect("tasks")[0] + .error + .as_deref() + .is_some_and(|error| error.contains("subprocess_output_limit_exceeded")) + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn normal_subprocess_success_redacts_secret_output() { + let directory = tempdir().expect("tempdir"); + let (workflow, plan) = compile_fixture( + r#" +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: process-secret-redaction } +spec: + policy: + workspaceRoot: . + processAllowlist: [sh] + environmentAllowlist: [SECRET, PATH] + approval: never + actions: + print: + kind: builtin.shell.exec + command: /bin/sh + args: [-c, 'printf "%s" "$SECRET"'] + env: + SECRET: { env: PATH } + tasks: [{ id: print, uses: "action:print" }] +"#, + ); + let outcome = runtime(SqliteStore::open_memory().expect("store"), directory.path()) + .start( + &workflow, + &plan, + serde_json::json!({}), + RunOptions::default(), + &CancellationToken::new(), + ) + .await + .expect("successful run"); + assert_eq!(outcome.state, RunState::Succeeded); + assert_eq!( + outcome.output.as_ref().expect("output")["print"]["stdout"], + "[REDACTED]" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn pack_process_uses_the_same_output_limit_contract() { + use agentctl_core::pack::PackManifest; + + let directory = tempdir().expect("tempdir"); + let mut workflow = parse_workflow( + r#" +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: { name: packed-process-output-limit } +spec: + policy: + workspaceRoot: . + processAllowlist: [sh] + approval: never + actions: + placeholder: { kind: builtin.assign } + tasks: [{ id: noisy, uses: "action:example.utility.noisy" }] +"#, + "fixture.yaml", + ) + .expect("workflow") + .workflow; + let pack: PackManifest = serde_json::from_value(serde_json::json!({ + "apiVersion": "agentctl.dev/pack/v1alpha1", + "name": "example.utility", + "version": "1.0.0", + "agentctl": ">=0.2.0, <1.0.0", + "actions": { + "noisy": { + "kind": "builtin.shell.exec", + "command": "/bin/sh", + "args": ["-c", "while :; do printf packed; done"], + "stdoutLimitBytes": 32, + "stderrLimitBytes": 32, + "combinedOutputLimitBytes": 64 + } + } + })) + .expect("pack"); + pack.validate().expect("valid pack"); + workflow.spec.actions.insert( + "example.utility.noisy".to_owned(), + pack.actions["noisy"].clone(), + ); + let plan = compile(&workflow, "fixture.yaml").expect("plan"); + let store = SqliteStore::open_memory().expect("store"); + let run_id = match runtime(store.clone(), directory.path()) + .start( + &workflow, + &plan, + serde_json::json!({}), + RunOptions::default(), + &CancellationToken::new(), + ) + .await + { + Err(RuntimeError::RunFailed { run_id, .. }) => run_id, + other => panic!("expected failed run, got {other:?}"), + }; + let error = store.list_effects(&run_id).expect("effects")[0] + .error + .clone() + .expect("error"); + let diagnostic: Value = serde_json::from_str(&error).expect("structured diagnostic"); + assert_eq!(diagnostic["code"], "subprocess_output_limit_exceeded"); + assert_eq!(diagnostic["limitBytes"], 32); + } + + #[cfg(unix)] + #[tokio::test] + async fn subprocess_cancellation_is_durable_uncertainty() { + let directory = tempdir().expect("tempdir"); + let marker = directory.path().join("started"); + let source = format!( + r#" +apiVersion: agentctl.dev/v1alpha1 +kind: Workflow +metadata: {{ name: process-cancellation }} +spec: + policy: + workspaceRoot: . + processAllowlist: [sh] + approval: never + actions: + wait: + kind: builtin.shell.exec + command: /bin/sh + args: [-c, "echo started > '{}'; sleep 5"] + timeoutSeconds: 10 + tasks: [{{ id: wait, uses: "action:wait" }}] +"#, + marker.display() + ); + let (workflow, plan) = compile_fixture(&source); + let store = SqliteStore::open_memory().expect("store"); + let cancellation = CancellationToken::new(); + let trigger = cancellation.clone(); + let observed_marker = marker.clone(); + tokio::spawn(async move { + while !observed_marker.exists() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + trigger.cancel(); + }); + let cancelled = runtime(store.clone(), directory.path()) + .start( + &workflow, + &plan, + serde_json::json!({}), + RunOptions::default(), + &cancellation, + ) + .await + .expect("cancelled outcome"); + assert_eq!(cancelled.state, RunState::Cancelled); + assert_eq!( + store.list_effects(&cancelled.run_id).expect("effects")[0].status, + EffectStatus::Uncertain + ); + assert!(matches!( + runtime(store, directory.path()) + .resume( + &cancelled.run_id, + RunOptions::default(), + &CancellationToken::new() + ) + .await, + Err(RuntimeError::UncertainEffect { .. }) + )); + } + #[test] fn subprocess_output_redaction_removes_every_known_secret_value() { let output = redact_text("token=top-secret; repeated=top-secret", &["top-secret"]); diff --git a/crates/agentctl-runtime/src/process.rs b/crates/agentctl-runtime/src/process.rs new file mode 100644 index 0000000..788f86f --- /dev/null +++ b/crates/agentctl-runtime/src/process.rs @@ -0,0 +1,456 @@ +use std::io; +use std::process::{ExitStatus, Stdio}; +use std::time::Duration; + +use thiserror::Error; +use tokio::io::{AsyncRead, AsyncReadExt}; +use tokio::process::{Child, Command}; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; + +const PIPE_CHUNK_BYTES: usize = 8 * 1024; +const PIPE_CHANNEL_CAPACITY: usize = 8; + +#[derive(Debug, Clone, Copy)] +pub struct ProcessOutputLimits { + pub stdout_bytes: u64, + pub stderr_bytes: u64, + pub combined_bytes: u64, +} + +#[derive(Debug)] +pub struct BoundedProcessOutput { + pub status: ExitStatus, + pub stdout: Vec, + pub stderr: Vec, +} + +#[derive(Debug, Error)] +pub enum ProcessRunError { + #[error("failed to spawn subprocess: {0}")] + Spawn(#[source] io::Error), + #[error("failed to read subprocess {stream}: {message}")] + Read { + stream: &'static str, + message: String, + }, + #[error("failed to wait for subprocess: {0}")] + Wait(#[source] io::Error), + #[error("subprocess timed out after {seconds} seconds")] + Timeout { seconds: u64 }, + #[error("subprocess execution was cancelled")] + Cancelled, + #[error("subprocess {stream} exceeded the configured {limit_bytes}-byte output limit")] + OutputLimitExceeded { + stream: &'static str, + limit_bytes: u64, + stdout: Vec, + stderr: Vec, + }, +} + +#[derive(Debug, Clone, Copy)] +enum Stream { + Stdout, + Stderr, +} + +impl Stream { + const fn name(self) -> &'static str { + match self { + Self::Stdout => "stdout", + Self::Stderr => "stderr", + } + } +} + +enum PipeEvent { + Chunk(Stream, Vec), + Eof, + ReadError(Stream, String), +} + +pub async fn run_bounded_process( + mut command: Command, + limits: ProcessOutputLimits, + timeout: Duration, + cancellation: &CancellationToken, +) -> Result { + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + #[cfg(unix)] + command.process_group(0); + let mut child = command.spawn().map_err(ProcessRunError::Spawn)?; + let process_id = child.id(); + let stdout = child.stdout.take().ok_or_else(|| ProcessRunError::Read { + stream: "stdout", + message: "stdout pipe was unavailable".to_owned(), + })?; + let stderr = child.stderr.take().ok_or_else(|| ProcessRunError::Read { + stream: "stderr", + message: "stderr pipe was unavailable".to_owned(), + })?; + let (sender, mut receiver) = mpsc::channel(PIPE_CHANNEL_CAPACITY); + let stdout_task = tokio::spawn(read_pipe(stdout, Stream::Stdout, sender.clone())); + let stderr_task = tokio::spawn(read_pipe(stderr, Stream::Stderr, sender)); + let deadline = Instant::now() + timeout; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut eof_count = 0; + + let outcome = loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + let event = tokio::select! { + () = cancellation.cancelled() => break Err(ProcessRunError::Cancelled), + () = tokio::time::sleep(remaining) => { + break Err(ProcessRunError::Timeout { seconds: timeout.as_secs() }); + } + event = receiver.recv() => event, + }; + match event { + Some(PipeEvent::Chunk(stream, chunk)) => { + if let Some((stream, limit_bytes)) = + append_bounded(stream, &chunk, &mut stdout, &mut stderr, limits) + { + break Err(ProcessRunError::OutputLimitExceeded { + stream, + limit_bytes, + stdout, + stderr, + }); + } + } + Some(PipeEvent::Eof) => { + eof_count += 1; + if eof_count == 2 { + break wait_for_child(&mut child, deadline, timeout, cancellation) + .await + .map(|status| BoundedProcessOutput { + status, + stdout, + stderr, + }); + } + } + Some(PipeEvent::ReadError(stream, message)) => { + break Err(ProcessRunError::Read { + stream: stream.name(), + message, + }); + } + None => { + break Err(ProcessRunError::Read { + stream: "output", + message: "output readers stopped before both pipes reached EOF".to_owned(), + }); + } + } + }; + + if outcome.is_err() { + terminate_and_reap(&mut child, process_id) + .await + .map_err(ProcessRunError::Wait)?; + } + finish_readers(stdout_task, stderr_task, outcome.is_err()).await; + outcome +} + +fn append_bounded( + stream: Stream, + chunk: &[u8], + stdout: &mut Vec, + stderr: &mut Vec, + limits: ProcessOutputLimits, +) -> Option<(&'static str, u64)> { + let stream_length = match stream { + Stream::Stdout => stdout.len() as u64, + Stream::Stderr => stderr.len() as u64, + }; + let stream_limit = match stream { + Stream::Stdout => limits.stdout_bytes, + Stream::Stderr => limits.stderr_bytes, + }; + let combined_length = (stdout.len() + stderr.len()) as u64; + let stream_remaining = stream_limit.saturating_sub(stream_length); + let combined_remaining = limits.combined_bytes.saturating_sub(combined_length); + let retained = chunk + .len() + .min(stream_remaining as usize) + .min(combined_remaining as usize); + match stream { + Stream::Stdout => stdout.extend_from_slice(&chunk[..retained]), + Stream::Stderr => stderr.extend_from_slice(&chunk[..retained]), + } + if retained == chunk.len() { + None + } else if combined_remaining <= stream_remaining { + Some(("combined output", limits.combined_bytes)) + } else { + Some((stream.name(), stream_limit)) + } +} + +async fn wait_for_child( + child: &mut Child, + deadline: Instant, + timeout: Duration, + cancellation: &CancellationToken, +) -> Result { + let remaining = deadline.saturating_duration_since(Instant::now()); + tokio::select! { + result = child.wait() => result.map_err(ProcessRunError::Wait), + () = cancellation.cancelled() => Err(ProcessRunError::Cancelled), + () = tokio::time::sleep(remaining) => { + Err(ProcessRunError::Timeout { seconds: timeout.as_secs() }) + } + } +} + +async fn terminate_and_reap(child: &mut Child, process_id: Option) -> io::Result<()> { + terminate_process_tree(process_id).await; + if child.try_wait()?.is_none() { + child.start_kill()?; + child.wait().await?; + } + Ok(()) +} + +#[cfg(unix)] +async fn terminate_process_tree(process_id: Option) { + use nix::sys::signal::{Signal, killpg}; + use nix::unistd::Pid; + + if let Some(process_id) = process_id.and_then(|value| i32::try_from(value).ok()) { + let _ = killpg(Pid::from_raw(process_id), Signal::SIGKILL); + } +} + +#[cfg(windows)] +async fn terminate_process_tree(process_id: Option) { + if let Some(process_id) = process_id { + let _ = Command::new("taskkill.exe") + .args(["/PID", &process_id.to_string(), "/T", "/F"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + } +} + +#[cfg(not(any(unix, windows)))] +async fn terminate_process_tree(_process_id: Option) {} + +async fn finish_readers(stdout_task: JoinHandle<()>, stderr_task: JoinHandle<()>, abort: bool) { + if abort { + stdout_task.abort(); + stderr_task.abort(); + } + let _ = stdout_task.await; + let _ = stderr_task.await; +} + +async fn read_pipe(mut pipe: R, stream: Stream, sender: mpsc::Sender) +where + R: AsyncRead + Unpin, +{ + let mut buffer = vec![0_u8; PIPE_CHUNK_BYTES]; + loop { + match pipe.read(&mut buffer).await { + Ok(0) => { + let _ = sender.send(PipeEvent::Eof).await; + return; + } + Ok(length) => { + if sender + .send(PipeEvent::Chunk(stream, buffer[..length].to_vec())) + .await + .is_err() + { + return; + } + } + Err(error) => { + let _ = sender + .send(PipeEvent::ReadError(stream, error.to_string())) + .await; + return; + } + } + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::fs; + use std::process::Command as StdCommand; + + use tempfile::tempdir; + + fn shell(script: &str) -> Command { + let mut command = Command::new("/bin/sh"); + command.args(["-c", script]); + command + } + + fn limits(stdout_bytes: u64, stderr_bytes: u64, combined_bytes: u64) -> ProcessOutputLimits { + ProcessOutputLimits { + stdout_bytes, + stderr_bytes, + combined_bytes, + } + } + + #[tokio::test] + async fn caps_stdout_and_reaps_the_child() { + let directory = tempdir().expect("tempdir"); + let pid_file = directory.path().join("pid"); + let script = format!( + "echo $$ > '{}'; while :; do printf 1234567890; done", + pid_file.display() + ); + let error = run_bounded_process( + shell(&script), + limits(64, 64, 128), + Duration::from_secs(5), + &CancellationToken::new(), + ) + .await + .expect_err("stdout limit"); + assert!(matches!( + error, + ProcessRunError::OutputLimitExceeded { + stream: "stdout", + limit_bytes: 64, + .. + } + )); + let pid = fs::read_to_string(pid_file).expect("pid"); + assert!( + !StdCommand::new("kill") + .args(["-0", pid.trim()]) + .stderr(Stdio::null()) + .status() + .expect("probe process") + .success() + ); + } + + #[tokio::test] + async fn caps_stderr() { + let error = run_bounded_process( + shell("while :; do printf 1234567890 >&2; done"), + limits(64, 32, 128), + Duration::from_secs(5), + &CancellationToken::new(), + ) + .await + .expect_err("stderr limit"); + assert!(matches!( + error, + ProcessRunError::OutputLimitExceeded { + stream: "stderr", + limit_bytes: 32, + .. + } + )); + } + + #[tokio::test] + async fn caps_combined_interleaved_output() { + let error = run_bounded_process( + shell("while :; do printf 12345; printf 67890 >&2; done"), + limits(128, 128, 48), + Duration::from_secs(5), + &CancellationToken::new(), + ) + .await + .expect_err("combined limit"); + assert!(matches!( + error, + ProcessRunError::OutputLimitExceeded { + stream: "combined output", + limit_bytes: 48, + .. + } + )); + } + + #[tokio::test] + async fn times_out_and_reaps() { + let directory = tempdir().expect("tempdir"); + let child_pid = directory.path().join("child-pid"); + let script = format!("sleep 10 & echo $! > '{}'; wait", child_pid.display()); + let error = run_bounded_process( + shell(&script), + limits(64, 64, 128), + Duration::from_secs(2), + &CancellationToken::new(), + ) + .await + .expect_err("timeout"); + assert!(matches!(error, ProcessRunError::Timeout { .. })); + assert_process_gone(&child_pid).await; + } + + #[tokio::test] + async fn cancellation_terminates_the_process() { + let directory = tempdir().expect("tempdir"); + let child_pid = directory.path().join("child-pid"); + let script = format!("sleep 10 & echo $! > '{}'; wait", child_pid.display()); + let cancellation = CancellationToken::new(); + let trigger = cancellation.clone(); + let observed_pid = child_pid.clone(); + tokio::spawn(async move { + while !observed_pid.exists() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + trigger.cancel(); + }); + let error = run_bounded_process( + shell(&script), + limits(64, 64, 128), + Duration::from_secs(5), + &cancellation, + ) + .await + .expect_err("cancellation"); + assert!(matches!(error, ProcessRunError::Cancelled)); + assert_process_gone(&child_pid).await; + } + + async fn assert_process_gone(pid_file: &std::path::Path) { + let pid = fs::read_to_string(pid_file).expect("child pid"); + for _ in 0..20 { + if !StdCommand::new("kill") + .args(["-0", pid.trim()]) + .stderr(Stdio::null()) + .status() + .expect("probe process") + .success() + { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("descendant process {pid} survived termination"); + } + + #[tokio::test] + async fn returns_normal_parseable_json_output() { + let output = run_bounded_process( + shell("printf '{\"ok\":true}'; printf warning >&2"), + limits(64, 64, 128), + Duration::from_secs(5), + &CancellationToken::new(), + ) + .await + .expect("output"); + assert!(output.status.success()); + assert_eq!(output.stderr, b"warning"); + let value: serde_json::Value = serde_json::from_slice(&output.stdout).expect("json"); + assert_eq!(value["ok"], true); + } +} diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 11330ff..63fecb0 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -71,6 +71,7 @@ dependencies = [ "async-trait", "chrono", "hex", + "nix", "serde", "serde_json", "sha2", @@ -944,6 +945,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "num" version = "0.4.3" diff --git a/schemas/workflow.schema.json b/schemas/workflow.schema.json index a7c349c..de827ef 100644 --- a/schemas/workflow.schema.json +++ b/schemas/workflow.schema.json @@ -459,6 +459,30 @@ "format": "uint64", "minimum": 0, "default": null + }, + "stdoutLimitBytes": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "stderrLimitBytes": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "combinedOutputLimitBytes": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "additionalProperties": false, diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index be3838e..c3d68c5 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -14,5 +14,8 @@ serde_json.workspace = true sha2.workspace = true tempfile.workspace = true +[target.'cfg(unix)'.dependencies] +nix.workspace = true + [lints] workspace = true diff --git a/xtask/src/acceptance.rs b/xtask/src/acceptance.rs index 52d181a..8eae639 100644 --- a/xtask/src/acceptance.rs +++ b/xtask/src/acceptance.rs @@ -2,13 +2,15 @@ use std::env; use std::ffi::OsStr; use std::fs; use std::path::{Path, PathBuf}; -use std::process::{Command, Output, Stdio}; +use std::process::{Command, Output}; use std::thread; use std::time::Duration; use anyhow::{Context, Result, bail, ensure}; use serde_json::Value; +use crate::process::{bounded_output, bounded_wait, configure_piped_command, output_diagnostics}; + const VERIFY_TOKEN: &str = "AGENTCTL_MOCK_FIXTURE_VERIFIED"; const LIVE_VERIFY_TOKEN: &str = "AGENTCTL_LIVE_FIXTURE_VERIFIED"; @@ -609,13 +611,27 @@ pub fn run(root: &Path) -> Result<()> { )?; let arguments = run_args(&hello, &concurrent_db, root, &[]); let mut first_command = command_for(&binary, root, &arguments); - first_command.stdout(Stdio::piped()).stderr(Stdio::piped()); + configure_piped_command(&mut first_command); let mut second_command = command_for(&binary, root, &arguments); - second_command.stdout(Stdio::piped()).stderr(Stdio::piped()); + configure_piped_command(&mut second_command); let first = first_command.spawn()?; let second = second_command.spawn()?; - ensure!(first.wait_with_output()?.status.success()); - ensure!(second.wait_with_output()?.status.success()); + let first_wait = thread::spawn(move || bounded_wait(first, "first concurrent agentctl run")); + let second_wait = thread::spawn(move || bounded_wait(second, "second concurrent agentctl run")); + ensure!( + first_wait + .join() + .map_err(|_| anyhow::anyhow!("first concurrent wait panicked"))?? + .status + .success() + ); + ensure!( + second_wait + .join() + .map_err(|_| anyhow::anyhow!("second concurrent wait panicked"))?? + .status + .success() + ); scenario(22, "SIGTERM produces a durable cancelled run"); signal_acceptance(&binary, &workspace, directory.path())?; @@ -840,7 +856,7 @@ fn signal_acceptance(binary: &Path, workspace: &Path, directory: &Path) -> Resul let db = directory.join("signal.db"); let args = run_args(&workflow, &db, workspace, &[]); let mut command = command_for(binary, workspace, &args); - command.stdout(Stdio::piped()).stderr(Stdio::piped()); + configure_piped_command(&mut command); let child = command.spawn()?; for _ in 0..50 { if db.exists() { @@ -853,7 +869,7 @@ fn signal_acceptance(binary: &Path, workspace: &Path, directory: &Path) -> Resul .args(["-TERM", &child.id().to_string()]) .status()?; ensure!(status.success(), "failed to deliver SIGTERM"); - let output = child.wait_with_output()?; + let output = bounded_wait(child, "SIGTERM agentctl run")?; ensure!( output.status.code() == Some(130), "SIGTERM exit was not 130" @@ -1082,16 +1098,15 @@ fn command_for(binary: &Path, cwd: &Path, args: &[String]) -> Command { command } -fn output_with_code(mut command: Command, code: i32, label: &str) -> Result { - let output = command.output().with_context(|| format!("run {label}"))?; +fn output_with_code(command: Command, code: i32, label: &str) -> Result { + let output = bounded_output(command, label).with_context(|| format!("run {label}"))?; if output.status.code() == Some(code) { Ok(output) } else { bail!( - "{label} returned {:?}, expected {code}\nstdout: {}\nstderr: {}", + "{label} returned {:?}, expected {code}\n{}", output.status.code(), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) + output_diagnostics(&output) ) } } @@ -1147,7 +1162,9 @@ fn debug_binary(root: &Path) -> PathBuf { } fn packaged_binary(root: &Path) -> Result { - let output = Command::new("rustc").arg("-vV").output()?; + let mut command = Command::new("rustc"); + command.arg("-vV"); + let output = bounded_output(command, "rustc -vV")?; ensure!(output.status.success(), "rustc -vV failed"); let version = String::from_utf8(output.stdout)?; let host = version @@ -1403,37 +1420,43 @@ fn executable_on_path(name: &str) -> bool { } fn ensure_engine_ready(engine: &Path) -> Result<()> { - let output = Command::new(engine).arg("info").output()?; + let mut command = Command::new(engine); + command.arg("info"); + let output = bounded_output(command, "container engine info")?; if output.status.success() { Ok(()) } else { bail!( - "container engine is installed but unavailable: {}", - String::from_utf8_lossy(&output.stderr).trim() + "container engine is installed but unavailable:\n{}", + output_diagnostics(&output) ) } } fn build_image(root: &Path, engine: &Path) -> Result<()> { - let output = Command::new(engine) - .current_dir(root) - .args([ - OsStr::new("build"), - OsStr::new("--file"), - OsStr::new("Containerfile"), - OsStr::new("--tag"), - OsStr::new("agentctl-acceptance:local"), - OsStr::new("."), - ]) - .output()?; + let mut command = Command::new(engine); + command.current_dir(root).arg("build"); + if let Some(ca_file) = env::var_os("AGENTCTL_BUILD_CA_FILE") { + let ca_file = PathBuf::from(ca_file); + ensure!( + ca_file.is_file(), + "AGENTCTL_BUILD_CA_FILE must refer to a readable certificate file" + ); + let secret = format!("id=agentctl_ca,src={}", ca_file.display()); + command.args([OsStr::new("--secret"), OsStr::new(&secret)]); + } + command.args([ + OsStr::new("--file"), + OsStr::new("Containerfile"), + OsStr::new("--tag"), + OsStr::new("agentctl-acceptance:local"), + OsStr::new("."), + ]); + let output = bounded_output(command, "OCI image build")?; if output.status.success() { Ok(()) } else { - bail!( - "OCI image build failed:\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ) + bail!("OCI image build failed:\n{}", output_diagnostics(&output)) } } @@ -1567,7 +1590,7 @@ fn container_signal_acceptance(engine: &Path, root: &Path) -> Result<()> { "--color", "never", ]); - command.stdout(Stdio::piped()).stderr(Stdio::piped()); + configure_piped_command(&mut command); let child = command.spawn()?; for _ in 0..100 { if layout.state.join("runtime.db").exists() { @@ -1580,20 +1603,20 @@ fn container_signal_acceptance(engine: &Path, root: &Path) -> Result<()> { "OCI run did not create durable state" ); thread::sleep(Duration::from_millis(100)); - let stopped = Command::new(engine) - .args(["stop", "--time", "10", &name]) - .output()?; + let mut stop = Command::new(engine); + stop.args(["stop", "--time", "10", &name]); + let stopped = bounded_output(stop, "stop OCI run")?; ensure!( stopped.status.success(), "failed to stop OCI run: {}", - String::from_utf8_lossy(&stopped.stderr) + output_diagnostics(&stopped) ); - let output = child.wait_with_output()?; + let output = bounded_wait(child, "SIGTERM OCI run")?; ensure!( output.status.code() == Some(130), - "OCI SIGTERM exit was {:?}, expected 130; stderr: {}", + "OCI SIGTERM exit was {:?}, expected 130; {}", output.status.code(), - String::from_utf8_lossy(&output.stderr) + output_diagnostics(&output) ); let value = parse_output(&output)?; ensure_eq(&value, "/data/state", "cancelled")?; diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 2101845..48378ca 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -9,7 +9,10 @@ use anyhow::{Context, Result, anyhow, bail}; use serde_json::Value; use sha2::{Digest, Sha256}; +use crate::process::{bounded_output, output_diagnostics}; + mod acceptance; +mod process; fn main() -> Result<()> { let command = env::args().nth(1).unwrap_or_else(|| "help".to_owned()); @@ -24,9 +27,13 @@ fn main() -> Result<()> { "acceptance-live-openai" => acceptance::live_openai(&root), "generate" => generate(&root), "package" => package(&root), + "secret-scan" => { + verify_no_secrets(&root)?; + verify_workflow_action_pins(&root) + } "help" | "--help" | "-h" => { println!( - "cargo xtask verify\ncargo xtask acceptance\ncargo xtask acceptance-container\ncargo xtask acceptance-live-openai\ncargo xtask generate\ncargo xtask package" + "cargo xtask verify\ncargo xtask acceptance\ncargo xtask acceptance-container\ncargo xtask acceptance-live-openai\ncargo xtask generate\ncargo xtask package\ncargo xtask secret-scan" ); Ok(()) } @@ -40,7 +47,9 @@ pub(crate) fn package(root: &Path) -> Result<()> { "cargo", &["build", "--release", "-p", "agentctl", "--locked"], )?; - let host_output = Command::new("rustc").arg("-vV").output()?; + let mut rustc = Command::new("rustc"); + rustc.arg("-vV"); + let host_output = bounded_output(rustc, "rustc -vV")?; ensure_success(&host_output, "rustc -vV")?; let version = String::from_utf8_lossy(&host_output.stdout); let host = version @@ -66,7 +75,9 @@ pub(crate) fn package(root: &Path) -> Result<()> { ("fish", "agentctl.fish"), ("powershell", "_agentctl.ps1"), ] { - let output = Command::new(&binary).args(["completion", shell]).output()?; + let mut completion = Command::new(&binary); + completion.args(["completion", shell]); + let output = bounded_output(completion, "agentctl completion")?; ensure_success(&output, "agentctl completion")?; fs::write(package.join(name), output.stdout)?; } @@ -165,8 +176,9 @@ fn verify(root: &Path) -> Result<()> { println!("[9/12] dependency advisories and policy"); verify_supply_chain(root)?; - println!("[10/12] secret scan"); + println!("[10/12] deterministic secret and workflow action-pin scan"); verify_no_secrets(root)?; + verify_workflow_action_pins(root)?; println!("[11/12] source installation smoke"); verify_install(root)?; @@ -212,13 +224,13 @@ fn verify_generated(root: &Path) -> Result<()> { fn generated_schema(binary: &Path) -> Result { let directory = tempfile::tempdir()?; let path = directory.path().join("workflow.schema.json"); - let output = Command::new(binary) + let mut command = Command::new(binary); + command .args(["schema", "--write"]) .arg(&path) .arg("--output") - .arg("json") - .output() - .context("generate schema")?; + .arg("json"); + let output = bounded_output(command, "generate schema").context("generate schema")?; ensure_success(&output, "agentctl schema")?; fs::read_to_string(path).context("read generated schema") } @@ -256,10 +268,9 @@ fn generated_cli_reference(binary: &Path) -> Result { "# CLI reference\n\nGenerated from the Rust CLI by `cargo xtask generate`. Do not edit by hand.\n\n", ); for command in commands { - let output = Command::new(binary) - .args(*command) - .arg("--help") - .output() + let mut help_command = Command::new(binary); + help_command.args(*command).arg("--help"); + let output = bounded_output(help_command, "agentctl --help") .with_context(|| format!("render help for {}", command.join(" ")))?; ensure_success(&output, "agentctl --help")?; let title = if command.is_empty() { @@ -288,18 +299,16 @@ fn verify_examples(root: &Path) -> Result<()> { continue; } let expected_failure = path.file_name() == Some(OsStr::new("capability-failure.yaml")); - let output = Command::new(&binary) - .arg("check") - .arg(&path) - .args(["--output", "json"]) - .output() + let mut command = Command::new(&binary); + command.arg("check").arg(&path).args(["--output", "json"]); + let output = bounded_output(command, "agentctl example check") .with_context(|| format!("check example {}", path.display()))?; if expected_failure { if output.status.code() != Some(2) { bail!( "negative capability fixture returned {:?}: {}", output.status.code(), - String::from_utf8_lossy(&output.stderr) + output_diagnostics(&output) ); } } else { @@ -356,20 +365,19 @@ fn verify_examples(root: &Path) -> Result<()> { } let denied_db = directory.path().join("denied.db"); - let denied = Command::new(&binary) - .current_dir(root) - .args([ - "run", - examples - .join("policy-denial.yaml") - .to_str() - .context("example path")?, - "--db", - denied_db.to_str().context("db path")?, - "--output", - "json", - ]) - .output()?; + let mut denied_command = Command::new(&binary); + denied_command.current_dir(root).args([ + "run", + examples + .join("policy-denial.yaml") + .to_str() + .context("example path")?, + "--db", + denied_db.to_str().context("db path")?, + "--output", + "json", + ]); + let denied = bounded_output(denied_command, "agentctl policy denial")?; if denied.status.success() { bail!("policy-denial example unexpectedly succeeded"); } @@ -380,10 +388,11 @@ fn verify_examples(root: &Path) -> Result<()> { } fn verify_metadata(root: &Path) -> Result<()> { - let output = Command::new("cargo") + let mut metadata = Command::new("cargo"); + metadata .current_dir(root) - .args(["metadata", "--format-version", "1", "--locked"]) - .output()?; + .args(["metadata", "--format-version", "1", "--locked"]); + let output = bounded_output(metadata, "cargo metadata")?; ensure_success(&output, "cargo metadata")?; let metadata: Value = serde_json::from_slice(&output.stdout)?; let packages = metadata["packages"] @@ -428,12 +437,7 @@ fn verify_supply_chain(root: &Path) -> Result<()> { } fn verify_no_secrets(root: &Path) -> Result<()> { - let forbidden = [ - ["sk-", "proj-"].concat(), - ["sk-", "ant-api"].concat(), - ["AI", "zaSy"].concat(), - ["-----BEGIN ", "PRIVATE KEY-----"].concat(), - ]; + let forbidden = forbidden_secret_patterns(); let mut files = Vec::new(); collect_files(root, &mut files)?; for path in files { @@ -451,6 +455,66 @@ fn verify_no_secrets(root: &Path) -> Result<()> { Ok(()) } +fn forbidden_secret_patterns() -> [String; 4] { + [ + ["sk-", "proj-"].concat(), + ["sk-", "ant-api"].concat(), + ["AI", "zaSy"].concat(), + ["-----BEGIN ", "PRIVATE KEY-----"].concat(), + ] +} + +fn verify_workflow_action_pins(root: &Path) -> Result<()> { + let workflows = root.join(".github/workflows"); + for entry in fs::read_dir(&workflows)? { + let path = entry?.path(); + if !matches!( + path.extension().and_then(OsStr::to_str), + Some("yml" | "yaml") + ) { + continue; + } + let source = fs::read_to_string(&path)?; + for (index, line) in source.lines().enumerate() { + let Some(reference) = line.trim_start().strip_prefix("- uses: ") else { + continue; + }; + if reference.starts_with("./") { + continue; + } + let (reference, version) = reference.split_once(" # ").ok_or_else(|| { + anyhow!( + "{}:{} action reference requires an exact-version comment", + path.display(), + index + 1 + ) + })?; + let (_, revision) = reference.rsplit_once('@').ok_or_else(|| { + anyhow!( + "{}:{} malformed action reference", + path.display(), + index + 1 + ) + })?; + if revision.len() != 40 || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) { + bail!( + "{}:{} action `{reference}` is not pinned to a full commit SHA", + path.display(), + index + 1 + ); + } + if version.trim().is_empty() { + bail!( + "{}:{} action `{reference}` has an empty version comment", + path.display(), + index + 1 + ); + } + } + } + Ok(()) +} + fn collect_files(directory: &Path, output: &mut Vec) -> Result<()> { let ignored = [ ".git", @@ -459,6 +523,7 @@ fn collect_files(directory: &Path, output: &mut Vec) -> Result<()> { "dist", ".runtime", ".agentctl", + ".release-evidence", ]; for entry in fs::read_dir(directory)? { let entry = entry?; @@ -497,7 +562,9 @@ fn verify_install(root: &Path) -> Result<()> { } else { "agentctl" }); - let output = Command::new(binary).arg("version").output()?; + let mut version = Command::new(binary); + version.arg("version"); + let output = bounded_output(version, "installed agentctl version")?; ensure_success(&output, "installed agentctl version") } @@ -559,17 +626,18 @@ fn run_with_env(root: &Path, program: &str, args: &[&str], vars: &[(&str, &str)] } fn run_binary(root: &Path, binary: &Path, args: &[&str], expected_code: Option) -> Result<()> { - let output = Command::new(binary).current_dir(root).args(args).output()?; + let mut command = Command::new(binary); + command.current_dir(root).args(args); + let output = bounded_output(command, "agentctl verification command")?; if output.status.code() == expected_code { Ok(()) } else { bail!( - "{} {} returned {:?}\nstdout: {}\nstderr: {}", + "{} {} returned {:?}\n{}", binary.display(), args.join(" "), output.status.code(), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) + output_diagnostics(&output) ) } } @@ -579,10 +647,9 @@ fn ensure_success(output: &Output, label: &str) -> Result<()> { Ok(()) } else { bail!( - "{label} failed with {:?}\nstdout: {}\nstderr: {}", + "{label} failed with {:?}\n{}", output.status.code(), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) + output_diagnostics(output) ) } } @@ -596,3 +663,18 @@ fn command_exists(name: &str) -> bool { }) }) } + +#[cfg(test)] +mod tests { + use super::forbidden_secret_patterns; + + #[test] + fn deterministic_secret_detector_recognizes_a_synthetic_credential() { + let fake = ["sk-", "proj-", "synthetic-not-a-real-credential"].concat(); + assert!( + forbidden_secret_patterns() + .iter() + .any(|pattern| fake.contains(pattern)) + ); + } +} diff --git a/xtask/src/process.rs b/xtask/src/process.rs new file mode 100644 index 0000000..26c6578 --- /dev/null +++ b/xtask/src/process.rs @@ -0,0 +1,273 @@ +use std::env; +use std::io::Read; +use std::process::{Child, Command, Output, Stdio}; +use std::sync::mpsc::{self, SyncSender}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail}; + +const STREAM_LIMIT_BYTES: usize = 4 * 1024 * 1024; +const COMBINED_LIMIT_BYTES: usize = 8 * 1024 * 1024; +const DIAGNOSTIC_BYTES: usize = 4 * 1024; +const COMMAND_TIMEOUT: Duration = Duration::from_secs(30 * 60); +const PIPE_CHUNK_BYTES: usize = 8 * 1024; + +enum Event { + Chunk(Stream, Vec), + Eof, + ReadError(Stream, String), +} + +#[derive(Clone, Copy)] +enum Stream { + Stdout, + Stderr, +} + +impl Stream { + const fn name(self) -> &'static str { + match self { + Self::Stdout => "stdout", + Self::Stderr => "stderr", + } + } +} + +pub(crate) fn bounded_output(mut command: Command, label: &str) -> Result { + configure_piped_command(&mut command); + let child = command.spawn().with_context(|| format!("spawn {label}"))?; + bounded_wait(child, label) +} + +pub(crate) fn output_diagnostics(output: &Output) -> String { + diagnostics(&output.stdout, &output.stderr) +} + +pub(crate) fn bounded_wait(mut child: Child, label: &str) -> Result { + let process_id = child.id(); + let stdout = child + .stdout + .take() + .with_context(|| format!("{label} stdout was not piped"))?; + let stderr = child + .stderr + .take() + .with_context(|| format!("{label} stderr was not piped"))?; + let (sender, receiver) = mpsc::sync_channel(8); + let stdout_thread = read_pipe(stdout, Stream::Stdout, sender.clone()); + let stderr_thread = read_pipe(stderr, Stream::Stderr, sender); + let started = Instant::now(); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut status = None; + let mut eof_count = 0; + + loop { + if status.is_none() { + status = child.try_wait().with_context(|| format!("poll {label}"))?; + } + if status.is_some() && eof_count == 2 { + join_readers(stdout_thread, stderr_thread, label)?; + return Ok(Output { + status: status.expect("status checked"), + stdout, + stderr, + }); + } + if started.elapsed() >= COMMAND_TIMEOUT { + terminate(&mut child, process_id); + drop(receiver); + join_readers(stdout_thread, stderr_thread, label)?; + bail!( + "{label} timed out after {} seconds", + COMMAND_TIMEOUT.as_secs() + ); + } + match receiver.recv_timeout(Duration::from_millis(25)) { + Ok(Event::Chunk(stream, chunk)) => { + if let Some((stream, limit)) = + append_bounded(stream, &chunk, &mut stdout, &mut stderr) + { + terminate(&mut child, process_id); + drop(receiver); + join_readers(stdout_thread, stderr_thread, label)?; + bail!( + "{label} exceeded the {limit}-byte {stream} capture limit\n{}", + diagnostics(&stdout, &stderr) + ); + } + } + Ok(Event::Eof) => eof_count += 1, + Ok(Event::ReadError(stream, message)) => { + terminate(&mut child, process_id); + drop(receiver); + join_readers(stdout_thread, stderr_thread, label)?; + bail!("failed to read {label} {}: {message}", stream.name()); + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => { + if eof_count == 2 { + thread::sleep(Duration::from_millis(10)); + continue; + } + terminate(&mut child, process_id); + drop(receiver); + join_readers(stdout_thread, stderr_thread, label)?; + bail!("{label} output readers stopped before both streams reached EOF"); + } + } + } +} + +fn append_bounded( + stream: Stream, + chunk: &[u8], + stdout: &mut Vec, + stderr: &mut Vec, +) -> Option<(&'static str, usize)> { + let stream_length = match stream { + Stream::Stdout => stdout.len(), + Stream::Stderr => stderr.len(), + }; + let combined_length = stdout.len() + stderr.len(); + let stream_remaining = STREAM_LIMIT_BYTES.saturating_sub(stream_length); + let combined_remaining = COMBINED_LIMIT_BYTES.saturating_sub(combined_length); + let retained = chunk.len().min(stream_remaining).min(combined_remaining); + match stream { + Stream::Stdout => stdout.extend_from_slice(&chunk[..retained]), + Stream::Stderr => stderr.extend_from_slice(&chunk[..retained]), + } + if retained == chunk.len() { + None + } else if combined_remaining <= stream_remaining { + Some(("combined output", COMBINED_LIMIT_BYTES)) + } else { + Some((stream.name(), STREAM_LIMIT_BYTES)) + } +} + +fn read_pipe(mut pipe: R, stream: Stream, sender: SyncSender) -> JoinHandle<()> +where + R: Read + Send + 'static, +{ + thread::spawn(move || { + let mut buffer = vec![0_u8; PIPE_CHUNK_BYTES]; + loop { + match pipe.read(&mut buffer) { + Ok(0) => { + let _ = sender.send(Event::Eof); + return; + } + Ok(length) => { + if sender + .send(Event::Chunk(stream, buffer[..length].to_vec())) + .is_err() + { + return; + } + } + Err(error) => { + let _ = sender.send(Event::ReadError(stream, error.to_string())); + return; + } + } + } + }) +} + +pub(crate) fn configure_piped_command(command: &mut Command) { + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt as _; + command.process_group(0); + } +} + +fn terminate(child: &mut Child, process_id: u32) { + terminate_process_tree(process_id); + if child.try_wait().ok().flatten().is_none() { + let _ = child.kill(); + let _ = child.wait(); + } +} + +#[cfg(unix)] +fn terminate_process_tree(process_id: u32) { + use nix::sys::signal::{Signal, killpg}; + use nix::unistd::Pid; + + if let Ok(process_id) = i32::try_from(process_id) { + let _ = killpg(Pid::from_raw(process_id), Signal::SIGKILL); + } +} + +#[cfg(windows)] +fn terminate_process_tree(process_id: u32) { + let _ = Command::new("taskkill.exe") + .args(["/PID", &process_id.to_string(), "/T", "/F"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); +} + +#[cfg(not(any(unix, windows)))] +fn terminate_process_tree(_process_id: u32) {} + +fn join_readers(stdout: JoinHandle<()>, stderr: JoinHandle<()>, label: &str) -> Result<()> { + stdout + .join() + .map_err(|_| anyhow::anyhow!("{label} stdout reader panicked"))?; + stderr + .join() + .map_err(|_| anyhow::anyhow!("{label} stderr reader panicked"))?; + Ok(()) +} + +fn diagnostics(stdout: &[u8], stderr: &[u8]) -> String { + if provider_secret_is_present() { + return "subprocess diagnostics omitted because provider credentials are present in the environment" + .to_owned(); + } + format!( + "stdout prefix: {}\nstderr prefix: {}", + String::from_utf8_lossy(&stdout[..stdout.len().min(DIAGNOSTIC_BYTES)]), + String::from_utf8_lossy(&stderr[..stderr.len().min(DIAGNOSTIC_BYTES)]) + ) +} + +fn provider_secret_is_present() -> bool { + [ + "OPENAI_API_KEY", + "AZURE_OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GOOGLE_API_KEY", + "GEMINI_API_KEY", + ] + .iter() + .any(|name| env::var_os(name).is_some()) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + #[test] + fn captures_normal_output() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "printf ok; printf warning >&2"]); + let output = bounded_output(command, "fixture").expect("output"); + assert!(output.status.success()); + assert_eq!(output.stdout, b"ok"); + assert_eq!(output.stderr, b"warning"); + } + + #[test] + fn rejects_unbounded_helper_output() { + let mut command = Command::new("/bin/sh"); + command.args(["-c", "while :; do printf 1234567890; done"]); + let error = bounded_output(command, "fixture").expect_err("limit"); + assert!(error.to_string().contains("stdout capture limit")); + } +} From 4afe4c07fbeb473dd563da81273df340d7f1c119 Mon Sep 17 00:00:00 2001 From: Ompragash Date: Wed, 22 Jul 2026 20:53:07 +0530 Subject: [PATCH 10/18] docs: define hosted RC validation handoff --- README.md | 3 +- docs/ARCHITECTURE.md | 2 +- docs/CONTAINER.md | 19 ++++++-- docs/DSL.md | 2 + docs/LIMITATIONS.md | 10 +++-- docs/RELEASE_PROCESS.md | 58 +++++++++++++++++++++++++ docs/SECURITY.md | 2 +- docs/TESTING.md | 5 ++- docs/execution/BLOCKERS.md | 2 +- docs/execution/DEFINITION_OF_DONE.md | 8 ++-- docs/execution/DRAFT_PR.md | 39 +++++++++++++++++ docs/execution/HOSTED_CI_PREPARATION.md | 37 ++++++++++++++++ docs/execution/INDEPENDENT_RC_REVIEW.md | 2 + docs/execution/STATUS.md | 10 +++-- docs/execution/VERIFICATION.md | 14 ++++-- 15 files changed, 188 insertions(+), 25 deletions(-) create mode 100644 docs/RELEASE_PROCESS.md create mode 100644 docs/execution/DRAFT_PR.md create mode 100644 docs/execution/HOSTED_CI_PREPARATION.md diff --git a/README.md b/README.md index c57c509..cb2206b 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Use `check` for strict syntax, references, templates, policy, and provider-capab - Files, processes, providers, MCP servers, and A2A peers require explicit policy grants. - Every non-pure operation is recorded before execution. A crash after an at-most-once effect starts is reported as uncertain and is never silently repeated. - Model turns, output tokens, tool calls, retries, and time are bounded. +- Shell stdout/stderr capture is bounded, concurrently drained, and terminated/reaped on output, timeout, or cancellation limits. - Check mode predicts deterministic actions; it does not claim to predict models or remote systems. - The process policy is an allowlist, not an operating-system sandbox. @@ -67,7 +68,7 @@ CI uses the scripted fake provider. Native, mock-tested adapters cover OpenAI Re - `crates/agentctl-cli`: production CLI - `xtask`: generated artifacts and canonical verification -Start with [Product](docs/PRODUCT.md), [Architecture](docs/ARCHITECTURE.md), [DSL](docs/DSL.md), [Operations](docs/OPERATIONS.md), [Container contract](docs/CONTAINER.md), [Limitations](docs/LIMITATIONS.md), [Security](docs/SECURITY.md), and the [generated CLI reference](docs/generated/CLI.md). Run the release-readiness layers with: +Start with [Product](docs/PRODUCT.md), [Architecture](docs/ARCHITECTURE.md), [DSL](docs/DSL.md), [Operations](docs/OPERATIONS.md), [Container contract](docs/CONTAINER.md), [Release process](docs/RELEASE_PROCESS.md), [Limitations](docs/LIMITATIONS.md), [Security](docs/SECURITY.md), and the [generated CLI reference](docs/generated/CLI.md). Run the release-readiness layers with: ```console cargo xtask verify diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6ff5e9c..8ec414f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -29,7 +29,7 @@ Clock and identifier generation are injected. Provider responses, tools, and ext ## Platform and packaging -The workspace uses Rust edition 2024, pins Rust 1.88 as the MSRV, forbids unsafe code, and denies clippy warnings. HTTP uses rustls and disables redirects. Subprocesses use direct argv, a cleared environment, explicit allowlists, timeout, and cancellation. SQLite is bundled for predictable installation and creates private files on Unix. SIGINT and SIGTERM converge on durable cancellation. +The workspace uses Rust edition 2024, pins Rust 1.88 as the MSRV, forbids unsafe code, and denies clippy warnings. HTTP uses rustls and disables redirects. Subprocesses use direct argv, a cleared environment, explicit allowlists, validated timeout/output limits, concurrent bounded pipe draining, cancellation, and kill/reap cleanup. SQLite is bundled for predictable installation and creates private files on Unix. SIGINT and SIGTERM converge on durable cancellation. The OCI build is multi-stage: only the optimized Rust binary enters a maintained distroless runtime with CA roots and a non-root identity. `/config` is workflow configuration, `/workspace` is the read-only working tree, `/state` holds SQLite, and `/artifacts` receives declared outputs. State must be mounted again for inspect/resume/replay. The root filesystem may be read-only. See [Container contract](CONTAINER.md) and ADR 0007. diff --git a/docs/CONTAINER.md b/docs/CONTAINER.md index 7a690dd..eaa4130 100644 --- a/docs/CONTAINER.md +++ b/docs/CONTAINER.md @@ -2,6 +2,19 @@ The repository `Containerfile` builds the Rust CLI in a pinned Rust 1.88 builder and copies only the optimized binary into a maintained distroless Debian runtime. The runtime has CA roots, version/source/license OCI labels, runs as `nonroot`, has a deterministic `agentctl` entrypoint, and contains no Node.js runtime, TypeScript source, credentials, workflows, or fixtures. +## Optional build-network CA + +The default build uses the builder's public CA roots. Networks that intercept TLS may supply a reviewed public CA certificate or bundle through a build secret: + +```console +docker build --secret id=agentctl_ca,src=/protected/path/build-ca.pem \ + --tag agentctl:local --file Containerfile . +``` + +For the repository acceptance wrapper, set `AGENTCTL_BUILD_CA_FILE=/protected/path/build-ca.pem` before `cargo xtask acceptance-container`. Hosted CI accepts the protected secret `AGENTCTL_BUILD_CA_PEM`, materializes it only in the runner's temporary directory, and removes it after the build. + +The `Containerfile` combines the secret with public roots on a tmpfs mount for the single Cargo build step. The CA value is not a build argument, image environment value, build-context file, layer, history value, runtime file, or artifact. Never use `--insecure`, `CARGO_HTTP_CHECK_REVOKE=false`, a TLS-verification disable flag, or a committed certificate. + ## Mounts and inputs | Path | Contract | @@ -44,7 +57,7 @@ jobs: agentctl: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: mkdir -p .agentctl-state artifacts && chmod 0777 .agentctl-state artifacts - name: Run agentctl image env: @@ -62,7 +75,7 @@ jobs: - name: Make mounted outputs collectable if: always() run: sudo chown -R "$(id -u):$(id -g)" .agentctl-state artifacts - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: name: agentctl-state-and-artifacts @@ -249,4 +262,4 @@ For a one-time invocation, use the same Pod template in a `batch/v1` `Job` and o ## Validation level -The native-arm image was executed with Podman as non-root with a read-only root. The final audit exercised a mock tool workflow, artifact and durable inspection, missing-secret and invalid-workflow exit propagation, SIGTERM, and recorded replay under `--network none`. The exact retained GPT-5.6 live database also replayed with no credential and `--network none`, identical declared output, an unchanged artifact digest, zero fresh effects/tool calls/provider sessions, and explicit source-effect audit links. Trivy 0.70.0 found no HIGH/CRITICAL findings, both with and without `--ignore-unfixed`, and generated a CycloneDX JSON SBOM in the ignored verification area. GitHub, GitLab, Jenkins, Harness, and Kubernetes examples were documentation-reviewed but not dispatched to those external platforms. The configured Ubuntu CI container job is the Linux amd64 execution, scan, and SBOM gate when that workflow runs. +The current native-arm image was built through the optional secret-mounted CA path and executed with Podman as non-root with a read-only root. The suite exercised a mock tool workflow, artifact and durable inspection, missing-secret and invalid-workflow exit propagation, SIGTERM, and recorded replay under `--network none`. Checksum-verified Trivy 0.72.0 found zero fixed HIGH/CRITICAL findings and generated valid CycloneDX JSON. The exact retained GPT-5.6 live database had previously replayed with no credential and no network, identical output and artifact digest, zero fresh effects/tool calls/provider sessions, and explicit source-effect audit links. GitHub, GitLab, Jenkins, Harness, and Kubernetes examples remain documentation-reviewed only; the automatic Ubuntu Linux x64 build, scan, and SBOM job is locally linted but has not been dispatched. diff --git a/docs/DSL.md b/docs/DSL.md index d967165..921727b 100644 --- a/docs/DSL.md +++ b/docs/DSL.md @@ -10,6 +10,8 @@ Providers, action environments, and protocol headers use `{ env: NAME }` secret The compiler validates missing references, duplicate tasks, cycles, task-aware templates, tool references, provider capabilities, agent limits, and sequential runtime settings before execution. Ready tasks follow declaration order. `maxConcurrency` must be `1` in this version. +`builtin.shell.exec` captures stdout and stderr concurrently. Its optional `stdoutLimitBytes`, `stderrLimitBytes`, and `combinedOutputLimitBytes` fields default to 1 MiB, 1 MiB, and 2 MiB respectively. Each configured value must be between 1 byte and 16 MiB. `timeoutSeconds` must be between 1 and 86,400. Exceeding an output bound terminates and reaps the process and records a structured failed effect; timeout or cancellation remains an uncertain effect because external changes may already have occurred. These fields are validated identically for workflow and pack actions. + The parser translates a limited unversioned `playbook:` document and emits a migration warning. Use `agentctl migrate old.yaml --write new.yaml`. Legacy pack-backed, MCP, A2A, provider-specific, and broad module configurations need manual migration; see [Migrating from TypeScript](MIGRATING_FROM_TYPESCRIPT.md). Not implemented in v1alpha1: `foreach`, matrix expansion, parallel groups, routers, loops, sub-workflows, `finally`, handlers, event triggers, or compensation execution. They remain excluded until their deterministic state, merge, and recovery semantics are specified. diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md index 13deaa3..7756430 100644 --- a/docs/LIMITATIONS.md +++ b/docs/LIMITATIONS.md @@ -4,7 +4,7 @@ This classification is part of the product contract. A deferred feature is not a ## Release blockers -No known P0/P1 implementation defect remains for the stated local, scheduled, and OCI journeys after the independent review. The exact final live OpenAI database passed another credential-free replay with identical output, zero fresh effects/tool calls/provider sessions, and explicit source-effect provenance. Hosted cross-platform CI has never run for the Rust branch, and the current default image build was blocked by this host's container CA before a current Trivy/SBOM run. These are release-candidate evidence blockers, so the current recommendation is internal review, not `v1alpha1` RC or stable v1.0. +No known P0/P1 implementation defect remains for the stated local, scheduled, and OCI journeys. The local container build now has a secure optional CA secret path, and the current image passed OCI acceptance, Trivy 0.72.0, and CycloneDX validation. The remaining RC gate is external evidence: the new Linux x64, macOS arm64, Windows x64, container, security, package, and SBOM workflows are configured and locally linted but have not been pushed or dispatched. The recommendation is **Ready for hosted RC validation**, not an already validated RC or stable v1.0. ## Required hardening completed for this release @@ -14,6 +14,8 @@ No known P0/P1 implementation defect remains for the stated local, scheduled, an - Timeout/transport ambiguity is not automatically retried; confirmed effects survive resume; call IDs are scoped by run; missing credentials fail before run/database creation. - Non-interactive approvals durably pause, signals cancel safely, JSON errors include available run/trace correlation, and SQLite uses WAL plus a bounded lock wait. - The packaged CLI, clean-directory quickstart, cron-like empty environment, and non-root/read-only OCI contract have executable acceptance coverage. +- Shell execution and acceptance/container helpers use bounded concurrent capture. Output overflow terminates/reaps the child with a structured secret-safe error; timeouts and cancellation retain durable uncertain-effect semantics. +- Hosted workflows use least privilege, full-SHA action pins with version comments, complete-history/tree Gitleaks, deterministic fake-secret detection, dependency/image scans, and required production/image CycloneDX artifacts with digests. ## Post-v1 features @@ -23,7 +25,7 @@ These are useful extensions but are not required by the product thesis. They nee - structured agent teams and handoffs; - model token streaming into CLI/workflow state; - opt-in MCP reconnection and A2A resubmission with explicit remote reconciliation; -- pack dependency resolution, pack lockfiles, remote fetching, publisher signatures, process-backed pack tools, and a versioned plugin ABI; +- pack dependency resolution, pack lockfiles, remote fetching, publisher signatures, and a versioned plugin ABI; - vector memory; - encrypted application-level persistence and external secret-manager adapters; - reliable monetary cost enforcement when providers expose sufficient authoritative metadata. @@ -44,5 +46,5 @@ These are useful extensions but are not required by the product thesis. They nee - At-most-once model/remote calls can become uncertain in the dispatch/acknowledgement window. Inspect and reconcile externally; use `fork` only when fresh effects are knowingly acceptable. - Tool-using OpenAI/Azure agents require stored-response continuation. `store: false` is rejected until stateless response-item replay is implemented. - Anthropic, Google, Azure OpenAI, MCP, and A2A are native and mock-tested in this release, not live-tested. Only the OpenAI GPT-5.6 tool path has live end-to-end evidence. -- The current local OCI runtime evidence is Linux arm64. Linux amd64 is configured in the unpushed Ubuntu CI workflow but has not executed. -- The earlier native arm64 image scan reported no HIGH/CRITICAL findings and produced a CycloneDX SBOM. The current source changes have no fresh completed image-build/scan/SBOM record because this host's container CA blocked dependency retrieval. +- The current local OCI runtime, vulnerability-scan, and SBOM evidence is Linux arm64. Linux x64 is configured in the unpushed Ubuntu workflow but has not executed. +- GitHub runner availability, organization action policy, branch protection, and required-check configuration are repository-owner operations and cannot be proven by repository-local lint. diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md new file mode 100644 index 0000000..9ceb480 --- /dev/null +++ b/docs/RELEASE_PROCESS.md @@ -0,0 +1,58 @@ +# Release process + +This process applies to the `agentctl.dev/v1alpha1` release candidate. A release is not approved from local evidence alone. + +## Required hosted checks + +Push the review branch and open a pull request only after the local gates below pass. Configure branch protection to require these checks: + +- `credential-free-ci / gates (x86_64-unknown-linux-gnu)` +- `credential-free-ci / gates (aarch64-apple-darwin)` +- `credential-free-ci / gates (x86_64-pc-windows-msvc)` +- `credential-free-ci / production SBOM` +- `container-security / container` +- `supply-chain-security / security` + +The three platform jobs run `cargo xtask verify`, `cargo xtask acceptance`, and `cargo xtask package`. The other jobs enforce the Linux container contract, HIGH/CRITICAL image vulnerability policy, production and image CycloneDX SBOMs, complete-history and checked-out-tree secret scans, dependency policy, immutable action pins, and workflow lint. + +The repository owner must enable GitHub Actions and required checks after the workflows reach the remote. This repository-local change does not modify remote settings or claim a hosted run. + +## Local preflight + +Run without provider credentials: + +```console +env -u OPENAI_API_KEY -u AZURE_OPENAI_API_KEY -u ANTHROPIC_API_KEY \ + -u GOOGLE_API_KEY -u GEMINI_API_KEY cargo xtask verify +env -u OPENAI_API_KEY -u AZURE_OPENAI_API_KEY -u ANTHROPIC_API_KEY \ + -u GOOGLE_API_KEY -u GEMINI_API_KEY cargo xtask acceptance +cargo xtask package +``` + +Run `cargo xtask acceptance-container` when Docker or Podman is available. If the builder requires an enterprise CA, provide a protected PEM file through `AGENTCTL_BUILD_CA_FILE`; see [Container](CONTAINER.md). Never disable TLS verification. + +Run checksum-verified actionlint against `.github/workflows`, then run Gitleaks against both `git log --all` and the checked-out tree. `cargo xtask secret-scan` retains the deterministic repository scan and verifies every action reference is a full 40-character commit SHA with an exact-version comment. + +## Hosted artifact verification + +For the candidate workflow run: + +1. Confirm every required check is green and was executed for the candidate commit. +2. Confirm the three `agentctl-` package artifacts exist. Extract each artifact and verify its binary against its packaged `SHA256SUMS`. +3. Confirm `agentctl-production-sbom-cyclonedx` exists, parses as CycloneDX JSON, and its file SHA-256 matches the job summary. +4. Confirm `agentctl-image-sbom-cyclonedx` exists and parses as CycloneDX JSON. +5. Record the GitHub artifact digests emitted by `actions/upload-artifact` and the local image digest emitted by the container job. +6. Confirm no workflow artifact path includes `.release-evidence`, a database, provider credential, or live-response evidence. +7. Manually dispatch `rc-release-preparation` for the exact candidate commit and verify all three RC packages before creating a tag. + +## Failure handling + +- Platform failure: reproduce on the named OS/architecture; do not waive a matrix leg. +- Secret-scan finding: stop, revoke any real credential, remove it from the complete history using the repository's incident procedure, then rerun both history and tree scans. +- Dependency or image finding: review the advisory and remediate or document an explicit time-bounded exception before release. The default HIGH/CRITICAL image gate ignores only unfixed findings. +- SBOM failure or missing artifact: treat as a release failure. SBOM generation is not best-effort. +- Container CA failure: configure only `AGENTCTL_BUILD_CA_PEM` as a protected repository/organization secret. Do not use insecure Cargo, Git, curl, or container flags. + +## Release decision + +The local recommendation is **Ready for hosted RC validation**. Promote to an RC only after the exact remote commit has all required hosted checks and artifacts. Stable `v1.0` remains outside this `v1alpha1` gate. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index c48a2de..606ddf3 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -5,7 +5,7 @@ - Workflow parsing is strict, bounded to 1 MiB, source-aware, and has no executable expression language. - Environment-backed primary credentials are resolved immediately before provider dispatch; custom header references are resolved while constructing the adapter, before a run or database is created. There are no API-key flags. Provider/protocol response JSON keys and values, provider request IDs, errors, subprocess output, and traces redact every known configured secret value before persistence or output. - Canonical read/write roots reject `..` and symlink escape. Writes use temporary files and rename. -- Processes require an allowed executable basename, direct argv, cleared environment, selected variables, timeout, and cancellation. +- Processes require an allowed executable basename, direct argv, cleared environment, selected variables, validated output/timeout bounds, concurrent stdout/stderr draining, and cancellation. Output-limit, timeout, and cancellation paths terminate and reap the child; diagnostics are bounded and omit captured output when secret environment values are present. - Network destinations require an exact/wildcard host grant. Provider and protocol clients disable redirects and use rustls. - Tool input and output JSON Schemas are enforced. Models, MCP annotations, A2A cards, remote schemas, and results cannot grant capabilities. - Requests are ledgered before effects. Global denial or approval cannot be weakened by a tool contract. Approval is durable; non-interactive mode pauses with exit `3` or uses an explicitly stricter deny/fail mode, never a prompt or implicit approval. diff --git a/docs/TESTING.md b/docs/TESTING.md index c2f60da..766fc71 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -13,9 +13,10 @@ cargo xtask acceptance cargo xtask acceptance-container cargo xtask acceptance-live-openai # explicit credentialed gate only cargo xtask package +cargo xtask secret-scan ``` -It checks rustfmt; clippy with all targets/features and warnings denied; locked build; unit, integration, compatibility, provider, protocol, persistence, runtime, and security tests; rustdoc; generated schema/CLI consistency; all workflow validation and deterministic examples; negative capability/policy/no-mutation cases; dependency sources/licenses/advisories; a repository secret-pattern scan; `cargo install`; and the Rust-only production boundary. +It checks rustfmt; clippy with all targets/features and warnings denied; locked build; unit, integration, compatibility, provider, protocol, persistence, runtime, and security tests; rustdoc; generated schema/CLI consistency; all workflow validation and deterministic examples; negative capability/policy/no-mutation cases; dependency sources/licenses/advisories; repository secret patterns and immutable workflow action pins; `cargo install`; and the Rust-only production boundary. Unit tests cover parser diagnostics, strictness, compiler order/cycles/capabilities, templates, tool schemas, policy traversal/network/redaction, state transitions, effect recovery, store migration/corruption/checkpoints, runtime dataflow/check/diff/approval/cancellation/replay/fork, provider mappings, protocols, and traces. `proptest` exercises arbitrary templates and typed preservation. Language-neutral fixtures in `fixtures/compat` preserve the TypeScript oracle’s external graph/dataflow contract. @@ -26,6 +27,6 @@ cargo install cargo-fuzz cargo fuzz run workflow_yaml -- -max_total_time=60 ``` -The local CI configuration would run the canonical suite on Linux, macOS, and Windows, stable and Rust 1.88, plus credential-free acceptance, a Linux amd64 container gate, and strict supply-chain checks. It has not yet been pushed or dispatched, so it is configured evidence rather than validated platform support. Provider/protocol conformance uses local mock HTTP servers. Normal examples are deterministic; MCP/A2A runtime behavior is covered by mocks rather than requiring a background service. +The local hosted-CI configuration runs the canonical suite, credential-free acceptance, and packaging on Rust 1.88 for Linux x64, macOS arm64, and Windows x64. Separate automatic jobs cover the Linux x64 container, current vulnerability scan, two CycloneDX SBOM artifacts, complete-history/tree secret scans, dependency policy, and workflow lint. The workflows are locally linted but have not been pushed or dispatched, so this is configured evidence rather than validated hosted-platform support. Provider/protocol conformance uses local mock HTTP servers. Normal examples are deterministic; MCP/A2A runtime behavior is covered by mocks rather than requiring a background service. The only full live gate is the separately invoked OpenAI acceptance described in [Providers](PROVIDERS.md). It performs two bounded Responses API requests locally and two in the OCI image for one tool-call/continuation journey each, then performs keyless replays. Never run it for debugging loops, fuzzing, load, or normal CI. diff --git a/docs/execution/BLOCKERS.md b/docs/execution/BLOCKERS.md index 3e4e9df..4a3f01a 100644 --- a/docs/execution/BLOCKERS.md +++ b/docs/execution/BLOCKERS.md @@ -1,5 +1,5 @@ # Blockers -There are no known P0/P1 implementation defects as of 2026-07-22. Release-candidate evidence is still blocked on an actual hosted Linux amd64/macOS/Windows CI run and a green committed image build with current Trivy/SBOM outputs. The exact retained live OpenAI state passed another credential-free replay with the current packaged CLI, and a current-source Linux arm64 binary passed the OCI runtime cases, but those results do not replace hosted evidence. Status is **ready for internal review**. +There are no known P0/P1 implementation defects as of 2026-07-22. The secure local image build, OCI acceptance, current Trivy scan, CycloneDX validation, actionlint, full-history/tree Gitleaks, deterministic secret scan, and credential-free Rust gates pass. Release-candidate evidence still requires an actual hosted Linux x64/macOS arm64/Windows x64 run and hosted package/SBOM artifact digests for the exact candidate commit. Status is **Ready for hosted RC validation**. Only blockers that prevent safe progress under the mission's definition are recorded here. Missing non-OpenAI live credentials will not be treated as blockers for native implementations with deterministic mock coverage. diff --git a/docs/execution/DEFINITION_OF_DONE.md b/docs/execution/DEFINITION_OF_DONE.md index eb87ac0..cb63b2b 100644 --- a/docs/execution/DEFINITION_OF_DONE.md +++ b/docs/execution/DEFINITION_OF_DONE.md @@ -14,9 +14,9 @@ Status values distinguish **deterministically tested**, **mock-provider tested** | Resume/reject/uncertainty/fork/retry/auth/rate-limit/malformed/cancellation semantics | deterministically tested | focused provider/runtime/store tests and acceptance scenarios | | Non-interactive approvals, cron, inputs, timeout, SIGTERM | operationally tested | empty-environment and signal acceptance; operations guide | | OCI non-root/read-only/mount/JSON/artifact/state contract | operationally tested | current-source Linux arm64 binary passed mock/failure/signal cases; earlier exact live-state replay ran as UID/GID 65532 | -| Image high/critical scan and SBOM | historical arm64 evidence only | earlier Trivy result and CycloneDX artifact recorded in verification ledger; current rebuild/scan pending | -| Linux amd64 image and external CI/vendor pipelines | syntax/configuration validated only | GitHub job and pipeline examples; not remotely dispatched here | +| Image high/critical scan and SBOM | locally operationally tested; hosted configured | current secret-CA build and OCI suite; checksum-verified Trivy 0.72.0 zero fixed HIGH/CRITICAL; valid CycloneDX JSON; hosted artifacts not dispatched | +| Linux x64, hosted macOS arm64, hosted Windows x64 | workflow syntax/lint validated only | automatic full gates and packages configured with standard GitHub runner labels; not remotely dispatched | | Anthropic/Google/Azure adapters; MCP/A2A | mock-provider/protocol tested | native mapping/protocol tests; not live-tested | -| Advisories/licenses/sources/secrets | deterministically tested | cargo-deny, metadata, source, and secret gates | +| Advisories/licenses/sources/secrets/actions | deterministically and locally security-tested | cargo-deny; metadata/source; deterministic scan; Gitleaks complete history/tree and synthetic detection; full-SHA action-pin check; actionlint | | Parallel/dynamic orchestration, pack ecosystem, vector/encrypted/distributed additions | deferred or non-goal | `docs/LIMITATIONS.md`, ADR 0005/0006/0007 | -| No known P0/P1 correctness/security defect in implemented boundary | verified for internal review | independent RC review and regressions; hosted CI and current image build/scan remain RC evidence blockers | +| No known P0/P1 correctness/security defect in implemented boundary | Ready for hosted RC validation | independent RC review, bounded-process regressions, credential-free gates, current local image security evidence; hosted checks/artifacts remain external evidence | diff --git a/docs/execution/DRAFT_PR.md b/docs/execution/DRAFT_PR.md new file mode 100644 index 0000000..2ff5304 --- /dev/null +++ b/docs/execution/DRAFT_PR.md @@ -0,0 +1,39 @@ +# Draft pull request + +## Title + +Harden bounded process execution and enable hosted `v1alpha1` RC gates + +## Summary + +- bound shell-action stdout, stderr, and combined capture with validated workflow/pack limits, concurrent draining, termination/reaping, secret-safe diagnostics, and durable timeout/cancellation semantics; +- bound repository acceptance/container command capture and preserve parseable JSON behavior; +- add automatic Linux x64, macOS arm64, Windows x64, container, dependency, complete-history/tree secret, workflow-lint, package, vulnerability, and SBOM gates; +- pin every external action to a reviewed full commit SHA with an exact-version comment; +- add an optional build-only CA secret mount without disabling TLS or retaining the CA in image layers/history; +- document the hosted handoff, required checks, artifacts, digests, and release procedure. + +## Local verification + +- `cargo xtask verify` +- `cargo xtask acceptance` +- `cargo xtask package` +- `AGENTCTL_BUILD_CA_FILE= cargo xtask acceptance-container` +- actionlint 1.7.12: passed +- Gitleaks 8.30.1 complete history and current tracked tree: no findings; synthetic credential: detected +- Trivy 0.72.0: zero fixed HIGH/CRITICAL findings; CycloneDX image SBOM validated + +All credential-free gates were run with provider credential variables removed. No live provider call was made. `.release-evidence` was not read, modified, staged, scanned as tree content, or uploaded. + +## Hosted validation required + +This PR configures hosted validation but does not claim it has run. Before RC promotion, require the three platform gates, production SBOM, container, and supply-chain checks; verify every uploaded package/SBOM digest; then manually dispatch `rc-release-preparation` for the exact candidate commit. + +## Risk and review focus + +- subprocess termination and simultaneous stdout/stderr pressure; +- failed-versus-uncertain durable effect classification; +- Windows compilation and acceptance behavior; +- action/Syft/Trivy/Gitleaks version and SHA review; +- optional CA cleanup and absence from build history/artifacts; +- no accidental `.release-evidence` artifact inclusion. diff --git a/docs/execution/HOSTED_CI_PREPARATION.md b/docs/execution/HOSTED_CI_PREPARATION.md new file mode 100644 index 0000000..eff39ff --- /dev/null +++ b/docs/execution/HOSTED_CI_PREPARATION.md @@ -0,0 +1,37 @@ +# Hosted CI preparation + +Prepared: 2026-07-22, Asia/Kolkata. + +Status: **workflow syntax/lint validated; hosted dispatch pending**. The files are configured locally and have not been pushed or dispatched. + +## Workflow inventory + +| Workflow | Trigger | Hosted purpose | +| --- | --- | --- | +| `credential-free-ci` | push, pull request, manual | Rust 1.88 full verification, acceptance, and packaging on Linux x64, macOS arm64, and Windows x64; production CycloneDX SBOM | +| `container-security` | push, pull request, manual | Linux x64 OCI build/runtime acceptance, Trivy 0.72 vulnerability gate, image CycloneDX SBOM | +| `supply-chain-security` | push, pull request, manual | full-history/tree Gitleaks 8.30.1 scans, synthetic detection proof, cargo-deny 0.20.2, deterministic scan, actionlint 1.7.12 | +| `rc-release-preparation` | manual | exact-commit three-platform RC verification, acceptance, packaging, and artifact digests | + +The selected standard runner labels are `ubuntu-24.04` (x64), `macos-14` (arm64), and `windows-2022` (x64). Every external action is pinned to a full commit SHA with a nearby exact-version comment. Workflows grant only `contents: read`. + +## Repository-owner preparation + +1. Push the branch and open a review PR. +2. Enable GitHub Actions if repository or organization policy currently disables them. +3. Allow the pinned GitHub, Anchore, and Aqua actions, or approve their exact SHAs under the organization action policy. +4. Require the checks listed in [Release process](../RELEASE_PROCESS.md) on the protected release branch. +5. Optionally define protected secret `AGENTCTL_BUILD_CA_PEM` only when the hosted build network uses a private CA. Do not configure provider API keys for these workflows. +6. Retain artifacts for at least the configured 14 days and record workflow URLs/digests in the RC evidence. + +The optional CA is written to a mode-restricted runner temporary file, mounted into the builder as `agentctl_ca`, combined with public roots only on tmpfs, and removed in an `always()` cleanup step. It is not a Dockerfile argument, image environment variable, ordinary build context file, or uploaded artifact. + +## Local validation already completed + +- actionlint 1.7.12, downloaded with its upstream SHA-256, reported no workflow errors; +- the deterministic action-pin scanner accepted every `uses:` reference; +- Gitleaks 8.30.1 complete-history and tracked-tree scans found no leaks, and a generated synthetic credential was rejected; +- the secure CA secret-mount build completed locally through Podman, followed by the full non-root/read-only OCI acceptance suite; +- checksum-verified Trivy 0.72.0 found zero fixed HIGH/CRITICAL findings in the current image and generated valid CycloneDX JSON. + +These are local results. No GitHub workflow run, Linux x64 package, hosted macOS result, hosted Windows result, hosted artifact digest, or required-check result is claimed yet. diff --git a/docs/execution/INDEPENDENT_RC_REVIEW.md b/docs/execution/INDEPENDENT_RC_REVIEW.md index 8f3de9b..782ee23 100644 --- a/docs/execution/INDEPENDENT_RC_REVIEW.md +++ b/docs/execution/INDEPENDENT_RC_REVIEW.md @@ -1,5 +1,7 @@ # Independent release-candidate review +> Point-in-time review snapshot. Its residual-risk and recommendation sections describe the reviewed baseline before the final bounded-process and hosted-CI hardening. Current status: [HOSTED_CI_PREPARATION.md](HOSTED_CI_PREPARATION.md) and [STATUS.md](STATUS.md). + Review date: 2026-07-22 (Asia/Kolkata) Recommendation: **Ready for internal review**. diff --git a/docs/execution/STATUS.md b/docs/execution/STATUS.md index f40bdb2..f63c4a5 100644 --- a/docs/execution/STATUS.md +++ b/docs/execution/STATUS.md @@ -4,9 +4,9 @@ Last updated: 2026-07-22 ## Current phase -ready for internal review +Ready for hosted RC validation -The independent release-candidate review found and remediated one P0, five P1s, and scoped journey P2s. Local deterministic verification, public CLI acceptance, packaging, current-source Linux arm64 compilation, OCI runtime cases, and keyless replay of retained live state pass. The recommendation is not yet release-candidate status because the Rust CI/release workflows have never run on GitHub and the default current-source image build was blocked by this host's container certificate trust. +The independent release-candidate review found and remediated one P0, five P1s, and scoped journey P2s. The final local hardening adds bounded subprocess capture, durable limit/cancellation regressions, full-SHA hosted workflows, complete-history/tree secret scanning, production/image SBOM gates, and a secure optional build CA path. Local deterministic verification, public CLI acceptance, packaging, current-source OCI acceptance, current Trivy/SBOM validation, and keyless replay evidence pass. The exact recommendation is **Ready for hosted RC validation** because the workflows have not yet run on GitHub. ## Accepted evidence @@ -17,6 +17,8 @@ The independent release-candidate review found and remediated one P0, five P1s, - Confirmed effects survive resume; fork is distinct and fresh; timeout/transport uncertainty blocks unsafe repetition. - Clean copied/source-installed/package layouts, empty-environment cron invocation, concurrency, SIGTERM, approvals, machine output, and recovery paths passed. - A current-source Linux arm64 binary built offline and passed mock-tool, failure-exit, SIGTERM, and offline-replay cases in the production distroless image as non-root with a read-only root and mounted durable state/artifacts. +- The current image built through a secret-mounted CA/tmpfs trust path, passed the full OCI suite, had zero fixed HIGH/CRITICAL findings under checksum-verified Trivy 0.72.0, and produced valid CycloneDX JSON. +- actionlint 1.7.12 accepted every workflow; Gitleaks 8.30.1 found no complete-history or tracked-tree leaks and rejected the generated synthetic credential. ## Product boundary @@ -24,11 +26,11 @@ The independent release-candidate review found and remediated one P0, five P1s, ## External evidence not claimed -The local environment executed macOS arm64 packaging and Linux arm64 OCI runtime tests. The committed default OCI build did not complete because the container trust store rejected Rust/crates.io certificates. The configured GitHub Linux amd64, macOS, Windows, Trivy, and SBOM jobs do not exist on the remote default branch and were not dispatched. Anthropic, Google, Azure OpenAI, MCP, and A2A remain native mock-tested rather than live-tested. +The local environment executed macOS arm64 packaging and Linux arm64 OCI runtime/security tests. The configured GitHub Linux x64, macOS arm64, Windows x64, Trivy, Gitleaks, package, and SBOM jobs do not exist on the remote default branch and were not dispatched. No hosted artifact digest or branch-protection result is claimed. Anthropic, Google, Azure OpenAI, MCP, and A2A remain native mock-tested rather than live-tested. ## Release-candidate blockers -No known P0/P1 implementation defect remains for the stated boundary. Hosted cross-platform CI and a green committed image-build/scan/SBOM record are still missing. See [INDEPENDENT_RC_REVIEW.md](INDEPENDENT_RC_REVIEW.md), [BLOCKERS.md](BLOCKERS.md), and [LIVE_OPENAI_REPLAY_EVIDENCE.md](LIVE_OPENAI_REPLAY_EVIDENCE.md). +No known P0/P1 implementation defect remains for the stated boundary. The remaining gate is hosted execution and artifact evidence for the exact candidate commit. See [HOSTED_CI_PREPARATION.md](HOSTED_CI_PREPARATION.md), [BLOCKERS.md](BLOCKERS.md), and [Release process](../RELEASE_PROCESS.md). ## Exact commands diff --git a/docs/execution/VERIFICATION.md b/docs/execution/VERIFICATION.md index a24c8d6..7e62e33 100644 --- a/docs/execution/VERIFICATION.md +++ b/docs/execution/VERIFICATION.md @@ -4,6 +4,12 @@ Date: 2026-07-22, Asia/Kolkata. Secret values were never printed, passed as argu The independent final audit and the completed exact live-state replay are authoritative in [RELEASE_AUDIT.md](RELEASE_AUDIT.md) and [LIVE_OPENAI_REPLAY_EVIDENCE.md](LIVE_OPENAI_REPLAY_EVIDENCE.md). +## Final hosted-CI and bounded-process hardening + +The final repository-local gate replaced unbounded subprocess `output()`/`wait_with_output()` capture in the runtime and verification/container helpers. Shell actions now drain stdout/stderr concurrently under independent and combined byte ceilings, isolate Unix process groups, terminate Windows process trees, kill/reap on output/timeout/cancellation paths, record structured output-limit failures, omit diagnostic output when secret environment values are present, and preserve uncertain-effect recovery for timeout/cancellation. Tests cover stdout, stderr, combined/interleaved output, normal parseable JSON, timeout, cancellation, direct-child and descendant termination, secret-safe durable errors, pack actions, and invalid configuration. + +Hosted workflows now target Linux x64, macOS arm64, and Windows x64; build the Linux container; run current dependency, secret, and vulnerability tools; and retain package plus production/image CycloneDX artifacts with digests. All actions use full commit SHAs with exact-version comments. Checksum-verified actionlint 1.7.12 accepted the workflows locally. Gitleaks 8.30.1 found no leak in all 21 commits or the current tracked tree and rejected the synthetic fixture. These workflows remain undispatched. + ## Independent audit corrections The reopened audit found that the earlier provider-only smoke did not substantiate runtime production readiness. It also found concrete implementation gaps: packaged YAML tools were not registered; declared outputs could not read workflow inputs; traces and provider/tool continuation evidence were not durable/publicly inspectable; non-interactive approvals did not durably pause; SIGTERM and in-flight provider cancellation were misclassified; resume/fork lost the original workspace; missing credentials created partial database state; provider options could be ignored; ambiguous transport failures could be retried; function-call IDs were treated as globally unique; and the repository had no user-journey, cron, or OCI acceptance layer. @@ -14,9 +20,9 @@ All release-blocking gaps above were fixed and covered by focused regression or | Command | Result | | --- | --- | -| `cargo xtask verify` | passed all 12 gates; 66 unit/integration/compatibility tests, doc tests, six fuzz-target builds, denied-warning clippy, generated artifacts, examples, source install, supply-chain/secret/Rust-only boundaries | +| `cargo xtask verify` | passed all 12 gates; 94 unit/integration/compatibility tests, doc tests, six fuzz-target builds, denied-warning clippy, generated artifacts, examples, source install, supply-chain/secret/action-pin/Rust-only boundaries | | `cargo xtask acceptance` | passed 25 credential-free public-binary scenarios covering the required deterministic/mock/tool/schema/policy/approval/resume/replay/fork/timeout/retry/auth/output/input/artifact/concurrency/SIGTERM/package-style/cron/quickstart journeys | -| `cargo xtask acceptance-container` | passed on Linux arm64 through Podman: non-root UID/GID, read-only root, mounted config/workspace/state/artifacts, strict tool continuation, parseable JSON, public inspect, expected artifact | +| `cargo xtask acceptance-container` | passed on Linux arm64 through Podman using the optional CA secret mount: non-root UID/GID, read-only root, mounted config/workspace/state/artifacts, strict tool continuation, parseable JSON, inspect/replay/failure/signal cases | | Manual final live/replay gate | packaged macOS arm64 GPT-5.6 tool workflow passed; its exact retained state replayed in the production Linux arm64 image with no credential, `--network none`, identical output/artifact digest, and zero fresh effects/tool calls | | `cargo xtask package` | passed; optimized binary, Bash/Zsh/Fish/PowerShell completions, README, license, and SHA-256 manifest at `dist/agentctl-0.2.0-aarch64-apple-darwin` | @@ -38,7 +44,7 @@ Official feature/pricing references used for the audit: [GPT-5.6 model catalog]( - An empty-environment, non-TTY cron-equivalent run passed with stable JSON and explicit paths. Approval pause, overall timeout, SIGTERM exit `130`, concurrent SQLite use, and recovery paths passed. - Mock tests cover redacted non-retryable authentication errors, explicit 429 retryability, malformed success responses, provider cancellation, tool timeout/cancellation, invalid UTF-8, the 1 MiB workspace-read bound, read-only artifact failure, traversal/symlink escape, database lock/corruption, and protocol malformed/version/origin/timeout cases. - The OCI image inspection reported `linux arm64`, `nonroot:nonroot`, and version label `0.2.0`. The acceptance invocation used `--read-only` plus only mounted writable state/artifact paths. -- The preceding Trivy run used `--ignore-unfixed`; the final audit repeated Trivy 0.70.0 both with and without that filter and found no HIGH/CRITICAL findings. A CycloneDX JSON SBOM was generated under the ignored `.runtime/scan` verification area. -- GitHub Actions, GitLab CI, Jenkins, Harness CI, Docker, and Kubernetes examples are syntax/documentation-validated only; they were not dispatched to external vendor platforms. Ubuntu CI is configured to execute the Linux amd64 container, scan, and SBOM gates. +- Checksum-verified Trivy 0.72.0 found zero fixed HIGH/CRITICAL findings in the current image and generated valid CycloneDX JSON. The temporary local SBOM was validation evidence, not the still-pending hosted artifact; hosted jobs surface retained artifact and file digests. +- GitHub Actions, GitLab CI, Jenkins, Harness CI, Docker, and Kubernetes examples were not dispatched to external vendor platforms. The GitHub workflow syntax/lint is validated locally; hosted dispatch is pending. `cargo deny check` passed advisories, bans, licenses, and sources. Duplicate-version reports remain reviewed non-blocking warnings. From 3883b630a71554807f15b040ff046f168b5bc7f0 Mon Sep 17 00:00:00 2001 From: Ompragash Date: Wed, 22 Jul 2026 21:25:19 +0530 Subject: [PATCH 11/18] ci: avoid duplicate pull request runs --- .github/workflows/ci.yml | 2 ++ .github/workflows/container.yml | 2 ++ .github/workflows/security.yml | 2 ++ 3 files changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 094beb9..338c9cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,8 @@ name: credential-free-ci on: push: + branches: + - main pull_request: workflow_dispatch: diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml index 54f73b7..d1a739d 100644 --- a/.github/workflows/container.yml +++ b/.github/workflows/container.yml @@ -2,6 +2,8 @@ name: container-security on: push: + branches: + - main pull_request: workflow_dispatch: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index f8db016..104dc89 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -2,6 +2,8 @@ name: supply-chain-security on: push: + branches: + - main pull_request: workflow_dispatch: From c6027a5186acf2f638a4e24adb5d62acd59b99e6 Mon Sep 17 00:00:00 2001 From: Ompragash Date: Thu, 23 Jul 2026 01:05:55 +0530 Subject: [PATCH 12/18] fix: satisfy Windows acceptance lint --- xtask/src/acceptance.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/xtask/src/acceptance.rs b/xtask/src/acceptance.rs index 8eae639..50874af 100644 --- a/xtask/src/acceptance.rs +++ b/xtask/src/acceptance.rs @@ -878,7 +878,10 @@ fn signal_acceptance(binary: &Path, workspace: &Path, directory: &Path) -> Resul ensure_eq(&value, "/data/state", "cancelled")?; } #[cfg(not(unix))] - println!("SIGTERM acceptance is not applicable on this platform"); + { + let _ = (binary, workspace, directory); + println!("SIGTERM acceptance is not applicable on this platform"); + } Ok(()) } @@ -907,6 +910,8 @@ fn read_only_write_acceptance(binary: &Path, directory: &Path) -> Result<()> { result?; ensure!(!workspace.join("result.txt").exists()); } + #[cfg(not(unix))] + let _ = (binary, directory); Ok(()) } From 8f9efb0309688582be9ec872b7f56fefdd531c4e Mon Sep 17 00:00:00 2001 From: Ompragash Date: Thu, 23 Jul 2026 01:14:05 +0530 Subject: [PATCH 13/18] fix: avoid rebuilding running xtask on Windows --- xtask/src/main.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 48378ca..7e27eb7 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -117,11 +117,18 @@ fn verify(root: &Path) -> Result<()> { ], )?; - println!("[3/12] workspace build"); + println!("[3/12] production workspace build"); run( root, "cargo", - &["build", "--workspace", "--all-features", "--locked"], + &[ + "build", + "--workspace", + "--exclude", + "xtask", + "--all-features", + "--locked", + ], )?; println!( From 8f0431053920b8eb6e7ea877f39a7d3e86e99656 Mon Sep 17 00:00:00 2001 From: Ompragash Date: Thu, 23 Jul 2026 01:24:08 +0530 Subject: [PATCH 14/18] fix: normalize generated artifacts across platforms --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2f8c450 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/schemas/workflow.schema.json text eol=lf +/docs/generated/CLI.md text eol=lf From ea52bf539eff6b18ae0b2d989a6c455e04afd833 Mon Sep 17 00:00:00 2001 From: Ompragash Date: Thu, 23 Jul 2026 01:33:55 +0530 Subject: [PATCH 15/18] fix: stabilize CLI help across platforms --- crates/agentctl-cli/src/main.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/agentctl-cli/src/main.rs b/crates/agentctl-cli/src/main.rs index dc09c2b..5920732 100644 --- a/crates/agentctl-cli/src/main.rs +++ b/crates/agentctl-cli/src/main.rs @@ -356,7 +356,8 @@ impl CliError { #[tokio::main] async fn main() { - let args = std::env::args_os().collect::>(); + let mut args = std::env::args_os().collect::>(); + normalize_binary_name(&mut args); let requested_output = requested_output(&args); let cli = match Cli::try_parse_from(&args) { Ok(cli) => cli, @@ -393,6 +394,12 @@ async fn main() { } } +fn normalize_binary_name(args: &mut [OsString]) { + if let Some(binary_name) = args.first_mut() { + *binary_name = OsString::from("agentctl"); + } +} + fn requested_output(args: &[OsString]) -> OutputFormat { args.windows(2) .find_map(|pair| (pair[0] == "--output").then(|| pair[1].to_str()).flatten()) @@ -1659,6 +1666,16 @@ mod tests { assert_eq!(error.exit_code(), i32::from(EXIT_VALIDATION)); } + #[test] + fn executable_suffix_does_not_change_help_reference() { + let mut args = vec![OsString::from("agentctl.exe"), OsString::from("--help")]; + normalize_binary_name(&mut args); + let error = Cli::try_parse_from(args).expect_err("help exits through clap"); + assert_eq!(error.kind(), ErrorKind::DisplayHelp); + assert!(error.to_string().contains("Usage: agentctl ")); + assert!(!error.to_string().contains("agentctl.exe")); + } + #[test] fn requested_json_output_is_detected_before_clap_parsing() { assert_eq!( From 86aa5b63866ae8a78c63198cb0caf9ecf9ae2339 Mon Sep 17 00:00:00 2001 From: Ompragash Date: Thu, 23 Jul 2026 01:42:52 +0530 Subject: [PATCH 16/18] fix: preserve pack integrity across platforms --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index 2f8c450..a5ffeba 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ /schemas/workflow.schema.json text eol=lf /docs/generated/CLI.md text eol=lf +*.pack.yaml text eol=lf From 588f825e8a444af24c85a7769664721621f3d9b8 Mon Sep 17 00:00:00 2001 From: Ompragash Date: Thu, 23 Jul 2026 02:02:22 +0530 Subject: [PATCH 17/18] fix: generate dependency-complete production SBOM --- .github/workflows/ci.yml | 44 ++++++++++++++++++++++++++++++---------- docs/RELEASE_PROCESS.md | 10 +++++++++ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 338c9cf..9877e5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,8 @@ concurrency: env: RUST_TOOLCHAIN: "1.88.0" CARGO_DENY_VERSION: "0.20.2" + CARGO_CYCLONEDX_VERSION: "0.5.9" + CARGO_CYCLONEDX_LINUX_X64_SHA256: "fb8dbee9f182173e062a64a387b21a0badc6fab8b2abf9294973f012972bf6d8" jobs: gates: @@ -88,24 +90,44 @@ jobs: rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal rustup default "$RUST_TOOLCHAIN" - - name: Build production package for SBOM input - run: cargo xtask package + - name: Install pinned cargo-cyclonedx + run: | + set -euo pipefail + archive="$RUNNER_TEMP/cargo-cyclonedx.tar.xz" + tool_dir="$RUNNER_TEMP/cargo-cyclonedx" + curl --proto '=https' --tlsv1.2 --retry 3 --fail --location \ + "https://github.com/CycloneDX/cyclonedx-rust-cargo/releases/download/cargo-cyclonedx-${CARGO_CYCLONEDX_VERSION}/cargo-cyclonedx-x86_64-unknown-linux-gnu.tar.xz" \ + --output "$archive" + echo "$CARGO_CYCLONEDX_LINUX_X64_SHA256 $archive" | sha256sum --check - + mkdir -p "$tool_dir" + tar -xJf "$archive" -C "$tool_dir" + "$tool_dir/cargo-cyclonedx" cyclonedx --version + echo "$tool_dir" >> "$GITHUB_PATH" - name: Generate CycloneDX production SBOM - uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 - with: - path: dist/ - format: cyclonedx-json - output-file: agentctl-production.cdx.json - syft-version: v1.49.0 - upload-artifact: false - upload-release-assets: false + run: | + set -euo pipefail + cargo cyclonedx \ + --manifest-path crates/agentctl-cli/Cargo.toml \ + --format json \ + --describe binaries \ + --target x86_64-unknown-linux-gnu \ + --spec-version 1.5 \ + --no-build-deps + mv crates/agentctl-cli/agentctl_bin.cdx.json agentctl-production.cdx.json - name: Validate and digest production SBOM id: sbom_file run: | set -euo pipefail - jq -e '.bomFormat == "CycloneDX" and (.components | type == "array")' agentctl-production.cdx.json >/dev/null + jq -e ' + .bomFormat == "CycloneDX" + and .specVersion == "1.5" + and .metadata.component.name == "agentctl" + and ((.components | type) == "array") + and ((.components | length) > 0) + and any(.components[]; ((.purl // "") | startswith("pkg:cargo/"))) + ' agentctl-production.cdx.json >/dev/null digest="$(sha256sum agentctl-production.cdx.json | cut -d ' ' -f 1)" echo "sha256=$digest" >> "$GITHUB_OUTPUT" echo "agentctl-production.cdx.json SHA-256 $digest" >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 9ceb480..2be95bf 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -29,6 +29,16 @@ env -u OPENAI_API_KEY -u AZURE_OPENAI_API_KEY -u ANTHROPIC_API_KEY \ cargo xtask package ``` +Reproduce the production binary dependency SBOM with the pinned generator used in CI: + +```console +cargo install cargo-cyclonedx --version 0.5.9 --locked +cargo cyclonedx --manifest-path crates/agentctl-cli/Cargo.toml --format json \ + --describe binaries --target x86_64-unknown-linux-gnu --spec-version 1.5 \ + --no-build-deps +mv crates/agentctl-cli/agentctl_bin.cdx.json agentctl-production.cdx.json +``` + Run `cargo xtask acceptance-container` when Docker or Podman is available. If the builder requires an enterprise CA, provide a protected PEM file through `AGENTCTL_BUILD_CA_FILE`; see [Container](CONTAINER.md). Never disable TLS verification. Run checksum-verified actionlint against `.github/workflows`, then run Gitleaks against both `git log --all` and the checked-out tree. `cargo xtask secret-scan` retains the deterministic repository scan and verifies every action reference is a full 40-character commit SHA with an exact-version comment. From 310dc787d87160d36f0648f44549df87b0112f86 Mon Sep 17 00:00:00 2001 From: Ompragash Date: Thu, 23 Jul 2026 02:15:04 +0530 Subject: [PATCH 18/18] fix: unpack pinned SBOM generator --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9877e5f..a601ae2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,7 +100,7 @@ jobs: --output "$archive" echo "$CARGO_CYCLONEDX_LINUX_X64_SHA256 $archive" | sha256sum --check - mkdir -p "$tool_dir" - tar -xJf "$archive" -C "$tool_dir" + tar -xJf "$archive" -C "$tool_dir" --strip-components=1 "$tool_dir/cargo-cyclonedx" cyclonedx --version echo "$tool_dir" >> "$GITHUB_PATH"