diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..bff29e6 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +rustflags = ["--cfg", "tokio_unstable"] diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..67f7590 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# EditorConfig is awesome: https://editorconfig.org +root = true + +# The Rust workspace: agentctl, the Sandbox crates and the root build files. +[{agentctl/**,sandbox/**,Makefile,*.toml,.cargo/**}] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.rs] +indent_style = space +indent_size = 4 +tab_width = 4 + +[Makefile] +indent_style = tab diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index edfd086..bef591a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -27,3 +27,19 @@ scripts/ @olebhansen # review-gaten der er deres, ikke orkestrator-pipelinens agentctl/ @martinothamar sandbox/ @martinothamar + +# Rust-arbeidsområdet på rot og workflowene til agentctl/sandbox. Står etter +# .github/ slik at review av disse ikke krever orkestrator-pipelinens eier, og +# omvendt. +/Cargo.toml @martinothamar +/Cargo.lock @martinothamar +/rust-toolchain.toml @martinothamar +/rustfmt.toml @martinothamar +/clippy.toml @martinothamar +/deny.toml @martinothamar +/Makefile @martinothamar +/.cargo/ @martinothamar +/.editorconfig @martinothamar +/.github/workflows/rust.yml @martinothamar +/.github/workflows/agentctl-release.yml @martinothamar +/.github/workflows/agentctl-changelog.yml @martinothamar diff --git a/.github/workflows/agentctl-changelog.yml b/.github/workflows/agentctl-changelog.yml new file mode 100644 index 0000000..c8531da --- /dev/null +++ b/.github/workflows/agentctl-changelog.yml @@ -0,0 +1,48 @@ +name: agentctl changelog entry + +# Requires pull requests that change agentctl or the Sandbox crates to add an entry to agentctl/CHANGELOG.md under +# [Unreleased], using agentctl/changelog.sh. Apply the 'skip-changelog' label to pull requests with no user-visible +# change (refactors, test-only or CI-only work) or a change an Unreleased entry already describes with its issue +# linked; the labeled/unlabeled triggers re-evaluate the check when the label is toggled. +# Markdown-only changes do not trigger the check, except the changelog itself, so that a pull request editing only the +# changelog is still validated. + +on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review, labeled, unlabeled] + paths: + - 'agentctl/**' + - 'sandbox/**' + - '.github/workflows/agentctl-changelog.yml' + - '!agentctl/**/*.md' + - '!sandbox/**/*.md' + - 'agentctl/CHANGELOG.md' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + require-changelog-entry: + name: Require agentctl changelog entry + if: ${{ !github.event.pull_request.draft && !contains(github.event.pull_request.labels.*.name, 'skip-changelog') }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Validate changelog structure + run: agentctl/changelog.sh validate + + - name: Require an Unreleased entry + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: agentctl/changelog.sh check-unreleased "$BASE_SHA" "$HEAD_SHA" diff --git a/.github/workflows/agentctl-release.yml b/.github/workflows/agentctl-release.yml new file mode 100644 index 0000000..c78f1b4 --- /dev/null +++ b/.github/workflows/agentctl-release.yml @@ -0,0 +1,171 @@ +# Publishes an agentctl release for an `agentctl/v` tag: the archives for every host, the installers, the +# license and third-party notices, with the version's CHANGELOG.md section as the release notes. +name: agentctl release + +on: + push: + tags: + - 'agentctl/v*' + +permissions: + contents: read + +jobs: + build: + name: Build ${{ matrix.archive }} + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + archive: agent-linux-x86_64 + - runner: ubuntu-24.04-arm + archive: agent-linux-aarch64 + - runner: macos-15 + archive: agent-macos-aarch64 + - runner: windows-2025 + archive: agent-windows-x86_64 + - runner: windows-11-arm + archive: agent-windows-aarch64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + defaults: + run: + shell: bash + steps: + - name: Enable long paths for git on Windows + if: runner.os == 'Windows' + run: git config --system core.longpaths true + + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install native build dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends libcap-ng-dev + + - name: Install Rust toolchain + run: | + RUST_VERSION="$(sed -n 's/^channel = "\([^"]*\)"$/\1/p' rust-toolchain.toml)" + test -n "${RUST_VERSION}" + rustup toolchain install "${RUST_VERSION}" --profile minimal --component clippy,rustfmt + rustup default "${RUST_VERSION}" + + - name: Build release binaries + run: | + AGENT_VERSION="${GITHUB_REF_NAME#agentctl/}" cargo build --release --locked -p agent --bins + + - name: Package release binaries + env: + ARCHIVE: ${{ matrix.archive }} + run: | + agentctl/package.sh "dist/${ARCHIVE}.tar.gz" target/release + + - name: Smoke-test managed and standalone installation + env: + ARCHIVE: ${{ matrix.archive }} + AGENT_RELEASE_TAG: ${{ github.ref_name }} + run: | + VERSION="${AGENT_RELEASE_TAG#agentctl/}" + export VERSION AGENT_VERSION="${VERSION}" + SMOKE_ROOT="${RUNNER_TEMP}/agent-install-smoke" + ARCHIVE_PATH="${GITHUB_WORKSPACE}/dist/${ARCHIVE}.tar.gz" + if [ "${RUNNER_OS}" = "Windows" ]; then + export AGENT_INSTALL_ROOT="$(cygpath -w "${SMOKE_ROOT}/install")" + export AGENT_INSTALL_DIR="$(cygpath -w "${SMOKE_ROOT}/bin")" + export AGENT_HOME="$(cygpath -w "${SMOKE_ROOT}/home")" + export AGENT_LOCAL_ARCHIVE="$(cygpath -w "${ARCHIVE_PATH}")" + pwsh -NoProfile -File "$(cygpath -w agentctl/install.ps1)" + export AGENT_SMOKE_BIN="${AGENT_INSTALL_DIR}" + unset AGENT_INSTALL_ROOT AGENT_INSTALL_DIR + pwsh -NoProfile -Command '& (Join-Path $env:AGENT_SMOKE_BIN "agentctl.cmd") --home $env:AGENT_HOME self update --version $env:VERSION' + export AGENT_INSTALL_MODE=standalone + export AGENT_INSTALL_DIR="$(cygpath -w "${SMOKE_ROOT}/standalone")" + pwsh -NoProfile -File "$(cygpath -w agentctl/install.ps1)" + pwsh -NoProfile -Command '& (Join-Path $env:AGENT_INSTALL_DIR "agentctl.exe") --version' + else + export AGENT_INSTALL_ROOT="${SMOKE_ROOT}/install" + export AGENT_INSTALL_DIR="${SMOKE_ROOT}/bin" + export AGENT_HOME="${SMOKE_ROOT}/home" + export AGENT_LOCAL_ARCHIVE="${ARCHIVE_PATH}" + sh agentctl/install.sh + AGENT_SMOKE_BIN="${AGENT_INSTALL_DIR}" + unset AGENT_INSTALL_ROOT AGENT_INSTALL_DIR + "${AGENT_SMOKE_BIN}/agentctl" --home "${AGENT_HOME}" self update --version "${VERSION}" + AGENT_INSTALL_MODE=standalone AGENT_INSTALL_DIR="${SMOKE_ROOT}/standalone" \ + sh agentctl/install.sh + "${SMOKE_ROOT}/standalone/agentctl" --version + fi + + - name: Upload release artifact + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: ${{ matrix.archive }} + path: dist/agent-* + if-no-files-found: error + + release: + name: Publish GitHub release + needs: build + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Build release notes from the changelog + env: + TAG: ${{ github.ref_name }} + run: | + agentctl/changelog.sh validate + agentctl/changelog.sh extract "${TAG#agentctl/}" > release-notes.md + cat release-notes.md + + - name: Download binaries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: dist + merge-multiple: true + + - name: Add installers + run: | + cp agentctl/install.sh agentctl/install.ps1 dist/ + + - name: Install Rust toolchain + run: | + RUST_VERSION="$(sed -n 's/^channel = "\([^"]*\)"$/\1/p' rust-toolchain.toml)" + test -n "${RUST_VERSION}" + rustup toolchain install "${RUST_VERSION}" --profile minimal + rustup default "${RUST_VERSION}" + + - name: Install cargo-about + run: cargo install cargo-about --locked --features cli --version 0.9.2 + + # The archives hold only the two binaries the installers and `agentctl self update` accept, so the license and + # notices are separate assets. + - name: Add license and third-party notices + run: | + cp LICENSE dist/LICENSE + agentctl/notices/third-party-notices.sh dist/THIRD_PARTY_NOTICES.md + + # Immutable releases lock their assets on publication, so upload every asset to a draft first and publish it + # last. A draft left by a failed run is not immutable and is replaced. + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + if [ "$(gh release view "${TAG}" --json isDraft --jq .isDraft 2>/dev/null)" = true ]; then + gh release delete "${TAG}" --yes + fi + gh release create "${TAG}" --draft --prerelease --latest=false --verify-tag \ + --title "agentctl ${TAG#agentctl/}" --notes-file release-notes.md + gh release upload "${TAG}" dist/* + gh release edit "${TAG}" --draft=false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02f0236..538ed5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,7 @@ jobs: pull-requests: read outputs: integrations: ${{ steps.areas.outputs.integrations }} + rust: ${{ steps.areas.outputs.rust }} steps: - name: Detect changed areas id: areas @@ -40,6 +41,7 @@ jobs: # Each area's paths, as an extended regular expression over the changed file names. areas=( 'integrations=^(integrations/|\.github/workflows/(ci|integrations)\.yml$)' + 'rust=^(agentctl/|sandbox/|\.cargo/|(Cargo\.toml|Cargo\.lock|rust-toolchain\.toml|rustfmt\.toml|clippy\.toml|deny\.toml|Makefile)$|\.github/workflows/(ci|rust)\.yml$)' ) # A renamed file counts under both its old and its new name. names='.filename, (.previous_filename // empty)' @@ -65,10 +67,15 @@ jobs: if: needs.changes.outputs.integrations == 'true' uses: ./.github/workflows/integrations.yml + rust: + needs: changes + if: needs.changes.outputs.rust == 'true' + uses: ./.github/workflows/rust.yml + ci-gate: name: ci-gate if: always() - needs: [changes, integrations] + needs: [changes, integrations, rust] runs-on: ubuntu-latest steps: - name: Check that no needed job failed diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..ac4d0d6 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,143 @@ +# Builds, lints and tests the Rust workspace: agentctl and the Sandbox crates. Called from ci.yml when the workspace +# changes. +name: rust + +on: + workflow_call: + +permissions: + contents: read + +jobs: + build: + name: Build and test + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install native build dependencies + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends libcap-ng-dev + + - name: Install Rust toolchain + run: | + RUST_VERSION="$(sed -n 's/^channel = "\([^"]*\)"$/\1/p' rust-toolchain.toml)" + test -n "${RUST_VERSION}" + rustup toolchain install "${RUST_VERSION}" --profile minimal --component clippy,rustfmt + rustup default "${RUST_VERSION}" + + - name: Restore Rust cache + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + # Saves only from main, so pull requests share its cache instead of evicting it with their own. + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Install cargo-machete + run: cargo install cargo-machete --version 0.9.2 --locked + + - name: Check formatting + run: | + make fmt + git diff --exit-code -- . + + - name: Build + run: make build + + - name: Lint + run: make lint + + - name: Run tests + run: make test + + - name: Test installation and managed upgrade + run: ./agentctl/installation-test.sh + + - name: Check dependencies + run: make deps-check + + portable-hosts: + name: Portable host (${{ matrix.name }}) + strategy: + fail-fast: false + matrix: + include: + - name: Linux ARM64 + runner: ubuntu-24.04-arm + - name: macOS ARM64 + runner: macos-15 + - name: Windows x64 + runner: windows-2025 + - name: Windows ARM64 + runner: windows-11-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + defaults: + run: + shell: bash + steps: + - name: Enable long paths for git on Windows + if: runner.os == 'Windows' + working-directory: ${{ runner.temp }} + run: git config --system core.longpaths true + + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install native build dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends libcap-ng-dev + + - name: Install Rust toolchain + run: | + RUST_VERSION="$(sed -n 's/^channel = "\([^"]*\)"$/\1/p' rust-toolchain.toml)" + test -n "${RUST_VERSION}" + rustup toolchain install "${RUST_VERSION}" --profile minimal --component clippy,rustfmt + rustup default "${RUST_VERSION}" + + - name: Check native host + run: >- + cargo clippy + -p sandbox + -p sandbox-authorization + -p agent + -p sandbox-microsandbox + --all-targets --all-features --locked + + - name: Test native host + run: >- + cargo test + -p sandbox + -p sandbox-authorization + -p agent + -p sandbox-microsandbox + --all-targets --all-features --locked + + - name: Test installation and managed upgrade + run: ./agentctl/installation-test.sh + + dependencies: + name: Advisories, sources and licenses + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Check dependency advisories, sources and licenses + uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 + with: + rust-version: '1.97.1' + arguments: '--locked' + command: check + command-arguments: 'advisories sources licenses' diff --git a/.gitignore b/.gitignore index df629fe..f3be51f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,7 @@ Thumbs.db # Claude Code scratch tmpclaude-* + +# Rust +/target/ +/build/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..1fdc911 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7681 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "agent" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "clap", + "crossterm", + "flate2", + "futures-util", + "ignore", + "mimalloc", + "rand_core 0.6.4", + "ratatui", + "reqwest", + "rusqlite", + "sandbox", + "sandbox-authorization", + "sandbox-microsandbox", + "semver", + "serde", + "serde_json", + "serde_yaml_ng", + "sha2 0.11.0", + "ssh-key", + "tar", + "tempfile", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", + "uuid", + "win_uds", + "zeroize", +] + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +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 = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "arrow" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cfdd0833e32a9874d2b55089333ad310c0be208aafa277385ce2461dec90be3" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a41203398f0eaa6f7ec8e62c0da742a21abf282c148fc157f6c35c90e29981a" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num-traits", +] + +[[package]] +name = "arrow-array" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae33dad492b7df00a217563a7b0ef2874df68a0deea1b1a3acf628152f7f7a69" +dependencies = [ + "ahash 0.8.12", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.17.1", + "num-complex", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9552f96391c005e6ab449fa941420935e7e062489b12b8b1b08879b2163f5b5" +dependencies = [ + "bytes", + "half", + "num-bigint", + "num-traits", +] + +[[package]] +name = "arrow-cast" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8a327c9649f30d8406995f27642b68df354713cca3baaaf100f076f18d5f34" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ord", + "arrow-schema", + "arrow-select", + "atoi", + "base64 0.22.1", + "chrono", + "half", + "lexical-core", + "num-traits", + "ryu", +] + +[[package]] +name = "arrow-data" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b24852db04738907e06c04ea61e42fe7fda962a34513022dc0d0e754fb7976b" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ord" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63a083ec750f5c043f02946b4baf05fcdbb55f4560a3277055caca5cc99f3eb0" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "514ba0ef0d4c5896202dae736251ce415abb43a950bed570fb7981b8716c0e4c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ca356ad6425cecb6eb7b28e4f659f1ee7880fbb1a16127de7dd62901efee9e" + +[[package]] +name = "arrow-select" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c58da39eb3d8350ad4a549e5c2bc49284dac554016c69829310350f1731b0aad" +dependencies = [ + "ahash 0.8.12", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num-traits", +] + +[[package]] +name = "arrow-string" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6789b388467525e3271326b6b4915666ecfdf5142aef09779445c954b67543c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num-traits", + "regex", + "regex-syntax", +] + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "astral-tokio-tar" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b18457efd137254e016bbde5e1d88df61c4e1a5ae2223746e56123bac6af2463" +dependencies = [ + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "rustix", + "tokio", + "tokio-stream", + "xattr", +] + +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[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 = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake3" +version = "1.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[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 = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bollard" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbe8358268799ebb3e4df23cb9d47f4c72bbc4f5247e2fa6a1bf7b6c0baea220" +dependencies = [ + "async-stream", + "base64 0.22.1", + "bitflags 2.13.1", + "bollard-buildkit-proto", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "home", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-rustls", + "hyper-util", + "hyperlocal", + "log", + "num", + "pin-project-lite", + "rand 0.10.2", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-buildkit-proto" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5c97450e79c7c565302dd92e86b08823b47550fcb4fc5ce910194d1b087a1a3" +dependencies = [ + "prost", + "prost-types", + "tonic", + "tonic-prost", +] + +[[package]] +name = "bollard-stubs" +version = "1.53.1-rc.29.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce412eb6f7096743011dc3cb5c674caeb24ced61d8c498fe07cf7998a4fea889" +dependencies = [ + "base64 0.22.1", + "bollard-buildkit-proto", + "bytes", + "prost", + "serde", + "serde_json", + "serde_repr", + "time", +] + +[[package]] +name = "borsh" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cap-primitives" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "cap-std" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ec78e242cfa2cfe276807ac2ecc00315a6c97786977414bcd1c3963b6c91b8" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes 3.0.1", + "rustix", +] + +[[package]] +name = "capng" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a26766f93f07f7e8b8309ed2824fa2a68f5d12d219de855e24688e9fbe89e85" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "caps" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd1ddba47aba30b6a889298ad0109c3b8dcb0e8fc993b459daa7067d46f865e0" +dependencies = [ + "libc", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +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 = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.6", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck 0.5.0", + "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 = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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 = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "futures-core", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core 0.24.1", + "darling_macro 0.24.1", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "docker_credential" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2 0.10.9", + "subtle", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[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 = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[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.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[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 = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[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", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[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 = "getset" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[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" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[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 = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[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 = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "hickory-proto", + "idna", + "ipnet", + "jni", + "rand 0.10.2", + "rustls", + "thiserror 2.0.20", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "rand 0.10.2", + "ring", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "httlib-hpack" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40cf60e5e8567c6ff914a590f1452821de9377a560338a562e570a6ff052aae3" +dependencies = [ + "httlib-huffman", +] + +[[package]] +name = "httlib-huffman" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a9fcbcc408c5526c3ab80d534e5c86e7967c1fb7aa0a8c76abd1edc27deb877" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-auth" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "150fa4a9462ef926824cf4519c84ed652ca8f4fbae34cb8af045b5cbcaf98822" +dependencies = [ + "memchr", +] + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +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 = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[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-named-pipe" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[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", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[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.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[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 = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[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 = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "instability" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling 0.24.1", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.60.2", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "ipnetwork" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" +dependencies = [ + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[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.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.20", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + +[[package]] +name = "kvm-bindings" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11cf0ca75d59e9d298647c59cf6c5286fa048120caa77972a7a504a0824d234f" +dependencies = [ + "serde", + "vmm-sys-util", + "zerocopy", +] + +[[package]] +name = "kvm-ioctls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ac372c120eb893b086d1a12027669cf2b478d1f71204021ffa7adf57948d63" +dependencies = [ + "bitflags 2.13.1", + "kvm-bindings", + "libc", + "vmm-sys-util", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libmimalloc-sys" +version = "0.1.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" +dependencies = [ + "cc", +] + +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.1", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[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" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "serde", + "winapi", +] + +[[package]] +name = "managed" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "microsandbox" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" +dependencies = [ + "astral-tokio-tar", + "async-compression", + "base64 0.23.1", + "blake3", + "bytes", + "cap-primitives", + "cap-std", + "chrono", + "crossterm", + "docker_credential", + "flate2", + "futures", + "hex", + "libc", + "microsandbox-agent-client", + "microsandbox-control-client", + "microsandbox-db", + "microsandbox-filesystem", + "microsandbox-image", + "microsandbox-metrics", + "microsandbox-migration", + "microsandbox-network", + "microsandbox-protocol", + "microsandbox-protocol-client", + "microsandbox-runtime", + "microsandbox-types", + "microsandbox-utils", + "nix 0.31.3", + "notify", + "object", + "rand 0.10.2", + "rayon", + "reqwest", + "scopeguard", + "sea-orm", + "semver", + "serde", + "serde_ignored", + "serde_json", + "sha2 0.11.0", + "sqlx", + "tar", + "tempfile", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tracing", + "typed-path", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "microsandbox-agent-client" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" +dependencies = [ + "bytes", + "ciborium", + "libc", + "memmap2", + "microsandbox-protocol", + "microsandbox-protocol-client", + "nix 0.31.3", + "serde", + "tempfile", + "thiserror 2.0.20", + "tokio", + "tracing", +] + +[[package]] +name = "microsandbox-control-client" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" +dependencies = [ + "microsandbox-protocol", + "microsandbox-protocol-client", + "microsandbox-utils", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "zeroize", +] + +[[package]] +name = "microsandbox-db" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" +dependencies = [ + "async-trait", + "microsandbox-types", + "sea-orm", + "semver", + "serde_json", + "sqlx", + "tokio", + "tracing", +] + +[[package]] +name = "microsandbox-filesystem" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" +dependencies = [ + "bincode", + "hex", + "libc", + "microsandbox-utils", + "msb_krun", + "scopeguard", + "serde", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.20", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "microsandbox-image" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" +dependencies = [ + "astral-tokio-tar", + "async-compression", + "blake3", + "chrono", + "futures", + "hex", + "libc", + "microsandbox-types", + "microsandbox-utils", + "msb-imago", + "oci-client", + "oci-spec", + "rand 0.10.2", + "rustls-pki-types", + "scopeguard", + "serde", + "serde_json", + "sha2 0.11.0", + "tar", + "thiserror 2.0.20", + "tokio", + "tracing", + "windows-sys 0.61.2", + "zstd", +] + +[[package]] +name = "microsandbox-metrics" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" +dependencies = [ + "chrono", + "libc", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "microsandbox-migration" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" +dependencies = [ + "microsandbox-db", + "sea-orm-migration", + "serde_json", +] + +[[package]] +name = "microsandbox-network" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" +dependencies = [ + "base64 0.22.1", + "bytes", + "crossbeam-queue", + "futures", + "hickory-net", + "hickory-proto", + "httlib-hpack", + "httparse", + "ipnetwork", + "libc", + "lru", + "microsandbox-protocol", + "microsandbox-types", + "microsandbox-utils", + "msb_krun", + "msb_krun_utils", + "parking_lot", + "pem", + "percent-encoding", + "rcgen", + "resolv-conf", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "smoltcp", + "socket2", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-rustls", + "tokio-socks", + "tracing", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "microsandbox-protocol" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" +dependencies = [ + "bytes", + "chrono", + "ciborium", + "microsandbox-types", + "serde", + "serde_bytes", + "strum 0.28.0", + "thiserror 2.0.20", + "tokio", + "zeroize", +] + +[[package]] +name = "microsandbox-protocol-client" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" +dependencies = [ + "microsandbox-protocol", + "serde", + "thiserror 2.0.20", + "tokio", + "zeroize", +] + +[[package]] +name = "microsandbox-runtime" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" +dependencies = [ + "base64 0.23.1", + "chrono", + "ciborium-ll", + "hex", + "libc", + "microsandbox-db", + "microsandbox-image", + "microsandbox-network", + "microsandbox-protocol", + "microsandbox-types", + "microsandbox-utils", + "msb_krun", + "nix 0.31.3", + "rand 0.10.2", + "sea-orm", + "serde", + "serde_json", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.20", + "tokio", + "tokio-util", + "tracing", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "microsandbox-types" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" +dependencies = [ + "chrono", + "hex", + "ipnetwork", + "microsandbox-types-macros", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.20", + "typed-path", + "zeroize", +] + +[[package]] +name = "microsandbox-types-macros" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "microsandbox-utils" +version = "0.7.4-digdir.3" +source = "git+https://github.com/digdir/microsandbox.git?rev=37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c#37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c" +dependencies = [ + "dirs", + "libc", + "reflink-copy", + "windows-sys 0.61.2", +] + +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "msb-imago" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b6feac477a4387d62fde89aad52900fa0ea7ccb4fec686f50b93a575cbf0ea5" +dependencies = [ + "async-trait", + "cfg-if", + "libc", + "miniz_oxide", + "msb-vm-memory", + "nix 0.30.1", + "page_size", + "rustc_version", + "tokio", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "msb-vm-memory" +version = "0.18.0-msb.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6646492f6bc51c4126c73854d377ba8ce4cebb1443cc9efe2517ab2215d87b49" +dependencies = [ + "libc", + "thiserror 2.0.20", + "winapi", +] + +[[package]] +name = "msb_krun" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0944407a6ae125935e64dcf9be666e56d2cca0907b9e34be6648784db453490c" +dependencies = [ + "crossbeam-channel", + "kvm-bindings", + "kvm-ioctls", + "libc", + "libloading", + "log", + "msb-vm-memory", + "msb_krun_devices", + "msb_krun_hvf", + "msb_krun_polly", + "msb_krun_utils", + "msb_krun_vmm", +] + +[[package]] +name = "msb_krun_arch" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b0ef48fe491098f6ffefe0f00a8813ba88b9c79f339b85f5acd597e7cad95e9" +dependencies = [ + "kvm-bindings", + "kvm-ioctls", + "libc", + "msb-vm-memory", + "msb_krun_arch_gen", + "msb_krun_smbios", + "msb_krun_utils", +] + +[[package]] +name = "msb_krun_arch_gen" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c702db3ef885cba2c206e367889fdea4db6c7990b5e1cface8dfcab297a3262c" + +[[package]] +name = "msb_krun_cpuid" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defe5de4f6aeeeb521847db53a36f6d0e931e9a2f5eb7f7851610cafb707115b" +dependencies = [ + "kvm-bindings", + "kvm-ioctls", + "vmm-sys-util", +] + +[[package]] +name = "msb_krun_devices" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221db60cf824f4054eb84a1dd594081fe33d82e69ffcf752fbe1927aa99abd17" +dependencies = [ + "bincode", + "bitflags 1.3.2", + "capng", + "caps", + "crossbeam-channel", + "kvm-bindings", + "kvm-ioctls", + "libc", + "libloading", + "log", + "lru", + "msb-imago", + "msb-vm-memory", + "msb_krun_arch", + "msb_krun_hvf", + "msb_krun_polly", + "msb_krun_utils", + "nix 0.30.1", + "rand 0.9.5", + "serde", + "tokio", + "virtio-bindings", + "vm-fdt", + "windows-sys 0.61.2", +] + +[[package]] +name = "msb_krun_hvf" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb8f89a777a0ef25a2124132ef0a056c600930a5baa103694b65245f34b3e8" +dependencies = [ + "crossbeam-channel", + "libloading", + "log", + "msb_krun_arch", + "serde", +] + +[[package]] +name = "msb_krun_kernel" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40dd7265831b32e0addac577b8228a2661eeacaf81041ef8ed6e3a8d6482f11" +dependencies = [ + "msb-vm-memory", + "msb_krun_utils", +] + +[[package]] +name = "msb_krun_polly" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "855a130299a20fba964686040286e2ff6495852bc72f8d067730f33c842034e9" +dependencies = [ + "libc", + "msb_krun_utils", +] + +[[package]] +name = "msb_krun_smbios" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c76e3cf0714e1a1d36703b5e8597f67def7925105437cda14fc95da275e83445" +dependencies = [ + "msb-vm-memory", +] + +[[package]] +name = "msb_krun_utils" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c5691e655d7ee9394d8c741dceee8e0422ce27c526f792e91d4cb0c5f60c951" +dependencies = [ + "bitflags 1.3.2", + "crossbeam-channel", + "kvm-bindings", + "libc", + "log", + "nix 0.30.1", + "vmm-sys-util", + "windows-sys 0.61.2", +] + +[[package]] +name = "msb_krun_vmm" +version = "0.1.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e512641c4b2300886a0ee77d0d728a1080cf11a924a4c76b0c11f6817da014" +dependencies = [ + "bincode", + "bzip2", + "crossbeam-channel", + "flate2", + "kvm-bindings", + "kvm-ioctls", + "libc", + "libloading", + "log", + "msb-vm-memory", + "msb_krun_arch", + "msb_krun_arch_gen", + "msb_krun_cpuid", + "msb_krun_devices", + "msb_krun_hvf", + "msb_krun_kernel", + "msb_krun_polly", + "msb_krun_utils", + "nix 0.30.1", + "serde", + "windows-sys 0.61.2", + "zstd", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.1", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "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-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "smallvec", + "zeroize", +] + +[[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-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +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", + "libm", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "object" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c" +dependencies = [ + "memchr", +] + +[[package]] +name = "oci-client" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc7848d781053285280fcd35aab24c9c0a677e26db4aa4209bd5db57467de0f" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "futures-util", + "hex", + "http", + "http-auth", + "oci-spec", + "olpc-cjson", + "regex", + "reqwest", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.20", + "tokio", + "tracing", + "unicase", +] + +[[package]] +name = "oci-spec" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df6f876ad774d6a676f7e968f5c3edacc32f90e65fe680a8b686235396556fb" +dependencies = [ + "const_format", + "derive_builder", + "getset", + "regex", + "serde", + "serde_json", + "strum 0.27.2", + "strum_macros 0.27.2", + "thiserror 2.0.20", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "olpc-cjson" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "696183c9b5fe81a7715d074fd632e8bd46f4ccc0231a3ed7fc580a80de5f7083" +dependencies = [ + "serde", + "serde_json", + "unicode-normalization", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2 0.10.9", +] + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[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 = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" +dependencies = [ + "pest", +] + +[[package]] +name = "pgvector" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3673cba5b9a124916096a423b806a9f29620972c6c97b08db5f2053e9428b481" +dependencies = [ + "serde", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.7", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "pluralizer" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3eba432a00a1f6c16f39147847a870e94e2e9b992759b503e330efec778cbe" +dependencies = [ + "once_cell", + "regex", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[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 = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", + "yansi", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[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 2.0.20", + "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 = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "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 = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "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.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[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.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[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 = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum 0.28.0", + "thiserror 2.0.20", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum 0.28.0", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rcgen" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "reflink-copy" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9dd7ab4af0363d5ccfd2838d782a28196cf32a5cc2e4fe3c5dc83f2be588b8b" +dependencies = [ + "cfg-if", + "libc", + "rustix", + "windows", +] + +[[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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +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 = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "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", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[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 = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2 0.10.9", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[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 2.0.20", +] + +[[package]] +name = "rusqlite" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rust_decimal" +version = "1.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.7", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "aws-lc-rs", + "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 = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sandbox" +version = "0.1.0" +dependencies = [ + "bytes", + "futures-core", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-util", + "uuid", + "zeroize", +] + +[[package]] +name = "sandbox-authorization" +version = "0.1.0" +dependencies = [ + "serde", + "thiserror 2.0.20", + "tokio", +] + +[[package]] +name = "sandbox-microsandbox" +version = "0.1.0" +dependencies = [ + "bollard", + "bytes", + "futures-util", + "ignore", + "microsandbox", + "microsandbox-image", + "microsandbox-network", + "sandbox", + "sandbox-authorization", + "serde", + "serde_json", + "sha2 0.11.0", + "tar", + "tempfile", + "tokio", + "tokio-util", + "tracing", + "uuid", + "zeroize", +] + +[[package]] +name = "sandbox-worktree" +version = "0.1.0" +dependencies = [ + "clap", + "futures-util", + "sandbox", + "sandbox-microsandbox", + "sha2 0.11.0", + "tokio", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sea-bae" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "260bbc7148a8d6818ac5032a1b9970da1a68bdd723d45b12d59f7c1bf3565e24" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sea-orm" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a334e83ced3ae3ee44db0f84d1fcf8d2087a1ad9bb9036f00f9f6067156ea197" +dependencies = [ + "async-stream", + "async-trait", + "bigdecimal", + "chrono", + "derive-where", + "derive_more", + "futures-util", + "itertools", + "log", + "mac_address", + "ouroboros", + "pgvector", + "rust_decimal", + "sea-orm-arrow", + "sea-orm-macros", + "sea-query", + "sea-query-sqlx", + "sea-schema", + "serde", + "serde_json", + "sqlx", + "sqlx-core", + "strum 0.28.0", + "thiserror 2.0.20", + "time", + "tracing", + "url", + "uuid", + "web-time", +] + +[[package]] +name = "sea-orm-arrow" +version = "2.0.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c800d9db902534d7d01728faf98e33d13c1d57bb8c57d8e4c518309172bddda" +dependencies = [ + "arrow", + "sea-query", + "thiserror 2.0.20", +] + +[[package]] +name = "sea-orm-cli" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a53505884d7c907bcf4f7b4ddb1b29425e62fef8b98aea9c99e17781cceb798" +dependencies = [ + "chrono", + "glob", + "indoc", + "regex", + "sea-schema", + "sqlx", + "tokio", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "sea-orm-macros" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4039a86f9acc4d3b52747508b347dddc6fd725bbc429902ebeb6d26225fc2528" +dependencies = [ + "heck 0.5.0", + "itertools", + "pluralizer", + "proc-macro2", + "quote", + "sea-bae", + "syn 2.0.119", + "unicode-ident", +] + +[[package]] +name = "sea-orm-migration" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd09adbef87100d07131a60a8c5508b53d0cf2136521f654aae47af5e6a097fe" +dependencies = [ + "async-trait", + "sea-orm", + "sea-orm-cli", + "sea-schema", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sea-query" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "546040c653a705e60ec65ecd3191a809603734bebbc225775916dea9ae409b31" +dependencies = [ + "chrono", + "ordered-float", + "rust_decimal", + "sea-query-derive", + "serde_json", + "time", + "uuid", +] + +[[package]] +name = "sea-query-derive" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b0f466921cdd3cf4b89d5c3ac2173dba89a873ab395b123a645de181ec7537" +dependencies = [ + "darling 0.20.11", + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.119", + "thiserror 2.0.20", +] + +[[package]] +name = "sea-query-sqlx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eaa419cdb9157da1361186b1959983eb2ea0dcb9a3c69dc45c449ecb2af8fef" +dependencies = [ + "sea-query", + "sqlx", +] + +[[package]] +name = "sea-schema" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3553c77dceed56e95bece9ea876c4dd67ca879ef51055a0b97a7bb89a8ae4fed" +dependencies = [ + "async-trait", + "sea-query", + "sea-query-sqlx", + "sea-schema-derive", + "sqlx", +] + +[[package]] +name = "sea-schema-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdc8729c37fdbf88472f97fd470393089f997a909e535ff67c544d18cfccf0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[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_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[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_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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 = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[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 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[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 = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[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" +dependencies = [ + "serde", +] + +[[package]] +name = "smoltcp" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f8b28ad56c6e35524a37dd492af5d1a47e31e1a4d175cd12f89c075f01980f" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "cfg-if", + "defmt", + "heapless", + "managed", +] + +[[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 = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[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 = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap", + "log", + "memchr", + "percent-encoding", + "rust_decimal", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.20", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags 2.13.1", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "rust_decimal", + "serde", + "sha1", + "sha2 0.11.0", + "sqlx-core", + "thiserror 2.0.20", + "time", + "tracing", + "uuid", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.13.1", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac 0.13.0", + "itoa", + "log", + "md-5", + "memchr", + "rand 0.10.2", + "rust_decimal", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.20", + "time", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "chrono", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror 2.0.20", + "time", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "cipher", + "ssh-encoding", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2 0.10.9", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "ed25519-dalek", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha2 0.10.9", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[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 = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[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 = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bitflags 2.13.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2 0.10.9", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "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-io", + "futures-sink", + "futures-util", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "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 = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[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 = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[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 = "virtio-bindings" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091f1f09cfbf2a78563b562e7a949465cce1aef63b6065645188d995162f8868" + +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + +[[package]] +name = "vm-fdt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e21282841a059bb62627ce8441c491f09603622cd5a21c43bfedc85a2952f23" + +[[package]] +name = "vmm-sys-util" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "506c62fdf617a5176827c2f9afbcf1be155b03a9b4bf9617a60dbc07e3a1642f" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[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.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +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-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[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 = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2 0.10.9", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + +[[package]] +name = "win_uds" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff1222d47fad8fc0ce90a9d259c4b4b52995beeea984483323eacf56e113a074" +dependencies = [ + "async-io", + "futures-io", + "socket2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[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-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[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-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[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 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[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 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags 2.13.1", + "windows-sys 0.52.0", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + +[[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.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +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" +dependencies = [ + "serde", + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f5ce747 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,74 @@ +[workspace] +members = [ + "agentctl", + "sandbox/authorization", + "sandbox/core", + "sandbox/core/examples/worktree", + "sandbox/microsandbox", +] +resolver = "3" + +[workspace.package] +edition = "2024" +license = "MIT" +rust-version = "1.97.1" +version = "0.1.0" + +[workspace.dependencies] +base64 = "0.22.1" +bollard = { version = "0.21.1", features = ["buildkit", "time"] } +bytes = "1.12.1" +clap = { version = "4.6.6", features = ["derive"] } +crossterm = { version = "0.29.0", features = ["event-stream"] } +futures-core = "0.3.34" +futures-util = "0.3.34" +flate2 = "1.1.9" +ignore = "0.4.33" +# Microsandbox fork commit tagged `v0.7.4-digdir.3`. +# Update all three revisions together with the runtime bundle digests. +microsandbox = { git = "https://github.com/digdir/microsandbox.git", rev = "37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c", default-features = false, features = ["local", "net"] } +microsandbox-image = { git = "https://github.com/digdir/microsandbox.git", rev = "37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c", package = "microsandbox-image" } +microsandbox-network = { git = "https://github.com/digdir/microsandbox.git", rev = "37a477d1921bf94f8c444e59d9f3e94a0ba3fe9c", package = "microsandbox-network" } +mimalloc = "0.1.52" +ratatui = "0.30.2" +reqwest = { version = "0.13.4", features = ["json"] } +rand_core = { version = "0.6.4", features = ["getrandom"] } +rusqlite = "0.39.0" +sandbox = { path = "sandbox/core" } +sandbox-microsandbox = { path = "sandbox/microsandbox" } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +serde_yaml_ng = "0.10.0" +semver = "1.0.27" +sha2 = "0.11.0" +ssh-key = { version = "0.6.7", default-features = false, features = ["ed25519", "rand_core", "std"] } +tar = "0.4.46" +tempfile = "3.27.0" +thiserror = "2.0.20" +time = { version = "0.3.55", features = ["formatting", "parsing", "serde"] } +tokio = { version = "1.53.1", features = ["fs", "io-util", "macros", "net", "process", "rt", "signal", "sync", "time"] } +tokio-util = { version = "0.7.19", features = ["io"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +uuid = { version = "1.24.1", features = ["serde", "v4"] } +zeroize = "1.9.0" + +[workspace.lints.rust] +unsafe_code = "deny" +warnings = "deny" +unreachable_pub = "deny" +unexpected_cfgs = { level = "deny", check-cfg = ["cfg(fuzzing)"] } + +[workspace.lints.clippy] +all = { level = "deny", priority = -1 } +pedantic = { level = "deny", priority = -1 } +nursery = { level = "deny", priority = -1 } +cargo = { level = "deny", priority = -1 } +cargo_common_metadata = "allow" +expect_used = "deny" +future_not_send = "allow" +iter_with_drain = "allow" +multiple_crate_versions = "allow" +panic = "deny" +redundant_pub_crate = "allow" +unwrap_used = "deny" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d1d58d6 --- /dev/null +++ b/Makefile @@ -0,0 +1,89 @@ +.PHONY: help build user-install clean fmt lint lint-fix test test-e2e check check-platforms deps deps-check \ + changelog-validate changelog-test +.DEFAULT_GOAL := help + +USER_INSTALL_VERSION := v0.0.1-dev.$(shell date -u +%Y%m%d%H%M%S) +USER_INSTALL_ARCHIVE := $(abspath build/user-install/agent-$(USER_INSTALL_VERSION).tar.gz) +RELEASE_BIN_DIR := $(abspath $(or $(CARGO_TARGET_DIR),target)/release) + +PACKAGES := \ + -p agent \ + -p sandbox-authorization \ + -p sandbox \ + -p sandbox-worktree \ + -p sandbox-microsandbox +PORTABLE_PACKAGES := \ + -p sandbox \ + -p sandbox-authorization \ + -p agent \ + -p sandbox-microsandbox + +help: ## Show this help message + @echo 'Usage: make [target]' + @echo '' + @echo 'Available targets:' + @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-20s %s\n", $$1, $$2}' + +build: ## Build all Rust crates + @echo "Building Rust crates..." + @cargo build $(PACKAGES) --all-targets --locked + @echo "✓ Build successful" + +user-install: ## Build, package and install agentctl and agentd for the current user +ifeq ($(OS),Windows_NT) + @powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$(abspath agentctl/make-user-install.ps1)" +else + @echo "Building agentctl $(USER_INSTALL_VERSION)..." + @AGENT_VERSION=$(USER_INSTALL_VERSION) cargo build --release --locked -p agent --bins + @./agentctl/package.sh "$(USER_INSTALL_ARCHIVE)" "$(RELEASE_BIN_DIR)" + @AGENT_VERSION=$(USER_INSTALL_VERSION) AGENT_LOCAL_ARCHIVE="$(USER_INSTALL_ARCHIVE)" ./agentctl/install.sh +endif + +clean: ## Clean Rust build artifacts + @echo "Cleaning Rust build artifacts..." + @cargo clean $(PACKAGES) + @echo "✓ Cleaned" + +fmt: ## Format Rust code + @echo "Formatting Rust code..." + @cargo fmt $(PACKAGES) + @echo "✓ Code formatted" + +lint: ## Run strict Clippy analysis + @echo "Linting Rust crates..." + @cargo clippy $(PACKAGES) --all-targets --all-features --locked + @echo "✓ Lint passed" + +lint-fix: ## Apply safe Clippy fixes + @echo "Applying Clippy fixes to Rust crates..." + @cargo clippy $(PACKAGES) --all-targets --all-features --fix --allow-dirty --locked + @echo "✓ Lint fixes applied" + +test: changelog-test ## Run all tests + @echo "Testing Rust crates..." + @cargo test $(PACKAGES) --all-targets --locked + @echo "✓ Tests passed" + +changelog-validate: ## Check that agentctl/CHANGELOG.md has the expected structure + @./agentctl/changelog.sh validate + +changelog-test: ## Run the changelog.sh tests + @./agentctl/changelog_test.sh + +test-e2e: ## Run integration tests that require Docker, Internet access, and KVM + @echo "Running end-to-end tests..." + @cargo test -p sandbox-microsandbox --tests --locked -- --ignored + @echo "✓ End-to-end tests passed" + +deps: ## Print the workspace dependency graph + @cargo tree $(PACKAGES) --locked + +deps-check: ## Check for unused direct dependencies + @cargo machete --with-metadata . + @git diff --exit-code -- ':(top)Cargo.lock' + +check: fmt lint build test test-e2e deps-check changelog-validate ## Run all local checks + @echo "✓ All checks passed" + +check-platforms: ## Check portable code on the current native host (CI covers every supported host) + @cargo clippy $(PORTABLE_PACKAGES) --all-targets --all-features --locked diff --git a/README.md b/README.md index e43b722..b168072 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,11 @@ agentens `triggers/`-katalog**. Formatet er beskrevet i [`integrations/README.md`](integrations/README.md) (resultatkontrakt med `intent`/`reply`). -### Planlagt innflytting: `agentctl/` og `sandbox/` +### `agentctl/` og `sandbox/` -Agent-laget fra `altinn-studio/src/experimental` flytter inn som to nye -rotkataloger (ki-lab-beslutning, september 2026): +Agent-laget fra `altinn-studio/src/experimental` har flyttet inn som to +rotkataloger (ki-lab-beslutning, september 2026), med Rust-arbeidsområdet på +rot: | Katalog | Rolle | |---|---| diff --git a/agentctl/.gitattributes b/agentctl/.gitattributes new file mode 100644 index 0000000..5922768 --- /dev/null +++ b/agentctl/.gitattributes @@ -0,0 +1,2 @@ +# Check out with LF on every platform: tests and scripts read these files byte for byte. +* text=auto eol=lf diff --git a/agentctl/AGENTS.md b/agentctl/AGENTS.md new file mode 100644 index 0000000..e3931d5 --- /dev/null +++ b/agentctl/AGENTS.md @@ -0,0 +1,78 @@ +# AGENTS.md + +This area contains the agent platform described in `README.md`: the `agent` crate here, with `agentctl` and +`agentd`, and the Sandbox crates under `../sandbox/`. + +## Architecture + +- `sandbox` is the generic Rust SDK for Sandbox lifecycle, features, images, storage, runtime file + transfer, execution, Network Backends and secret storage. It must not depend on Agent + automation or a concrete Sandbox implementation. +- `sandbox-microsandbox` contains the Microsandbox Backend and network enforcement implementation. It depends on + `sandbox`, never the reverse. +- `sandbox-authorization` contains context-aware authorization contracts and policy-engine interfaces for operations + originating inside Agents and Sandboxes. The name communicates its scope; it must not depend on enforcement + points, the Agent Control Plane or the Sandbox SDK. +- `agent` owns Agent resources, the host Control Plane, Agent Control API, host-side Harness Adapters, + `agentd` and `agentctl`. It builds on the lower crates above. The M0 agent has no sandbox-resident + Agent Runtime; host-side management drives tmux and the harness through Sandbox executions. +- The backend owns Sandbox lifecycle, execution, runtime file transfer, storage and mount behavior; do not split + those into speculative replaceable component traits. +- Sandbox progress is reported through phase spans and step tokens. A step belongs to the phase in progress and is + either unmeasured or measures one quantity in the unit it started with. Every phase and step ends exactly once. + Consumers read the folded `Progress` and a `ProgressCursor` instead of interpreting events, and the Agent layer + delivers progress to clients as state behind a revision, never as an event stream. +- A Provider pairs a Sandbox Backend with an Image Backend over one image materialization domain. Both expose + discovery-first, per-Platform capabilities; Backend trait operations are required and have no default behavior. +- A Network Backend is independently selectable from a Sandbox Backend. They negotiate an owned Network Endpoint: + raw Ethernet/IP packets, intercepted TCP streams and UDP datagrams, or a jointly implemented versioned control + protocol. The Network Backend consumes the endpoint and owns host authorization and endpoint driving; a trusted + Sandbox runtime may perform protocol-aware enforcement only through the negotiated control protocol. Packet, + datagram and control-message data planes use bounded batch polling rather than per-item futures. Packet endpoints + carry immutable interface configuration chosen and persisted by the Sandbox Backend. Microsandbox implements both + traits separately even when both dispatch to the same runtime. Concrete Backend SDKs own platform-specific local + IPC binding, framing and cleanup; the generic endpoint adapter handles complete bounded messages. +- The Network Backend identity and selected endpoint contract are recorded when a Sandbox is created and are + immutable for its lifetime. Network start, stop and reconnection follow Sandbox lifecycle without changing that + attachment. A Sandbox Backend must block traffic not represented by the selected endpoint; it must never become an + unobserved egress path. +- Do not add a `NetworkPolicy` to the Sandbox contract or manifest. Network implementations use + `sandbox-authorization` for live Sandbox-originated decisions. +- Do not use authorization intended for Sandbox-originated operations to authorize platform users calling the + Agent Control API. That is a separate concern. +- The host-to-Agent-Runtime connection mechanism is not selected. Do not add a generic Sandbox control channel + until a concrete Agent Runtime integration demonstrates the required contract. +- Agent-stack secrets remain on the trusted host and use mediated access. The independent Sandbox SDK may expose + caller-selected bind mounts for other products and trusted local workflows; it does not impose Agent policy. + +Use crate boundaries for architectural separation and modules for internal organization. Add another crate +only when a component needs an independent dependency, versioning or distribution boundary. Do not add +`sandboxd` or `sandboxctl` unless the generic Sandbox SDK gains an independent process boundary. + +Use Tokio's `LocalRuntime` for asynchronous work. Keep control-plane state single-threaded and use `Rc`, +`Cell`, or `RefCell` as appropriate. Do not use `Arc` or `Mutex`. + +## Development + +Run `make help` at the repository root to list the available development targets. + +Follow [MICROSANDBOX.md](../sandbox/MICROSANDBOX.md) when updating the Microsandbox source and runtime pins. The +forks are synchronized and their runtimes released in digdir/microsandbox and digdir/libkrunfw. + +When adding or updating a harness installation or adapter, follow [HARNESSES.md](HARNESSES.md). + +## Changelog and releases + +[CHANGELOG.md](CHANGELOG.md) is the release notes for the Agent platform. There is one changelog because there is +one release unit, the `agentctl` and `agentd` binaries published by the `agentctl/v*` tag. The version in the changelog is that release +version; the Rust workspace version is a build detail and is not tracked there. + +Run `make changelog-validate` to check the file's structure, and `make changelog-test` for the tests covering +[changelog.sh](changelog.sh) itself. `make check` runs the validation, and `make test` runs the tests. + +Releasing is a promotion pull request that renames `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and adds a fresh +empty `## [Unreleased]` above it. That rename is itself a change to the Unreleased section, so the pull request +needs no `skip-changelog` label. Once it is merged, push the tag +`agentctl/v`; the release workflow extracts that section with `changelog.sh extract` and +publishes it as the GitHub release body, and fails before creating the release when the section is missing or has +no date. diff --git a/agentctl/CHANGELOG.md b/agentctl/CHANGELOG.md new file mode 100644 index 0000000..9e7b427 --- /dev/null +++ b/agentctl/CHANGELOG.md @@ -0,0 +1,255 @@ +# Changelog + +All notable changes to the Agent platform will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +Entries should describe only user-facing functionality in clear, user-friendly language; omit implementation details that do not affect how people use the product. +Section ordering: Added, Changed, Fixed, Removed, Security, Deprecated. + +The version is the Agent release published by the `agentctl/v*` tag, covering `agentctl`, `agentd` and the example +Agents. The Rust workspace version is a build detail and is not tracked here. + +## [Unreleased] + +### Added + +- The self-development Agent clones `digdir/microsandbox` and `digdir/libkrunfw` beside `digdir/digdir-agents`, and has the `changelog` skill for writing changelog entries. ([#136](https://github.com/digdir/digdir-agents/pull/136)) + +### Changed + +- Breaking: `agentctl` is now developed and released in [digdir/digdir-agents](https://github.com/digdir/digdir-agents). Rerun the [installer](https://github.com/digdir/digdir-agents/tree/main/agentctl#install) once; Agents and Sessions carry over, and `agentctl self update` then follows the new releases. ([#136](https://github.com/digdir/digdir-agents/pull/136)) + +## [0.1.0-preview.9] - 2026-10-02 + +### Added + +- `agentctl tui` opens an Agent with `o`: + - in a shell, VS Code, Zed or SSH, offering to add the `Include` to `~/.ssh/config` first ([#20762](https://github.com/Altinn/altinn-studio/pull/20762)) + - on its desktop, in the browser or a VNC client ([#20763](https://github.com/Altinn/altinn-studio/pull/20763)) + - through a forward, from the forwards view ([#20763](https://github.com/Altinn/altinn-studio/pull/20763)) +- `agentctl ssh-info` reports the directory editors open, as `workingDirectory` in JSON. ([#20762](https://github.com/Altinn/altinn-studio/pull/20762)) +- `agentctl stop` and `agentctl start`, or `x` in `agentctl tui`, stop an Agent's VM and start it again on the same disk, also one that stopped responding. Its Sessions go Idle and resume when attached after the start, and re-applying keeps a stopped Agent stopped. ([#20807](https://github.com/Altinn/altinn-studio/issues/20807)) + +### Changed + +- Altinn, self-development, minimal and worktree Agents install Claude Code 2.1.286. ([#20886](https://github.com/Altinn/altinn-studio/pull/20886)) +- Altinn, self-development and worktree Agents install Codex CLI 0.159.3. ([#20886](https://github.com/Altinn/altinn-studio/pull/20886)) +- `agentctl tui` port forwards: ([#20763](https://github.com/Altinn/altinn-studio/pull/20763)) + - `q` asks before quitting would close them + - they close when their Agent is deleted or re-created + +### Fixed + +- Windows Agents remain responsive after startup, so commands, SSH and Sessions keep working. Restart running Agents to apply the runtime update. ([#20911](https://github.com/Altinn/altinn-studio/pull/20911), [martinothamar/microsandbox#9](https://github.com/martinothamar/microsandbox/pull/9)) +- Windows Agents can mount host directories. ([#20911](https://github.com/Altinn/altinn-studio/pull/20911), [martinothamar/microsandbox#10](https://github.com/martinothamar/microsandbox/pull/10)) +- On Windows, files in shared directories remain accessible after their parent directory is renamed. ([#20911](https://github.com/Altinn/altinn-studio/pull/20911), [martinothamar/microsandbox#14](https://github.com/martinothamar/microsandbox/pull/14)) +- On macOS and Linux, stopping an Agent no longer risks corrupting Sandbox state. ([#20911](https://github.com/Altinn/altinn-studio/pull/20911), [martinothamar/microsandbox#12](https://github.com/martinothamar/microsandbox/pull/12)) +- In Altinn, self-development and worktree Agents, Rust commands in an Altinn Studio checkout no longer fail with `Permission denied` or need a manual `rustup` update: they use the toolchain the checkout pins, installing it on first use when the image is older. ([#20909](https://github.com/Altinn/altinn-studio/pull/20909)) +- Old Agent images no longer fill the disk: `agentd` removes an image 3 days after its last Agent is deleted, so recreating an Agent does not download it again. Images from earlier releases are removed once every Agent has started. ([#20865](https://github.com/Altinn/altinn-studio/pull/20865)) +- An Agent whose first start failed no longer fails with `image manifest digest … is not present in this Microsandbox cache` after its image tag, such as `:latest`, moves to a newer version. ([#20865](https://github.com/Altinn/altinn-studio/pull/20865)) +- Interrupted commands that wait for an Agent, such as an editor retrying its SSH connection to an Agent that cannot start, no longer make `agentd` stop answering every other command. ([#20884](https://github.com/Altinn/altinn-studio/pull/20884)) +- An Agent whose Sandbox stops responding, for example after the host wakes from sleep, shows `SandboxUnresponsive` in `agentctl get agents` and `Unresponsive` in `agentctl tui`. `agentctl exec`, `attach`, `ssh`, `prompt`, `turns` and Session creation then fail instead of hanging, and `agentctl delete` completes. ([#20868](https://github.com/Altinn/altinn-studio/pull/20868)) + +## [0.1.0-preview.8] - 2026-09-30 + +### Changed + +- Agents run on a newer sandbox runtime, which `agentd` installs by itself; running Agents move to it when they restart. Once the new `agentd` has started, earlier releases cannot read Agent state, so you cannot downgrade. ([#20831](https://github.com/Altinn/altinn-studio/pull/20831)) +- On macOS, Agents resolve names through the host's system resolver, so VPN split DNS and `/etc/resolver` domains work inside an Agent as they do on the host. ([#20792](https://github.com/Altinn/altinn-studio/pull/20792)) +- `.local` names, reverse lookups of private network addresses and names with non-ASCII characters are no longer resolved through the host, so an Agent cannot discover devices on the host's local network. Names in the Agent's own `/etc/hosts` still resolve. ([#20792](https://github.com/Altinn/altinn-studio/pull/20792)) + +### Fixed + +- Agents resolve names again, without a restart, after the host changes networks or comes back online, for example when a laptop moves between Wi-Fi networks or an Agent was started while the host was offline. ([#20792](https://github.com/Altinn/altinn-studio/pull/20792)) +- `agentd` gives back the memory it used to prepare an Agent image once the image is ready. ([#20799](https://github.com/Altinn/altinn-studio/pull/20799)) +- Pressing Ctrl-Z in an attached Session no longer freezes it. ([#20830](https://github.com/Altinn/altinn-studio/pull/20830)) +- Agents with a direct root filesystem, such as the full Altinn Agent, start again after their VM stops, instead of failing with `VMDK missing` until the Agent is deleted. ([#20831](https://github.com/Altinn/altinn-studio/pull/20831)) + +### Security + +- Changing a running Agent's resources after an `agentd` restart no longer gives the Agent network access that bypasses network authorization and secret mediation. ([#20826](https://github.com/Altinn/altinn-studio/pull/20826)) +- An Agent can no longer tunnel non-HTTP traffic through an HTTPS connection to an allowed host to bypass network authorization and secret mediation. WebSocket connections keep working. ([#20831](https://github.com/Altinn/altinn-studio/pull/20831)) + +## [0.1.0-preview.7] - 2026-09-28 + +### Added + +- `agentctl describe agent` shows the provisioning in progress, or the one that failed with its failing step's output, + whether a failure is being retried, and how long each condition has held its state. +- Agent status in `agentctl get -o yaml` and `-o json` includes condition transition times, the failure class and + provisioning progress. +- Provisioning shows Agent setup and SSH access as phases of their own. +- The `agentctl` terminal UI is a live triage view: every Agent and Session with its state and how long it has been in + it. Sessions that need input are marked and counted, `tab` jumps to the next one, `/` filters, and `?` lists every + key. +- In the terminal UI, a side panel shows the selected Session's recent turns or the selected Agent's status. `p` follows + an Agent's provisioning, which also opens for an Agent created with `c`, or prompts a Session without attaching. +- Altinn Agents include `typos` and `hunspell`, so the repository spell check (`yarn spell:quick`, `yarn spell:check` + and the pre-commit hook) runs inside an Agent. +- The full Altinn Agent includes `cargo-machete`, so `make deps-check` and `make check` in the Rust workspaces run + inside an Agent. +- `agentctl delete session/` and `d` in the terminal UI delete one Session: its harness is stopped and its name + becomes free. The harness's own conversation files stay in the Sandbox. +- `agentctl archive session/` and `a` in the terminal UI archive a Session: its harness stops once any turn in + progress ends, and it is hidden until `agentctl unarchive`. `get sessions --archived` and `A` show archived Sessions. +- A new `desktop` Altinn Agent has a graphical screen it can see and use, driven by a `desktop` helper and a + `computer-use` skill: screenshot, zoom, point, scroll and type, including Norwegian text, and read what is showing + as an accessibility tree, including the browser's own controls and dialogs. A terminal opens with `Ctrl+Alt+T` + or the panel's launcher and has the Session's environment. +- Agents can declare `access: [{type: vnc}]`. Watch or take over the desktop with `agentctl vnc --web`, in a browser + with nothing installed, or `agentctl vnc` for a VNC client of your own. + +### Changed + +- Commands that wait for an Agent, such as `apply --wait`, pick up provisioning already in progress and no longer drop + output when they fall behind. +- Image pulls and imports show downloading, materializing and assembling as separate steps. +- The terminal UI updates as Agents and Sessions change instead of every two seconds, and keeps the last state on screen + while `agentd` is unreachable. +- Terminal UI forms share one layout with aligned fields, and `NO_COLOR` turns off color while every state keeps its + glyph. +- New full Altinn Agents finish setup faster. Chromium's trust in the certificate bundle is imported faster and in the + background, so Sessions no longer wait for it. + +### Fixed + +- The terminal UI keeps the selection on the same Agent or Session when rows move or an Agent is folded. +- A Claude Code Session left Idle for more than 30 days resumes its conversation instead of starting a new one. Claude + Code no longer deletes transcripts it considers old. +- The terminal UI's new-Session form rejects a name the Agent already uses instead of attaching to that Session. + +## [0.1.0-preview.6] - 2026-09-23 + +### Changed + +- Altinn, self-development, minimal and worktree Agents install Claude Code 2.1.280. +- Altinn, self-development and worktree Agents install Codex CLI 0.156.0, with workspace routing and activity hooks updated for its startup flow. + +## [0.1.0-preview.5] - 2026-09-22 + +### Added + +- Codex and Claude Code Sessions show their model, working directory, Git branch, context usage, usage limits, harness + version and Fast mode in a persistent status line. +- Altinn Agents can authenticate ordinary HTTPS Git commands to Azure DevOps with an optional host-mediated personal + access token, including cloning the `altinn-studio-infra` repository without exposing the token in the Sandbox. +- Altinn and self-development Agents include Neovim with line numbers, cursor highlighting, a filetype statusline, the + `habamax` theme and built-in syntax highlighting for C#, JavaScript, TypeScript, JSON and XML. +- Agent images include the `gh stack` extension for creating and managing stacked pull requests. +- The `agentctl` terminal UI supports mouse selection, scrolling, clickable controls and deliberate double-click + actions while retaining all keyboard controls. +- Agent Skill entries may declare an installed `name` separately from their source directory. +- A harness installation may be declared `optional`, so an Agent is created without it when its host login is + absent. Altinn Agents declare Codex this way, and signing in on the host installs it on the next convergence. +- Altinn Agents install the repository's text-review and Norwegian copy-editing Skills, so a Session has them as + well as a local checkout. +- Agent manifests may mark a mediated secret as optional, so an absent or empty value omits that binding instead of + blocking Agent provisioning. +- Altinn Agent images include `studioctl`, the Altinn Studio app-development skill and `/home/agent/code/apps` for + app checkouts. They log `studioctl` in to each configured production, staging or development Studio environment + with a host-mediated API key. Full images also prepare LocalTest hostnames for browser testing. + +### Changed + +- Pull request evidence guidance is shorter, with readable pacing and no fixed clip + duration. The GIF conversion helper now accepts files up to 10 MiB instead of 8 MiB. +- Altinn, self-development, minimal and worktree Agents install the latest stable Claude Code and Codex CLI + harnesses, and Codex command failures remain visible in `agentctl turns` with the new transcript format. + +### Fixed + +- Agent Sessions set `XDG_RUNTIME_DIR`, so `skopeo`, `buildah` and other tools that expect a user runtime + directory run instead of failing with a permission error on a path they cannot read. +- `podman run --init` works in full Agents; the `catatonit` binary the flag needs was missing from the image. +- Chromium in a full Altinn Agent trusts the same host-mediated certificate authorities as command-line tools, so + browser tests can load HTTPS dependencies without disabling certificate verification. + +## [0.1.0-preview.4] - 2026-09-18 + +### Added + +- The release installers accept `AGENT_INSTALL_MODE=standalone` to verify and copy only `agentctl` and `agentd` into + `AGENT_INSTALL_DIR`. This supports immutable images and CI jobs without creating self-update state, migrating Agent + data, starting the daemon, or changing `PATH`. +- Agent manifests support chained `AgentVariant` files named `agent..yaml`. Select them with `--variant` or the TUI, which also supports ignored local variants and an environment file. +- SSH access to Agents. Declare `spec.access: [{type: ssh}]`, then `agentctl ssh [-- command]` opens a shell or runs a command in the Sandbox as `agent`. `agentctl ssh-config install` lets plain `ssh`, `sftp` and editors reach the Agent as `agentctl-`, and `agentctl ssh-info -o json` prints the connection details. The Altinn Agent images and the examples declare it; an Agent created from an older image must be deleted and re-applied. +- Windows contributors can run `.\make-user-install.ps1` to build, package and install a local Agent without Make. + +### Changed + +- `agentctl apply` defaults to `./agent.yaml`. The self-development and Altinn Agents provide nested and worktree variants; Altinn also provides nested-build variants. + +### Fixed + +- SSH shells, remote commands and editor terminals now inherit the same Agent tool, configured environment and + mediated certificate settings as Sessions and `agentctl exec`. +- Concurrent network requests from an Agent no longer intermittently fail with DNS, HTTP or TLS errors, especially on Windows hosts. +- Deleting an Agent no longer logs a panic when its Sandbox has an active network-control connection. +- The self-development Agent examples build with their SSH configuration, so the checkout, worktree and nested variants can be applied. +- On Windows, detaching from a Session with `Ctrl-b d` returns control to the terminal UI without dropping the next key press. +- Attached Sessions support mouse-wheel scrolling through up to 50,000 lines of terminal history for new panes. Codex and Claude Code keep their conversations in that history; Claude Code no longer uses its fullscreen renderer, which could corrupt the display when scrolling in tmux. Reattaching enables mouse support for existing Sessions, but cannot recover discarded output. +- Agent setup now writes Sandbox files only when their contents changed, and replaces them atomically. Codex no longer reports missing skill frontmatter after each reconciliation pass. + +### Security + +- Applying an Agent rejects bind mounts containing `.env` files, case-insensitively and regardless of ignore rules. + +## [0.1.0-preview.3] - 2026-09-17 + +### Added + +- `agentctl create` and `agentctl attach` accept `--model` and `--effort`, and the terminal UI's new-session form has the same fields, to choose the model and effort level a Session's harness launches with. Values are the harness's own, for example `fable` and `high` for Claude Code. `spec.harnesses[].defaults` declares per-installation defaults. The choice is fixed for the Session, applied on every relaunch and resume, and shown by `agentctl get sessions`. + +### Changed + +- Claude Code Sessions launch on the `fable` alias only when the manifest declares it; the `agents/` manifests and the examples do, and your own manifests need `defaults: { model: fable }` on the Claude Code installation to keep it for new Sessions. Sessions created earlier keep launching on `fable`. +- The Sandbox runtime (microsandbox) was updated. Linux hosts with older system libraries, such as Ubuntu 22.04, can now install it, and a Sandbox that fails to start reports the runtime's own error instead of a bare timeout. +- Agent instructions now tell Claude Code and Codex not to add `Co-Authored-By` or similar AI-attribution trailers to commits and pull requests. +- The Altinn Agent images run on Norwegian local time (Europe/Oslo) instead of UTC, so `date`, file timestamps and log output inside an Agent match the clock where the work is reviewed. An existing Agent keeps the image it was created with; delete and re-apply it to pick this up. + +### Fixed + +- On Windows, starting a Sandbox with a large root filesystem could take an hour while its disk was copied. The copy now takes seconds. +- Linkerd could not start inside a kind cluster running in a Sandbox because the Sandbox kernel lacked the iptables owner match its proxy-init needs. The match is now built in. +- Building the Agent images, or the minimal and worktree examples, failed with a certificate error where the network inspects TLS, such as inside another Agent. The npm, Yarn, Corepack and Playwright downloads now trust the Agent's certificate bundle while the image is built. +- Test suites and dev servers inside an Agent could fail to start with `user limit (128) on inotify instances reached` before running anything, because the guest kept the kernel's desktop-sized file-watcher limits. The Agent images now raise them to the values the self-hosted CI runners already use. +- Logging a nested Agent into Claude failed with an empty credential. Claude Code hides `CLAUDE_CODE_OAUTH_TOKEN` from the commands it runs, so the documented `agentctl claude login --from-stdin` step had nothing to read. An Agent now also carries its Claude credential as `AGENT_CLAUDE_ACCESS_TOKEN`, matching `AGENT_CODEX_ACCESS_TOKEN`, and the self-development instructions use it. + +## [0.1.0-preview.2] - 2026-09-15 + +### Added + +- `agentctl self update` installs a newer release, and `--check` only reports whether one exists. It migrates your Agent state behind a backup, relaunches resumable Sessions, and refuses to run while work is in flight. ([#20397](https://github.com/Altinn/altinn-studio/pull/20397)) +- `agentctl apply --wait` stays attached and streams provisioning progress — image pull, build, Sandbox creation, harness setup — until the Agent is Ready. `wait`, `attach` and `exec` show the same progress. ([#20341](https://github.com/Altinn/altinn-studio/pull/20341)) +- `agentctl port-forward [ADDRESS:]LOCAL:GUEST...` forwards local TCP ports into a running Sandbox, and `:GUEST` picks a free local port. In the terminal UI, `f` adds a forward and `F` lists them. ([#20189](https://github.com/Altinn/altinn-studio/pull/20189), [#20245](https://github.com/Altinn/altinn-studio/pull/20245)) +- `agentctl create`, `prompt` (with `--wait`) and `turns` drive a Session from the command line without attaching. Prompt text comes from `--prompt`, `--file` or piped standard input. ([#20374](https://github.com/Altinn/altinn-studio/pull/20374)) +- Press `c` in the terminal UI to create an Agent, choosing from the manifests found by walking down from the repository root. ([#20242](https://github.com/Altinn/altinn-studio/pull/20242), [#20358](https://github.com/Altinn/altinn-studio/pull/20358)) +- `spec.environment` copies declared non-secret values from the manifest's `.env` into the Sandbox. `GIT_USER_NAME` and `GIT_USER_EMAIL` also set the Sandbox user's global Git identity. ([#20416](https://github.com/Altinn/altinn-studio/pull/20416)) +- `spec.skills` installs skill directories into the Sandbox and `spec.instructions` accepts several sources. Every image gains asciinema and agg, the full image ffmpeg and a Playwright CLI, and the manifests a `pr-evidence` skill. ([#20348](https://github.com/Altinn/altinn-studio/pull/20348)) +- `agentctl apply --env-file` names the file supplying the manifest's declared environment and secret values, instead of the `.env` beside the manifest. Keep files holding secrets outside bind-mounted directories. ([#20339](https://github.com/Altinn/altinn-studio/pull/20339)) +- `agentctl get` and `agentctl describe` accept `-o json`, so tooling can read Agent and Session state instead of parsing the table. ([#20374](https://github.com/Altinn/altinn-studio/pull/20374)) +- `agentctl claude login --from-stdin` and `agentctl codex login --from-stdin` take a credential from standard input, so an Agent can log in a nested Agent without ever holding a real one. ([#20339](https://github.com/Altinn/altinn-studio/pull/20339)) + +### Changed + +- **Upgrading from preview 1.** Stop the running `agentd` first — preview 1 cannot stop itself — then re-run `install.sh` or `install.ps1` once, which migrates your Agents and stored logins. Use `agentctl self update` from then on. ([#20397](https://github.com/Altinn/altinn-studio/pull/20397)) +- **A host release does not change your Sandbox image.** The `agents/` manifests reference `:latest`, and an existing Agent keeps the image it was created with. Delete and re-apply the Agent to pick up a newer one. +- `agentctl` explains failures instead of reporting them bare: the manifest setting at fault, the failing image build step, or the Agent's own reason when a wait times out. ([#20341](https://github.com/Altinn/altinn-studio/pull/20341)) +- The hostname inside a Sandbox is the Agent name, so prompts and logs read `agent@`. Existing Sandboxes keep their old hostname until they are recreated. ([#20363](https://github.com/Altinn/altinn-studio/pull/20363)) +- `spec.harnesses[].version` is optional: the image owns the harness version, so an image bump needs no manifest change. The `agents/` manifests no longer pin one. ([#20250](https://github.com/Altinn/altinn-studio/pull/20250)) +- A Session goes idle after 30 minutes rather than 5, and the timer restarts on activity. Codex no longer checks for its own updates at startup. ([#20374](https://github.com/Altinn/altinn-studio/pull/20374)) +- Git in the published images authenticates through the `gh` CLI, so `git push` works in a fresh Sandbox without `gh auth setup-git`. The token is also allowed against `gist.github.com`. ([#20151](https://github.com/Altinn/altinn-studio/pull/20151)) + +### Fixed + +- Sessions launch Claude Code on the `fable` alias. Fable never appeared in the `/model` picker, so it could not be selected at all, and a pinned generation would not follow new releases. ([#20147](https://github.com/Altinn/altinn-studio/pull/20147), [#20314](https://github.com/Altinn/altinn-studio/pull/20314)) +- Agents start on macOS hosts with long home directory paths, where the Microsandbox control socket could exceed the 104-byte limit on Unix socket paths. ([#20411](https://github.com/Altinn/altinn-studio/pull/20411)) +- Configuring Podman waits for the guest to finish booting, instead of failing once with `System has not been booted with systemd as init system`. ([#20346](https://github.com/Altinn/altinn-studio/pull/20346)) +- Installing on Windows no longer fails while `install.ps1` verifies the downloaded release archive. ([#20143](https://github.com/Altinn/altinn-studio/pull/20143)) + +## [0.1.0-preview.1] - 2026-08-26 + +### Added + +- First public preview: `agentctl` and `agentd`, declarative Agents with durable tmux-backed Sessions in isolated Sandboxes, mediated harness and GitHub authentication, and the published Agent images. diff --git a/agentctl/Cargo.toml b/agentctl/Cargo.toml new file mode 100644 index 0000000..0be9c4e --- /dev/null +++ b/agentctl/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "agent" +description = "Agent automation and local control plane built on the sandbox crate" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +base64.workspace = true +sandbox = { path = "../sandbox/core" } +sandbox-authorization = { path = "../sandbox/authorization" } +sandbox-microsandbox = { path = "../sandbox/microsandbox" } +clap.workspace = true +crossterm.workspace = true +ignore.workspace = true +mimalloc.workspace = true +futures-util.workspace = true +flate2.workspace = true +reqwest.workspace = true +rand_core.workspace = true +ratatui.workspace = true +rusqlite.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_yaml_ng.workspace = true +semver.workspace = true +sha2.workspace = true +ssh-key.workspace = true +tar.workspace = true +thiserror.workspace = true +time.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +tempfile.workspace = true +zeroize.workspace = true +uuid.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } + +[target.'cfg(windows)'.dependencies] +tokio-util = { workspace = true, features = ["compat"] } +win_uds = { version = "0.2.4", features = ["async"] } + +[lints] +workspace = true diff --git a/agentctl/HARNESSES.md b/agentctl/HARNESSES.md new file mode 100644 index 0000000..11169ab --- /dev/null +++ b/agentctl/HARNESSES.md @@ -0,0 +1,75 @@ +# Harness compatibility + +Treat harness installation version bumps as adapter changes: existing unit tests and transcript fixtures do not +establish compatibility with a new binary. Terminal behavior, transcript formats, hook semantics, authentication +and resume behavior all need live verification. Version verification checks identity, not compatibility. + +An existing Agent keeps the image it was created with, but `agentctl self update` gives it the new `agentd` at once. +Adapter changes that accompany a bump must therefore also work with the previous pin. + +Implementation: [adapters](src/harness), [terminal runtime](src/sessions/runtime/tmux.rs), +[Session service](src/sessions/service.rs). Harness pins: [self-dev](examples/self-dev/Dockerfile), +[minimal](examples/minimal/Dockerfile) (Claude Code only) and [worktree](../sandbox/core/examples/worktree/Dockerfile); +update them together. + +## Upgrade test plan + +Install the current platform with `make user-install` from the repository root; it replaces and restarts `agentd`, +and refuses while any Session reports `Working` (Altinn/altinn-studio#20871, Altinn/altinn-studio#20872), so archive or delete earlier test Sessions first. +Test the self-dev image built from the branch: from `agentctl/examples/self-dev`, `agentctl apply --variant nested +--env-file --wait` builds it with both harnesses and fits inside another Agent. Keep the env file outside the +checkout. Use fresh Session names and confirm `claude --version` and `codex --version` in the Sandbox; testing an +existing Sandbox does not prove the rebuilt image works. + +Run each check against both harnesses unless the table names one. A low-cost model is enough, but Sessions on each +installation's manifest `defaults` must work at least once. Before attributing a failure to the bump, repeat the check +on an Agent built with the previous pin. + +`agentctl` has no command for some of the steps: + +- Keys without attaching: `agentctl exec agent/ -- tmux send-keys -t '=agent-session-:' Escape`, with the + Session `id` from `agentctl get sessions -o json`. `tmux capture-pane -p -t '=agent-session-:'` prints the screen. +- Idle without waiting 30 minutes: `agentctl archive`, then `agentctl unarchive`; `agentctl create` or `attach` then + resumes the Session. Run the real idle-stop once, in the background. +- A long foreground tool call: ask for `timeout 90 tail -f /dev/null`. Claude Code refuses a bare `sleep` and may run a + command in the background instead. +- A Claude Code permission prompt: add `"permissions": {"ask": ["Bash(touch:*)"]}` to `~/.claude/settings.json` in the + Sandbox, start a new Session and ask it to run `touch`; ask rules prompt despite `--dangerously-skip-permissions`. + Restore the file afterwards. The `AskUserQuestion` tool blocks on the operator without any configuration. +- A daemon restart: `pkill -x agentd`; the next `agentctl` command starts it again. + +| Check | Harness | Expected result | +| -------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Create with an initial prompt | Both | One submission and answer; a daemon restart does not replay the prompt. A failed launch may lose the initial prompt and recover with an empty conversation. | +| Create without a prompt, then immediately `prompt --wait` | Both | Input submits without manual Enter. Repeat several times to expose startup races. Codex readiness depends on its `› ` composer and the truncated session-ID pane title. | +| Short, long, multiline, XML-shaped and literal request-heading input | Both | `turns` preserves the complete operator input and shows no harness-injected text as operator input (Altinn/altinn-studio#20870). | +| Prompt again after completion, including identical text | Both | Waits for one more completed turn, ignoring previous completions. | +| Prompt during an active tool call, including identical text | Both | Input appears in `turns`; waiting follows work observed during settling, but does not demand an extra turn when input is absorbed into the current one. | +| Prompt while a background command runs, until it finishes | Claude Code | The wait ends with the operator's turn; the completion notification's own turn does not end a later wait early. | +| Tool success and tool failure | Both | STATE reflects activity; tool completion alone does not complete a turn; `turns` marks only the failed call. | +| Permission prompt and `AskUserQuestion`, approved and denied | Claude Code | While blocked, STATE is `WaitingForInput` and `activity.turns` does not advance; answering or approving completes the turn. A denial is an interruption (Altinn/altinn-studio#20869). Not tested for Codex, which launches without approvals. | +| Interruption during a tool call, then another prompt | Both | Codex reports `Interrupt`, which releases the wait. Known gap (Altinn/altinn-studio#20869): Claude Code has no interrupt hook, so STATE stays `Working` and a wait times out. The next prompt works in both. | +| Model error reported through `StopFailure` | Claude Code | Create with an unknown `--model`. The completion report ends the wait, but does not imply a successful model response; inspect `turns`. | +| Model error without a completion report | Codex | Create with an unknown `--model`. Codex shows the API error without a turn ending, so the wait times out and STATE stays `Working` (Altinn/altinn-studio#20872); inspect the Session and recover manually. | +| Short completion timeout | Both | Queuing, input readiness and delivery finish before the completion timeout starts. A timeout reports that the prompt was submitted; inspect turns before retrying. The next prompt contains no leftover draft. | +| Idle-stop and resume, before and after the first turn | Both | After 30 unattached, quiet minutes the Session is Idle. An untouched Session remains usable; an established conversation resumes with its history. | +| Archive mid-turn, then unarchive and resume | Both | The harness stops once the turn ends; `create` or `attach` after unarchive resumes the conversation. | +| Idle Session whose transcript is older than 30 days | Claude Code | After a prompt in another Session of the Agent, it still resumes with its history. Backdate the transcript with `touch -d`. | +| Codex rollout compression | Codex | `local_thread_store_compression` is still off by default. Otherwise, check that `turns` and resume work for an Idle Session whose rollout is older than 7 days. | +| Create with `--model`/`--effort`, and with only manifest `defaults` | Both | `get sessions` shows the resolved selection and the harness reports the same model and effort, also after idle-stop and resume; an unknown value fails visibly in the terminal. | +| Transcript location | Both | `get sessions -o json` reports a `harnessTranscriptPath` that exists and grows with each turn, also after resume. | +| Authentication and configuration | Both | Mediated login/inference works without unexpected onboarding or authentication dialogs; startup shows no new warnings; the status line renders; configured instructions and skills are available. | +| Nested Agent | Both | Inside a Session, `agentctl claude login --from-stdin` with `$AGENT_CLAUDE_ACCESS_TOKEN` and `agentctl codex login --from-stdin < ~/.codex/auth.json` let a nested Agent run both harnesses. | +| New `agentd` with the previous pin | Both | When the change touches adapter code, an Agent on the previous image still creates, prompts, reads turns and resumes. | + +Completion waits poll local database activity every 250 ms and require identical completed, waiting activity +in two consecutive polls. + +Inspect `get sessions` (including `-o json`) and `turns` alongside the terminal. Check user messages, assistant answers, +tool results and turn boundaries, including after compaction. A successful model response alone is insufficient. + +Run the normal formatting, lint and test checks, plus `cargo test -p agent --lib -- --ignored` on a host with Node.js +and tmux. Those tests drive tmux with a synthetic program, not the harnesses, so they do not replace a live check; the +scrollback and Ctrl-Z checks also require Linux and util-linux `script`. Record in the PR, for each check and harness, +the result or why it was not run, with the tested versions and commands. Update adapter fixtures when native output +changes, and add a changelog entry. Never publish credentials or authentication-bearing process arguments. diff --git a/agentctl/README.md b/agentctl/README.md new file mode 100644 index 0000000..e00f8e3 --- /dev/null +++ b/agentctl/README.md @@ -0,0 +1,237 @@ +# Agent platform + +This area explores an open agent platform built on a reusable Sandbox SDK. The platform is designed to run locally +or under later cloud orchestration without coupling Agent automation to one isolation backend, network implementation, +harness or model. + +The main goals are: + +- long-running Agents with multiple durable Sessions; +- strong isolation with mediated network access and no real secrets inside Sandboxes; +- backend-neutral Sandbox lifecycle, execution, storage, file-transfer and terminal APIs; +- harness-neutral Agent and Session concepts with harness-specific behavior kept in adapters; +- host APIs that work on Linux, macOS and Windows while initially materializing Linux Sandboxes; and +- a Sandbox layer reusable by CI runners and other isolated workloads that do not depend on Agent concepts. + +## Install + +Install the released `agentctl` and `agentd` on Linux or macOS, then log in to Claude Code: + +```sh +curl -fsSL https://raw.githubusercontent.com/digdir/digdir-agents/main/agentctl/install.sh | sh +agentctl claude login +``` + +Windows additionally requires the `HypervisorPlatform` optional feature. Install from PowerShell, then open a new +PowerShell window so the updated user `PATH` takes effect: + +```powershell +irm https://raw.githubusercontent.com/digdir/digdir-agents/main/agentctl/install.ps1 | iex +``` + +Update an installation with `agentctl self update`. + +## Development + +Run `make help` at the repository root for the supported development commands. `make user-install` builds, packages and +installs `agentctl` and `agentd` for the current user. On Windows without Make, run `.\agentctl\make-user-install.ps1` for the +same build, package and installation flow. + +Maintainers updating the Microsandbox pin should follow [MICROSANDBOX.md](../sandbox/MICROSANDBOX.md). + +User-visible changes are recorded in [CHANGELOG.md](CHANGELOG.md), which covers the whole stack and provides the +release notes for each `agentctl/v*` release. + +Upgrading from a released version migrates the Agent database, so Agents and Sessions carry over. State created by +unreleased development builds is not migrated. + +## Architecture + +```text +Host +├── agentctl local CLI, transient execution and Session attachment +└── agentd control plane, reconciliation, policy and SecretStore + └── Agent declarative durable resource + └── Sandbox isolated execution environment + ├── Session durable tmux-backed harness process + └── Session durable tmux-backed harness process +``` + +The implementation has two deliberately separate layers. + +### Sandbox layer + +`sandbox` is the generic Rust SDK. A Node hosts Sandboxes, and Sandboxes host Executions. Providers pair a Sandbox +Backend with an Image Backend and advertise platform capabilities before selection. Network Backends are selected +independently and consume a negotiated packet, intercepted-flow or versioned-control endpoint. + +`sandbox-microsandbox` implements the Sandbox, Image and Network contracts for Microsandbox. Network enforcement and +secret substitution happen on the trusted mediation path; the Sandbox Backend must not leave an unobserved egress +path. `sandbox-authorization` defines the context-aware authorization vocabulary without depending on the Agent +control plane or an enforcement implementation. + +The Sandbox crates do not depend on Agent automation. + +### Agent layer + +`agentd` owns the durable desired state and all lifecycle effects. `agentctl` starts the adjacent daemon on demand and +communicates through the versioned local control API. Its resource-oriented commands follow `verb resource [name]`; +Session scope is explicit through `--agent` or inferred from the closest unique persisted Agent source directory. +Transient `exec` commands similarly converge the Agent first, then target its exact materialized Sandbox without +creating durable Session state or taking Sandbox lifecycle ownership away from `agentd`. + +An Agent owns one retained Sandbox incarnation. The Agent controller is the sole owner of Sandbox selection, +materialization, setup, network mediation and release. A Session controller can only open the already-materialized +Sandbox and owns the in-Sandbox tmux and harness effects for that Session. Both use the same keyed reconciliation +scheduler, which serializes work per resource identity while allowing unrelated resources to progress concurrently. + +Desired state is persisted before reconciliation. Wakeups provide low-latency progress, while startup and periodic +scans ensure dropped notifications or daemon restarts do not lose work. Provider assignment is sticky for an Agent +incarnation, and a reused Agent name never inherits resources from a deleted incarnation. The Sandbox is named after the +incarnation, while its guest hostname is the Agent name so shell prompts and logs identify the Agent. + +A running Sandbox's guest can stall while its VM process keeps running. The Sandbox SDK reports the guest's heartbeat +without a round trip to the guest. While reconciliation works inside the guest, `agentd` inspects the heartbeat every 2 +seconds and records when it last advanced on the host clock. After 15 seconds without progress the work ends, and the +Agent reports `SandboxResponsive=False` and `Ready=False` with reason `SandboxUnresponsive`. A stalled guest therefore +cannot hold its Agent's reconciliation, and a command waiting for the Agent to become Ready fails once the stall is +recorded. The next pass after the heartbeat advances makes the Agent Ready again. A stalled guest is not restarted +automatically; `agentctl stop` and then `agentctl start` restart it. + +An Agent's `spec.runState` is `Running`, the default, or `Stopped`. `agentctl stop` and `agentctl start` set it as a +new generation; a manifest may set it, and `apply` of one that omits it keeps the current run state. For a stopped Agent +the reconciler stops the Sandbox VM and its Network, killing a VM that does not stop gracefully, and keeps its root +filesystem, Volumes, identity and Provider assignment. It reaches nothing in the guest and reports `Ready=False` and +`SandboxReady=False` with reason `Stopped`. Commands that need the Sandbox fail at once, and its Sessions go Idle, as +after inactivity. A start is an ordinary pass on the same root filesystem; it launches no harness, and the next attach +to a Session resumes its conversation. + +Provisioning progress is observed as state, not as a stream. The Sandbox SDK folds progress events into a `Progress` +value, so an observer that joins late or falls behind sees what one that saw every event would. `agentd` keeps each +Agent's latest provisioning pass in memory, and records a routine resync of a Ready Agent only when it fails; the +durable outcome is the Agent's conditions, with their transition times, and its failure class. Clients follow one Agent +with `agents.v1.progress` and every Agent and Session with `resources.v1.watch`, long-polls that return when the daemon +drains. Commands that wait for an Agent, such as `apply --wait`, `wait`, `start` and `stop`, call `agents.v1.converge` +while they follow its progress. It wakes the Agent and returns once a pass for its generation recorded its desired run +state, Ready or stopped, waiting through transient failures. + +`agentctl tui` builds on the same two calls: it follows `resources.v1.watch` for the fleet and `agents.v1.progress` for +one Agent's provisioning, and derives each Agent's state from its conditions and failure class. + +Sessions have platform-assigned identities independent of tmux and harness-native conversation IDs. Each Session binds +immutably to one of its Agent's declared harness installations and to a model selection (model and effort level) +resolved at creation: the caller's explicit choice, else the installation's manifest `defaults`, else nothing, leaving +the harness's own defaults. Both values are provider-owned identifiers the platform validates but does not interpret. +The selection is recorded with the Session, shown by `agentctl get sessions`, and applied on every launch including +resume, so a later manifest change affects only new Sessions and a model change made inside the harness lasts until +the next relaunch. Detaching leaves a Session running. An inactive, unattached Session becomes Idle and is relaunched +on the next ensure or attach, resuming the harness conversation when its native state still exists. Repeated +unexpected harness exits use bounded backoff. Deleting a Session hides it at once; the Session controller stops its +harness before removing it, so an unreachable Sandbox delays the deletion rather than leaving a harness untracked. +Archiving a Session stops its harness once any turn in progress ends, but not for a turn waiting for approval or quiet +for a minute, and keeps it stopped until the Session is unarchived; it keeps its name and conversation, and the next +attach resumes it. + +Tmux is the current Session runtime, not a security boundary or a permanent generic driver abstraction. A second +runtime must establish the common interface before one is introduced. + +## Images, home and harnesses + +See the [harness compatibility test plan](HARNESSES.md) when updating harness installations. + +Agent images own installed tools and optional workspace initialization. Repository checkouts are persistent runtime +data beneath `/home/agent/code`; they are not declared, updated or deleted by the Agent controller. Sessions may clone +repositories they can access, and image init may make a simple best-effort checkout for convenience. +`spec.sandbox.mounts` can instead attach caller-owned host directories or temporary memory filesystems when the selected +Sandbox Provider supports them; these attachments are immutable for the Agent incarnation. + +`spec.home` is a continuously applied overlay onto `/home/agent`. It converges files supplied by the builder but does +not delete guest files that disappear from the source. Builders may use it to own harness configuration explicitly, +with the consequence that those files are reapplied on every Agent pass. + +`spec.harnesses` declares the harness installations available to Sessions and selects the default used for new Sessions. +A declared `version` is verified against the image at setup; omit it when the image owns the version, so image bumps need no manifest change. +Set `optional: true` when an absent host login should omit that installation instead of blocking Agent creation, so a +manifest can offer a harness that not everyone has signed in to. The check runs on every convergence, so signing in on +the host installs the harness on the next pass; until then a Session on it is refused, naming the login. The default +installation cannot usefully be optional, since it is what a Session selecting no harness gets. +Each installation may declare `defaults` with a `model` and an `effort` level for its new Sessions, in the harness's +own vocabulary. The published manifests select `model: fable` for Claude Code because a mediated token cannot list +Fable in the `/model` picker. +`spec.instructions` names one harness-neutral Agent instruction file. Every declared Harness Adapter installs that source +at its global instruction location: `~/.claude/CLAUDE.md` for Claude Code and `~/.codex/AGENTS.md` for Codex. +Repository-local instruction files continue to be discovered by the harness itself. + +Harness Adapters own authentication, version verification, managed configuration, hooks, native conversation IDs and +launch arguments. The current adapters support Claude Code and Codex CLI. Harness-owned mutable state is seeded by the +image or the user and is not used as a trusted bootstrap marker. + +## SSH access + +`spec.access: [{type: ssh}]` gives the Agent's user OpenSSH access to the Sandbox as the platform-owned user `agent`: +`agentctl ssh [-- command]` opens it, `agentctl ssh-config install` makes the alias `agentctl-` +available to plain `ssh`, `sftp` and editors that read OpenSSH configuration, and +`agentctl ssh-info -o json` describes the connection for other tools. The server listens only inside the +Sandbox and is reached through `agentctl ssh-proxy`; the image must provide OpenSSH, systemd and a usable `agent` +account, while `agentd` installs the isolated server policy and unit. `agent` has passwordless `sudo`, so an SSH +login shares the Sandbox's one trust boundary with Sessions. SSH shells, remote commands and editor servers inherit +the same image, Agent and mediated trust environment as Sandbox Executions; terminal- and Session-specific variables +remain local to their process. + +## Secrets and network policy + +A secret is any protected host-owned value. Credentials are the subset used for authentication. Generic storage and +mediation therefore use the `SecretStore` concept, while harness login remains an authentication concern. + +`spec.environment` explicitly selects non-secret values from the same `.env` file, with `name` as both the Sandbox +variable and default source name. An optional `source` selects a differently named entry. Only declared values are +copied, and they enter the Sandbox in plaintext, where image init and Sandbox Executions inherit them. Reapplying +after changing the file updates the Sandbox environment. Do not declare secrets here. + +Manifest secret bindings name a guest environment variable and the hosts where its value may be substituted. The +matching real value is loaded from the manifest directory's `.env` file, or the file named by +`agentctl apply --env-file`, and retained only in the owner-protected host database. A bind mount whose source +contains any active Agent's secret file is refused at apply time, because the Sandbox would otherwise read the real +values from the mounted directory. The Sandbox sees an inert placeholder in the named environment variable. The Network Backend substitutes +the current real value only for an authorized request to an allowed host; rotation does not require copying new +material into the Sandbox. A custom placeholder is optional for clients that validate token shape. +Set `optional: true` when a missing or empty environment-file value should omit that secret binding instead of +rejecting the Agent apply. Required secrets remain the default. + +Policy is evaluated for live Sandbox-originated operations and fails closed when the destination, authorization, +secret resolution or trusted mediation path is unavailable. Host-destined traffic is restricted to the registered +Platform API endpoint. This authorization is separate from authorization of users calling the host Agent API. +When an Agent image includes Podman, the platform makes the guest's mediated CA bundle available to containers and +build steps through standard trust paths. An OCI hook copies the bundle into the container root filesystem rather +than bind-mounting it, so package managers can still replace the bundle, and it adds the mediator CA as a trust +anchor so a regenerated bundle keeps trusting mediation. Docker and dockerd are not covered by this convenience +wiring. + +SQLite `secure_delete` and owner-only filesystem permissions provide local hygiene. They are not a cryptographic +erasure guarantee across WAL history, filesystem snapshots or backups. + +## Current scope and direction + +The current milestone provides persistent Agents and Sessions, real Microsandbox lifecycle, mediated harness and GitHub +authentication, image-owned workspace initialization, idle/resume behavior, local packaging and release-pinned runtime +downloads. + +Important current limitations are: + +- Codex uses a separate ChatGPT subscription login owned and refreshed by `agentd`; +- Sessions share one Sandbox user and tmux server and therefore one trust boundary; +- attachment is still a client-side Provider operation rather than a daemon-owned terminal capability; +- Session content, prompt steering and plugin APIs are not implemented; and +- global scheduling and Kubernetes orchestration are future work. + +The next planned slices are: + +1. expose harness-native Session content and prompt/steer/interrupt operations; +2. add an authorized Sandbox-facing Platform API for delegation and isolated host plugins; and +3. add global orchestration only after the local control-plane contracts are proven. + +## References + +- agentdp and nvt-agent: earlier agent-platform prototypes +- Microsandbox and smolvm: microVM and Sandbox implementations +- Herdr: harness multiplexing and native session-state exploration diff --git a/agentctl/changelog.sh b/agentctl/changelog.sh new file mode 100755 index 0000000..725119b --- /dev/null +++ b/agentctl/changelog.sh @@ -0,0 +1,434 @@ +#!/bin/sh +# Changelog tooling for the Agent platform. +# +# One changelog, agentctl/CHANGELOG.md, covers the whole stack: the `agentctl` and `agentd` +# binaries published by the `agentctl/v*` tag, and the Agent images they work with. It +# follows Keep a Changelog 1.1.0 and Semantic Versioning 2.0.0. The script has no dependencies +# beyond a POSIX shell, coreutils, awk and git. +# +# Usage: +# changelog.sh validate [path] +# Check the changelog's structure. Fails unless the file starts with the Keep a Changelog +# header, has exactly one `## [Unreleased]` section as its first version heading, writes +# every released section as `## [X.Y.Z] - YYYY-MM-DD` in descending Semantic Versioning +# order, uses only the sections Added, Changed, Fixed, Removed, Security and Deprecated in +# that order within a version, leaves no `###` section empty, and writes every entry as a +# `- ` bullet (continuation lines are indented by at least two spaces). +# +# changelog.sh extract [path] +# Print the body of one released version's section, without its heading, to standard output. +# The version may be written with or without a leading `v`. Exits non-zero when the section +# is missing, has no release date, or has no content. Used by the release workflow to build +# the GitHub release notes. +# +# changelog.sh check-unreleased [path] +# Compare the `## [Unreleased]` section between two Git references and exit non-zero when it +# is unchanged. The comparison uses the commit where the two references diverged, not the tip +# of the base reference, so an entry another pull request added in the meantime cannot +# satisfy the check. A file missing at that commit counts as changed, but a reference that +# cannot be resolved is an error rather than a pass. Used by the pull request workflow; a +# pull request with no user-visible change carries the `skip-changelog` label instead. +# +# `path` defaults to CHANGELOG.md beside this script. + +set -eu + +SCRIPT_NAME="$(basename "$0")" +SCRIPT_DIRECTORY="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)" +DEFAULT_CHANGELOG="${SCRIPT_DIRECTORY}/CHANGELOG.md" + +# Semantic Versioning 2.0.0, without build metadata: the core numbers carry no leading zeroes, and a +# prerelease is a dot-separated list of identifiers that are alphanumeric or numeric without leading +# zeroes. Written out because a version that cannot be compared must not be accepted as a heading. +SEMVER_NUMBER='(0|[1-9][0-9]*)' +SEMVER_IDENTIFIER='([0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*|0|[1-9][0-9]*)' +SEMVER_PATTERN="${SEMVER_NUMBER}[.]${SEMVER_NUMBER}[.]${SEMVER_NUMBER}(-${SEMVER_IDENTIFIER}([.]${SEMVER_IDENTIFIER})*)?" + +fail() { + printf '%s: %s\n' "${SCRIPT_NAME}" "$1" >&2 + exit 1 +} + +usage() { + cat >&2 < [path] + ${SCRIPT_NAME} check-unreleased [path] +USAGE + exit 2 +} + +require_file() { + [ -f "$1" ] || fail "changelog not found: $1" +} + +# Print the path of a file as Git records it, so `git show :` can resolve it from +# anywhere inside the working tree. +repository_path() { + directory="$(CDPATH='' cd -- "$(dirname -- "$1")" && pwd)" + prefix="$(git -C "${directory}" rev-parse --show-prefix)" || + fail "not inside a Git repository: $1" + printf '%s%s\n' "${prefix}" "$(basename -- "$1")" +} + +# Compare two dot-separated prerelease strings. Prints -1, 0 or 1. +compare_prerelease() { + left_rest="$1" + right_rest="$2" + while :; do + # Prerelease identifiers are never empty, so an empty remainder means the string ended. + if [ -z "${left_rest}" ] && [ -z "${right_rest}" ]; then + printf '%s\n' 0 + return 0 + fi + if [ -z "${left_rest}" ]; then + printf '%s\n' -1 + return 0 + fi + if [ -z "${right_rest}" ]; then + printf '%s\n' 1 + return 0 + fi + left="${left_rest%%.*}" + right="${right_rest%%.*}" + case "${left_rest}" in *.*) left_rest="${left_rest#*.}" ;; *) left_rest='' ;; esac + case "${right_rest}" in *.*) right_rest="${right_rest#*.}" ;; *) right_rest='' ;; esac + left_numeric=no + right_numeric=no + case "${left}" in '' | *[!0-9]*) ;; *) left_numeric=yes ;; esac + case "${right}" in '' | *[!0-9]*) ;; *) right_numeric=yes ;; esac + if [ "${left_numeric}" = yes ] && [ "${right_numeric}" = yes ]; then + if [ "${left}" -gt "${right}" ]; then + printf '%s\n' 1 + return 0 + fi + if [ "${left}" -lt "${right}" ]; then + printf '%s\n' -1 + return 0 + fi + elif [ "${left_numeric}" = yes ]; then + # Numeric identifiers always have lower precedence than alphanumeric ones. + printf '%s\n' -1 + return 0 + elif [ "${right_numeric}" = yes ]; then + printf '%s\n' 1 + return 0 + elif [ "${left}" != "${right}" ]; then + lower="$(printf '%s\n%s\n' "${left}" "${right}" | LC_ALL=C sort | head -n 1)" + if [ "${lower}" = "${left}" ]; then + printf '%s\n' -1 + else + printf '%s\n' 1 + fi + return 0 + fi + done +} + +# Compare two Semantic Versioning 2.0.0 versions. Prints -1, 0 or 1. +compare_versions() { + left_core="${1%%-*}" + right_core="${2%%-*}" + case "$1" in *-*) left_prerelease="${1#*-}" ;; *) left_prerelease='' ;; esac + case "$2" in *-*) right_prerelease="${2#*-}" ;; *) right_prerelease='' ;; esac + field=1 + while [ "${field}" -le 3 ]; do + left="$(printf '%s' "${left_core}" | cut -d. -f"${field}")" + right="$(printf '%s' "${right_core}" | cut -d. -f"${field}")" + if [ "${left}" -gt "${right}" ]; then + printf '%s\n' 1 + return 0 + fi + if [ "${left}" -lt "${right}" ]; then + printf '%s\n' -1 + return 0 + fi + field=$((field + 1)) + done + if [ -z "${left_prerelease}" ] && [ -z "${right_prerelease}" ]; then + printf '%s\n' 0 + return 0 + fi + # A version with a prerelease has lower precedence than the same version without one. + if [ -z "${left_prerelease}" ]; then + printf '%s\n' 1 + return 0 + fi + if [ -z "${right_prerelease}" ]; then + printf '%s\n' -1 + return 0 + fi + compare_prerelease "${left_prerelease}" "${right_prerelease}" +} + +# Check everything that can be checked one line at a time, and print the released versions in the +# order they appear so the caller can check their ordering. +validate_structure() { + awk -v label="$2" -v semver="${SEMVER_PATTERN}" ' + BEGIN { + count = split("Added Changed Fixed Removed Security Deprecated", allowed, " ") + for (index_ = 1; index_ <= count; index_++) rank[allowed[index_]] = index_ + order = "Added, Changed, Fixed, Removed, Security, Deprecated" + date = "[0-9][0-9][0-9][0-9]-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])" + version_heading = "^## \\[" semver "\\] - " date "$" + } + + function days_in_month(year, month, lengths) { + split("31 28 31 30 31 30 31 31 30 31 30 31", lengths, " ") + if (month == 2 && year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) return 29 + return lengths[month] + 0 + } + + function problem(message) { + printf "%s:%d: %s\n", label, NR, message > "/dev/stderr" + failures++ + } + + function close_section() { + if (section != "" && entries == 0) { + printf "%s:%d: section \"### %s\" is empty\n", label, section_line, section > "/dev/stderr" + failures++ + } + section = "" + entries = 0 + } + + { if (sub(/\r$/, "")) carriage_returns++ } + + NR == 1 { + if ($0 != "# Changelog") problem("the first line must be \"# Changelog\"") + } + + # Only the introduction counts as the header, so the same links inside an entry cannot stand + # in for it. + headings == 0 && /keepachangelog\.com\/en\/1\.1\.0/ { keep_a_changelog = 1 } + headings == 0 && /semver\.org\/spec\/v2\.0\.0\.html/ { semantic_versioning = 1 } + + /^# / && NR > 1 { problem("only the first line may be a level 1 heading"); next } + + /^## / { + close_section() + if ($0 == "## [Unreleased]") { + unreleased++ + if (unreleased > 1) problem("there must be exactly one \"## [Unreleased]\" section") + if (headings > 0) problem("\"## [Unreleased]\" must be the first version section") + } else if ($0 ~ version_heading) { + if (unreleased == 0) problem("\"## [Unreleased]\" must be the first version section") + released = substr($0, length($0) - 9) + if (substr(released, 9, 2) + 0 > days_in_month(substr(released, 1, 4) + 0, substr(released, 6, 2) + 0)) { + problem(released " is not a date that exists") + } + version = $0 + sub(/^## \[/, "", version) + sub(/\].*$/, "", version) + if (version in seen) problem("duplicate section for version " version) + seen[version] = 1 + print version + } else { + problem("expected \"## [Unreleased]\" or \"## [X.Y.Z] - YYYY-MM-DD\", found: " $0) + } + headings++ + highest = 0 + next + } + + /^### / { + close_section() + if (headings == 0) { problem("\"" $0 "\" appears before any version section"); next } + name = substr($0, 5) + if (!(name in rank)) { + problem("unknown section \"### " name "\"; allowed sections are " order) + next + } + if (rank[name] <= highest) problem("\"### " name "\" is out of order; sections must appear as " order) + highest = rank[name] + section = name + section_line = NR + next + } + + /^#### / { problem("headings deeper than \"###\" are not used in this changelog"); next } + + { + if ($0 ~ /^[ \t]*$/) next + if ($0 ~ /^\[[^]]+\]: /) next # Keep a Changelog link reference definitions + if (headings == 0) next # introduction above the first version section + if (section == "") { problem("content must sit under a \"###\" section: " $0); next } + if ($0 ~ /^- ./) { entries++; next } + if ($0 ~ /^ +[^ ]/ && entries > 0) next # continuation of the preceding bullet + problem("every entry must be a \"- \" bullet: " $0) + } + + END { + close_section() + if (!keep_a_changelog) { + printf "%s: the header must link to Keep a Changelog 1.1.0\n", label > "/dev/stderr" + failures++ + } + if (!semantic_versioning) { + printf "%s: the header must link to Semantic Versioning 2.0.0\n", label > "/dev/stderr" + failures++ + } + if (unreleased == 0) { + printf "%s: an \"## [Unreleased]\" section is required\n", label > "/dev/stderr" + failures++ + } + if (carriage_returns > 0) { + printf "%s: the file uses CRLF line endings; write the changelog with LF\n", label > "/dev/stderr" + failures++ + } + if (failures > 0) exit 1 + } + ' "$1" +} + +command_validate() { + path="${1:-${DEFAULT_CHANGELOG}}" + require_file "${path}" + label="$(basename -- "${path}")" + versions="$(validate_structure "${path}" "${label}")" || + fail "${path} is not a valid changelog" + previous='' + while IFS= read -r version; do + [ -n "${version}" ] || continue + if [ -n "${previous}" ] && [ "$(compare_versions "${previous}" "${version}")" != 1 ]; then + fail "${path}: released sections must be in descending order, but ${previous} is listed above ${version}" + fi + previous="${version}" + done <= first && body[last] ~ /^[ \t]*$/) last-- + if (first > last) exit 5 + # Collapse runs of blank lines, which a skipped link reference can leave behind. + for (index_ = first; index_ <= last; index_++) { + if (body[index_] ~ /^[ \t]*$/) { + if (blank) continue + blank = 1 + } else { + blank = 0 + } + print body[index_] + } + } + ' "$1" +} + +command_extract() { + [ $# -ge 1 ] || usage + version="${1#v}" + path="${2:-${DEFAULT_CHANGELOG}}" + require_file "${path}" + status=0 + section_body "${path}" "${version}" yes || status=$? + case "${status}" in + 0) ;; + 3) fail "${path} has no section for version ${version}" ;; + 4) fail "${path}: the section for version ${version} has no release date; write it as \"## [${version}] - YYYY-MM-DD\"" ;; + 5) fail "${path}: the section for version ${version} is empty" ;; + *) fail "${path}: could not read the section for version ${version}" ;; + esac +} + +# Print the Unreleased section's body at one reference. Returns 1 when the file does not exist +# there, which the caller reads as "nothing to compare against". +unreleased_at_reference() { + directory="$1" + reference="$2" + tracked="$3" + content="$(mktemp)" + if ! git -C "${directory}" show "${reference}:${tracked}" >"${content}" 2>/dev/null; then + rm -f "${content}" + return 1 + fi + status=0 + section_body "${content}" Unreleased no || status=$? + rm -f "${content}" + # Exit 3 (missing) and 5 (empty) both mean "nothing recorded", which compares as empty. + case "${status}" in + 0 | 3 | 5) return 0 ;; + *) fail "could not read the \"## [Unreleased]\" section of ${tracked} at ${reference}" ;; + esac +} + +require_commit() { + git -C "$1" cat-file -e "$2^{commit}" 2>/dev/null || + fail "cannot resolve $3 reference: $2" +} + +command_check_unreleased() { + [ $# -ge 2 ] || usage + base="$1" + head="$2" + path="${3:-${DEFAULT_CHANGELOG}}" + # Every Git call runs inside the checkout that holds the changelog, so the subcommand works + # from any working directory. + directory="$(CDPATH='' cd -- "$(dirname -- "${path}")" && pwd)" || + fail "changelog directory not found: $(dirname -- "${path}")" + tracked="$(repository_path "${path}")" + # An unresolvable reference is a broken invocation, not a passing check. + require_commit "${directory}" "${base}" base + require_commit "${directory}" "${head}" head + # Compare against the commit the two references diverged from, not the tip of the base branch. + # Otherwise an entry another pull request added to Unreleased in the meantime makes the two + # sections differ, and a pull request that never touched the changelog passes. + fork_point="$(git -C "${directory}" merge-base "${base}" "${head}" 2>/dev/null)" || fork_point='' + [ -n "${fork_point}" ] || fork_point="${base}" + if ! base_body="$(unreleased_at_reference "${directory}" "${fork_point}" "${tracked}")"; then + printf 'No %s at %s; treating the Unreleased section as changed.\n' "${tracked}" "${fork_point}" + return 0 + fi + head_body="$(unreleased_at_reference "${directory}" "${head}" "${tracked}")" || + fail "${tracked} does not exist at ${head}" + if [ "${base_body}" = "${head_body}" ]; then + cat >&2 < +assert_status() { + name="$1" + expected="$2" + shift 2 + status=0 + "$@" >"${WORK}/stdout" 2>"${WORK}/stderr" || status=$? + if [ "${status}" -eq "${expected}" ]; then + report_pass "${name}" + else + report_failure "${name}" "expected status ${expected}, got ${status}" + sed 's/^/ /' "${WORK}/stderr" + fi +} + +# assert_output +assert_output() { + name="$1" + expected="$2" + shift 2 + status=0 + "$@" >"${WORK}/stdout" 2>"${WORK}/stderr" || status=$? + actual="$(cat "${WORK}/stdout")" + if [ "${status}" -ne 0 ]; then + report_failure "${name}" "command failed with status ${status}" + sed 's/^/ /' "${WORK}/stderr" + elif [ "${actual}" != "${expected}" ]; then + report_failure "${name}" "unexpected output" + printf ' expected: %s\n actual: %s\n' "${expected}" "${actual}" + else + report_pass "${name}" + fi +} + +# assert_message +# Asserting on the message as well as the status keeps two different rules from covering for +# each other when one of them is removed. +assert_message() { + name="$1" + expected="$2" + needle="$3" + shift 3 + status=0 + "$@" >"${WORK}/stdout" 2>"${WORK}/stderr" || status=$? + if [ "${status}" -ne "${expected}" ]; then + report_failure "${name}" "expected status ${expected}, got ${status}" + sed 's/^/ /' "${WORK}/stderr" + elif ! grep -qF "${needle}" "${WORK}/stderr"; then + report_failure "${name}" "stderr did not mention \"${needle}\"" + sed 's/^/ /' "${WORK}/stderr" + else + report_pass "${name}" + fi +} + +header() { + cat <<'HEADER' +# Changelog + +All notable changes to the Agent platform will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +HEADER +} + +# fixture — reads the body from standard input and prints the fixture's path. +fixture() { + path="${WORK}/$1.md" + { + header + cat + } >"${path}" + printf '%s\n' "${path}" +} + +# ---------------------------------------------------------------- validate --- + +good="$(fixture good <<'BODY' + +## [Unreleased] + +### Added + +- `agentctl port-forward` forwards a local port into a running Agent. + +### Fixed + +- Installing on Windows no longer fails while verifying the archive. + +## [1.0.0] - 2026-09-01 + +### Changed + +- The hostname inside a Sandbox is the Agent name. + +## [1.0.0-rc.2] - 2026-08-20 + +### Added + +- Second release candidate. + +## [1.0.0-rc.1] - 2026-08-10 + +### Added + +- First release candidate. + +## [0.9.0] - 2026-08-01 + +### Added + +- First public preview. +BODY +)" + +assert_status 'validate accepts a well formed changelog' 0 "${CHANGELOG}" validate "${good}" + +empty_unreleased="$(fixture empty-unreleased <<'BODY' + +## [Unreleased] + +## [1.0.0] - 2026-09-01 + +### Added + +- First release. +BODY +)" +assert_status 'validate accepts an Unreleased section with no entries yet' 0 \ + "${CHANGELOG}" validate "${empty_unreleased}" + +no_header="${WORK}/no-header.md" +cat >"${no_header}" <<'BODY' +# Release notes + +## [Unreleased] + +### Added + +- Something. +BODY +assert_status 'validate rejects a missing header' 1 "${CHANGELOG}" validate "${no_header}" + +two_unreleased="$(fixture two-unreleased <<'BODY' + +## [Unreleased] + +### Added + +- Something. + +## [Unreleased] + +### Added + +- Something else. +BODY +)" +assert_status 'validate rejects two Unreleased sections' 1 "${CHANGELOG}" validate "${two_unreleased}" + +unreleased_late="$(fixture unreleased-late <<'BODY' + +## [1.0.0] - 2026-09-01 + +### Added + +- First release. + +## [Unreleased] + +### Added + +- Something. +BODY +)" +assert_status 'validate rejects an Unreleased section that is not first' 1 \ + "${CHANGELOG}" validate "${unreleased_late}" + +undated="$(fixture undated <<'BODY' + +## [Unreleased] + +## [1.0.0] + +### Added + +- First release. +BODY +)" +assert_status 'validate rejects a released section with no date' 1 "${CHANGELOG}" validate "${undated}" + +bad_date="$(fixture bad-date <<'BODY' + +## [Unreleased] + +## [1.0.0] - 01.09.2026 + +### Added + +- First release. +BODY +)" +assert_status 'validate rejects a date that is not YYYY-MM-DD' 1 "${CHANGELOG}" validate "${bad_date}" + +out_of_order="$(fixture out-of-order <<'BODY' + +## [Unreleased] + +## [1.0.0] - 2026-08-01 + +### Added + +- First release. + +## [1.1.0] - 2026-09-01 + +### Added + +- Second release. +BODY +)" +assert_status 'validate rejects released sections in ascending order' 1 \ + "${CHANGELOG}" validate "${out_of_order}" + +prerelease_order="$(fixture prerelease-order <<'BODY' + +## [Unreleased] + +## [1.0.0-rc.1] - 2026-08-10 + +### Added + +- Release candidate. + +## [1.0.0] - 2026-09-01 + +### Added + +- First release. +BODY +)" +assert_status 'validate rejects a prerelease listed above its own release' 1 \ + "${CHANGELOG}" validate "${prerelease_order}" + +prerelease_numbers="$(fixture prerelease-numbers <<'BODY' + +## [Unreleased] + +## [1.0.0-preview.2] - 2026-09-01 + +### Added + +- Second preview. + +## [1.0.0-preview.10] - 2026-08-01 + +### Added + +- Tenth preview, released earlier by mistake. +BODY +)" +assert_status 'validate compares numeric prerelease identifiers numerically' 1 \ + "${CHANGELOG}" validate "${prerelease_numbers}" + +prerelease_lengths="$(fixture prerelease-lengths <<'BODY' + +## [Unreleased] + +## [1.0.0-alpha.1] - 2026-09-01 + +### Added + +- Second alpha. + +## [1.0.0-alpha] - 2026-08-01 + +### Added + +- First alpha. +BODY +)" +assert_status 'validate ranks a longer prerelease above a shorter prefix of it' 0 \ + "${CHANGELOG}" validate "${prerelease_lengths}" + +malformed_versions="$(fixture malformed-versions <<'BODY' + +## [Unreleased] + +## [01.0.0] - 2026-09-01 + +### Added + +- A core number with a leading zero. +BODY +)" +assert_status 'validate rejects a leading zero in a core version number' 1 \ + "${CHANGELOG}" validate "${malformed_versions}" + +empty_identifier="$(fixture empty-identifier <<'BODY' + +## [Unreleased] + +## [1.0.0-alpha..1] - 2026-09-01 + +### Added + +- An empty prerelease identifier. +BODY +)" +assert_status 'validate rejects an empty prerelease identifier' 1 \ + "${CHANGELOG}" validate "${empty_identifier}" + +numeric_leading_zero="$(fixture numeric-leading-zero <<'BODY' + +## [Unreleased] + +## [1.0.0-preview.01] - 2026-09-01 + +### Added + +- A numeric prerelease identifier with a leading zero. +BODY +)" +assert_status 'validate rejects a leading zero in a numeric prerelease identifier' 1 \ + "${CHANGELOG}" validate "${numeric_leading_zero}" + +hyphenated_prerelease="$(fixture hyphenated-prerelease <<'BODY' + +## [Unreleased] + +## [1.0.0-rc-1.2] - 2026-09-01 + +### Added + +- A hyphenated alphanumeric prerelease identifier. +BODY +)" +assert_status 'validate accepts hyphens inside a prerelease identifier' 0 \ + "${CHANGELOG}" validate "${hyphenated_prerelease}" + +section_order="$(fixture section-order <<'BODY' + +## [Unreleased] + +### Fixed + +- Something. + +### Added + +- Something else. +BODY +)" +assert_status 'validate rejects sections in the wrong order' 1 "${CHANGELOG}" validate "${section_order}" + +unknown_section="$(fixture unknown-section <<'BODY' + +## [Unreleased] + +### Improved + +- Something. +BODY +)" +assert_status 'validate rejects an unknown section' 1 "${CHANGELOG}" validate "${unknown_section}" + +empty_section="$(fixture empty-section <<'BODY' + +## [Unreleased] + +### Added + +### Fixed + +- Something. +BODY +)" +assert_status 'validate rejects an empty section' 1 "${CHANGELOG}" validate "${empty_section}" + +loose_text="$(fixture loose-text <<'BODY' + +## [Unreleased] + +### Added + +Something happened. +BODY +)" +assert_status 'validate rejects an entry that is not a bullet' 1 "${CHANGELOG}" validate "${loose_text}" + +continuation="$(fixture continuation <<'BODY' + +## [Unreleased] + +### Added + +- Something happened, and the explanation + continues on the next line. +BODY +)" +assert_status 'validate accepts an indented continuation line' 0 "${CHANGELOG}" validate "${continuation}" + +duplicate_version="$(fixture duplicate-version <<'BODY' + +## [Unreleased] + +## [1.0.0] - 2026-09-01 + +### Added + +- First release. + +## [1.0.0] - 2026-08-01 + +### Added + +- The same version again. +BODY +)" +assert_message 'validate rejects a duplicate released version' 1 'duplicate section for version 1.0.0' \ + "${CHANGELOG}" validate "${duplicate_version}" + +deep_heading="$(fixture deep-heading <<'BODY' + +## [Unreleased] + +### Added + +- Something. + +#### Details + +- More. +BODY +)" +assert_message 'validate rejects a heading deeper than "###"' 1 'deeper than' \ + "${CHANGELOG}" validate "${deep_heading}" + +outside_section="$(fixture outside-section <<'BODY' + +## [Unreleased] + +Some prose before any category. + +### Added + +- Something. +BODY +)" +assert_message 'validate rejects content outside a "###" section' 1 'must sit under' \ + "${CHANGELOG}" validate "${outside_section}" + +bad_month="$(fixture bad-month <<'BODY' + +## [Unreleased] + +## [1.0.0] - 2026-13-45 + +### Added + +- An impossible date. +BODY +)" +assert_status 'validate rejects an impossible month and day' 1 "${CHANGELOG}" validate "${bad_month}" + +impossible_dates="$(fixture impossible-dates <<'BODY' + +## [Unreleased] + +## [1.0.2] - 2026-02-31 + +### Added + +- The 31st of February. +BODY +)" +assert_message 'validate rejects a day past the end of the month' 1 '2026-02-31 is not a date' \ + "${CHANGELOG}" validate "${impossible_dates}" + +short_month="$(fixture short-month <<'BODY' + +## [Unreleased] + +## [1.0.1] - 2026-04-31 + +### Added + +- The 31st of April. +BODY +)" +assert_message 'validate rejects the 31st of a 30-day month' 1 '2026-04-31 is not a date' \ + "${CHANGELOG}" validate "${short_month}" + +common_year="$(fixture common-year <<'BODY' + +## [Unreleased] + +## [1.0.0] - 2025-02-29 + +### Added + +- The 29th of February in a common year. +BODY +)" +assert_message 'validate rejects 29 February outside a leap year' 1 '2025-02-29 is not a date' \ + "${CHANGELOG}" validate "${common_year}" + +leap_years="$(fixture leap-years <<'BODY' + +## [Unreleased] + +## [2.0.0] - 2024-02-29 + +### Added + +- A leap year divisible by four. + +## [1.0.0] - 2000-02-29 + +### Added + +- A leap year divisible by four hundred. +BODY +)" +assert_status 'validate accepts 29 February in a leap year' 0 "${CHANGELOG}" validate "${leap_years}" + +century="$(fixture century <<'BODY' + +## [Unreleased] + +## [1.0.0] - 1900-02-29 + +### Added + +- A century that is not a leap year. +BODY +)" +assert_message 'validate rejects 29 February in a non-leap century' 1 '1900-02-29 is not a date' \ + "${CHANGELOG}" validate "${century}" + +header_in_entry="${WORK}/header-in-entry.md" +cat >"${header_in_entry}" <<'BODY' +# Changelog + +## [Unreleased] + +### Added + +- A link to https://keepachangelog.com/en/1.1.0/ and https://semver.org/spec/v2.0.0.html in an entry. +BODY +assert_message 'validate does not accept header links found inside an entry' 1 'Keep a Changelog' \ + "${CHANGELOG}" validate "${header_in_entry}" + +link_references="$(fixture link-references <<'BODY' + +## [Unreleased] + +### Added + +- Something. + +## [1.0.0] - 2026-09-01 + +### Added + +- First release. + +[Unreleased]: https://example.com/compare/1.0.0...HEAD +[1.0.0]: https://example.com/releases/1.0.0 +BODY +)" +assert_status 'validate accepts Keep a Changelog link reference definitions' 0 \ + "${CHANGELOG}" validate "${link_references}" +assert_output 'extract leaves link reference definitions out of the body' \ + '### Added + +- First release.' \ + "${CHANGELOG}" extract 1.0.0 "${link_references}" + +# A link reference between categories must not truncate the section. +interleaved="$(fixture interleaved-link <<'BODY' + +## [Unreleased] + +## [1.0.0] - 2026-09-01 + +### Added + +- Something. + +[1.0.0]: https://example.com/releases/1.0.0 + +### Fixed + +- Something else. +BODY +)" +assert_output 'extract keeps categories that follow a link reference' \ + '### Added + +- Something. + +### Fixed + +- Something else.' \ + "${CHANGELOG}" extract 1.0.0 "${interleaved}" + +crlf="${WORK}/crlf.md" +sed 's/$/\r/' "${good}" >"${crlf}" +assert_message 'validate reports CRLF line endings' 1 'CRLF' "${CHANGELOG}" validate "${crlf}" + +assert_status 'validate reports a missing file' 1 "${CHANGELOG}" validate "${WORK}/absent.md" + +# ----------------------------------------------------------------- extract --- + +assert_output 'extract prints a released section without its heading' \ + '### Changed + +- The hostname inside a Sandbox is the Agent name.' \ + "${CHANGELOG}" extract 1.0.0 "${good}" + +assert_output 'extract accepts a leading v' \ + '### Added + +- First release candidate.' \ + "${CHANGELOG}" extract v1.0.0-rc.1 "${good}" + +assert_message 'extract fails for a missing version' 1 'no section for version 2.0.0' \ + "${CHANGELOG}" extract 2.0.0 "${good}" +assert_message 'extract fails for an undated version' 1 'has no release date' \ + "${CHANGELOG}" extract 1.0.0 "${undated}" +assert_message 'extract rejects an undated section by name' 1 'has no release date' \ + "${CHANGELOG}" extract Unreleased "${good}" + +dated_but_empty="$(fixture dated-but-empty <<'BODY' + +## [Unreleased] + +## [1.0.0] - 2026-09-01 + +## [0.9.0] - 2026-08-01 + +### Added + +- First release. +BODY +)" +assert_message 'extract rejects a dated section with no content' 1 'is empty' \ + "${CHANGELOG}" extract 1.0.0 "${dated_but_empty}" + +# -------------------------------------------------------- check-unreleased --- + +REPOSITORY="${WORK}/repository" +mkdir -p "${REPOSITORY}/agentctl" +git -C "${REPOSITORY}" init --quiet +git -C "${REPOSITORY}" config user.email changelog-test@example.com +git -C "${REPOSITORY}" config user.name 'Changelog Test' +tracked="${REPOSITORY}/agentctl/CHANGELOG.md" + +git -C "${REPOSITORY}" commit --quiet --allow-empty -m 'empty' +empty_base="$(git -C "${REPOSITORY}" rev-parse HEAD)" + +cp "${good}" "${tracked}" +git -C "${REPOSITORY}" add -A +git -C "${REPOSITORY}" commit --quiet -m 'add changelog' +base="$(git -C "${REPOSITORY}" rev-parse HEAD)" + +assert_status 'check-unreleased treats a missing base file as changed' 0 \ + "${CHANGELOG}" check-unreleased "${empty_base}" "${base}" "${tracked}" + +# A change that leaves the Unreleased section alone. +printf '\n' >>"${tracked}" +git -C "${REPOSITORY}" commit --quiet -a -m 'unrelated change' +unchanged="$(git -C "${REPOSITORY}" rev-parse HEAD)" +assert_status 'check-unreleased fails when the Unreleased section is untouched' 1 \ + "${CHANGELOG}" check-unreleased "${base}" "${unchanged}" "${tracked}" + +# A change that adds an entry. +awk '{ print } /^## \[Unreleased\]$/ { print ""; print "### Changed"; print ""; print "- Another entry." }' \ + "${tracked}" >"${tracked}.next" +mv "${tracked}.next" "${tracked}" +git -C "${REPOSITORY}" commit --quiet -a -m 'add an entry' +changed="$(git -C "${REPOSITORY}" rev-parse HEAD)" +assert_status 'check-unreleased passes when an entry is added' 0 \ + "${CHANGELOG}" check-unreleased "${base}" "${changed}" "${tracked}" + +# An entry another pull request added to the base branch after this branch forked must not +# satisfy the check. +git -C "${REPOSITORY}" checkout --quiet -b feature "${base}" +printf 'code\n' >"${REPOSITORY}/code.txt" +git -C "${REPOSITORY}" add -A +git -C "${REPOSITORY}" commit --quiet -m 'change code only' +feature="$(git -C "${REPOSITORY}" rev-parse HEAD)" +git -C "${REPOSITORY}" checkout --quiet main 2>/dev/null || git -C "${REPOSITORY}" checkout --quiet master +moved_on="$(git -C "${REPOSITORY}" rev-parse HEAD)" +assert_status 'check-unreleased ignores entries the base branch gained after the fork' 1 \ + "${CHANGELOG}" check-unreleased "${moved_on}" "${feature}" "${tracked}" + +assert_message 'check-unreleased rejects an unresolvable base reference' 1 'cannot resolve base' \ + "${CHANGELOG}" check-unreleased no-such-ref "${feature}" "${tracked}" +assert_message 'check-unreleased rejects an unresolvable head reference' 1 'cannot resolve head' \ + "${CHANGELOG}" check-unreleased "${base}" no-such-ref "${tracked}" + +# -------------------------------------------------------------------------- # + +printf '\n%d checks, %d failures\n' "${checks}" "${failures}" +[ "${failures}" -eq 0 ] diff --git a/agentctl/examples/minimal/.dockerignore b/agentctl/examples/minimal/.dockerignore new file mode 100644 index 0000000..bdb2e3b --- /dev/null +++ b/agentctl/examples/minimal/.dockerignore @@ -0,0 +1,3 @@ +* +!Dockerfile +!claude-state.json diff --git a/agentctl/examples/minimal/Dockerfile b/agentctl/examples/minimal/Dockerfile new file mode 100644 index 0000000..960c96b --- /dev/null +++ b/agentctl/examples/minimal/Dockerfile @@ -0,0 +1,76 @@ +FROM ubuntu:26.04@sha256:2260313b31c8c011cd2eebe728008efac1b3982be73eb71348ea2648d2c0e09b + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + ca-certificates \ + curl \ + git \ + nodejs \ + npm \ + ripgrep \ + sudo \ + tmux \ + && rm -rf /var/lib/apt/lists/* + +ARG TARGETARCH +ARG GH_VERSION=2.98.0 +ARG GH_STACK_VERSION=0.1.1 + +RUN case "${TARGETARCH}" in \ + amd64) GH_SHA256=3b8ac6b30336802fc1a858d7c084e11cdf24ac1a761ca90b68022d7d729208de; \ + GH_STACK_SHA256=9ed103934fab0f90d3341fdfc4a342785396d39f5621fc7313a62602ce2b5462 ;; \ + arm64) GH_SHA256=cf689084f3a3618f7eae4a2420d335d74626d65f5e594b9828d125d69f800d86; \ + GH_STACK_SHA256=2da13f8c46f2770237c744b341ab6be9f07508585a6762634c4a88aa355460bc ;; \ + *) echo "unsupported target architecture: ${TARGETARCH}" >&2; exit 1 ;; \ + esac \ + && GH_ARCHIVE="gh_${GH_VERSION}_linux_${TARGETARCH}.tar.gz" \ + && curl -fsSL "https://github.com/cli/cli/releases/download/v${GH_VERSION}/${GH_ARCHIVE}" -o /tmp/gh.tar.gz \ + && echo "${GH_SHA256} /tmp/gh.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/gh.tar.gz -C /tmp \ + && install -m 0755 "/tmp/gh_${GH_VERSION}_linux_${TARGETARCH}/bin/gh" /usr/local/bin/gh \ + && curl -fsSL \ + "https://github.com/github/gh-stack/releases/download/v${GH_STACK_VERSION}/linux-${TARGETARCH}" \ + -o /tmp/gh-stack \ + && echo "${GH_STACK_SHA256} /tmp/gh-stack" | sha256sum -c - \ + && rm -rf /tmp/gh.tar.gz "/tmp/gh_${GH_VERSION}_linux_${TARGETARCH}" \ + && gh --version + +ARG CLAUDE_CODE_VERSION=2.1.286 + +# Agent Sandboxes mount their CA bundle here; this step's Node downloads trust it. +RUN if [ -r /run/agent/tls/ca-bundle.pem ]; then \ + export NODE_EXTRA_CA_CERTS=/run/agent/tls/ca-bundle.pem; \ + fi; \ + npm install --global --allow-scripts=@anthropic-ai/claude-code \ + "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ + && usermod --login agent --home /home/agent --move-home ubuntu \ + && groupmod --new-name agent ubuntu \ + && printf 'agent ALL=(ALL) NOPASSWD:ALL\n' >/etc/sudoers.d/agent \ + && chmod 0440 /etc/sudoers.d/agent \ + && install -d -m 0755 -o agent -g agent \ + /home/agent/.claude /home/agent/.local/share/gh/extensions/gh-stack /home/agent/code \ + && install -m 0755 -o agent -g agent \ + /tmp/gh-stack /home/agent/.local/share/gh/extensions/gh-stack/gh-stack \ + && printf '%s\n' \ + 'owner: github' \ + 'name: gh-stack' \ + 'host: github.com' \ + "tag: v${GH_STACK_VERSION}" \ + 'ispinned: true' \ + 'path: /home/agent/.local/share/gh/extensions/gh-stack/gh-stack' \ + >/home/agent/.local/share/gh/extensions/gh-stack/manifest.yml \ + && chown -R agent:agent /home/agent/.local \ + && rm -f /tmp/gh-stack + +ENV DISABLE_AUTOUPDATER=1 +ENV GH_NO_UPDATE_NOTIFIER=1 +ENV GH_NO_EXTENSION_UPDATE_NOTIFIER=1 +ENV HOME=/home/agent +ENV PATH=/home/agent/.local/bin:${PATH} + +COPY --chown=agent:agent --chmod=0600 claude-state.json /home/agent/.claude/.claude.json + +USER agent +WORKDIR /home/agent/code diff --git a/agentctl/examples/minimal/README.md b/agentctl/examples/minimal/README.md new file mode 100644 index 0000000..2109d9b --- /dev/null +++ b/agentctl/examples/minimal/README.md @@ -0,0 +1,39 @@ +# Minimal Agent + +This manifest-secret-free example exercises the core Agent lifecycle with mediated Claude Code authentication and one +or more persistent tmux sessions. It does not require a `.env` file or expose secrets to +the network mediator. Its small local Dockerfile contains only the tools needed for this +flow on the multi-platform Ubuntu 26.04 LTS base, and its layered root filesystem keeps the smoke-test sandbox +capacity-efficient. Sessions start in the platform's stable `/home/agent/code` workspace root; this example is +intentionally repository-free and uses the +Sandbox Provider's backend init instead of an image entrypoint. A builder that needs a boot-time checkout should use an +image init or entrypoint, like the self-development example, which offers both a boot-time clone and a bind-mounted +host checkout. Sessions can instead clone repositories on demand when their +Agent declares a suitable mediated secret. It is not intended for running Docker inside the Agent. + +```sh +agentctl claude login +agentctl apply --name agent-test --wait +agentctl get agent agent-test +agentctl describe agent/agent-test +agentctl wait --for=condition=Ready agent/agent-test --timeout=10m +agentctl exec agent/agent-test -- pwd +agentctl exec -it agent/agent-test -- bash +agentctl attach session/s1 --agent agent-test +agentctl get sessions --agent agent-test +``` + +`--wait` keeps `apply` attached and streams provisioning progress until the Agent is Ready; `wait` +does the same for an Agent that was applied earlier. Both stop with an error when desired state +is invalid and otherwise follow background retries until the timeout. + +When the current directory is inside the source directory of exactly one applied Agent, `agentctl exec -- pwd` and +Session commands infer the Agent; for example, `agentctl attach session/s1` works from this directory after applying +without another Agent name from the same source. + +Run these commands from this directory so paths in the manifest resolve against the intended example inputs. +The image seeds Claude's mutable `.claude.json` once for first-run prompts. Configuration intentionally placed in the +`home/` source is reapplied every reconciliation pass instead; that is appropriate for builder-owned declarative files +such as a Codex `config.toml`, and is also available when continuous ownership of Claude state is desired. +The builder-wide `instructions.md` payload is declared through `spec.instructions`; the Claude adapter installs it as +`~/.claude/CLAUDE.md`. diff --git a/agentctl/examples/minimal/agent.yaml b/agentctl/examples/minimal/agent.yaml new file mode 100644 index 0000000..edc3c88 --- /dev/null +++ b/agentctl/examples/minimal/agent.yaml @@ -0,0 +1,32 @@ +apiVersion: agents.platform/v1alpha1 +kind: Agent +metadata: + name: minimal +spec: + sandbox: + image: + type: build + context: . + dockerfile: Dockerfile + platform: + os: linux + resources: + cpu: "4" + memory: "8Gi" + rootFilesystem: + capacity: "64Gi" + mode: layered + home: + source: home + instructions: + - source: instructions.md + harnesses: + - type: claudeCode + auth: mediated + # The mediated token cannot list Fable in the /model picker, so new Sessions select it at launch. + defaults: + model: fable + secrets: [] + network: + mode: mediated + allow: all diff --git a/agentctl/examples/minimal/claude-state.json b/agentctl/examples/minimal/claude-state.json new file mode 100644 index 0000000..e2ab4b2 --- /dev/null +++ b/agentctl/examples/minimal/claude-state.json @@ -0,0 +1,9 @@ +{ + "hasCompletedOnboarding": true, + "bypassPermissionsModeAccepted": true, + "projects": { + "/home/agent/code": { + "hasTrustDialogAccepted": true + } + } +} diff --git a/agentctl/examples/minimal/home/.gitkeep b/agentctl/examples/minimal/home/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/agentctl/examples/minimal/instructions.md b/agentctl/examples/minimal/instructions.md new file mode 100644 index 0000000..d8a04a6 --- /dev/null +++ b/agentctl/examples/minimal/instructions.md @@ -0,0 +1,11 @@ +# Agent home + +Sessions start in `/home/agent/code`. Select or create a repository beneath that directory, follow its `AGENTS.md` +files before changing code, keep changes tied to the requested outcome, and run the closest relevant checks before +reporting completion. + +This example intentionally has no boot-time repository checkout. You may clone a repository on demand only when the +Agent is configured with GitHub access; use the installed `gh repo clone OWNER/REPOSITORY` command. Preserve existing +workspaces; never delete and reclone one as a retry strategy. + +Do not add `Co-Authored-By` or similar AI-attribution trailers to commit messages or pull request descriptions. diff --git a/agentctl/examples/self-dev/.dockerignore b/agentctl/examples/self-dev/.dockerignore new file mode 100644 index 0000000..e920afe --- /dev/null +++ b/agentctl/examples/self-dev/.dockerignore @@ -0,0 +1,9 @@ +* +!Dockerfile +!nvim-sysinit.vim +!ssh.service +!ssh-tmpfiles.conf +!sshd_config +!tmpfiles.conf +!workspace-init.service +!workspace-init.sh diff --git a/agentctl/examples/self-dev/.env.sample b/agentctl/examples/self-dev/.env.sample new file mode 100644 index 0000000..509b068 --- /dev/null +++ b/agentctl/examples/self-dev/.env.sample @@ -0,0 +1,5 @@ +# Git identity enters the Sandbox in plaintext. The GitHub token remains mediated. + +GIT_USER_NAME=Your Name +GIT_USER_EMAIL=you@example.com +GITHUB_TOKEN= diff --git a/agentctl/examples/self-dev/.gitignore b/agentctl/examples/self-dev/.gitignore new file mode 100644 index 0000000..b1c9d3a --- /dev/null +++ b/agentctl/examples/self-dev/.gitignore @@ -0,0 +1,6 @@ +# Checkout-local Agent variants are private by default. +agent.*.yaml + +# Repository-owned variants. +!agent.nested.yaml +!agent.worktree.yaml diff --git a/agentctl/examples/self-dev/Dockerfile b/agentctl/examples/self-dev/Dockerfile new file mode 100644 index 0000000..1d2c306 --- /dev/null +++ b/agentctl/examples/self-dev/Dockerfile @@ -0,0 +1,194 @@ +FROM ubuntu:26.04@sha256:2260313b31c8c011cd2eebe728008efac1b3982be73eb71348ea2648d2c0e09b + +# The repository's rust-toolchain.toml selects the toolchain inside a checkout, and Renovate moves +# this ARG together with it. The agent user (the image's ubuntu user until it is renamed below) +# owns RUSTUP_HOME, so a checkout pinned ahead of this image installs its toolchain on first use +# instead of failing. +ARG RUST_VERSION=1.97.1 +ARG CARGO_MACHETE_VERSION=0.9.2 + +ENV DEBIAN_FRONTEND=noninteractive +ENV RUSTUP_HOME=/usr/local/rustup +ENV PATH=/usr/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +# The toolchain the agent platform's own Rust workspace needs (libcap-ng-dev links the Microsandbox +# runtime), plus Podman so that a nested agentd can build Agent images through the Docker Engine API. +# Podman's netavark backend invokes nft for rootful bridge networking and aardvark-dns for +# container-name resolution. +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + aardvark-dns \ + build-essential \ + ca-certificates \ + clang \ + curl \ + fonts-jetbrains-mono \ + fonts-liberation \ + git \ + iproute2 \ + jq \ + libcap-ng-dev \ + libssl-dev \ + lldb \ + linux-perf \ + neovim \ + nftables \ + openssh-server \ + pkg-config \ + podman \ + podman-docker \ + procps \ + ripgrep \ + rustup \ + strace \ + sudo \ + systemd \ + tmux \ + && rm -rf /var/lib/apt/lists/* \ + && nvim --version \ + && CARGO_HOME=/usr/local/cargo rustup set profile minimal \ + && CARGO_HOME=/usr/local/cargo rustup default "${RUST_VERSION}" \ + && CARGO_HOME=/usr/local/cargo rustup component add clippy rustfmt \ + && CARGO_HOME=/usr/local/cargo cargo install cargo-machete --version "${CARGO_MACHETE_VERSION}" --locked \ + && rm -rf /usr/local/cargo/registry /usr/local/cargo/git \ + && chmod -R a+rX /usr/local/cargo \ + && chown -R ubuntu:ubuntu /usr/local/rustup + +COPY nvim-sysinit.vim /etc/xdg/nvim/sysinit.vim + +RUN nvim --headless \ + "+lua assert(vim.g.colors_name == 'habamax'); assert(vim.o.number); assert(vim.o.cursorline); assert(vim.o.termguicolors)" \ + +quit + +ARG TARGETARCH +ARG GH_VERSION=2.100.0 +ARG GH_STACK_VERSION=0.1.1 +ARG ASCIINEMA_VERSION=3.2.1 +ARG AGG_VERSION=1.9.0 +ARG NODE_VERSION=22.23.2 + +RUN case "${TARGETARCH}" in \ + amd64) NODE_ARCH=x64; RUST_ARCH=x86_64; \ + GH_SHA256=e4d4bb4498e8d007abe545b6568926793ace1b6447da598294a610018cb164be; \ + GH_STACK_SHA256=9ed103934fab0f90d3341fdfc4a342785396d39f5621fc7313a62602ce2b5462; \ + ASCIINEMA_SHA256=1b405bbda565b33c3c4718de67fedc3535580603c0694b1ff3fb04f363430a20; \ + AGG_SHA256=f111e315cd71056b116302342553dd765b7297579ed511f111d0cedb442aeda6 ;; \ + arm64) NODE_ARCH=arm64; RUST_ARCH=aarch64; \ + GH_SHA256=ea4e7a581a32ccad6cc7923cb1576ac5859ba4b9a16ab22eb8f8a96e78e2e961; \ + GH_STACK_SHA256=2da13f8c46f2770237c744b341ab6be9f07508585a6762634c4a88aa355460bc; \ + ASCIINEMA_SHA256=b516a6d896844c0ffbc96e0a55afe4cbcc79216abde0fc64fdda4e39bee421ea; \ + AGG_SHA256=2b4be407b97e00e1c313a41d154ced8fa3d02c560c8f47a0db4950a2576444c9 ;; \ + *) echo "unsupported target architecture: ${TARGETARCH}" >&2; exit 1 ;; \ + esac \ + && NODE_ARCHIVE="node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \ + && NODE_SHA256="$(curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/SHASUMS256.txt" \ + | awk -v filename="${NODE_ARCHIVE}" '$2 == filename { print $1 }')" \ + && test -n "${NODE_SHA256}" \ + && curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/${NODE_ARCHIVE}" -o /tmp/node.tar.xz \ + && echo "${NODE_SHA256} /tmp/node.tar.xz" | sha256sum -c - \ + && tar -xJf /tmp/node.tar.xz --strip-components=1 -C /usr/local \ + && GH_ARCHIVE="gh_${GH_VERSION}_linux_${TARGETARCH}.tar.gz" \ + && curl -fsSL "https://github.com/cli/cli/releases/download/v${GH_VERSION}/${GH_ARCHIVE}" -o /tmp/gh.tar.gz \ + && echo "${GH_SHA256} /tmp/gh.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/gh.tar.gz -C /tmp \ + && install -m 0755 "/tmp/gh_${GH_VERSION}_linux_${TARGETARCH}/bin/gh" /usr/local/bin/gh \ + && curl -fsSL \ + "https://github.com/github/gh-stack/releases/download/v${GH_STACK_VERSION}/linux-${TARGETARCH}" \ + -o /tmp/gh-stack \ + && echo "${GH_STACK_SHA256} /tmp/gh-stack" | sha256sum -c - \ + && curl -fsSL \ + "https://github.com/asciinema/asciinema/releases/download/v${ASCIINEMA_VERSION}/asciinema-${RUST_ARCH}-unknown-linux-gnu" \ + -o /tmp/asciinema \ + && echo "${ASCIINEMA_SHA256} /tmp/asciinema" | sha256sum -c - \ + && install -m 0755 /tmp/asciinema /usr/local/bin/asciinema \ + && curl -fsSL \ + "https://github.com/asciinema/agg/releases/download/v${AGG_VERSION}/agg-${RUST_ARCH}-unknown-linux-gnu" \ + -o /tmp/agg \ + && echo "${AGG_SHA256} /tmp/agg" | sha256sum -c - \ + && install -m 0755 /tmp/agg /usr/local/bin/agg \ + && rm -rf /tmp/node.tar.xz /tmp/gh.tar.gz "/tmp/gh_${GH_VERSION}_linux_${TARGETARCH}" /tmp/asciinema /tmp/agg \ + && node --version \ + && gh --version \ + && asciinema --version \ + && agg --version + +# Route GitHub credentials through the gh CLI so plain git commands authenticate with the +# host-mediated token. +RUN git config --system credential.https://github.com.helper '!/usr/local/bin/gh auth git-credential' + +ARG CODEX_VERSION=0.159.3 +ARG CLAUDE_CODE_VERSION=2.1.286 + +# Node ignores the system trust store, and Buildah drops default environment from build stages, +# so a build inside an Agent needs the system store selected per step for npm to trust mediation. +RUN NODE_OPTIONS=--use-openssl-ca npm install --global "@openai/codex@${CODEX_VERSION}" \ + && NODE_OPTIONS=--use-openssl-ca npm install --global --allow-scripts=@anthropic-ai/claude-code \ + "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ + && claude --version \ + && codex --version + +# A nested Microsandbox runtime needs /dev/kvm; tmpfiles.conf hands the device to the kvm group. +# OpenSSH rejects even public-key authentication for a locked account when PAM is disabled. +# Password and keyboard authentication remain disabled by the platform-owned SSH policy. +RUN { getent group kvm >/dev/null || groupadd --system kvm; } \ + && usermod --login agent --home /home/agent --move-home ubuntu \ + && passwd --delete agent \ + && groupmod --new-name agent ubuntu \ + && usermod --append --groups kvm agent \ + && printf 'agent ALL=(ALL) NOPASSWD:ALL\n' >/etc/sudoers.d/agent \ + && chmod 0440 /etc/sudoers.d/agent \ + && install -d -m 0755 -o agent -g agent \ + /home/agent/.local/share/gh/extensions/gh-stack /home/agent/code \ + && install -m 0755 -o agent -g agent \ + /tmp/gh-stack /home/agent/.local/share/gh/extensions/gh-stack/gh-stack \ + && printf '%s\n' \ + 'owner: github' \ + 'name: gh-stack' \ + 'host: github.com' \ + "tag: v${GH_STACK_VERSION}" \ + 'ispinned: true' \ + 'path: /home/agent/.local/share/gh/extensions/gh-stack/gh-stack' \ + >/home/agent/.local/share/gh/extensions/gh-stack/manifest.yml \ + && chown -R agent:agent /home/agent/.local \ + && rm -f /tmp/gh-stack \ + && rm -f /etc/machine-id /var/lib/dbus/machine-id \ + && touch /etc/machine-id + +COPY tmpfiles.conf /usr/lib/tmpfiles.d/agent-dev.conf +COPY workspace-init.service /etc/systemd/system/agent-workspace-init.service +COPY --chmod=0755 workspace-init.sh /usr/local/libexec/agent-workspace-init + +# agentd owns the loopback SSH server policy, unit and per-Agent state. The distribution's own +# ssh.service and socket are masked so the image cannot expose its package-generated host keys. +RUN systemctl mask ssh.service ssh.socket \ + && rm -f /etc/ssh/ssh_host_*_key /etc/ssh/ssh_host_*_key.pub + +# All guest Executions inherit init.scope, whose default task limit is too low +# for concurrent harnesses. The kernel pid limit remains the Sandbox boundary. +# The guest also has no udev to activate hvc0's device unit, so its generated +# serial login would otherwise wait 90 seconds at boot. +RUN install -d /etc/systemd/system/init.scope.d \ + && printf '[Scope]\nTasksMax=infinity\n' >/etc/systemd/system/init.scope.d/50-agent-tasks.conf \ + && install -d /usr/lib/sysctl.d \ + && printf 'fs.inotify.max_user_instances = 1024\nfs.inotify.max_user_watches = 1048576\nfs.inotify.max_queued_events = 32768\n' \ + >/usr/lib/sysctl.d/50-agent-inotify.conf \ + && systemctl mask serial-getty@hvc0.service \ + && systemctl enable agent-workspace-init.service + +# Keep Cargo's registry and build output on the Sandbox root filesystem so a worktree mount does +# not share target state with the host and a fresh checkout keeps reusable artifacts outside Git. +ENV CARGO_HOME=/home/agent/.cargo +ENV CARGO_TARGET_DIR=/home/agent/.cache/cargo-target +# The Sandbox Image Backend speaks the Docker Engine API; point it at the Podman socket the +# platform enables and grants to the agent user. +ENV DOCKER_HOST=unix:///run/podman/podman.sock +ENV DISABLE_AUTOUPDATER=1 +ENV GH_NO_UPDATE_NOTIFIER=1 +ENV GH_NO_EXTENSION_UPDATE_NOTIFIER=1 +ENV HOME=/home/agent +ENV PATH=/home/agent/.local/bin:/home/agent/.cargo/bin:${PATH} + +USER agent +WORKDIR /home/agent/code + +ENTRYPOINT ["/usr/lib/systemd/systemd"] diff --git a/agentctl/examples/self-dev/README.md b/agentctl/examples/self-dev/README.md new file mode 100644 index 0000000..532ff39 --- /dev/null +++ b/agentctl/examples/self-dev/README.md @@ -0,0 +1,41 @@ +# Agent platform self-development Agent + +This Agent develops the Agent platform itself: `agentctl/`, `sandbox/` and the Rust workspace in digdir-agents. + +| Variant | Checkout | Resources | +| --- | --- | --- | +| default (`agent.yaml`) | Fresh `digdir/digdir-agents`, `digdir/microsandbox` and `digdir/libkrunfw` clones made at boot | Normal | +| `nested` | Fresh clones | Reduced to fit inside the default Agent | +| `worktree` | Current host checkout mounted read-write, fresh fork clones | Normal | + +Every variant builds the directory's `Dockerfile` locally. Self-development images are not published to GHCR. + +```sh +make user-install +agentctl claude login +cd agentctl/examples/self-dev +mkdir -p ~/.agent +cp .env.sample ~/.agent/self-dev.env +agentctl apply --env-file ~/.agent/self-dev.env --wait +agentctl attach session/s1 +``` + +For the reduced nested variant: + +```sh +agentctl apply --variant nested --env-file ~/.agent/self-dev.env --wait +agentctl create session/s1 --variant nested --harness codex +``` + +The worktree variant requires an environment file outside the mounted checkout: + +```sh +agentctl apply --variant worktree --env-file ~/.agent/self-dev.env +``` + +Ignored local variants such as `agent.mine.yaml` may extend another sibling variant. Keep their credentials outside +the mounted checkout. + +Inside a running Agent, `instructions.md` tells the harness how to build, test and run the platform nested, and the +`pr-evidence` skill how to record `agentctl` demonstrations and attach them to pull requests, and the `changelog` +skill how to write changelog entries. diff --git a/agentctl/examples/self-dev/agent.nested.yaml b/agentctl/examples/self-dev/agent.nested.yaml new file mode 100644 index 0000000..4326575 --- /dev/null +++ b/agentctl/examples/self-dev/agent.nested.yaml @@ -0,0 +1,15 @@ +apiVersion: agents.platform/v1alpha1 +kind: AgentVariant +extends: agent.yaml + +metadata: + name: agent-dev-nested + +spec: + sandbox: + resources: + cpu: "2" + memory: "3Gi" + rootFilesystem: + capacity: "16Gi" + mode: direct diff --git a/agentctl/examples/self-dev/agent.worktree.yaml b/agentctl/examples/self-dev/agent.worktree.yaml new file mode 100644 index 0000000..167282e --- /dev/null +++ b/agentctl/examples/self-dev/agent.worktree.yaml @@ -0,0 +1,17 @@ +apiVersion: agents.platform/v1alpha1 +kind: AgentVariant +extends: agent.yaml + +metadata: + name: agent-dev-worktree + +spec: + sandbox: + mounts: + - type: bind + source: ../../.. + target: /home/agent/code/digdir-agents + readOnly: false + - type: tmpfs + target: /tmp + capacity: "2Gi" diff --git a/agentctl/examples/self-dev/agent.yaml b/agentctl/examples/self-dev/agent.yaml new file mode 100644 index 0000000..95f84a6 --- /dev/null +++ b/agentctl/examples/self-dev/agent.yaml @@ -0,0 +1,58 @@ +apiVersion: agents.platform/v1alpha1 +kind: Agent +metadata: + name: agent-dev +spec: + sandbox: + image: + type: build + context: . + dockerfile: Dockerfile + platform: + os: linux + resources: + cpu: "4" + memory: "8Gi" + rootFilesystem: + capacity: "64Gi" + mode: direct + initSystem: image + mounts: + - type: tmpfs + target: /tmp + capacity: "2Gi" + home: + source: home + instructions: + - source: instructions.md + skills: + - source: skills/pr-evidence + - source: skills/changelog + harnesses: + - type: claudeCode + auth: mediated + default: true + # The mediated token cannot list Fable in the /model picker, so new Sessions select it at launch. + defaults: + model: fable + - type: codex + auth: mediated + environment: + - name: GIT_USER_NAME + - name: GIT_USER_EMAIL + secrets: + - environment: GITHUB_TOKEN + # gh only attaches files with a token it classifies by prefix; the inert placeholder + # therefore carries the fine-grained PAT prefix and is far shorter than a real token. + placeholder: github_pat_AGENT_MEDIATED_GITHUB_TOKEN + allowedHosts: + - github.com + - api.github.com + - uploads.github.com + access: + - type: ssh + network: + mode: mediated + allow: all + deny: + - metadata.google.internal diff --git a/agentctl/examples/self-dev/home/.claude/.claude.json b/agentctl/examples/self-dev/home/.claude/.claude.json new file mode 100644 index 0000000..e2ab4b2 --- /dev/null +++ b/agentctl/examples/self-dev/home/.claude/.claude.json @@ -0,0 +1,9 @@ +{ + "hasCompletedOnboarding": true, + "bypassPermissionsModeAccepted": true, + "projects": { + "/home/agent/code": { + "hasTrustDialogAccepted": true + } + } +} diff --git a/agentctl/examples/self-dev/home/.gitkeep b/agentctl/examples/self-dev/home/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/agentctl/examples/self-dev/instructions.md b/agentctl/examples/self-dev/instructions.md new file mode 100644 index 0000000..85a9416 --- /dev/null +++ b/agentctl/examples/self-dev/instructions.md @@ -0,0 +1,44 @@ +# Agent platform self-development Agent + +You develop the agent platform in the checkout at `/home/agent/code/digdir-agents`: `agentctl/`, `sandbox/` and the +Rust workspace at its root. Never delete, reset or reclone that directory. If the checkout is absent, run +`gh repo clone digdir/digdir-agents /home/agent/code/digdir-agents`. The Microsandbox and libkrunfw forks it builds on +are cloned beside it, at `/home/agent/code/microsandbox` and `/home/agent/code/libkrunfw`, with `upstream` remotes for +their upstream repositories; each fork's `CONTRIBUTING-digdir.md` describes how to change it. + +Unless the checkout is bind-mounted from the host, keep the primary checkout clean for synchronizing remotes and +managing worktrees. Do each task in its own Git worktree under `/home/agent/code/.worktrees/`, starting new work from +the current `origin/main`. Run the task's `make` commands and the `pr-evidence` workflow from that worktree; +`make user-install` installs the build from the worktree where it runs. + +If `mount | grep digdir-agents` shows that the checkout is bind-mounted from the host, treat it as the task's existing +worktree and work on its current branch. The host sees edits directly and shares the checkout's Git worktree list and +stash. Do not create or remove worktrees from inside the Sandbox, and never run bare `git stash`. + +Read `agentctl/AGENTS.md` first. Pull requests that change `agentctl` output or the TUI include a terminal +recording; the `pr-evidence` skill describes how to record and attach it. The `changelog` skill describes how to write +`agentctl/CHANGELOG.md` entries. `make help` at the worktree's root +lists the targets; run `make fmt lint build test` before reporting completion. `make test-e2e` and +`make user-install` work here too: the Sandbox has `/dev/kvm` and Podman. + +Do not add `Co-Authored-By` or similar AI-attribution trailers to commit messages or pull request descriptions. + +To run a nested Agent, log the nested `agentd` in with the placeholders this Sandbox already holds, then apply the +`nested` variant with its secret file outside any bind-mounted directory: + +```sh +printf '%s\n' "$AGENT_CLAUDE_ACCESS_TOKEN" | agentctl claude login --from-stdin +agentctl codex login --from-stdin < ~/.codex/auth.json +printf 'GITHUB_TOKEN=%s\nGIT_USER_NAME=%s\nGIT_USER_EMAIL=%s\n' \ + "$GITHUB_TOKEN" "$GIT_USER_NAME" "$GIT_USER_EMAIL" > ~/nested.env +cd digdir-agents/agentctl/examples/self-dev +agentctl apply --variant nested --env-file ~/nested.env +``` + +Real secrets are host-mediated: never search for, print, copy or persist their values. The credential placeholder +above is inert. Git identity is explicitly selected non-secret data and enters both Sandboxes in plaintext. + +Build steps inside Podman trust the mediated CA through the system store and `/run/agent/tls/ca-bundle.pem`. Buildah +drops default environment from build stages, so a `RUN` that downloads through Node exports +`NODE_EXTRA_CA_CERTS=/run/agent/tls/ca-bundle.pem` when that file is readable. Do not persist that with Dockerfile +`ENV`. diff --git a/agentctl/examples/self-dev/nvim-sysinit.vim b/agentctl/examples/self-dev/nvim-sysinit.vim new file mode 100644 index 0000000..47d19c1 --- /dev/null +++ b/agentctl/examples/self-dev/nvim-sysinit.vim @@ -0,0 +1,3 @@ +set number cursorline termguicolors laststatus=2 +set statusline=%t\ \ [%{&filetype}]%=%l:%c +colorscheme habamax diff --git a/agentctl/examples/self-dev/skills/changelog/SKILL.md b/agentctl/examples/self-dev/skills/changelog/SKILL.md new file mode 100644 index 0000000..a861d77 --- /dev/null +++ b/agentctl/examples/self-dev/skills/changelog/SKILL.md @@ -0,0 +1,145 @@ +--- +name: changelog +description: Write and edit agentctl/CHANGELOG.md entries as release notes for the people who use agentctl and its Agents. Use when adding or changing a changelog entry, when a pull request needs one, or when preparing a release's changelog. +--- + +# Changelog entries + +A changelog is release notes for the people who use the product. Pull request titles and descriptions are for the +people who review the code. Do not copy one into the other: a reviewer needs to know how and why, a reader of the +changelog needs to know what changed for them and whether they must act. + +## Rules + +- **One entry per change a reader notices**, however many pull requests it took. Do not join unrelated changes in one + sentence, even when one pull request made them: make them separate entries, or sub-bullets under what they change. + If `[Unreleased]` already has an entry for the same feature, extend or rewrite that entry instead of adding another. + No entry for refactors, tests or CI, or for a change an `[Unreleased]` entry already describes with its issue + linked: apply the `skip-changelog` label. +- **Short.** One or two sentences, 40 words or fewer as a rule, and never more than 60. +- **Lead with what changed for the reader**, then say what they can do now or what they must do. +- **Use the names readers know**, written exactly as they appear in the product, so the entry can be searched. Give a + few examples rather than a complete list. +- **Leave out** how it is implemented, why it was designed that way, internal components, and what used to happen, + unless the reader must act on it. +- **Fixed** entries describe the symptom the reader saw, not the cause. +- **Breaking changes, deprecations and removals** say what to do instead, and breaking changes start with `Breaking:`. + Step-by-step migration belongs in the documentation, such as `agentctl/README.md`; link to it. Say so when a tool, + such as `agentctl self update`, makes the change for the reader. +- **End with the references in parentheses**: the documentation link first, when there is one, then every issue whose + problem or request the entry describes, or the pull request when there is no issue: + `([install](https://github.com/digdir/digdir-agents/tree/main/agentctl#install), [#1234](https://github.com/digdir/digdir-agents/issues/1234))`. + Links do not count toward the word limit. +- **Link the issue, not its pull requests.** An issue says what readers asked for or ran into and leads to the pull + requests for it, so link it once, however many pull requests it took. When the issue is part of a larger one about the + same change, such as a feature, link the larger one, but not an issue that gathers unrelated work, such as an epic or + a list of findings. Do not link an issue the change is only related to. Write an issue or pull request in another + repository as `[digdir/microsandbox#12](https://github.com/digdir/microsandbox/pull/12)`. +- **Without an issue, link the pull request.** Do not open an issue afterwards to have one to link. +- **Sub-bullets group changes by what readers know them by**, such as a command, a manifest field or a view in + `agentctl tui`: the top line names it, and each sub-bullet is one change to it. Use one level, at most five short + sub-bullets, and put each reference on the line it belongs to, or on the top line when it covers every sub-bullet. + The word limit counts the whole entry, sub-bullets included. Changes in different categories are separate entries. +- **Do not wrap lines.** Only sub-bullets start a new line within an entry. + +Before you finish, read the entry as someone who has only the changelog: can they tell what changed for them and +whether they need to do anything? Delete every clause that does not help with that. + +## Writing the entry for a pull request + +You know the implementation too well to see it from the reader's side. Write the entry from what the reader will +notice after upgrading, not from what you did: + +1. Decide whether the change is visible to the changelog's readers at all, and whether an `[Unreleased]` entry already + describes it with its issue linked. In either case, use the `skip-changelog` label. +2. If your harness can start a subagent, give a fresh one only this skill, the pull request title and description, and + the diff of what the reader sees or uses, and have it draft the entry. Otherwise, write the entry before rereading + the implementation. +3. Merge it with any related `[Unreleased]` entry, keeping that entry's references. Link the issue for this pull + request, as the rules above describe, and reference it in the pull request description (`Closes #1234`, or + `Part of #1234`) so readers can get from the issue to the change. Without an issue, add this pull request's link + once it is open. +4. Commit, then check the changelog with `agentctl/changelog.sh validate` and + `agentctl/changelog.sh check-unreleased origin/main HEAD`. + +## Preparing a release + +Read the entries being released together. If they follow the rules above, promote them as they are, as +`agentctl/AGENTS.md` describes. Otherwise, fix them in the promotion pull request: + +- Merge entries about the same feature, keeping all their references. Drop a pull request link when the merged entry + links the issue for that pull request. +- Replace a pull request link with the issue the rules above point to, when there is one. +- Drop entries for something added and fixed within the same release: readers never saw the problem. +- Cut entries to the rules above, and move migration detail to the documentation. + +## Examples + +Each block shows entries exactly as they are written in the changelog. Long entries are cut short with `...`. + +Too long, with implementation detail: + +```markdown +- `agentctl stop` and `agentctl start` change the Agent's desired run state, which the Sandbox controller reconciles by asking the Microsandbox backend to stop the VM process while keeping the root filesystem and volumes, so that a later start reuses the same disk instead of materializing the image again. ... +``` + +Better: + +```markdown +- `agentctl stop` and `agentctl start`, or `x` in `agentctl tui`, stop an Agent's VM and start it again on the same disk, also one that stopped responding. ([#123](https://github.com/digdir/digdir-agents/issues/123)) +``` + +Explains the mechanism instead of the effect: + +```markdown +- On macOS, the network backend answers DNS queries for host-default names by calling the system resolver instead of forwarding them to the servers in `/etc/resolv.conf`. +``` + +Better: + +```markdown +- On macOS, Agents resolve names through the host's system resolver, so VPN split DNS and `/etc/resolver` domains work inside an Agent as they do on the host. ([#123](https://github.com/digdir/digdir-agents/pull/123)) +``` + +Several entries for one feature: + +```markdown +- `agentctl tui` opens an Agent's shell, VS Code, Zed or SSH with `o`. ... +- `agentctl tui` opens an Agent's desktop in the browser or a VNC client with `o`. ... +- `agentctl tui` opens a port forward with `o` from the forwards view. ... +``` + +Better, as one entry with sub-bullets: + +```markdown +- `agentctl tui` opens an Agent with `o`: + - in a shell, VS Code, Zed or SSH, offering to add the `Include` to `~/.ssh/config` first ([#123](https://github.com/digdir/digdir-agents/pull/123)) + - on its desktop, in the browser or a VNC client ([#124](https://github.com/digdir/digdir-agents/pull/124)) + - through a forward, from the forwards view ([#124](https://github.com/digdir/digdir-agents/pull/124)) +``` + +Two changes in one entry: + +```markdown +- `agentctl tui` asks before quitting when that would close port forwards, and forwards are now closed when their Agent is deleted or re-created. +``` + +Better, grouped under what they change: + +```markdown +- `agentctl tui` port forwards: ([#124](https://github.com/digdir/digdir-agents/pull/124)) + - `q` asks before quitting would close them + - they close when their Agent is deleted or re-created +``` + +A fix described by its cause: + +```markdown +- `agentd` no longer blocks its request loop while a cancelled client waits for an Agent to become ready. +``` + +Better, by its symptom: + +```markdown +- Interrupted commands that wait for an Agent, such as an editor retrying its SSH connection to an Agent that cannot start, no longer make `agentd` stop answering every other command. ([#123](https://github.com/digdir/digdir-agents/pull/123)) +``` diff --git a/agentctl/examples/self-dev/skills/pr-evidence/SKILL.md b/agentctl/examples/self-dev/skills/pr-evidence/SKILL.md new file mode 100644 index 0000000..e1ca9b0 --- /dev/null +++ b/agentctl/examples/self-dev/skills/pr-evidence/SKILL.md @@ -0,0 +1,57 @@ +--- +name: pr-evidence +description: Help pull request reviewers understand changes to the agentctl and agentd developer experience through terminal recordings. Use when a change affects CLI output, provisioning progress or TUI workflows. +--- + +# Show the change to reviewers + +Demonstrate the scenario, the relevant change and its result so a reviewer can understand the experience without +running it locally. Explain the premise and starting state in the recording or PR caption. + +- Keep artifacts under `/home/agent/code/.artifacts///`, outside the checkout. No capture report is required. +- Keep each attachment within 10 MiB. There is no fixed duration limit, but GIFs should be brief enough to follow + without seeking. Split longer demonstrations into focused clips. +- Capture actual behavior from the tested revision, using test data without secrets. + +## Record + +Prepare incidental setup before recording. Prefer familiar command names on `PATH` and a sensible working directory; +avoid cluttering the demonstration with full binary paths, custom environment variables or a custom `HOME`. If such +configuration is part of the behavior being demonstrated, show it and explain why it matters. + +Set terminal capabilities on the recorder so the demonstrated program inherits them. The prefix below removes +`NO_COLOR` and replaces an inherited `TERM=dumb`; it runs before capture, keeping setup out of the demonstration. +Omit the override when demonstrating behavior under those settings. From the artifact directory: + +```sh +env -u NO_COLOR TERM=xterm-256color COLORTERM=truecolor \ + asciinema rec --window-size 120x36 --command 'agentctl tui' terminal.cast +agg --font-size 14 terminal.cast terminal.gif +agg --select 50% terminal.cast frame.gif +``` + +For a scripted CLI demonstration, replace `agentctl tui` with `bash demo.sh` and have the script display the commands +it runs. Pause before execution and after output so a human can follow along. For a TUI, pause on relevant states +before moving on. `--idle-time-limit` can compress long waits, but preserve enough time to read. + +For containerized programs, forward the capabilities with `podman run -e TERM -e COLORTERM ...` and ensure +`NO_COLOR` is unset inside the container. For missing picker glyphs, try `agg --font-family 'JetBrains Mono'`. +Keep the application's presentation faithful to the tested revision. Inspect representative frames with the image +viewer, using `agg --select` at relevant positions to check readability beyond the GIF's first frame. + +## Attach + +From the artifact directory, use local image references in the PR body; `gh --attach` uploads files and rewrites +those references to hosted URLs. Pass one `--attach` per file. For example, after pushing the branch: + +```sh +gh pr create --repo digdir/digdir-agents --base main --head \ + --title 'fix: ...' --body-file pr-body.md --attach ./result.gif +gh pr edit --attach ./result.gif +``` + +If an upload fails, inspect the PR before retrying: partial success can create or update the PR despite a nonzero +exit. Retry missing attachments with `gh pr edit`, rather than repeating creation. Uploads need repository write +access and a token recognized by `gh` as a personal access or OAuth token. + +Read the body back with `gh pr view --json body -q .body` and confirm attachments have hosted URLs. diff --git a/agentctl/examples/self-dev/tmpfiles.conf b/agentctl/examples/self-dev/tmpfiles.conf new file mode 100644 index 0000000..1111441 --- /dev/null +++ b/agentctl/examples/self-dev/tmpfiles.conf @@ -0,0 +1,2 @@ +d /home/agent 0755 agent agent - +z /dev/kvm 0660 root kvm - diff --git a/agentctl/examples/self-dev/workspace-init.service b/agentctl/examples/self-dev/workspace-init.service new file mode 100644 index 0000000..3f8a527 --- /dev/null +++ b/agentctl/examples/self-dev/workspace-init.service @@ -0,0 +1,19 @@ +[Unit] +Description=Initialize the Agent workspace checkout +Wants=network-online.target +After=network-online.target + +[Service] +# Best-effort clones started at boot; a bind-mounted checkout already has `.git`, so the script +# leaves it alone. `Type=exec` completes the start job once the +# script has been started, keeping the clone out of the boot transaction so that +# `systemctl is-system-running --wait` does not wait for it. +Type=exec +User=agent +Group=agent +Environment=HOME=/home/agent +PassEnvironment=GITHUB_TOKEN +ExecStart=/usr/local/libexec/agent-workspace-init + +[Install] +WantedBy=multi-user.target diff --git a/agentctl/examples/self-dev/workspace-init.sh b/agentctl/examples/self-dev/workspace-init.sh new file mode 100644 index 0000000..a62cf06 --- /dev/null +++ b/agentctl/examples/self-dev/workspace-init.sh @@ -0,0 +1,34 @@ +#!/bin/sh +set -eu + +# The agent platform and the Microsandbox and libkrunfw forks it builds on, +# cloned beside each other. A checkout that already has `.git`, such as a +# bind-mounted host checkout, is left alone. +missing= +for repository in digdir/digdir-agents digdir/microsandbox digdir/libkrunfw; do + if [ ! -e "/home/agent/code/${repository#*/}/.git" ]; then + missing="$missing $repository" + fi +done +if [ -z "$missing" ]; then + exit 0 +fi +mkdir -p /home/agent/code + +# Guest boot can race the host-mediated network handshake. Wait for DNS, +# while leaving each repository operation itself as one best-effort attempt. +remaining=30 +while ! /usr/bin/getent ahosts github.com >/dev/null 2>&1; do + if [ "$remaining" -eq 0 ]; then + echo "github.com did not become resolvable within 30 seconds" >&2 + exit 1 + fi + remaining=$((remaining - 1)) + /usr/bin/sleep 1 +done + +status=0 +for repository in $missing; do + /usr/local/bin/gh repo clone "$repository" "/home/agent/code/${repository#*/}" || status=1 +done +exit "$status" diff --git a/agentctl/high-level.excalidraw.svg b/agentctl/high-level.excalidraw.svg new file mode 100644 index 0000000..dae64ea --- /dev/null +++ b/agentctl/high-level.excalidraw.svg @@ -0,0 +1,2 @@ +eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO2953LrWLIl/L+fglHzpyemiN7edMRcdTAwMTdcdTAwMTPyhjKUl/idXHRcdTAwMDVFJ0o0XHUwMDEynczEfffJ5DklXHUwMDAyxFx1MDAwNlxikDiSqi9Y96qrRFx02iD2yrUyd5r/+49C4Y/R21Pjj39cdTAwMTf+aLzWqp12fVB9+eNP/P6kMVx1MDAxOLb7PXiLTf972Fx1MDAxZlx1MDAwZmrTn7xcdTAwMWaNnob//te/utXBY2P01KnWXHUwMDFh3qQ9XHUwMDFjVzvD0bje7nu1fvdf7VGjO/zf+PWo2m38f0/9bn008GZ/pNiot0f9wc+/1eg0uo3eaFxiV///4b9cdTAwMGKF/zv9XG7vtOv4XHUwMDE3zy53ZHFQpjevumVPTb/S7293pr86/aG/bmHQqI2qvVanMXvrXHUwMDE1vk9cdTAwMTnhXHUwMDFlV0IxQqU2nMqPt9/w9lx1MDAwNONcdTAwMWWRjFx1MDAxOS0pfFx1MDAxMbO3X9r10T1egVx1MDAxOOop34t//Mh9o926XHUwMDFmTX9cdTAwMDYuY2YvPbvMzzX9u0A+vjNcdTAwMWNccvqPjY1+XHUwMDA3Plx1MDAwMVj4/6Bccvxntuy7au2xNeiPe/WPn1x1MDAxOVxyqr3hU3VcdTAwMDCf0+znmu1O52z0Nr06PCH4aP+Y+1x1MDAxYle/boHNfT/qt+CPtu57jSE+XHUwMDBi+vHd/lO11lx1MDAxZb1Nb3N2XHUwMDE3uMKnvfr0sf2f2ZpcdTAwMDbwwPfwufXGnc7Ht9u9elx1MDAwM5/GXHUwMDFmlUngr/Xqv/7aX8989kD5r+/812ztjVx1MDAwNl5YaMNcdTAwMThT1ny84dutVM1/96jfm+5cXM2sosbI2a+1h5uw90bTizZh/zZmT1x1MDAwMFe2Nb8v/XszsPVGjdfZY/Ht3Mv7K9qpdFx1MDAxYrx9u7td3W2f07Mr9sfHz/3Xr3+bfXrjp3r153qoNpoxroyUZLbfOu3e4/xH2+nXXHUwMDFlZ7fwXHUwMDBm30c2hyP3akI4XG7czF9cdTAwMTAy8Vx1MDAxMDJcdTAwMGIgxKj1qOGWXG5qhNTEgSCeIyZcbjGXbshcdTAwMDR+/Fx1MDAxNzasXHUwMDE11kiiXdigkkZhgyowYIRpK5ZcdTAwMDFHzP41UkhOU+zf2XbEbVxit1+otuDv1Fx1MDAwYk+Dflxy73v2ufV7o7P2Oy6fmcB3t6vddlx1MDAwNz98XHUwMDE5uNpap93Cz+GPTqPp21x1MDAxM/BRjNpATFx1MDAxZm+P+k+zd2twvWq711x1MDAxOOwl4aL+oN1q96qd80WLr45H/dPG8OfyR4Nxw/9cdTAwMTE1dj9oxWMyXHUwMDA20faucUNvNva3L05cdTAwMGWq18/XL7XysJ2cXHUwMDE5jbWepoBGXHUwMDA2RoZcdTAwMTgx2/M/d66FP2/B1lx1MDAxMkCtXHUwMDE2NFxma0W1p5WEnWNcdTAwMTmRYFx1MDAxZsKwlsp6lkhp4Vx1MDAxZqVyXoxE+cvKvGiURbJQIVx1MDAwNsRfopHY50CM3Fi9XHUwMDE09JfjxebN/u0lN+KQN7afdfe2dve8R1x1MDAxMvOissAzyCXSXHUwMDBmmuV50b2aJLxoiYhcdTAwMDeQWlx1MDAwMCAtPMHBZlNBXHUwMDE0k2H0sFx1MDAxYy5cdTAwMTFweU3OiVRb0FHCcOJcdTAwMDBcdTAwMDbT0cBgisCjoWRcdTAwMTlgZLl3w5xY64yHo8bATYYk8N3fSIZcdTAwMGLoJ0SGoVVnxIKMTa6uq9eStPY2XnmndrFuy4lZXHUwMDEw8OdcdTAwMTEmXHUwMDE1XHUwMDEzSitOxExKTHeqUp6APaCVIPCFhSEspfJcZlx1MDAxOF5cdPpcdTAwMThUlJ5Z31x1MDAwZlx1MDAxNFx1MDAwYoA5ZVxcg1x1MDAwMmZaWsNnpjZHdVx1MDAwMNVvK5Mg5dRcdTAwMWEhwFtxgZ1Hglx1MDAxZDWMsETJT6TB3eenwYa5XHUwMDFkX1x1MDAxZWyW9ivXL5fH763TNDQoLGwmorOhQfdqXHUwMDEy0KDi3GOEXHUwMDBieFGwmfNcYtJ0XHUwMDAxgqgynlx1MDAwNFx1MDAxNqTKWkb9XHUwMDEyMefBhYh5T8GDloCPTv1/1OdcdTAwMWOyXHUwMDEwYD6cQ025XHUwMDE2nNuMiTD97lxyXHUwMDEx4e7x2Xnhn0+1P1x1MDAwYpPun1x1MDAwNc/z/mdcdTAwMTaUWIO1+2kqXHUwMDFkKcaz0Twpxt1BNvRYfb3q1C9OXt+aTbHfKTdcdTAwMTiI3avk9GilJ6nQRCpcdTAwMDJfXGKdhW6msVx1MDAxZqlcdTAwMTegWzBcdTAwMTDBXHUwMDBlSFObY9qN6SpbmVx1MDAwNVx1MDAxNZBcdTAwMTlcdTAwMTPa5Vx0goGNXHUwMDA0ulx1MDAwMlxu1MTvx/92XHUwMDBlXHUwMDFjNjb1UJXa1eb6PX1ovD2+nt/IL+NA92pcdTAwMTJwILh/sTBRbFx1MDAwMUxcdTAwMTg3XHUwMDFl8J9hllx1MDAxMiqdziCVJEeMXHUwMDFiMTxcdTAwMDVcdTAwMGJcdTAwMWFBtWRWM1x1MDAwNzi4ilx1MDAwMVx1MDAwN9VaXHUwMDEzKpZcdTAwMDLHb2XBX1HGXHUwMDFmvWJcdTAwMDF+stopICVccvqdTmOA31sr71x1MDAxNZr9XHUwMDAxuF9tXFyO/1v9p8agOupPf2xcYp9cdTAwMTncL743/c9qr37Xfy2cbZbwesNx98v9zVx1MDAwNUxcdTAwMTZcdTAwMTF8/cyPJVx1MDAxYsa+bZ6sXHUwMDFkrdWeN+snpdPu5eGTUOc0XHUwMDA1Y1tPMiOtXHUwMDAw7rag2oKnNfDdZVx1MDAxOZtcdTAwMDS+n5uggFx0siuTNmfSXHUwMDAye4RPMKc6K2SuPlxmXHUwMDEzXHUwMDAxyjCW0qVcdTAwMGU2P4lYr2/eqmeqvf/2VCyuXHUwMDBmr65cdTAwMWJccsVcdTAwMDdJiFVw4Vx0cOi10mCyXHUwMDE1XHLs5VwiXHUwMDA1z9NcdTAwMTLczpIoXHUwMDA1XHUwMDFmRGgzK0adm1nkXHUwMDFiOWIjryfnUsk5XHUwMDEwKXxx6Uwpo3ashM1FXHUwMDE1NyrbXHJcdTAwMGJcdTAwMWGMXHRjUmzYXHUwMDEwk1x1MDAxZfdcbvXGpFDeKPxzSlx1MDAxOZ23XHUwMDAy0EG7V1x1MDAxON03gCj647rbweQq8N3fx4GBm5knvFSrXHUwMDBmUFXw00jDVfpq/eit2GP1w6065Yds+2hjYz1cZmy85NzhibSekZxqoVxifpXBsJFcdTAwMTHcXHUwMDAzWyiAyKZfZ1x1MDAxZvBH2IjDXHUwMDE1hLJcXFHJNZdkdoFcdTAwMGaUa1x1MDAwZiN6qPZcdTAwMDQnXFwymaM+XHUwMDAy9Vx1MDAxYknpi0XRXHUwMDE3k5yBtXZcdTAwMDaXfClToeBcdTAwMTLXsFxyTPbBJWBcZsP8ezqpMXjqt+dd2tm/XHUwMDE1Zlx1MDAxYmb6XHUwMDFmXHUwMDFm//5//nT+dPQuxVcxvEFnXHUwMDE3/Mfchf/oVIejjX6321x1MDAxZcGdlnGVIbs8qlx1MDAwZUbr8FTbvdb8e41ePeKd6W+tXHJcdTAwMDb9l/tGNbRH4Pfm34uxXHUwMDA3g1x1MDAxYl1cdTAwMTmd8HNVXHUwMDFk8M396+dDO1H9RETPtGeZRUJcdTAwMDE3WviEXHUwMDA37nDLiadcdTAwMWRes+SeK16cs3tcdTAwMTTOt9PEiy1sSFx1MDAwNeLLXHUwMDA1aVx1MDAxMZloR62wXFxcdTAwMThuMvaUgd9cdTAwMDVsj1x1MDAxNfn90bhziL5ccoVcdTAwMDdcdTAwMTaYXHUwMDExS/efNmW1RdqT8at8PVx1MDAxZlx1MDAxOHtyvP9cdTAwMWVGZYRHyYCePUalXHUwMDEwQnIhhe+jwk9IXHTlUVx1MDAwNtJcdTAwMGKUuFSahGHKXHUwMDE58ThFejBWXG5rZs/wXHUwMDAzsVx1MDAwNq6hLeeEXHUwMDEww+FcdTAwMWEzMs9cdTAwMDFcdTAwMWNcdTAwMDDwzepHpNZQo4T/XHUwMDE4eobrXHUwMDE4ppbEMsblZ56Q3uvDzlulerd2cbJJ+y+VvaONUvJcdTAwMDRalFx1MDAwMdKAXHUwMDE3m8ZmxKDIvZpcdTAwMDTcxphCL5ZRXCJcdTAwMTm3XG40T1x1MDAxMEBcbuGFXHUwMDAwI5i96dv7M61rPa2tRZRJLql0ZFx1MDAxOeTR4SjIVJJznlaGXHUwMDEy8EhcXDFcdTAwMThcdTAwMWRcdFxyzVxmPFeqs9awqTdviPBKjUGv0fnRK11cdTAwMWX+WahNPz88ZfyUU9Juu173U8lcXDR3XHUwMDAxJ80z48I7ySb2urlcdTAwMDVcdTAwMWN1PKqU9njl+Ppyr9h4fX9Kw5TSU0JcdTAwMWJijGKUXHUwMDA2w1VSXHUwMDExIDlQVJSCeyvYXGZcIjOipJ5kXHUwMDE0s84sXFxEW1x1MDAwN85zpvy1pkWwr67OlJoq1LJOc2BDnu5cdTAwMDdTwrPHWlx1MDAxM/aJ56j2+WCXXHUwMDFjvLZJrXx0aC93xyN+PUzJlFrSNNGzXHUwMDE4XHUwMDE0uVeTiCmZXHUwMDAwqWjw+Fx1MDAwMuwprCuIIG08pkCETit5mMsjhN9cdTAwMDd4XHUwMDE4XHUwMDAx6NCgSJ056TlTRkDmLjlTXHUwMDFhLE5cdTAwMDJD5oz9isjYr5VAlLDSjGO/6XdviCpPtlx1MDAwZS/+LGziXHUwMDBmXHL+LFx1MDAxY7TvXHUwMDFlXHUwMDA3496fP3qH7dqg/+vY7zvw5lx1MDAwMoaa5033bVx1MDAxNdx3lVxyh66fjU/X7ePk4v5m3LxqXHJHxZunx+RcdTAwMWNKiPZcZlx1MDAwM/CCXHUwMDEyM9bO5Vx1MDAxM2IyrVKKcI1GVlxuX93CzFx1MDAwNmhcdTAwMGZ8TSmnZ/ZCMkdkOGfRX2taZFx1MDAxMlx1MDAxYauzKLNSaWPcRWkmpiiNXHUwMDE4qixhS9mK5WhUXHJcdTAwMWGqcdjtvIr66GD7dVuMq/fdVDSqqZU0o4pN92qS0Cjh05xaXHJUXG5QISaIIUkkIFhq+Gg5R6rkIVxmUSU9Qy3ckoX7odpBo3kuQFx1MDAxNGaaKYKsfFrhS93VzDLS5WTwnlx1MDAwNHch60P/6fZcdTAwMTUptm+IR89+ZcjgXHUwMDEzbvTq34EyXHUwMDE3XHUwMDEw0jxlRt5BNvT43H4oVqv7leP75stcdTAwMTHprb2cjU5VcnpcdTAwMTSaXHUwMDAwu4FzKCTnWuu59Fx1MDAxZc2wnFx1MDAwNTjNgEJjOoxskNieXHUwMDAwcaxcdTAwMTh8XHUwMDA1aM9cdTAwMWV2To5pgd7KIFx1MDAxOKs4I0pJJ/7D6vqjOE1qXHUwMDE5oNTfTo2blcudId1+Wmtccl6qrHlwuXHwtJ6SXHUwMDFhueJpjmRjQOReTVx1MDAxMmpcdTAwMTRcdTAwMTabXHUwMDE5MGxVQKQlXCJcdTAwMTiLXHUwMDE1lniUgklmxij4XHUwMDE5R96BmqJcdTAwMDM7SSjBpHaIy9zBjFx1MDAwMsx9cmZUsF1cdTAwMDR4XHUwMDAwzjxdbaOAgYXoXHUwMDE2n0/Webqpd28kM/ZcdTAwMWGjl/7g8Ufvn/2nXHUwMDExLL3a+ZyylVx1MDAwNSy5gJeiWDL2bjIqYWm1brZ23o+eXHUwMDBmts77XdVaO+iMXHUwMDFkaflxXHUwMDBlpaVcdTAwMWPsJcdCa1x1MDAxYVx1MDAxNMNcdTAwMWPYjlx1MDAwYmWYJNifRIRcdTAwMTGvjPGM5VQqrsAr9XksOWOmNVx1MDAwMO3VXHUwMDE5k8OTZEh/LsNAXCIjT4xpKVx1MDAwNV8u8rRcdTAwMWNlnlxmWW3bXFxcdTAwMWRuVC5udieTtetcdTAwMDNZTVfgqbWVPjOwXHUwMDEyZbpXk8ibpCxcdTAwMGVAli5cdTAwMDBcdTAwMTC84XFrQeSAY0pd6bh5X5AowDwkZ0zOlVx1MDAwMsakrnN9SaMrW4hVXHUwMDE4cF/uYD9m81x1MDAxYUrAQ12FMbHMXHUwMDAyqy7OXHUwMDFhtUFjVFx1MDAxOI76g1x1MDAwNlZiXGZcdTAwMGK+V7GwNlx1MDAxZd1cdTAwMDM9vVeRf1xujVx1MDAxZfBUY/prvyiqWa1V640sOHaV6pV4XHUwMDEyXHUwMDBiVa/8nlvPhpCbT7LRvry/bVx1MDAxY2329Lh6dfdcIktr6VximVx1MDAxYlx1MDAwZZ4sXHUwMDE4XGZcdTAwMDHWJWhPXGLxXHUwMDA0s1Yry4zm1jhOeVxi9yy20VCEK1x1MDAwYlYyZ+SlXHLM4+qMjGVEXHUwMDEyu184XGaPiJbqVFx0XHUwMDEw8lJ8Zku+1sZ+pfIuKtpcdTAwMTbPzlx1MDAxZveK3f37/mE6SoZcckszyihyryYpJcdBSC6CXHUwMDEwXHUwMDE11iPwjsbEI+TlnJSTY6aTnJSNXHUwMDExnFPKXWpV8ehsO3i2XHUwMDAwKmYzXHUwMDBm8KbdvmFSntZVXCLJTP+lOFx1MDAxOPdG7e5P1plcdTAwMTVLfjHbLmAod61o8nvKhkZ748r63qRe27+g6vzweqheX7arSeCPRcxcdTAwMWU8TqWFMFZSXHUwMDE1XGZcdTAwMDJcdTAwMWKPXHUwMDEzw1x1MDAxNFx1MDAxOFx1MDAwN8A2UyZ8RIo1nkJL4M6fL+6IXHUwMDAy50n0UfDvJ4c/SFx1MDAxNEExT8LdiyGy6Vxu2GVcdTAwMTDl3Fx1MDAxN0nMqCxcdTAwMDa8N7lSnsT6uN3BKpLCXHUwMDFk/uCw8K9Cp/pcdTAwMDaLd1x1MDAwMv57ZNUvXnJGefYvZnPwXFw/2Vt7ei6eXHUwMDFmb1+OdPvqXCKNLlx1MDAxNp4hQNpSgZ5cIiaIakapR6UmXHUwMDFhME2pcuTZM/h9Llx1MDAxOVGYXHUwMDFmJSnPz3ZcbsuD/GllXaw5YZYyV1x1MDAwMFvYyPJcdTAwMTl4iOjIL5dMvJwoLt3sTSaXLzfl85ctM3w9KrY3682UothcdTAwMTCVxtWPXHUwMDAxkXs1iUQxsbH4UVx1MDAwYvBDhfastCDZXHUwMDE0plx1MDAwZlx0h1uZa+IovDynIUWC6V1OTlx1MDAwNM1cdTAwMTKNXGZwZ4jJXlx1MDAxMistfVx1MDAwN33LSOJAx5DafaM+hqu08L/6XHUwMDAz+E94ONNcdTAwMDDNXHUwMDE3i+JcdTAwMDX0XHUwMDE0XHUwMDEyxanvKlx1MDAxYlksr3dHe+PB4Ob+4fB8Y3Otd7F94shcdTAwMWWOS5AgROFcdTAwMTGu5EzOt2KyWI9cdTAwMGIuXHUwMDEwm1x1MDAwNqRtOFrNmPFgP1x1MDAxMEpcdTAwMTRcdTAwMTBpoFx1MDAwN0bOommtwmBlXHUwMDE2NdhJXlHtjGrL6CpUPOyxxH5misS9fFLm5qlW6Vx1MDAxY1xmOuq0Lm7kxXk6XHUwMDFlJXhElVxyj7pXk4RHhdWAUCunh952XHUwMDBlQJxqT0ogV1x1MDAwM5tfg0dcdTAwMTGmUS08grmD4F5yLi3PUyRSIGaYnEclluNq90moXHUwMDE2kTxKKeFaZJ2Bn3rrhlh0Z7ewdrpcdTAwMDHeWbWGtDL80Vx1MDAxYox7QF6F4WjcbGbBnaumSCxgpnlcdTAwMDKdv6GC+36yYU1x//LIj59cdTAwMWHDxy5/2X1s2f7Zdlxu1mRCejg9hOOMXHUwMDE3q+ycdrbAiVx1MDAxMsu7QSNcdTAwMGLwamac+IF66zjXNblcXI6A+WhlYlx1MDAwNFx1MDAxNVxyKlx1MDAwNs/AXHUwMDFj8Gc69N2PalVs1CzUZ566XHUwMDE02+WKJbfXQ8H2XiqPbKfNK+lcdTAwMWNMZS1RXHUwMDE5Vae5V5OEXHUwMDE4mcTyTlx0vGe1XHUwMDA2/Vx1MDAxN6xO4+Bgklx1MDAwNSDB+lD7s0M5yVx1MDAwYrlTYmacwsVkVIOjL1TKvHqusFx1MDAwN7nNnFx1MDAxZFPv31x1MDAxMDtuXHUwMDAyQ7SQPaYnXHUwMDE0w8K/fvTWOqN2XHUwMDBmP91vQI1cdTAwMGLoZ55cdTAwMWFDd1NcYt9MNrx4cjQ4uz186781bss9eX5ap91dh1xmdnQoU1R6nMBXrYhgZm7sWVx1MDAxMVx1MDAwN1x1MDAwN+LMM1x1MDAwNmKKmMBcdTAwMTnKLCDrge/CXHJcdTAwMDBcdTAwMWWwzoxDXGJTI8BqXHUwMDEwg8dcdTAwMDR4XHUwMDFhk3uSXHUwMDEx4E88ODCyQ1x1MDAxOZhmqvBROUxcdTAwMDJccovoXHUwMDBmuVxmXHUwMDBmWHNBl5uQXHUwMDE0d1x1MDAxNiNcdTAwMTnziaavalFcdTAwMTa5SfFcdTAwMTXanrOrhUj779Cf7Gjrdv2SrjfK43s2aL713nffn4qJrIGmxrOSM4JHq4r45iVMj1xcOY5CM7BRNI57olx1MDAwZa+YSFx1MDAwZVx1MDAxNyBMXHUwMDFiXHUwMDAzwk07miBpXHUwMDBmVFx1MDAwMbNcdTAwMDLQXCJcdTAwMDSRavYjuS1cYtiCxMPSYrpcdTAwMTUyo4hcZlx1MDAxZsPgs4xpXHUwMDAyLjSDXHUwMDA3mHn5ujVMizRVo7/HXHUwMDE0RO5RfFx1MDAxNUPb829uXHUwMDBiZOf9cUM9yb6xe+R6c7DProujJH5cdTAwMDB+MmBcdTAwMTRcdTAwMTnWXHUwMDBmXHUwMDE5Y+ZcdTAwMDJkVnPP3VU7z7hYXHUwMDE28CnGvTHCNbaEXHUwMDE27nLzSJrHXHUwMDAxN1x1MDAxMkxCpnVD4Fx0TvtcdTAwMTCslHB10Jg0Ot85v2J+gVx1MDAxOWVT7G6Z09N1WVx1MDAxZbRrunGxWWmtXHUwMDFmXHUwMDFmlFJkU1DhXHRcdTAwMDW2SmosWlxigpRiSzWGIzu45pRqX/fgXHUwMDBmxHLpoULjzEjYUEw4UiSFwVx0kFIw0IhcXDHio45cdTAwMWPBflx1MDAwNN+RNZqUtKMjXoqjL1x1MDAwNi66XHUwMDAz2jycfvxcdTAwMTe04fEonMG5XGayP1aXKuDVbV2cXd69ymr/5Xir8Xg5PDCDVGnGsDMpyWrCqXs1XHSIXHUwMDBlMGTjMCTEXHUwMDAyXGaBnODSWk2IZehEOUgvbyNcdTAwMTFccpmISVDOsyBtXHUwMDE4bHFnLDia9KhWXHUwMDFhpydk3os/9fZccrFeodf/8pqdXHUwMDA1XGZcdTAwMTRKmFxiLjmb+NXb/mGr07Gj+qW8PqX7JfV4PmmkYEHFPGUoXHUwMDFl51I8+JubUYzpXHUwMDEw4ONcdTAwMTNtXHLhRMpwv1xiXHUwMDAwrYedf7VijFx1MDAxOf+MjVx1MDAwZlxiY39fYTXOkKfgIYCHlEM6XG7SYnVcdTAwMTaEh4g5S+GJjXiv0SxcYtpcdTAwMTe2XHUwMDAxU8tccjRejlx1MDAwNzeb8v1OXHUwMDFjlm+aXHUwMDE310dXXGb+uVx1MDAxYWtcdTAwMWZcdTAwMGb+6b7sz1/ePlbPdPK6W9q4PntcdTAwMWJcZndP27ul0+Bf+evvV9HrS8evWvLMmlE47zJcdTAwMTG/Klx1MDAxZIdOTsRcdTAwMDJ0XHUwMDFh7UlqreAoQIlQjqhSTrDRaDTJXHRWW8Gkm19ZTMCIgYugrcqcX5lOl3BcdTAwMWLm11xme1x1MDAxYa5AsVx1MDAwYugtRLG/qWfh0dr28brudFx1MDAwN5WauS5Wtu+f7DD5zDVGOShhXHKmVTAspZ7LnqA4XHUwMDFlUlx0XHUwMDAxXG7ZSi6VcKfuM204nihxY/wtZ/OkwyBcdTAwMDH9cajP1676xfXh0aR5W++8nVx1MDAwYtJs/WX8U8F/J4PsRK1sxEhlXHUwMDE1WfkqpJ0moGZcXN2T3irEIGJn9/WSXHUwMDBmlDiq7Fx1MDAxNWvV94udycOmSIFcYrFcdTAwMDBcdTAwMTHWY1x1MDAxNDxKLZmw1nl2miPi81x1MDAxMXG5ujylUlx1MDAxYcy8dkFcIrrsXHUwMDA15CxcdTAwMGVA+Mx03cnZYfVsYzxcdTAwMTk/XHUwMDFmbNzXt99cdTAwMWHdzrCYTkRmiDf3apKISOxIhkO0KFx1MDAwNWtkzDzSmPZAO1x1MDAxYVx1MDAwMtrfwrpcdTAwMWRJSVx1MDAxMkOh1lx1MDAxOKyccfdn+W+Wk/RcdTAwMWKQlWKsotHSMiGIe+5SpH9nJTPc2KxcdTAwMDdcdTAwMTRnIDV/zdBcdTAwMWT+6P2z1qmO641CrV9vzI+7/7rcpVx1MDAwNVQ3r0GT3U42uvRqOCF3azdrg4PT6mbv8OTu8LmbXCL6XHUwMDAzttjDie5aaor/zJlcdTAwMDaqwXBoiaNTlWA0J+FvYSpcdTAwMTLPYozpyMJcdTAwMDXmqHB31XlkONjgXHUwMDE4Xc7Ul5uQXHUwMDE4RPDHg4vmaK+6+TBcXLvYeay3XHUwMDFlStu1NLqUe1x1MDAwNotVp+PM/Fx0uD8jLpJ6wJVKcqJcdTAwMTk88/CMXHUwMDE2alx1MDAwMVx1MDAxMr+mkk6/OlpjXHUwMDFi4ylcdTAwMDU2nFiNk1x1MDAxZn3dWf47QFwizV7fW3mvY+M5XHUwMDAx8sc9hDi6uzzYRos271x1MDAxM/tcdTAwMDGO61x1MDAxZLOhhTy2451hvXvRYoRcdTAwMWUmjYbWXHUwMDA2rFZ/Zc1bMa5fn9r70/EhWVx1MDAwYv6VqGho7HXv30u995fR1o59LpUt3Tl7p5OXZNdNIJBBdtKMgO/+9Fx1MDAxMlx0ZGHiMa9cdTAwMTZg3lx1MDAxMmyGb6nUXHUwMDAweCNcXFx1MDAwNW15YXgkxvdTpO3DY1x1MDAxMJJcdTAwMTHmXHUwMDBlqESWtMFzVeD9ZN1cdTAwMGU/kzDrT7VYx7Lp8vlccv7PfXUw/VxmMpC+K0ReXHUwMDE3XHUwMDEwaTjymuBGslx1MDAxMb3xpm7OXHUwMDFjXHUwMDA1oc449SxQL9eC4VCgINLBqfKUIZisIDWYgjDSpUdcdTAwMDR4wFxuxzRa7k9cIpv5wsbjVGtsJIGN13yhq1x1MDAxY/hB4Fx1MDAxZiYl98g0XSFxxIHVTnJXkeRucaj4kvPXYs9cZinlX5+lXHUwMDFiuUenb87vztnVQnSdOknX9+RcdTAwMWE/P7IkpmRqympjvH3igUW1llhcdIRrXHUwMDE091xyocV9Vn2aXHUwMDFhXHUwMDA3wlx1MDAxNOXKYMjKXGJLaWh3ZJRcdTAwMWY8b0FcdTAwMWGdu/5LXCItcipcdTAwMDZa1a/K98PaXfOc0POztZ5N7IRIyj3NXHUwMDE4bFBlpKK+mdbTXHUwMDExXHUwMDA0Sniw1fBUWDKBjcBCVlxuk4ldYTpKXHUwMDAy38/tUdBcdTAwMWVcdTAwMWSt7GzAXHUwMDAzYZQzZ4tlplwij4FccrNcdTAwMDRnMWU9k8DaQHuSleR1u9jqyub6VW3nprJ9ZFrbR+et5CegXHUwMDE4ZSbUTOvHcbzf3FRcci09sFbEXHUwMDE4pjlTXFzmWzqjLV1ePVY0ndxcdTAwMGU869rT/t1cdTAwMTWuhcEqXHUwMDA29pmNyuTp1eZrdVKcXHUwMDFjdU/HO2us3Hja66SqI89cdTAwMTIx7tUkcEglZ3FgMWRcdTAwMTFYuNJcdTAwMWXF9r5cdTAwMDZ7XHUwMDE0XHUwMDExlbfUT1x1MDAwNZqTXHUwMDE0XHUwMDBlqdBCaiZcXIFUXHUwMDExbu03iy5h61x1MDAxYm4y76mfev86XHUwMDFj0l+t4Unhn5Pun4VcdTAwMGav8M9CXHUwMDFinlx1MDAwNfy5nydcdTAwMTnTpl+/XFy+P//6t9qo88v9K1Q78Iem/b/+/MtcdTAwMGac/uJcdTAwMTd7tVx1MDAwYmgsKp/o939cdTAwMWHZuMZnvVx1MDAxMn2ovtrS5sn7e3FULovT56PELM0l8DB4xdg2RCj/Uc1PaEmwS1ZiYzRqKVx1MDAxNzP8fbjHUnlcdTAwMDbEu1RcdTAwMDL7vWvHcZBQ0lOKXHUwMDE5rlx1MDAxOaVcdTAwMDJcdTAwMGaNcjtcdTAwMTRhh05XJ29hqGDE0TRx+rSjo9+Yl6OV5p+YbvF0MXlhpduz9c7zy+7ZaP9UV0snSaPUnSPBr8915Xifv9Dn88lDr/F8XHUwMDEy/CvLR6lcdDH+XFyslUSB+y5cdTAwMTOIXHUwMDAygKOnsS1cdTAwMGZcdTAwMTHC4GCLeWzqRdhUXHUwMDFlRVFgjTRcdTAwMWNbzISxmVx1MDAwYulILJ6l0Fx1MDAwNIqBblx1MDAwM/nm9Fx1MDAwMmNAx1x1MDAxNUZsMpZcdTAwMDTpd29IXHUwMDEyPPXrXHUwMDA18imJXHUwMDE4ccy9gNrmmXtu0Vx1MDAxOaVbXHUwMDFj6c2153tx+rpWutnce5mMm+e7ielcdTAwMTVcdTAwMDQhttJcdTAwMDCjrOFBs7nRrNNyuWmOPjfIv8xdJO50gm1cdTAwMGXcKOCer06iXFxcdTAwMTIptWYukc99bX9CeOaKWfaphaX8uKYmrTo1T3trXHUwMDFiXHUwMDBmpH17fDG+TkqipzdcdTAwMGZ6r9i9rfNn8fg82a7K/dvxdyRR910mIFFcdTAwMTA18VxitFx1MDAwYlx1MDAxMMi4mTZoY5ZcdTAwMTIqnY41/W+WXGaZXHUwMDA2jFx1MDAxNylYlFx1MDAxMI09mLWrT4ODW2c5SporKbNuwZJcdTAwMDGN/uxlNj1cdTAwMWZFZ7AzdSZcdTAwMDf9Tqcx7aC9Vt4rNPuDQq3TxuX4v1x1MDAxNWi0PZs3M/3PX/7p2WZcdK83XHUwMDFjd/1cdTAwMDT7JS72XHUwMDAyknTP1vnUjyWjznH7pV1zfTN5njyMXHUwMDBlTlx1MDAwZeTa1Vx1MDAxMamnXHUwMDEwXHUwMDAzXG70PCFcXCsjXHTOOlx1MDAwYtpcIiCPZdVAXHUwMDFlXHUwMDEyj7NBq5cwMCOZXHUwMDE2jlx1MDAxNqp/xI6eNlx1MDAxYTxubpZrrPpJzNpcdTAwMWK+nD1enlxcbW+XXHUwMDBmhoevT1x1MDAwN7tcdTAwMDPWT76nXHUwMDA18SyHfSlccra0JGyOXzF7klx1MDAxYspx9Fx1MDAxY6NKu1x1MDAwN0/lezr1nr5aXeRKZjBcdTAwMWVnXXTLo+l22rGK0+VcdTAwMDJFn7SpR1x1MDAwN+Vma3f39YSPbWNLXHUwMDE0ZfN03TFHLWpTS+Ex2LHYv1x1MDAwN1PY1Vxc0lx1MDAxMHxcdTAwMWbLY1x1MDAxNHY8w7C+o1NQvqmX2dTXq29q8M6M4cZcdTAwMTX99HdYXHUwMDBiXHUwMDFkx0vMXHUwMDAzXS4/6GN1qfy2fnNyN1x1MDAxONZ2b0c71dF6hzyNzX37y/wr92qS+FdYKlx1MDAxNotcdTAwMTW9XHUwMDAwK/nR5fS7y2LmJrmDhVx1MDAxM1x1MDAwNLmi1qVipIhUMYxgp1eq+PeLU/6HXHUwMDFmXS6gsb/70eVreSS2j3pic692+fZwd3HwfMxcdTAwMWZcdTAwMTKztFx1MDAwNZbGQUBSwDZcboxcdTAwMDP6XHQtzT0mMEmfXHUwMDEz7NJcdTAwMWLulJLg6JKjdZtOs1NcdTAwMDQjQLlcdTAwMTmKMkOVlalcdTAwMWLcYiG0didWxJyhaEpcZlx1MDAxNlV9XCJ3X19cdTAwMWXd11tnW6PLnc77281mv7JW735cdTAwMTl3u1eTgLux4smz2LtcdTAwMTdWxSkjIVximVx1MDAwNVx1MDAxMOJcdTAwMDBBbq2BXHUwMDFm/Vx1MDAxOVx1MDAxZVxyQyiXu5GYqSanbsqxdkEpd05ezNRwwuDZUPv13lqIu5/69SzodaVcdTAwMTPGXHUwMDA1XGbkOGHMmlx1MDAwM1x1MDAxZvrt6laPyNpW5fquz/V5R/aSXHUwMDE3r8LjZZ6izDBQ15qB51x1MDAxM0QwVntzq8A6Yv68NuHylvyAMT1u71bPXHUwMDFhx1x1MDAxMlx1MDAxNmGdWeNcXEeimcFjxNlcdTAwMTOfeLyonu/McUf3XHUwMDBlXHUwMDA3o/Le1kSw09bWQ9LjxXLv6rTcXHUwMDFllt42XHUwMDFlL2m336F77VIx+FeWqlD921x1MDAxY1u6P70k1EyYjlx1MDAwNbZcIlx1MDAwYoDNXHUwMDA08eS0TSCoW22Va9RM7lZHYryWnJtx8lx1MDAwMbdu4apIpFtNXHSHpyNM5p1cdTAwMDAzcKtcdTAwMWRzflx1MDAxYoN/zWb8/nyHXHUwMDE2MOVcdTAwMDZ+tNCt9tpNeOt/f7HDvIBNk8wzTnuf2ciAvdFcdTAwMWQ7e37bXHUwMDEwj8dcdTAwMWKsvW5cdTAwMWaPXlx1MDAxZd+TXHUwMDA3rLFAzFx1MDAxOCtcZpHYVNDO1bPDe1x1MDAxZZBccmxcdTAwMGY1XHUwMDFkglx1MDAxYtbxSmKNnTRcdTAwMTK83cDJ5ExcdTAwMTDAj0jwgDlcdTAwMTFUMeVrJJKbjqDpqK8sXHUwMDBmLFh/Lbgzh5dGn8xQcOi4wt72n6dcdTAwMGZcdTAwMDbjrbfihL+cicOj9a0j81jvydF/XFxcdTAwMGWv+y5cdTAwMTPwuFA2XHUwMDFlmZbHI5NcdTAwMWHrXHUwMDE5aY02XHUwMDEyU1x1MDAwN6yr31x1MDAxMs197EgwNpLzOFx1MDAwM/goJmy4qcRcdTAwMTR2kT2SXHUwMDE4wdJcdLXk/NTfS+SFR/gs/l3Y6Pea7dZh9elHr1BcdTAwMTg+NWr/xn8pfPCZ91btdv6NMd6f39/Ey1x1MDAwZeCJN3zf/Gd/UKhcdTAwMTbuYP2dRiZd2lYg+lx1MDAwNXxcdTAwMTlcIvpP+Fx1MDAxY7JcdTAwMTFcdTAwMDLxJjGu04XEZGLFcLRcdTAwMDZRhs+P5pCGYlx1MDAxNY5cIsxcYoqxpLDHXHUwMDAwXHUwMDBlXHUwMDAz9k1cdTAwMTKUY7dB7ohcbihcdTAwMDbGiDFpNSPYciB3IFwiXHJPM6lcbohsdUG5MUpqpVxcjlx1MDAwNePRjoVkXHUwMDE0M49+g2NhwcotY48y7XZRjNyo03fDe3R2wZBqyKzhRbxBKsxcdTAwMWFeXHUwMDE0wXFcdTAwMTd4pKRcZohcdTAwMDJcdTAwMWOkOfPtXHUwMDBif3W8wDNzeIiYOWUwg8zI0CZcdHS8iFpVfFx1MDAwNYVvVfCJMmlcdTAwMTSsTFx1MDAxOcuYnjWoK/i6cEjs0lxmXHUwMDFhxGpcdTAwMGWfv1x1MDAwZW/cz+m1cXH1cvpyeVVeeym93K5cdTAwMWLW6XV6L4mdJUpcdTAwMDTH0fXYcVx1MDAwNDtcIs6VPEpcdTAwMGJmVOLGgrs1TDlcdTAwMGU9cmcpOzPZWj03k1xuxS04S04zKaKPRiRYNM7Mcmlccm63ZpG39PLe5vS+NFx1MDAxOPavXHUwMDBlT/T7+KC8s3fxXHTR1JDd+63ekvsuXHUwMDEzeEvTclxmXHRcdTAwMDaRWHSG9FxcXHUwMDFmXHUwMDA0qpTw1NREaeA/XHUwMDE5ziVcdTAwMDLq86xcdTAwMDVvSVtcdTAwMDY/plx1MDAxZFx1MDAxMib3lVwisXif4jxcdTAwMTJcdTAwMDedgv10g05FV0gx8GGxg8L385VK47vGoFx1MDAwN1x1MDAwYlx1MDAxOWJJwadcdTAwMWNNLuhCvYDn5p2cqFx1MDAxYsjGMYm3PnGOXHSlXHUwMDFha7BcYs45XHUwMDAxuvTPSv6VXCJcYp6JtjF1XHUwMDBm3EPHxVKLk1x1MDAxZaXw5Vx1MDAxOM5gzahHLVx1MDAwNT+cM6Buf/5yXHUwMDBl8yDM2yt7Jlxme7CBXHUwMDA2dFx1MDAwNUpYdE9OKzXR9jekXHUwMDExWr+P9FVuSeQmxVd4e84uXHUwMDE3XCLnzJyS+OOQgPxnXHUwMDFha5Jwpp+Q1jLjW/1f+lx1MDAxZlxmhFx1MDAwMW9cbpCstVx1MDAxMrNGgil9knirXHUwMDE29JTgj1x1MDAxOFxuXHUwMDFlkuJcdTAwMTLzT4WjNyD1XHUwMDA0qHTQeVx1MDAxYVx1MDAwN+iYr/NK4lx1MDAwZqVjXHIk7GBPYy1cdTAwMDY4tFx1MDAxMoerXHUwMDA2XHIkI8yjXHUwMDFjTKeFXHUwMDFkpFx1MDAxOVxyXHUwMDFiSCrBO8PGgDinXSrqXHUwMDFhJe5cdTAwMTl02iwjlnFuSG5cdTAwMWaj7OPDyvaRWiUwo9RZhsDCOVxcXHUwMDFmI28sXHUwMDA1dJnMu+1/XHUwMDEzXHUwMDBiWYzcptN3Qzv0m5lIXHUwMDAxXHUwMDBmXHUwMDA3XHUwMDFjflx1MDAxMCmAU8l9TVb/skXgfFx1MDAxMISwXHUwMDA0sGK9edhcdTAwMTYlspDx5bRBXHUwMDBiKaywhlx0o4ghXFzpcNjGgsdkKMVcdTAwMWNNIG2gnq+yj8OToqxvd5qV7f6kWGa9/lxyXHUwMDFmJuqDg1x1MDAxZjuOM1wiXHUwMDFjuMDg4NmAeVx1MDAwNKR56MnDPsdpUcLhXHUwMDE1XHUwMDFhg6kwXHUwMDE2k2pcZmfEXHUwMDExsOG5V1x1MDAxOGVcdTAwMGW7yb1ChcdcdTAwMGbWL/D91VdRVo9TTbA9Q8YuIWx1Kn29xpZwXHTPftVTdKpvUTX2JvDd33fiXHUwMDE1WPa851x1MDAxN7HOjDrg3K+VdvaPK/2Xp2pl22yJ1nmtXHUwMDE0xi1eclx1MDAwZbeWYet1bcGeT+OtQVkjNPPAXHUwMDFiwFx1MDAxOUKScaZcdTAwMWTDhjT3NCeSSyFcYmCfOoCLsVxcsMdcdTAwMDRT5LAlVj5YJVxux73V3T48XHUwMDE5MO6sVVx1MDAxYlxyb6ByeMrLJa3GwFx1MDAxYnaG1l8valwi9yi+iqHtmaWmmefpLDqg43sxxuB2r7u23XzcXHUwMDE19+9deVxcZ9v7p623hMZAeNxIXHUwMDBle0FcdTAwMTFKWfDYRVjucVx1MDAxMFhA8ThUxeHiTOdiI1GAl1x1MDAwN16oi8PBXHUwMDE1ZNg8XHUwMDFk/lx1MDAwMGHgaedD56NsQX91XHUwMDE3XHUwMDA3+z2AJpOu7lx1MDAwMdFcdTAwMWQxqJTMwuPJuudcdTAwMWSQXHS27Ppya1x1MDAxMLVL8Vx1MDAxNd6ff3NjsL119nj1drhWuejussbR1aFZXHUwMDFiJ++ywJjAjFx1MDAxNEGp4thEX1x1MDAwNcPCQlx1MDAwYvy4XGJFZ1BKaVx1MDAxZFx0K2BSXGZ8olKD10WZf+JXPoswtU14SmpcdTAwMTOiT2JcdMexkcaZt8rC00tnjeNxQLBeLl/lY3mpXHUwMDBlYtlO87C+9X5SrWxccmtv963Bw97mfqq+8Vx1MDAxOMXwjTRe6cDUvZpcdTAwMTCQXHUwMDFjg8yYXHUwMDAx+aE1JitcdTAwMThMiZkjVebhcDMjsGhIUMfJXG7FKlx1MDAxMWuNXHUwMDA1e0Xhi1x1MDAwM0F5c7tIyDynKFx1MDAxMlx1MDAwMVx1MDAxM8ZcdTAwMTh3usYsOrdcdTAwMTRcdTAwMWVcdTAwMWZcdTAwMDd46MzpMu3unW3GOZ+z3a22XHUwMDFhP3qDXHUwMDA2fP6TbDrRrXpuuoCXorznyDvJxo8+WCtcdTAwMTdcdTAwMWaGt4O7XHUwMDEx2zk/3nq5Wr9hz0lAXHUwMDBlqlx1MDAxOMSxtVJcdMlcdTAwMTXTPDi0l2nlXHSilOHgYSiguDDIpfA0MCimXHUwMDA0XHUwMDFhK5zaOY9/RYF8kFx1MDAxY+RcdTAwMTScXHUwMDFihmmHLlx1MDAwMoxcdTAwMDa55Vx1MDAwMrQr96UvZFx1MDAwM3JgXHUwMDA0blZcdTAwMWFccrGGjVx1MDAxYb99+Mu5yoxAyyv68EjdnFxmTvu9w0P2VLw8u03k71x1MDAxYYszRCVT0lx1MDAxMtCnc+1JkLcxXHUwMDEzm2hpLHbXXHSDljBPc4vjJq1iII9cdTAwMWRnemBcdTAwMTeM4JJcdKFcdTAwMDS4XHUwMDEzvmneOYiDIFx1MDAxZSZcdTAwMTW3kVx1MDAwZa/G9i+BNiP+ltCR0pZjfm/mOVx1MDAwZsLgIF3j39Jf4u5Gb1J8XHUwMDE1Q/tzdr2QwP47OLznrztnd4fj9W6L2rOKrZOn+uFlQmsgPa5cdTAwMTVocUWEJiZcdTAwMTj94lx1MDAwNFxi2lxiXHUwMDBipoLjXHUwMDFjXHUwMDE2R1x1MDAxOVx1MDAxOOGeXHUwMDE1eKRcYmJd+itcdTAwMDNnxsB4XG7buHBcdTAwMWPi4Vx1MDAxZnyb24KgLVx1MDAxOK1sXHUwMDBiKHapXHUwMDAz/HFXJzVcdTAwMTY9II1cdTAwMTiqgjV8WVlcdTAwMDMpfDMjv85cdTAwMWFEbFJ8zW/Pv7kpmIzfKmed0V73YJ3eXHUwMDFjjtrHz2tmklDNW09cYk4kXHUwMDA1S2CoXGbmOCtOPa24sth0jyuXKeDKw/Tnn9FXaV2zx3M5XHUwMDFmif5xXG45L7lAXHRcdTAwMWSe6oZvimicXHUwMDBiLDxcIjx71tfC38R0XHRBf3x29meh3YOP6Vx1MDAxYut511wis5HzOy/D5mhysqf65Fq9vU3M5dn5XTJcdTAwMDI3yrNcdTAwMThLg6dgpLBh1FLOMb4jYcfYsFx1MDAwZo5eurZUwWZiRlx0V9FcdTAwMTBnnuJcdTAwMDJcdTAwMWPIaWzG5ifZUVx1MDAxMJ6sTuDoXHUwMDFiXHUwMDFiXHUwMDAwlVx1MDAwM9hcIjpcdTAwMThnXHUwMDE4kL7JPFHFmEDrvK+i78gtiq/i/O78m/P3afngpnZE9m+Or5rlzcYtXHUwMDE5brGNhFJcdTAwMWXLT6nWXHUwMDE2cC60XGJKeYVcdTAwMWTXiKZaaUIsseHyQYb9jsFXwnQ2QrGnn8NcdTAwMTRcdTAwMDSPXG7zMutIW/CSgS3Q2PjBOPvgs5hDK3h+TJDsSd5cdTAwMDCPLFx1MDAxNbXL1lx1MDAxYURuU3yFNujf3Fx1MDAxY5Sqpc6dkmuWVVx1MDAwZe/fTjvsaK/1mvwomzLlXHUwMDExKiTOYcTJYVx1MDAwMZNAqbJcdTAwMWVcdTAwMThNjU3mjWQynOjGqPHAXFxg7j+IfqlcXCfZlnuGXHQ1XHUwMDE1/aA/fFx1MDAxZOFym1x1MDAxMLRcdO9JbULkSTbiXHUwMDEwXHUwMDFi3zrnOUY7+Fxcckrsko3SP1aX6iD78NS+93ftRcV0umZvX1x1MDAxZN2XN/tJK4q3j9Uznbzuljauz95cdTAwMDbD3dP2buk0+FeW6s94/17qvb+Mtnbsc6ls6c7ZO528JLtuyHqEilx1MDAxOaghjM62zkpcdTAwMDfv7k8vXHUwMDA0e8fBO1x1MDAwNVx1MDAwYlx1MDAxOIN4LVx1MDAxNiCeWuZcdTAwMTlrjaCcoFx1MDAxM+8qVc5pP1xu4pS4Ie724rWSXFy6+zMyXHUwMDFiXapcZtZWY+frjI/lJFXaypXO3n1cdTAwMDPUzoJT0P46y8anXHUwMDBmXGboO/b4kjZNXHUwMDBiaDVmMFxc4vvKJjBcdTAwMTBvXHTjivfw1Fx1MDAwM3P+OFx1MDAxNjZpRYOH8yBcZoxcdTAwMDduguRY9i6ZXHUwMDBlXHUwMDFm9M1cdTAwMWWGr0eBXHUwMDA3PqGUXHUwMDE2y0VcdTAwMDEuedZapCGgK+t/XHRGmEfMeqWhb35YXHUwMDA3XHUwMDBiz1x1MDAxYVx1MDAwZmyybrJENeyVpaxDpvI/8NPh/Zil3I8qg4s3XHUwMDFlXHUwMDA1f2lcdTAwMWW4b+iigDPCiVx1MDAxMpKEOypJT1x1MDAxMsxcdTAwMTS1ONtXXHUwMDFivWRp3tv+YavTsaP6pbw+pfsl9Xg+abjWVIRFXHUwMDExqTVVXG44hFvLRXhRwuNcdTAwMDL8TuBcdTAwMDPGKbpZ4T36SbV541x1MDAxM8UnneHm9Val8TBZf3xTa8eJ3Vx1MDAxZsqk9ahg3GrMMVwiNlx1MDAxOFx1MDAxMsGiSY9Ixlxm7G74XHUwMDEyXHUwMDBlicC2h1xy9vGyM2Mxq/NReXJDpFxy3FnZ4cFcZmlcdN68M3M3/N2/rCDmIVAhlktPdHtcdTAwMTCLXHUwMDFjnuJ5m9HqZO9Ad0t3pVNanrS21lx1MDAxMztcdTAwMTCaMVx1MDAxMIOE+DpcdTAwMGKt5EC4V5PAgVx1MDAwMGEg4jGj4jFcdTAwMDP2wqPYqkFzMH44OC1cZpr8XHUwMDE4MFx1MDAxYTNcdTAwMTFDYZ1cdTAwMWWEksRgsNddzlx1MDAxZpn8o60l2L4xy3L+pTZwyIE4m1xyS/7ac8BFzFx1MDAxM5WrXHUwMDFiWHg2LkB80Fwi3lx1MDAwNVx1MDAxMFx1MDAxYUS+hW1iKabzzTU4YjiKXGY7XHUwMDFjXHUwMDExIVxyUSzsXHUwMDAzMICy0pQzKeFcdTAwMDc1d4T/4Fx1MDAxYVx1MDAxYywxqFx1MDAxOCyolTRvKVx1MDAxOFx1MDAwNW22+pFcdTAwMDB80spcdTAwMDZSK1x1MDAxMiX7gXTHWlxumnWHo9Qhr9/jXHUwMDEzRO5SfIX257dyXHUwMDExpnJcdTAwMWM+Q2ZcdTAwMTVHvqTMv/qPVlx1MDAxOVx1MDAwNlxcOiNcdTAwMDRcdTAwMTNEMFcroUQ+XHUwMDAyfzy4aI72qptcdTAwMGbDtYudx3rrobRcdTAwMWTRUoRcdTAwMTJNQFx1MDAwMEhCtDXYgMnhXCKAeypcdTAwMDRcdTAwMDX/wcKatP0qXHUwMDE3oWpp65Ko/fPTt+v7g2Fl/exaOqbMRbtcYsYjWKSnXGZcdTAwMTPCmmDAlFx0Kz2F3TvAfzPCn/ww81x1MDAxMYinfC+HiVx1MDAxNFx1MDAxMjST70XzXHUwMDAy4ChcdTAwMWKpVvZcdTAwMTioXHUwMDAyu1x1MDAwNNvWXHUwMDE5VnW0hvsodlx1MDAwMFx1MDAwMsR5gZ84w+piZMdcdTAwMTdH5lxcvUxqL7Vq+dne3XWSnmWcX/X3b6k4a9TEbXP81Lt62DlTwb+y3FlcdTAwMDYoOVx1MDAwMILmOk2wJ1x1MDAwNqDuu0zmivA4bIJ1WoDNvMCosFx1MDAwMlx1MDAxNnVcbk9cdTAwMDRb41x1MDAxMs6lM/E4XGbFXHUwMDE56FxmI4LKLI8ypvtXXHUwMDEySVY6yvg2XHUwMDE1RotcYu5cdTAwMTOLjrZqXbn5vls8L1x1MDAxZo+vWkebd5XaUYr+5pwzuD7sXHUwMDExS4SvI9ivYkHCXHUwMDAyYFx1MDAwZbtcIpRjXHUwMDBi5dnLUXekc2KNXHUwMDAys1mZWDmhUqqI5qo0XHUwMDA0/FlnZY0zrD91OCSdXGbeVaNRNddNeTS4mrxcdTAwMThyxZJcdTAwMTLrcal2dlThJ4NH1t+9fzB9+9xPOPwp9rqk+7C313w6fms9XHUwMDFjXHUwMDBm9i+qrfuJbWZw3f4pv66MSufdo2ve6mzfXHUwMDE0qzdmmMF113pcdTAwMGZk0DmZlKuVo9Z75apO3lgpg+s+XGaatWJleFdcdTAwMTGbXHUwMDBm5f3b0fh+cp5wvVx1MDAwYoWLJFxcXHUwMDEwklFcdTAwMTKGe1x1MDAxNyVcdTAwMTEuXFxST1x1MDAwMlx1MDAxYlx1MDAxYUGptf7Ayk9bx0y8rVx1MDAwM4fdXHUwMDEzXHUwMDFjXHUwMDFjLCxXwU5cdFx1MDAwZadcIu9cdTAwMTdcdTAwMWZp62yaJFxmXHUwMDFjXHUwMDFhXCKFs3dcdTAwMThcdTAwMGJ3RZhlWeKTNXy5kXYxylx1MDAwNeSQTLWBQ8plY9Cow1x1MDAxZmpXO4fVXrX1PVpcdTAwMWYskFx1MDAwZfNcIibmXHUwMDFlspEyj71X2VRsa3+9yVx1MDAwZi+NYlXxZpJLXHUwMDE5kC9cdTAwMWVXQjFsTW04nfNMjCCeXHUwMDAw7GslMEXVMVx1MDAwNDdcdTAwMTczhVx1MDAxNVx1MDAwML62elwipVx1MDAwMshcdTAwMWKrXf5cbmeRYoZzLTFb5lx1MDAxM89cdTAwMTXvu+e7zTVzUieVdrvycnqpt9f4V3P45dbz1eNRjau1y/r95KK71excdTAwMWTvZnBdMX5cdTAwMWGvrd9cdTAwMWVcdTAwMWW3T0ovd+/kdnP8yDNcdTAwMGJqcCpkRtrA/VSSaFx1MDAwM2WtZ8BcdTAwMDEyXHUwMDFjtDS3Ys5cdTAwMGYySsWbXHUwMDBlijlcctRiYpfIJ8mkNFx1MDAxY+vJlVx1MDAwMTfSUEGdpVicR2dngoOEOTWZXHUwMDBiXHUwMDAzbvD8YiVh0IfPYaPTXqtXn0bfQ1x1MDAxNiyg4ZAsiLqDbERcdTAwMDEp1YaH1cH2XHUwMDA1bdn2+8Vl88ast1KIXHUwMDAyxlx1MDAwMplcdTAwMTPB01ZOQDNgd3lGXGZcbodcdTAwMTm756IgXHUwMDEzbG+sLFxuXGZO3aPSfXJA445XJU5ElJ+oXG5q/F6eNDuXh+So2uGj8rh/XU3MsmdcdTAwMDfP98OzXHUwMDFiPbZvXHUwMDE3XHUwMDBmvWNV7lx1MDAxZDevM2DvXHUwMDE1XCInXHUwMDBi2Vx1MDAxYsyfoqlqzGOA7v70XHUwMDEysTee9km0xoxcdTAwMWFDmZ7DuDXxXHUwMDE4p1R5XHUwMDE2W/5bXHUwMDAxJOIur8j5O1x1MDAxMuObKTx77LtqTERjJFx1MDAxNt1cdFhrgs3sdMZnXHUwMDEy6TdwiMDPXHUwMDFhtUFjdDbqXHUwMDBmXHUwMDFh34G8XHUwMDE30GUoRcq1+myIe0J3h/WDij1pXHUwMDFlX7fO1c7p6enIMdgx2ps3nsJ0XHUwMDA3jvPDVLBZKZ9cdTAwMTacxlx1MDAxZTPmxD397rKg3lr9zJ+C4Fx1MDAxNjg31Yn1SHeeUYKD/fztZX87c0/232/2znnreP2iSq9cdTAwMGZcdTAwMGZa23fDs69m7pNO9a5UXHUwMDE5XHUwMDFli9fH5sXz/e677ZwkXHUwMDFj4Vx1MDAxYXvdca9zUp286vPiyPZPT/RcdTAwMDWpnN5+R0XgfipJXHUwMDE0gVx1MDAxNsqjYDy04jhD0MypfnBcdTAwMDZcdTAwMTdYXHUwMDBmMD5oM6SAW1KcXHUwMDE5R5FBrlxiXCKNx3ZcbkVcdTAwMDCvn23uXFxWQkR2VND4i1xcf0NBcNRcdTAwMTi99Fx1MDAwN4/fQVxmLKDgeTFcdTAwMTBaeTZCIN4+xs461Mri9HTGmJSKzqUngFxiUN60XHUwMDFjXG5cdTAwMWJhYKJpXGLG07prabBcdTAwMTePXHUwMDAwJ9JqR/tcdTAwMTTgKVx1MDAwZt41Qlx1MDAxYY49k/NZh1GwTlxcOVx1MDAxND1cdTAwMTSIXHUwMDEwZqzmzvYpMmxcdTAwMDNmXHUwMDAxPFx1MDAxY2nJ6XJcdTAwMDG85TTB2tlNsbtbqonKXvNl9HTZeHh/OE7LhdJ8fSZ2MVx1MDAxYVx1MDAwNdO3Q1x1MDAwMJhdMHSPmSVjx5umQiBcdTAwMTmbgG+vXGLWwsNXXCKEY5hiaMMlSr6Od5Z8i4A1UJyMiaFcdTAwMDaL0UTmXHUwMDE4eetZIbRcdTAwMDZJoeGpW0tcdTAwMWQo+Jzsa/fOTaKbqLGewL7kSMiWy7lzXHUwMDEw+PxBXHUwMDE2cYMjXCKFZipcXGiGbpmeZlfAbsM00VxcN6UxsCnqzFx1MDAwNKbaXHUwMDEyqVxccdHIOFxuQ9XLqVhuekqkqaNg1Fx0Wa5cdTAwMGbVX7Lp1ySF7yCbXHUwMDE2XGKWedlcdTAwMTRaeTayKT5wXHUwMDFiK5uE4nBtolx1MDAxOefYiDzYfpJpbT0r6XRcdTAwMDJcdTAwMDTOXHUwMDEwdpSLgn+kwVx1MDAwNoDkMkBcdTAwMWGGuFx1MDAwMqI4L8ZcdTAwMTJcdTAwMDOGUSv4K3mHqShU760sm6iySlx1MDAxYuZcdTAwMWaK6WtBKWN6yzLDpTKfeVxiwo5t+2n8+tQz7HD9stZ4O9yumVQpXHUwMDAxy2dbZd+uXHUwMDFlXHUwMDE0nFx1MDAwNfVcdTAwMDGeplxudLikgnuG4CxHXHUwMDA1Zlx1MDAxOKC28GqRkMJXXGJMnyHB4tO/guqHaauNXHUwMDEyXGbbzYHlIL7W/Z8kwUBcdTAwMDdyXHJwZOg9XHUwMDEwJYBwXHUwMDFjXHUwMDFh7Kskl3vXJ5JcXFx1MDAxMlxcXFyQlXp6XHUwMDAyxdWcsVx1MDAwNrXrgYurtNBWXHUwMDEwf7nNh4/LPNik4FRRgVOMWX54lco4pynt18pcdTAwMTiAvDNWXHUwMDE12VximIKNIIL5XHUwMDFiY2SlubCj5iqaa9hcdTAwMTh9XHUwMDA3vbVA6czrrcCqs9Fao1xyMrx5PmJcdTAwMGbj3vshXHUwMDFmXlx1MDAxN0uT7mHysypcdTAwMGLm219cdTAwMTM3nz4m8ChKXHUwMDAxQq2EL9rR8Ds/rCqsXHUwMDAw4v2kXG4r5rCKXHUwMDE5baS7iSdcdTAwMTfRXHUwMDA301ZcdTAwMTJ45ny5tlx1MDAxZMsprMdTWX47U2+14avZql+o0fbl1dVXXHUwMDE3vFRvj1x1MDAxZUp7raPt3qF9u314XHUwMDEwz5W2yeC67EI15GlP1KvFbqNe2tuolVuTZNdNoDRBRLCMkk/dTyWBXHUwMDAyYKBcdTAwMWY9qoD6wYOmRtHgUTc2l1hgPajROFjJSLhcdTAwMWZcdPpIOKxHLlx1MDAwMVwirUcpuVx1MDAwMlDwXGIwyddlJKLnWDNucaJnpmOsp9tXXG6dqiA8JFx1MDAwMDY61XG9gVx1MDAxOZzfKP90XHUwMDAxXHUwMDE5h/JPo+8ho1xm1FiTXHUwMDE5XHUwMDFiiJGSejhtl3BmpFJiriRcdTAwMDVndVxuycCn4thtxNG4S3DrKVx1MDAwMb9cdTAwMGW/yyWljnNo8E09hc2kKc6/pjZcdTAwMDd6XHUwMDE00Fx1MDAwZlZcdTAwMGXEgOxcdTAwMDaHzJl+LkmkXHUwMDA3YLD+kSj6icW2/dp+q3svSseqdMVcdTAwMWXE5oFiL6/puJHDvvz6oYGRXHUwMDAwwFdo68+uXHUwMDE2usHPXHUwMDBmm1x1MDAxNHFaNpCxXHUwMDEwXHUwMDE0yNlcdTAwMWEzq1x1MDAxNCj4Oo1cdTAwMWEmwUFQYFx1MDAxZIDew1x1MDAwMjVRXHUwMDE0Jd5kXHUwMDA218Qp0VpcdTAwMWIhsbWKpTK0pi9cdTAwMGKiuDdtXHUwMDAyXHRFwVx1MDAxYvZAJ1x07H1ltJ9pp3aWgZ2Fm1x1MDAwNaWFs9J4OIZcdTAwMDJcdTAwMTKLodfFXGKeMfrn6+RcdTAwMDIqgV1NXHUwMDExQ1F4LK7TnVpxKi3q4mz1XHUwMDEzeHxGidVGXHUwMDFlXHUwMDBmXHUwMDFhzUFjeP9cdTAwMWRU01x1MDAwMpVcdTAwMTI+tJpbeTZaqdZv7bxsv76X5Zq4vj/cnrRGLcdcYuTIxl/EeEKjm8OwN1x1MDAxMFx1MDAwYuKYXHUwMDBiTTxcdTAwMWJbh0dcZlx1MDAxOF3fy+FcdEmKdcCzXHUwMDE3z1x1MDAxM36igH20elxcRStsWS2c5TtcIia9jyjCJP9ExTQ8rGyq67dy+frgoLn2ptn12d5+0ihF64U/V9ZL91x1MDAwN1vdh955u/5A1H4lg+iHPXlcdTAwMTjsqN2T9Vx1MDAwM0JEy5Zf1LbJZDZcbio8bIbB0pi/XHUwMDE43Ls/vWTUzeMhL1x1MDAxN0DeSE/4U3XDiM/biUVcIvw4OXNcdTAwMDNrW43OrFx1MDAwM8ksnOQ/azVErOHULtdqKH778lTbN8TehcOz9cLToF/Dm3ZS+Kf1XHUwMDEzW8CbodknzpVnQ+Gnjd3hydrFzfpO/bIoXHUwMDFmrHnfmlSTU7hQOtCFY65wR2CGWd6F47dcdTAwMDG6vDplXHUwMDEzK4gwwjnjxJ9kPU/ZzCgwwMtFOZfj7Luj4d7a6OH98oCsXHUwMDFm0f3r8tZlu5qUW9Vo6+6yaLcv2uyy+lTstCtcdTAwMGbNLE5CesXTm1xyZq/3X6s3tbfB1e56dzDI4Lq/q1x1MDAxY4jviN6WIFfV89b704XunrPa+k1GXHUwMDFhg6JPTtM0SIgxTO6nnURjSC488PWUwPZcdTAwMWNk3iRJKuNNUl5cZlRYwVwinSSXXHUwMDE4lGlcIkBcdTAwMDe6O5bq6Dw3RbRlVmbaS32p7Vx1MDAxYtJcdTAwMTjfqFx1MDAxYWhcdTAwMDGxf1I1UGlz8lJ+XW+N9y9Grclg1Lh7bt2kkFx1MDAxN9pcdTAwMDbchbnKPthcYlx1MDAxZceJXHUwMDE3eFx1MDAxNorjT3N9kS2aT1fWXHUwMDE3XHUwMDEyzKzmhjub++noQ1x1MDAxNENcdTAwMTm3VHxiXHUwMDA10O559WLt4PrugLx3R0e1O92pdSp/Y3nxuXTt/vRcdTAwMTLRNbbmXHUwMDE3UisqXGblYo6uXHUwMDE151x1MDAwYiCuiGekxYnrWlIrec7XaVx1MDAxMH6WnK8x3Y0q6q7dje7NY1x0jqbmy1x1MDAxNfj/VrbutO9cdTAwMWVcdTAwMDfj3ndg61x1MDAwNTw5z9ahlWfD1vFWLD73wVx1MDAxMI9cdTAwMTFcdTAwMWPRh1x1MDAwM6hcYpkr3lVEeThaXHUwMDA1c5+ldVx1MDAxMbU3zbdcdTAwMDVtJ6yWVlx1MDAxMUftLrdcdTAwMWVcdTAwMGWV0/BcdTAwMGbA3eg80lx1MDAxN1x06/OVk1x1MDAxZqiZziczzkFHjEZcdTAwMDZcdTAwMDZwko6UYNB/gzrnbKla/Wzra6N36vTt8CadXTFEydlNO4q1XHUwMDFmhWCBLdeWcFx1MDAwM1x1MDAwZa5cdTAwMTVCUiUyK++I9znmVkFBXHUwMDE4XHUwMDFhYH9C4dlKqZOs4pNSXHUwMDEz4lVXrFx1MDAxZERDZ8E/xdpcdTAwMWZi53tcdTAwMThIXHUwMDA17ypCuLBcdTAwMDbEjCNcdTAwMDVcZuczwHtGg3dcZvvLXHUwMDBmtNxcdTAwMGWmt4NcdTAwMTcr20FuXHUwMDE15cyG+5BPbV1kkMKAbVx1MDAxMIRlfVx1MDAwZfJdrGD0NsXX19jANNaHaa2tXHUwMDE0XHUwMDE2XGY5OFx1MDAxZMRV4L+cXHRMbIhhXHI47FxurJ9cdTAwMTCCYGhcIjx17stcZmB89DleXGJqXHUwMDAzQlx1MDAxMFRcdTAwMDBcdTAwMGVcdTAwMTaS3M5ZQGG0p1x1MDAxOG5cbsOwO8VsK8+yOrhHLbj/hFLcPi5cdTAwMGJIhcWRglx1MDAxNPjXXHUwMDAwznTexiXSXHUwMDAyRmRrpVGCXHUwMDFh9Vx1MDAxY1x1MDAxODNnXHUwMDFiR1x1MDAxNd2UlWKfdmqWXHUwMDFiLfWxvnT1yJdyWL4+XHUwMDExo4cjUde74909flv6vS3Nfo+BXHUwMDE1OFhNYSVcdTAwMDG1mislXHUwMDAzXHUwMDA2tmiFp1x1MDAxMEBUMMUtXHUwMDE42kXXi0bV9HphQH03i02JsUzgwNNphlx1MDAxMVx1MDAwYreFkaApXHL2XHUwMDFjXHUwMDA1cVx1MDAwNbdpljPgiVvVYF5cdTAwMTl4Opjqa1x1MDAxNVc6XHUwMDExh3xWgbJcdTAwMTNcdTAwMDYh++0sUKZcdTAwMWVocSU1h8/bn7r5szyJi8DpWVjAYm27XHUwMDA2MpNcdTAwMTJcdTAwMGK4/XNyZzl5M4OQW+ugtb5yW+uIXnpcbvSVdFx1MDAxNjGGbfWHf47VyUJl7Z9jQVx1MDAwYoBypXhcdTAwMWPGxFx1MDAwNv3Oj151PLrvXHUwMDBmMJg1++i+LDS3QFx1MDAwYs2H5uJuXCKbKN3e+d2wf90q717Yg7OT6uX67d1Vilx1MDAxZflcXIq4lFx1MDAxZKpEoFtm2EPNj9Rcbisg/DqpXHUwMDFlizxSY2BWpTBOjzSmhyZAlONcdMlyx+bLybH12ulwZ+P6zFx1MDAwZeR2bbBblVx1MDAwZq3Xt0/oXFxcdTAwMWJ73fdjY8XGQN7qnbdTdnT6cH59fZbsur9BPsZcdTAwMDDd/eklYXGB3WyIUMpaQoU/hvtcdTAwMTPjlsRjXHUwMDFjXGLeg5+yhEjKVH6ollx1MDAxMuM3KVhcXFJcdTAwMDX/x5zT72KqjNG5MpIxkvGxWlx1MDAwNi1xy/CB1962ekCL34LAXHUwMDE38OU8gbuXn1xydcebtPjAsqaeXHUwMDAxLFx1MDAxYoynqVAqXGY2uVx1MDAwNs9NMm6x1C+MaGw7wFx1MDAwMPJKK5yWJ1x1MDAxZOdrUmNnUPBcdTAwMTSFXHUwMDA2XHUwMDA3kdk8qFx1MDAxMlx1MDAwNfBKUlx1MDAxMo9cZqpcYm5cYmEqnFx1MDAwMTP1j1wi1bvgQmLNY8Zh5W/TvDZ6m07fXHUwMDBl7dDZXHUwMDA1Q9T8Jc1rXHUwMDAxf0RoSbAnLDHc10XuV0hAedhUSnGFwTHGuVxybZBEUYp4m1x1MDAxNlxcXHUwMDEzXHUwMDAz0aeoUkpcdTAwMGJcIn3tij7WxDyQlIJcblx1MDAwYnJBY69IXHUwMDFk3rWfXHUwMDEzt9irVJ96e1x1MDAwZj2+b24vdmXpuWLejpIpXHUwMDFlpj3YXHUwMDE2SmLluFx1MDAxNnbOq2GGL1A87GdjNVx1MDAwNsbVgFx1MDAwZkRcdTAwMWNFwXncXCLSIN6lUDxYuo9cdTAwMTFIV1x1MDAxZVx1MDAxMWjOKNOnLNdML5lcdTAwMTL4W1x1MDAwNc/9aPTkXHJcdTAwMWHP48Zw9KM3nFx1MDAwZdnxxsNMxM+CKqN46Vx1MDAxM7iJeZ2zeNXZaFx1MDAxZX7RpIOdrdExqNzL2snWsPfeTFFhhIN8/SP9XHUwMDAyuGbCXHUwMDA22iWFdU9cdTAwMWWtmH53WVxcN5Jcbp3oXHUwMDAyI5znwDBcdTAwMWbC5eDQSKVjqFx1MDAwNEttMzw7+lx1MDAxNVx1MDAxYW9cdTAwMWSfblx1MDAwMVx1MDAxOY6rI8H1UNT2d+/2fMw5TzNcdTAwMWZvLKir2drit3fHXHUwMDBmXHUwMDBmr1x1MDAwZsPNcrdULFx1MDAxZPuYK3DZVMGK86v+/i1cdTAwMTVnjZq4bY6feldcdTAwMGY7ZyqD65Jqm+pcdTAwMDa52N3vXFxcdTAwMGZ6XHUwMDBmO8eXr7uHya5cdTAwMWJSWqFiTUJcdTAwMDGOaVpVxZiP+OdcdTAwMTUnXHSMXHUwMDAxtUiYmFxuXHUwMDAyYuRcXDsmyWS86aDU4MBcdTAwMTFcdTAwMGJ3w6aTflx1MDAxZEdcdTAwMTl5XHUwMDEwJNJ0NJNLXHUwMDAyrYXE4brOXHUwMDE4yExEh4uNucL2L1krXHUwMDAyZak/0XypMYFDXFxoYa28l4VcYlg1XHUwMDAysoCCw2NcdTAwMDJcdTAwMWSrz0hcZsSaydhcdTAwMDCIlYBXQ7i0WoI7M9f0R2rtKYJcdTAwMDMnYUNo6ZhV4Vx1MDAxOVx1MDAwNWLCwu+CLyS5K/4hPKE1xa3IXGKTvsyGXHUwMDFj20Fst5LKgpjZQFx1MDAxY1x1MDAxZVx1MDAwM3ibrlx1MDAwMIiImVxyioVBzJCs04ux2TmTS/VXzjZcdTAwMDJcdTAwMTK5T/FcdTAwMTXaobPLhVg5s/hHvPUoXHUwMDA0szRcdTAwMThlXHUwMDAwTmZA9U07jvh+7K/BOVrgbCdAMFx1MDAxNpNwtWRcdTAwMTM0dXLy+rr1/PpSPO5cdTAwMWbfjlx1MDAwZu5PLt+fXFyrmqZcdTAwMWFzbY1cdTAwMTBcdTAwMDJcdTAwMDNu8OmGO7MpT1hBtbCcM8yPXHUwMDE24X37OVx1MDAxMZDHm8PTY1V7K2+eli6rnafX7Z3NnbCBjDzcNdI/R1XMXHUwMDFk/ICGWVBNJeAnXCL6Jc1EXHUwMDBmXHUwMDA3YeR75T2VXCJccuV9UkNcdTAwMTntP1x1MDAxOWUxxchZdiVFtDhcItwoIc1yXHUwMDA3RG7XYWH2XWNMXHUwMDFhj2fnXFzd1G9L47eb0Vx1MDAxNe2n61IklH/K42qeg3M1STxcdTAwMDduSSyK7KKaRPA8JLWYqs01ulx1MDAwZdZcdTAwMDGi3HOIXHUwMDAyTTu552A1VpxcdFdmqlxmf3c2YFxcgk+HXU8yXHUwMDE2XHUwMDExsH1Zqu1cdTAwMWLyXHUwMDFjXG7Daq9+13/Nwm1YoUPRXHUwMDAyXHUwMDFhXG51KFxurTpcdTAwMWJ3YUC6/dez8fXr9j2hXHUwMDBmrYNav3hwkFxiwlpoXHUwMDBmXHUwMDA3Xlx1MDAxMSA0KXQwbMgl+Fx1MDAxMkQyPEnDUVx1MDAwMI6ZWFx1MDAxNJWWtVx1MDAxNDBcZlx1MDAxZVx1MDAwN9eOJPT8OCBcdTAwMTLBXHUwMDBmKY5cdTAwMDOIJHj45OxcdTAwMDJcdTAwMDLGN1x1MDAxMsOMWODGTLtcdTAwMDZ+x1x1MDAxNjkhXHUwMDBiMWlcdTAwMDOO+8VeY/Sj9+vfJ0P4le992rB41Vx1MDAxOVx1MDAwNVx1MDAxOGJcdTAwMWZe/PhhTj1p8HhcdTAwMTDP5uVcXGtCqaWnjVx1MDAwNZeMUiyEXHUwMDBmmVxmnOxcdTAwMDSCXHUwMDAwZ5ArUFxi3GExhGeIgVxyILDiSPhcdTAwMWJi5lx1MDAwNiRoQFx1MDAxZZPq5uiqXHUwMDE1ib6oYe7OI9HaXHUwMDAwXHUwMDA3LTHGlzt4iDtmZIKor68rKUZuU3yFNujsciHTlll8IZ7kXHUwMDBifk+eXHUwMDAzXHUwMDFiK+wxq3B9ZFx1MDAxNq8pfIRcdTAwMTekJ1xmY4qBLMehqeFUhmwrmXFcdTAwMTRcdTAwMTTOv9VcdTAwMDQ+SiO5dVSBSCyt0dhcdTAwMWRe4rLZl41cbn5pnmhF71x1MDAwZt5Pitf1Xql0Kt7oelwiOVx1MDAwNUzrWYZjkTXl4FjOzbxcdTAwMDIx7ZmZR1x1MDAxNDaOjID1JFx1MDAxY/ab/jkr2GFcdTAwMWNzhyjKXHUwMDFhdlLIKarwsJU4h4fqyORwXHUwMDBiwiQ4XHUwMDEwKyOrZ43RK8lcdTAwMWQwMU/93vRPuVx1MDAwNFx1MDAwZVeB7/4+lyhW3bhcdTAwMTaZUcbouWzfXHUwMDFjVlx1MDAwZvpcdTAwMWJcdTAwMTO9dlFcdTAwMWRUVeuWhiHbXHQkq+K2kYL71VxmXHUwMDBmXCKWWrNcYrHUWXiL8cPZi/nC9zlog6Dtrn5GQik303FcIi4sR/ZOXHUwMDAzptFcdTAwMDSgnPFcdTAwMDRKLoFVSZpT/d+jYPxcdTAwMWJcdTAwMTNfxfCezFK1zDNxxDupXHUwMDE4XHUwMDFj34vBfPGo2CqWXHUwMDE0XHUwMDFiXHUwMDBlzcaBKG3L7sCS5GdcdTAwMDA4N4FJLlx0Jt3MdSBh0/4kQOGgobBcbp+HY5eaeVx1MDAwZeAr7umcoKOw3kuK9Zg0KVx1MDAwMFx1MDAxN0fp7cA6jVx1MDAxYvpNgPGl+syh38dcdTAwMWLD0+LkulXe3lx1MDAxMneb3etcdTAwMWIx3molTTzqn/Lryqh03j265q3O9k2xemOGwb+ywohHSvyN5lY6PnDfZVx1MDAxMrGMPS5cZkBcdTAwMGLcXHUwMDAyiUXUc1xiZMpj8HY0XHUwMDAyufbgXHUwMDFki41V4Fx1MDAwMtKVspifXHUwMDFlRGKxn0IsXHUwMDAzu2KcwJWaSEV0R0OOJ/As+1x0j2l3b0gse43exCmTPznhaFx1MDAwMYPNy+fgsrNcdTAwMTHO8XYmNlx1MDAxMCiwzIRinVx1MDAwNteGyHlnV1lcdTAwMWNqgFx1MDAwZstSgKijllx1MDAwMHxdXHUwMDBippxcdTAwMDN2MZeDuVx1MDAwNoxcdTAwMTlcdTAwMGbn2FmOx1DgV+ehwCg8P62so6m0XGa7rjl9Ylx1MDAxNs2tzFx1MDAxYU1cdTAwMDOF1l+N80yFdPQ2xVcxtEOzVNVZjFuEzSUlUfCRanBijVxuR96MJ1xinvBxkMKYc1x1MDAxNN4hiYKB8das4Fx1MDAwZlAyXFyQIZRpXHUwMDFj9KzD9V/CXHUwMDAz7sBcdTAwMDRcdTAwMDPsZs6VXHUwMDBl1399UixwtFljr8Va11x1MDAxZXSP1o+a70VaOU1Rllx1MDAwMVx1MDAxY+iRyPBcdTAwMDKnhi9cdTAwMWFjrUFcdTAwMDY5XHUwMDFjXHJtckcj2lx1MDAxOD6v7mhoXHUwMDBlkCbamXjJots4cyqEtuIz04kmXHUwMDFi593Hp7O3x7WNl81K+bhCT8uJXHUwMDBiMn7XkLah3LeiUrt57eu9i53TzbPxhj7N4Lo7l7y0O6iwPlx1MDAxZG/u7lx1MDAxZdfv1lx1MDAxZlx1MDAxZt8yuC67UFxyedpcdTAwMTP1arHbqJf2Nmrl1iSD615uPV89XHUwMDFl1bhau6zfTy66W83e8W5WfpyRIL/TlCPHXHUwMDE4OvcuSuLHWSGwj6UlzFx1MDAxMFjSXFxcdTAwMTZcdTAwMTiwuFx1MDAwN/ogzsRcdLBlXHUwMDE0RFx1MDAwNNZcdTAwMTOAyfdcdMnckVts61x1MDAwNm5b53LkgHWZXHUwMDA2ied05Ex08IRcdTAwMTlhuWWZ54Gl3r8hT+7sZ0LVWWMwade+RVx1MDAxYo1cdTAwMDWCIVREXHUwMDEycVx1MDAwM9l4d/HWMr6OXHUwMDA0W1x1MDAxZEqpLMdcdTAwMWVcdTAwMDPKXHUwMDA2Yc20XHUwMDAwYcM0R3dcdTAwMDGgT8PjozXH8VJcdTAwMThatcRcYuXK81x1MDAxMMyDv6Bccof3uTS+485cdTAwMWPkQZBcdTAwMGaTXG6amPakxFpDrbN6TPiSc+dcdTAwMDWN1uA5fGp30sOrx+ZwZ//+ddBd21x1MDAxM8VbtbtH27+XXHUwMDE4f4/riCcuhlx1MDAwMa0p8GRcdTAwMTWzgVwiXHUwMDE1poTHLbY3MWLa6HfR1aJcdTAwMDCFrzCUZldcdTAwMGJ9XFyZuaGJi0uwkT0sXGYoxMJcdTAwMWRIQ41ccjcmpdhcdTAwMTBcdTAwMTlcdTAwMWJqKkmJYUT91Vx1MDAwZTulXHUwMDFmXHUwMDFhb4FcdTAwMDN+KDVcdTAwMTR9Myy1kfCoyDdqru9cdTAwMDZBXCI9RrVnuSCUK2qkmOtqxmDLeYJJTuCGpTHWcbRlXHUwMDE3puXnSb3RplqkiayDrsJOKs7ZfpHJd5YpKcE7zTriJolkqVxu0kN6rNFcdTAwMWKOXHUwMDA3jVx1MDAxZr1cZtPzV1x1MDAxNWRcdTAwMGI00Lwgi7yDbFx1MDAxNFm8v1x1MDAxZKfIsFx1MDAxMamnNMXuRNjUdrZ/f/pZXG7ng4FcdTAwMGZcdTAwMDW2U9hwtF1P45RqXHUwMDFh4tXwj+u0jFxijyhCsK9cdTAwMGaQksr1WCTIR6vrMdBcdTAwMDNGXHUwMDAy9p1cdTAwMTMzbHS0XHUwMDFku0pcdTAwMDNhZY39b1wimYqR+1x1MDAxNF/hXHUwMDFk+lx1MDAxOTInsaCYVvYqXHUwMDA2T1x1MDAwZif2aCG4o7WZ9pbUNfHj0IO6XHUwMDA2Plx1MDAxYVx1MDAwM+TNNMEyvPBcIqhnXHUwMDAz1av8yyp53+zzySOvlsr0oPh6eG3uu6f1l7BBjFxusEtKPYH6WYJYoXauXHUwMDBmO1x1MDAxM0GtXHUwMDEzsoqgklx1MDAwMjW6XHUwMDBlL9XwQN6UyFP5oozieOWoO1x1MDAwN82qiGPg6Vx1MDAxZphaXHUwMDE1ZVx1MDAxMkFFKWLNZ05BfbQnRdbtXXJa2Sixo7vSbfGU582KXHUwMDEyxprdn15cdTAwMTLfXHUwMDA2dC9Gk8FgYVVcIveFhX/iXHUwMDFkfGpQR1ZcdTAwMTNUstLXqGJWschwwqLFXHUwMDAzyKiKxXxQWCTEJyl8XHUwMDFiXHUwMDAxz0lZyp1cdTAwMTX5MZl6XHUwMDEyKctmn2MvsSZtXHUwMDE156bagj9TXHUwMDFiddxejVx0fPd3ezVcdTAwMGJoc96rXHQvPVx1MDAxYncm3pQtXHUwMDFjhcq55IZwXHUwMDAwrORzUOaMe1ZJoUDLaOJcdTAwMTiApVx1MDAwMOk4v0ZOOYNLh0NDtfRASFx1MDAxMi6oXHUwMDExWqpcdTAwMWPYkcB+ycChIVxum0o5u1xmMFx1MDAxM1x1MDAxOcygmJ5cIlx1MDAwNVkumrFcdTAwMWN9X1x1MDAxMPY02Hndulx1MDAxMIo9PfW2yPnNzVt6OuR+zHzVmFVYjFx1MDAwNl9cYvBkibKBqLC0nrZ45sokXHUwMDE2eUq58HKRoMJXXHUwMDE4TrPrhT6xzLyveEtcdTAwMTdwe5RcdTAwMTBcZj9cYlxmWlLlqjFcZm3eRJ5X2cqLx4Pz56MxXFx9bbjXP14/2o9wXHUwMDAwp2UlXHUwMDFhfT/CMDPZ4XpJIbSW4FwiXHUwMDEyXHUwMDA2jklcdTAwMThPn+N4uTGQRIRxYz2psbBUXG6p2HztXHUwMDA09pi2sVx1MDAwMWbMXHRcdTAwMDCGN1xmh1x1MDAwM1x1MDAxYlwiXHUwMDFjTebyXHUwMDAz/2hTXHUwMDFkMavQOYxe4ORg96FcdTAwMWaLLHOkllx0zFx1MDAxNcx6XHUwMDE2/TRNeCmj+SHBnp46b4Vis9Ct9trNxnDkvVW7XHUwMDExguxzw8xcdTAwMGKUUEiQLbiRbORZvOFaXHUwMDFjXFxcdTAwMTFAIFx1MDAxYbu+So3V8PPNJMmC6FxutSpcdTAwMTBd0a6u0vlJUlx1MDAxNNDfkmqy6CxGXHUwMDAybKS1cmoyXHUwMDEzmcRIXHTjPHgs9dslWbW4vcW361x1MDAwN+UmebqSL+2z+qXY/eqIylxuWYxcdTAwMGIlJHy+VqeaUVx1MDAxZFx1MDAwM3T3p5eEzCVgVGA0RHFcdTAwMDPLmlx1MDAwYqBKxlx1MDAwMlxcXHUwMDFlhjjh01x1MDAxMVhcdTAwMWP+XHUwMDA184FEXoWVXHUwMDA24u/JqVx1MDAxY56FgeekXFznReGuUFx1MDAxZv3hiWVUXHUwMDEwvVx1MDAxNJJjmz/jMeQqVL6GIYnv0vp5XHUwMDAxUc6zt2Pt2Vx1MDAxMHa8wVnQl8mCL0iIXHUwMDE1XHUwMDEywCjkPJKV9VxiOCZcdTAwMDT0oJR+w/NcdTAwMDFlpTwuqcaB5FL5wT4rbyZcdTAwMWVT4F3hISQ4mzpvRlx1MDAxMoVsRpOSd2RARStCiHGfXHUwMDBms0jAU0ooXHUwMDBleP5cdTAwMGZt/Fx1MDAxY7lJ8Vx1MDAxNd6es8uF+DizXHUwMDAwReLogICVK1x1MDAwM1x1MDAwZtWCKVx1MDAwN5hcdTAwMWHRKFJHgCC0IbLu9IxBV00xXFxnrFx1MDAxMdxcdTAwMTEnXHSeitovm3RcdTAwMTV/U1x0puJgNlx1MDAwMTZusdjnd+Z6/qxQ5XJBJlx1MDAxY50//HWUqOIsNuJ75SYxyiSylf1cdTAwMTmQolx1MDAwMFx1MDAxY+Zuglx1MDAxOW5hN1x1MDAxYuaNUVDwaT7Rn7l+2D993+Nv5Ll8Pjrc5OXScfn9q6unftf4nd9VlVW9PXoo7bWOtnuH9u324UE8V9omg+uK8dN4bf328Lh9Unq5eye3m+NHnsF1aZGut0eT/lx1MDAxZK+v64uzTa1cdTAwMGXfXHUwMDEzzmJcdTAwMGXxU4iBQdJcdFx1MDAxZlxuV/JcdTAwMTfduzOJv2hwwCo4elpO673svE3Vfpvq8Fx1MDAxN8HmXHUwMDFh7NSuMU1RkbxrRypcdTAwMTPK3SbUef6uhZLaXG7nRDFcdTAwMWFuoPMhILkxhNHMXHUwMDEzXGaXXHUwMDE3kFx1MDAwMY/xsNqrtvyu3tc5jVx1MDAwYsSJ02lcZi0/XHUwMDFiv5HR27PH16uzu+5B8+H67mr9cXJ8n1xcJzHLPEy6+JVFJ+fGXHUwMDA3XHUwMDFhxTwyS6txZNXwueZiXHUwMDBlnZRcdTAwMDd6I1FcdTAwMWRRMZCqXFxcdTAwMWRLazTnrsy58DdngV6cZvu5gV5SXHUwMDE5n19W1EZnXdxW7i+O+5dcdTAwMDdXN0lcdPb9XHUwMDE49N/GQN7qnbdTdnT6cH59fZZVQFYrjHlnQ7Duu0xCsOBleFRcdTAwMTIgUk3g//lcXFx1MDAxY8eYQIpbXHUwMDE4i1x1MDAxMlximFqLI86Z1Ia6ukfkXGZcdTAwMWKJRZmCYYmglGrubJhcdTAwMTPTkl8r2HBiOcxluX1DXHUwMDA0W4aPu/b2XHUwMDFkqHVcdTAwMDGfzVPr/MKzIdV4Y1x1MDAxM99cdTAwMWKLXHRcdTAwMGbnaCugQkKoXGaqZE6BUCmnkilcdTAwMDEqzSGTLfW0IUZcdTAwMTFp4Ln6VfZcZsPMeJZcdTAwMWLsOiRcdTAwMDSxyjcuIcd0XHUwMDEw0yopv0Ynt1GCXHUwMDEzXHUwMDEznS1mabhHjC/wXHUwMDAwT5D5R3/9dn5tXHUwMDFmVsWlXps8vraO7cmk8qpf4YGm4UGjwT/7+lIgbTxFpWTAdZpcdTAwMDJcdTAwMTi4/7eLiFx1MDAxMKJgtVx1MDAwNFtnW7roakXKPaqwylx1MDAxN/NcdTAwMTTNrG3h9M0wmGbXXHUwMDBifWCZRY73zu+G/etWeffCXHUwMDFlnJ1UL9dv765avmfqzyvD8iyc3IldTFxytb7lz0p6gFx1MDAwMeBcdTAwMTYt19zC/4X2cqIwcrzlXHKuiU1PXHUwMDAxJVx1MDAxOChcboqDutaEp1x1MDAxMZphg0AsdSMmtKhPXG4ku1GRSI9J7SmFXHUwMDEzXHUwMDE44f+lXGKmKWOSX0COOWLIRGF7XHUwMDFiXHUwMDBiu0pp0NquioO8lVek7dYpTshx1JHROlxcOI1PKrLiQE9cdTAwMWLM+X8rIz3GXHJZLkX4Q49cctq9Wvup2in8r8KP3lpthOOG/9ePXHUwMDFlSJv+ePA9+t0sXHUwMDEwSSGllviWstFw8TVRsVx1MDAxYc5InPPCNdYnWObvPfqrQEF5P2NlgHvhK8f+qLh2XHJcdTAwMDZQeVx1MDAxNCRcbulmZZUmLKYkXHUwMDEz6zpcdTAwMWWKaW8lsEWLoeZcdTAwMTNcdTAwMGJcdTAwMTCKr4Pa47uyx2Mq6udnb+a8dp5KpIFOofZcdTAwMWJcZkqKnnSBL2HmamdcdTAwMTdWIOhAXHUwMDAxXHUwMDAzwOUzdFjiXHUwMDEyXHUwMDAz1GGGaOxcdTAwMDcrmLHKXG7H2flfS04pvFx1MDAxMs+PJlx1MDAxZY4j4VjebTSXXHUwMDA09FdoXHUwMDExysOGo1x1MDAxOFx1MDAwNMKvIM++Sne5N3pcdTAwMTLdpTX259FcdTAwMTSgqVxy80+i/2l+XHUwMDE1XG5enLch0Vx1MDAwMVx1MDAxMOFAXHUwMDE4XHUwMDBlU8KjJkHBR4go9cxcdTAwMDNh0eY4RZlcdTAwMDFcdTAwMDUvjVx1MDAxMCGdyUq+vIpwradcdTAwMTCWKZV1Y0FcZsyt1lwiftjvNu77L4Ve46UwhFx1MDAxYsdb+Vx1MDAwNmprgZyZV1uxd5GNwFrrPZBB52RSrlaOWu+Vqzp5Y6VkXHUwMDAyS3LpccHAp9JcZsyUmUM4mFmPYJlcdTAwMTRcdTAwMTUg0ClzjJ9cdTAwMDVcdFx1MDAwNq7u9ChaUeFv1utvlFxmyNfg2oPfLJWvUWFcdTAwMGX4IOD3VtZfXHUwMDE4XHUwMDA0XHUwMDEwXHUwMDAwPOJywGz0LEkqrDHiM1x1MDAwYkC3XHUwMDFmeen9ub27tbV1e361/dQ8XHUwMDFmib1U+lx1MDAwYqNkZKmRbdnqr0hcYuArtPk/Q02lak5vXHJO4YKlWcxcdTAwMTdxyanl1NRj71U2XHUwMDE129pfb/LDS6NYVbyZiEVcYlx1MDAxY79cdTAwMDc7l0lcbqtcdTAwMTHMkZJJPYleoGaEXHUwMDEyjr3XvkpPuTduXHUwMDEyPSUxc1ZrjpW7jDEzf65I0dBcdTAwMWKMhVBcdTAwMWPtXHUwMDFhXHUwMDBlZCnrMVx1MDAwYspcdTAwMTc+XHUwMDAzpfCZ5XIqhXXdT66mMEAt0ZQ6rGh00SbIXSBUQZdrPVx1MDAxZpe4w1x0pyuJqUGjOWhcZu+/g4BaIFfmXHUwMDA1VGjl2Yim+LzG+C6ARHlGXHUwMDE5PEPWlM+N38ZpxFx1MDAxZVbm4zBcdTAwMGaibFgygVNlsKqLXHUwMDBiPFx1MDAwNGGz3f6BYYtcdTAwMDFt0M/KWFx1MDAwNj9BcsFcdTAwMTRcdTAwMDXp0uqCXHSeXCIlWJPjgLqM9pskJ0Jo9ZmK6bC111Yvp7dHm/SubN9e32rUbqSbOodnXHUwMDFmafJXf49iikJcdTAwMDC+XHUwMDAwPYQwzoSgRjK7MFpcdTAwMDWUqjnGWKQk8Fx1MDAwYsGLhYD0XHUwMDE54itFXHUwMDE1XGJOTcVZioxLXCJAXHUwMDAwhbXXtGCFayY1OGc4jmc5LTbaIMOb5yP2MO69XHUwMDFm8uF1sTTpXHUwMDFlRmgxnPsnKOfSKnBcdTAwMGLBK3AsSlx0hbWmsDKDh9VfJcXciEhcIsWs1Ni2hSiNOZeczffWN4ukXHUwMDE4sIA3/VWBXHUwMDFkd5jKtVgqw32QXCKyxfGIXHUwMDFijK1LjPHIloSgxMB8aF++SEZaTMCeWS2w9VR96Xm/gkGFf5bPb/7nd9BlXHUwMDBiXHUwMDE0USiwXHUwMDE1d1x1MDAxN1x1MDAxOaVUx05cdTAwMDSK12hKg1NplMXiaynsXFxgXHUwMDBivlx0nlx1MDAxOFx1MDAxN9jkTmtLwipNMU9wjVxyM1xi52DsXHUwMDFjpbhMWlx1MDAwZlPLXGLXXHUwMDE2dX8u06LQfriyTFx1MDAwM46kViunXHKgJGZcdTAwMDCqJGo5XHUwMDEzsJxG2ylfNI5uz0/JhK+r3k3tpfbcf/47XHUwMDBlzuBcdTAwMWNrVFx1MDAxNXC8XHUwMDAwXWKDXcio9jjOm1LgXHUwMDAwXHTOlF50NZzRaCzgzGpcdTAwMWOHwH1+XHUwMDExvkJI+lxmnZZYXHUwMDEzXHUwMDExjysqXHUwMDA0LNIqjkNcdTAwMWNdWVZcdTAwMWYnfdjmkplwmUDWgzNcYtFARmDctLBcdTAwMTQj+K4jSEvAR6E/v4JK+Sqd5kZFXCKdps10koZcdTAwMDStJXz5bX9cctLQXHUwMDBiqmLgXHUwMDA3YJ/CXlVcdTAwMDbELHEk8eZzNFwizfZRcpFcdTAwMDao1cRY6ix+ifai0axPg75cdTAwMTmLNMOB9FdcdTAwMWKj8dqo/Vx1MDAwYkTNj95cdTAwMDB2f+G+OsC7L1x1MDAwMGBG31wi6WuBNlxuzdRIdDvZyLb4gYvxXHUwMDFkVFxm8Fx1MDAwNDHAKVx1MDAwNrP32FxcaStA3SPUckK4wFxmXFzHeaRcdTAwMDXLqEG4wT5cdTAwMTB4aOlK21dcdTAwMWO8aiPBt6M4XHUwMDFiO/fSolxmwPHKulxy57cqaYx74GG4MPbDMihcdTAwMDLKTVj+idptvb5duTq5eTguypPic3fnvqJu639H7SY9iZEwS1xmoZz4RtHgXHUwMDBiXHUwMDEwXHUwMDA2ylx1MDAwMTuKXHUwMDEwwIdYrN2iIYWvXHUwMDEwmD5DuqU5WiSKM8opZsiDwDTGXHUwMDE5Y7NoebBmT1NDw+GszLVcdTAwMWKjIHQxbUpgXG6Zr2/Gx6K4h1lumHpcdTAwMDVeqoRb+Crt5kZFXHUwMDEy7Vx1MDAwNrfmgW/ALdNgzoWYa1I7P1x1MDAwNS1cdTAwMTdvmdrucopcYptmxGhpnX1cbqJtNKOUWyV59oOQwNvi/9HqbYFE+kr1djQ62z65Odw+Yd2XzcvNtff29sZRXHUwMDEyvFx1MDAwM5+Ao/zR6WmujyWXbIGvXHUwMDA23nOgi5PjYDRvYFx1MDAxMFx099NcdTAwMTRw55wwZpw5+iraV5vWl3G13FxiardcIvu54+zJw2BH7Z6sXHUwMDFmXHUwMDEwXCJatvyits1LUKAt37KHY5P0lUqA9nqF0X2jUKtcdTAwMGVcdTAwMWKFfvNH77BdXHUwMDFi9H/NJiw8XHL6k3Y9qlx1MDAxOUq6gSSdRnNcdTAwMTRjUkb9pyh7XHUwMDEyuJ1541x1MDAxMVxcf2HB8lx1MDAwM0Yk+IGksVwi8c801lx1MDAwNyTBTijzZkQxT4NsXHUwMDEyXHUwMDE2q7ON42RcdTAwMGVknietpGBwrEFJ6khJpcJcdTAwMDNcdTAwMDOlXHUwMDE0M1x1MDAwMoefKJZXbkdcdTAwMTmWM7dhSZNiIeAxXHUwMDEyKZ1cdTAwMDZHRM9cdTAwMTTDXHUwMDE0XHUwMDFi8Fx1MDAxZnXWwSHJkYy+3EuL3qf4Koa36OyCIbOXmWdcdTAwMTXP/lx1MDAwMSdcdTAwMDYwaDBhdPq/moT9qqjKo5R+VYqhi1pzxbTQmIdHyExCfiyJSjBcdTAwMWXYllx1MDAwYj5R+Mrkl4XE4/vfxVdFKlx1MDAxYlx1MDAxMFrzuVx1MDAwYnLRdGlfXHUwMDA15GzGYlx1MDAxZVx1MDAwNYuygOdZZOVLonCqj8NcdTAwMDKyXHUwMDE4XHUwMDBiSFx1MDAxNKOCZ95JXHUwMDE4L/lccvLAiirw8+ZTYkcp0rNcdTAwMThcdTAwMTHTxFum8Fx1MDAwMUpXo2D4Ico1wziNsWirl7NxqVx1MDAwMlqgZzW3XHUwMDFjq1xuzfSsI7Qs6Vx1MDAxMazZ49Ov7OtcdTAwMDZcdTAwMWOdq+b+sXh92Z+csd1xS+nec9tRelx1MDAxONVcdTAwMTNcdTAwMGYsnIdcdTAwMTNcclwihp+AK7Lg9E84XGadylxyXZShu0hq6GKa4Fx0bNDB3S3TI1x1MDAxM+dhI1NjxXLTjj5WlyrY3y+dNJ5ftjrNp8dbvmbuX0vHT2uf0GQ29rpcdTAwMGaDZq1YXHUwMDE53lXE5kN5/3Y0vp+cXHUwMDBmM/KEJeHCXHUwMDFmVFmpt57701x1MDAwYlx1MDAwMds1PtZcYlx1MDAwZlxmXHUwMDE4XHUwMDE34JqD8pVyXHUwMDBl03JBlEhzz1xubSVjmLOtjSNKlM+YjER4mrll2G1LWmfGVWS3LdDeYKwzbqsnhGTGrJZy+dxpR0V+P3dq7Fx1MDAwMkpcZmVZzi08m1x1MDAxOG+8/Yp3PkRMdIYpvYCShaNcdTAwMDNAXHUwMDFl1I2C61VcdTAwMDbn78Cr4CpqV+yFRlx1MDAwN3uxXVx1MDAxODxdlvXRzrSD+tefkFx1MDAxN4WM+PlcdTAwMTCPfoXjwX3ynVPiXHUwMDBiXGZ9XGJ8QWOHgyTyOuJt0ZzXoZVl/NdXf8FEYZZtXHUwMDE4XHUwMDFjNMjC8Z5Pcjts2Z6RYXdrv79pRvywT86GI8dcXNVIt0OrgNsxXHUwMDE3gVx1MDAwNobzdGxtXGLV1rP+l0OkKD5cdTAwMWZcdTAwMWTLrWCEXHUwMDE1vF7ZLVx1MDAxMVx1MDAxY8cx+Xui+41gZFsqzKo18jNTkI7PL9/21dPL2lWRXGZVs/duR5P9pN7Da3X/ukNvr4+PyrXGZe36bqO0VsrIe1x1MDAxMFxum3Km0WAx+HTfZVx1MDAxMu9BM+EpqjH6aVx1MDAwNOYjzUGTL5p7bJhn8CBTYua6XHUwMDEx3HE2lFdtRVwi8SaF+2CwqZtyXHUwMDA2XHUwMDAy/HGZOchhO1MubOZcdTAwMTX0XG6odLleIVx1MDAxZi7Ez0qnutuJ+Nz0kVx1MDAwNVx1MDAwNFx1MDAxN3JcIkJLz8aNeNx7v7uv7Z9vXHUwMDFm3W7vq45cXHtcdTAwMThtpOBZQXTAlZjr0K2DoVx1MDAwMFd8L+fZLNFdWZ1nsVFcdTAwMDV+cYHel+FcdTAwMTdKLVx1MDAwMVOtXHUwMDE0Wy5wsFx1MDAxY9Nuqc39c/l8vb/2cPj+tnd6cv58nJJcdTAwMTGZtamiXHUwMDEyMUhyryZcdCNcbspiQVx1MDAxNJxcdTAwMWLjXHUwMDAyXHUwMDExoViaZalWXHUwMDA0qNNcdTAwMTJHIXOeZlx1MDAxOVx0mmpySrRofsCldrXmouG+9rNcdTAwMTZ9Wlx1MDAwYkPEcjo0llx1MDAxM2m66dchTjw++9ErXHUwMDE2NtaQQoajLJhx+Vx1MDAxY6hFbDRPilFrz4ZcdTAwMWFcdTAwMDct2z1T426pu31bu36/Py097qY4+ZqjxrlpUNwsXHUwMDFlm5lTY5Yor61+Mlx1MDAwNsZcdTAwMTeI0Vx1MDAxZIjzXHUwMDA3XHUwMDExwsOEsYlcdTAwMDFfLlx1MDAwYmo5bqxcdTAwMWOvy0p5fdQ5M5aNXs3a7Utx9Fx1MDAxZueFuu8yXHUwMDEx50rraS6wXHSbJpzQOc7FruXxXHUwMDAzXHS09OB9XHUwMDBi0ObwYJ2Um3uhkWCsJ6dcXMooXHUwMDA1oUYjzqMjS1x1MDAxYlx1MDAxNOgmwbJv5Ka41T5rvVx1MDAwNOXW8e3Bt/BCXHUwMDE3cNw84YZWnlxy08bbm/hq07izLFx1MDAwMOai4nJXeolcdTAwMGWGweVsXHUwMDA35TBcdTAwMGXCuJGUU2NGQlx0Ou1cdOpKq1x1MDAwYn/z45BcdTAwMWFcdTAwMWK1kk9cdTAwMWRFXT5g9++bl0ftXHUwMDFie3lxW1x1MDAxZD1u72w9pWM+7EGzVFx1MDAxZEO2XHUwMDA3Zyzw81wiOKVe0oX92oo+1OArhJfZXHUwMDA1Qlx1MDAxZkpmR3Hx8bNCoFRcdTAwMTP7o1uOedggPjhcdFdqLpnWXHUwMDFjbz1cdTAwMDNrXHUwMDEwmuKULUalYVx1MDAxYXZBeFxyVFx1MDAwNkqnXHUwMDE4+7KWbO6dnkRVSVx1MDAwMU6N1IJcdTAwMTLBjOBzLZu4oVx1MDAwYqZuXHUwMDAy1XtcdTAwMTSMOcU0TPB/Zr+fi6rF1riZXCK0L2CLXHUwMDE56WwxXHUwMDFlk8xsKfZv8Nf8Z6SpMLV6pVxur+F9u1v416Q6+NdPjeJccuF/voPCWqBtQnH++PvIRm/F5yjGT1x1MDAxYpBxXHUwMDA362DaXHUwMDE2XHUwMDFkrFx1MDAxYr5wxjVcdTAwMGIxUlx1MDAwZflcYsi3Vlx1MDAxN2BYa+Mv7PdcdTAwMTmC6IiGIWA7sI15toYgfYLr75FIkZtcdTAwMTRfxfD+/FxmwZNmIFx1MDAwMD5cdTAwMWKtKFx1MDAwZVbTQjp6nVHPXHUwMDEyoFchXHUwMDA0XHUwMDA1XHUwMDEw89+efUQ8io9XXHUwMDEyK+HjxJYmzvFcdTAwMDDuj/XzXHUwMDBiu+SG2GSXlf3axtawOtSbe/u360lcdTAwMDSQMkFvdK7XXHUwMDE5qFx1MDAxYb91XGa3Plx1MDAxMtrhjvrtRG7/gvbvPoXkYVx1MDAwMmd3uLtcdTAwMTj5ctBDaZTwi4TLrFx1MDAwN43DXHUwMDA2V4YsVcL6l+ahXuGw+tgo3MHzrN3/6DGvcDH9K4Vhrdps9jv1XHUwMDFmPe5cdTAwMTU2XHUwMDA2jTqsp13tXHUwMDFjVnvVVmNQqPbqhV5j9NJcdTAwMWY8XHUwMDE2QK70XHUwMDFh01x1MDAwMZI/MpnN9Jsq4H/jvWZULr+5K9dOtlx1MDAxZppcdTAwMTfH5dvKVXtTrLVOXHUwMDFkXlPUcZHBWedEol+EU1x0gqdFlIFcdTAwMDc5XHUwMDFkwkZcdTAwMDVcdTAwMDf3Uc32zYfrZMCmT3t+M4lcdTAwMTNTZyidzSSgnuFcZsyvwpHDxjf3MDcrQbPSXvmsiFHGLcdcdTAwMTMjl7CKtjaYlkukL89idWNjtFx1MDAwMe9KXHSZ5pw4ZqOf6+Lrzrnolt+2XHUwMDFhdX5/+LI22msl3ujg4lx1MDAxM1x1MDAwZjPTXHUwMDA17ECwg8FcdTAwMTBcdTAwMDFFXHUwMDExXHUwMDAwqoVpjdOs/NL/Y6Nr7lx1MDAxMUM4nlxiXGJcdTAwMDbb3lGOYCVcdTAwMDZ9JNH4XHUwMDA0sDNSvtMjdvrDyjtdTjvmqvCexl+KzohcdTAwMDBcdTAwMDbUXHUwMDFhq2Az3+nWpDqe+bnT//FLs/9RfXo6m/Zk+utDgI+2XZ/jwp/fXHUwMDFiNZ5mPDj91mG/3tjqVe868zfxx6TdeFlcdTAwMGbvpP/RnL4wjDz9YHFcdTAwMTc1plx1MDAwZuC//vFf/1x1MDAwZrCHTlIifQ== agentd process clusterHOST (pc, vm, ...) agentd- local controller- API for clients- API for operator- session API- sandbox SDK consumerOn dev PC (locally or in the cloud)On k8sKernelKVM, cgroup, ..QEMU, Docker, Libkrun,MicrosandboxSandbox backendSandbox network(optional) SDK- Secret store APIs - Authorization engine- Sandbox facade agent- agent-runtime- Session APIBuilding blocks / layers operator- scheduling- orchestrationGH ARC / actionsrunner stuffDesigner agents /AltinityLevels node sandboxsessions(claude code, ...) sessiond- PTY- harness sandbox 0 (vm, container, isolate, ...)- sessiond, sessionctl- PTY allocation, harness, ...pod 0 agentd- local controller- API for clients- API for operator- session API- sandbox SDK consumer sandbox 0 (vm, container, isolate, ...)- sessiond, sessionctl- PTY allocation, harness, ...pod operator- scheduler/orchestrator- 1 pod per manifest? kind: ConfigMap spec: manifest.yaml: ... Dockerfile: ... (or a bundle)Kubernetes APISandbox layerSandbox imageresolverAgent layerOSS, infra agentd- Session API- Sandbox backendsSandbox SDKAgent layerCredentialManagerCodexCliAdapterSecretStoreNetworkresolvesetClaudeCodeAdapterrefresh MSB processNetworklibkruncontrolauthorizePolicyEnginehttp.requestsecret.useSession API sandboxvirtio-netvirtio-vsockComponents.envSandboxServiceensuresandboxagentctlapply -f manifest.yamlAgent APIAgentManagerPolicyPrincipal + Action +Resourcesomehow new sessionrefreshspawn.session (PTY)exec/PTYread harness stateexec/PTYread harness stateIn the case ofMicrosandbox providersqlitesessiondOS- CA trustdockerdshim /var/docker.sock1. Make branch2. Update scaffold3. CredentialManager and network connection \ No newline at end of file diff --git a/agentctl/install.ps1 b/agentctl/install.ps1 new file mode 100644 index 0000000..6bc9b01 --- /dev/null +++ b/agentctl/install.ps1 @@ -0,0 +1,127 @@ +$ErrorActionPreference = "Stop" + +$Repository = if ($env:AGENT_GITHUB_REPOSITORY) { $env:AGENT_GITHUB_REPOSITORY } else { "digdir/digdir-agents" } +$Version = $env:AGENT_VERSION +$InstallMode = if ($env:AGENT_INSTALL_MODE) { $env:AGENT_INSTALL_MODE } else { "managed" } +$InstallRoot = if ($env:AGENT_INSTALL_ROOT) { $env:AGENT_INSTALL_ROOT } else { Join-Path $env:LOCALAPPDATA "Agent" } +$BinDirectory = if ($env:AGENT_INSTALL_DIR) { $env:AGENT_INSTALL_DIR } else { Join-Path $InstallRoot "bin" } +$AgentHome = if ($env:AGENT_HOME) { $env:AGENT_HOME } else { Join-Path $env:USERPROFILE ".agent" } +$LocalArchive = $env:AGENT_LOCAL_ARCHIVE +if ($InstallMode -notin @("managed", "standalone")) { + throw 'AGENT_INSTALL_MODE must be "managed" or "standalone"' +} +$InstallRoot = [IO.Path]::GetFullPath($InstallRoot) +$BinDirectory = [IO.Path]::GetFullPath($BinDirectory) +$AgentHome = [IO.Path]::GetFullPath($AgentHome) +if ($LocalArchive) { $LocalArchive = [IO.Path]::GetFullPath($LocalArchive) } +$JournalPath = Join-Path $InstallRoot "update.json" + +function Invoke-Completion($Journal) { + $Target = $Journal.targetRelease + $TargetVersion = $Journal.targetVersion + if (-not $Target -or -not $TargetVersion) { + throw "The Agent update journal does not name a usable staged release: $JournalPath" + } + $Agentctl = Join-Path $Target "agentctl.exe" + if (-not (Test-Path $Agentctl -PathType Leaf)) { + throw "The Agent update journal does not name a usable staged release: $JournalPath" + } + $Arguments = @( + "--home", $AgentHome, "self", "__complete-update", + "--install-root", $InstallRoot, "--bin-directory", $BinDirectory, + "--target-release", $Target, + "--target-version", $TargetVersion, "--repository", $Repository + ) + if ($Journal.previousRelease) { $Arguments += @("--previous-release", $Journal.previousRelease) } + & $Agentctl @Arguments + if ($LASTEXITCODE -ne 0) { throw "Target Agent updater exited with code $LASTEXITCODE" } +} + +if ($InstallMode -eq "managed" -and (Test-Path $JournalPath -PathType Leaf)) { + $Journal = Get-Content $JournalPath -Raw | ConvertFrom-Json + if ($Journal.phase -ne "complete") { + Invoke-Completion $Journal + Write-Host "Installed agentctl and agentd to $BinDirectory" + exit 0 + } +} + +if ($LocalArchive -and -not $Version) { throw "AGENT_VERSION is required when AGENT_LOCAL_ARCHIVE is set" } +if (-not $Version) { + $Page = 1 + do { + $Releases = Invoke-RestMethod "https://api.github.com/repos/$Repository/releases?per_page=100&page=$Page" + $Release = $Releases | Where-Object { $_.tag_name -like "agentctl/v*" } | Select-Object -First 1 + $Page++ + } while (-not $Release -and $Releases.Count -eq 100) + if (-not $Release) { throw "Could not resolve the latest agentctl release" } + $Version = $Release.tag_name.Substring("agentctl/".Length) +} +if (-not $Version.StartsWith("v")) { $Version = "v$Version" } + +$Architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() +$Platform = switch ($Architecture) { + "X64" { "windows-x86_64" } + "Arm64" { "windows-aarch64" } + default { throw "Unsupported Windows architecture: $Architecture" } +} +$Temporary = Join-Path ([System.IO.Path]::GetTempPath()) ("agentctl-install-" + [guid]::NewGuid()) +$SourceRelease = Join-Path $Temporary "release" +New-Item -ItemType Directory -Path $Temporary | Out-Null +try { + $ReleasesDirectory = Join-Path $InstallRoot "releases" + $Target = Join-Path $ReleasesDirectory "$Version-$Platform" + if ($InstallMode -eq "standalone" -or -not (Test-Path $Target -PathType Container)) { + if ($LocalArchive) { + $Archive = Split-Path $LocalArchive -Leaf + Copy-Item $LocalArchive (Join-Path $Temporary $Archive) + $Checksum = if ($env:AGENT_LOCAL_ARCHIVE_SHA256) { $env:AGENT_LOCAL_ARCHIVE_SHA256 } else { "$LocalArchive.sha256" } + } else { + $Archive = "agent-$Platform.tar.gz" + $Base = "https://github.com/$Repository/releases/download/agentctl/$Version" + Invoke-WebRequest "$Base/$Archive" -OutFile (Join-Path $Temporary $Archive) + $Checksum = Join-Path $Temporary "$Archive.sha256" + Invoke-WebRequest "$Base/$Archive.sha256" -OutFile $Checksum + } + $Expected = (Get-Content $Checksum -Raw).Split(' ')[0].Trim().ToLowerInvariant() + $Actual = (Get-FileHash (Join-Path $Temporary $Archive) -Algorithm SHA256).Hash.ToLowerInvariant() + if ($Actual -ne $Expected) { throw "Agent archive checksum mismatch" } + New-Item -ItemType Directory -Path $SourceRelease | Out-Null + Push-Location $SourceRelease + try { + tar -xzf "../$Archive" + if ($LASTEXITCODE -ne 0) { throw "Failed to extract Agent archive" } + } finally { + Pop-Location + } + if ($InstallMode -eq "standalone") { + New-Item -ItemType Directory -Force -Path $BinDirectory | Out-Null + Copy-Item -Force (Join-Path $SourceRelease "agentctl.exe") (Join-Path $BinDirectory "agentctl.exe") + Copy-Item -Force (Join-Path $SourceRelease "agentd.exe") (Join-Path $BinDirectory "agentd.exe") + Write-Host "Installed standalone agentctl and agentd to $BinDirectory" + exit 0 + } + & (Join-Path $SourceRelease "agentctl.exe") --home $AgentHome self __publish-release ` + --install-root $InstallRoot --bin-directory $BinDirectory ` + --source-release $SourceRelease --target-version $Version + if ($LASTEXITCODE -ne 0) { throw "Target Agent publisher exited with code $LASTEXITCODE" } + } + + $Previous = $null + $Current = Join-Path $InstallRoot "current" + if (Test-Path $Current -PathType Leaf) { $Previous = (Get-Content $Current -Raw).Trim() } + $Journal = [pscustomobject]@{ + targetRelease = $Target + targetVersion = $Version + previousRelease = $Previous + } + Invoke-Completion $Journal +} finally { + if (Test-Path $Temporary) { Remove-Item -Recurse -Force $Temporary } +} + +$UserPath = [Environment]::GetEnvironmentVariable("Path", "User") +if (($UserPath -split ';') -notcontains $BinDirectory) { + [Environment]::SetEnvironmentVariable("Path", (($UserPath.TrimEnd(';') + ';' + $BinDirectory).TrimStart(';')), "User") +} +Write-Host "Installed agentctl and agentd to $BinDirectory" diff --git a/agentctl/install.sh b/agentctl/install.sh new file mode 100755 index 0000000..91007d6 --- /dev/null +++ b/agentctl/install.sh @@ -0,0 +1,132 @@ +#!/bin/sh +set -eu +umask 077 + +repository="${AGENT_GITHUB_REPOSITORY:-digdir/digdir-agents}" +version="${AGENT_VERSION:-}" +install_mode="${AGENT_INSTALL_MODE:-managed}" +bin_directory="${AGENT_INSTALL_DIR:-${HOME}/.local/bin}" +install_root="${AGENT_INSTALL_ROOT:-${XDG_DATA_HOME:-${HOME}/.local/share}/agent}" +agent_home="${AGENT_HOME:-${HOME}/.agent}" +local_archive="${AGENT_LOCAL_ARCHIVE:-}" + +case "${install_mode}" in + managed | standalone) ;; + *) echo "AGENT_INSTALL_MODE must be \"managed\" or \"standalone\"" >&2; exit 1 ;; +esac +case "${install_root}" in /*) ;; *) install_root="$(pwd)/${install_root}" ;; esac +case "${bin_directory}" in /*) ;; *) bin_directory="$(pwd)/${bin_directory}" ;; esac +case "${agent_home}" in /*) ;; *) agent_home="$(pwd)/${agent_home}" ;; esac +if [ -n "${local_archive}" ]; then + case "${local_archive}" in /*) ;; *) local_archive="$(pwd)/${local_archive}" ;; esac +fi +journal="${install_root}/update.json" + +resume_update() { + target="$(sed -n 's/^ "targetRelease": "\(.*\)",$/\1/p' "${journal}")" + target_version="$(sed -n 's/^ "targetVersion": "\(.*\)",$/\1/p' "${journal}")" + previous="$(sed -n 's/^ "previousRelease": "\(.*\)",$/\1/p' "${journal}")" + if [ -z "${target}" ] || [ -z "${target_version}" ] || [ ! -x "${target}/agentctl" ]; then + echo "The Agent update journal does not name a usable staged release: ${journal}" >&2 + exit 1 + fi + set -- --home "${agent_home}" self __complete-update \ + --install-root "${install_root}" --bin-directory "${bin_directory}" \ + --target-release "${target}" \ + --target-version "${target_version}" --repository "${repository}" + if [ -n "${previous}" ]; then + set -- "$@" --previous-release "${previous}" + fi + "${target}/agentctl" "$@" +} + +if [ "${install_mode}" = managed ] && [ -f "${journal}" ] && ! grep -q '^ "phase": "complete"$' "${journal}"; then + resume_update + echo "Installed agentctl and agentd to ${bin_directory}" + exit 0 +fi + +if [ -n "${local_archive}" ] && [ -z "${version}" ]; then + echo "AGENT_VERSION is required when AGENT_LOCAL_ARCHIVE is set" >&2 + exit 1 +fi +if [ -z "${version}" ]; then + page=1 + while [ -z "${version}" ]; do + releases="$(curl -fsSL "https://api.github.com/repos/${repository}/releases?per_page=100&page=${page}")" + version="$(printf '%s' "${releases}" \ + | sed -n 's/.*"tag_name": "agentctl\/\(v[^"]*\)".*/\1/p' \ + | head -n 1)" + [ "${releases}" != "[]" ] || break + page=$((page + 1)) + done +fi +if [ -z "${version}" ]; then + echo "Could not resolve the latest agentctl release" >&2 + exit 1 +fi +case "${version}" in + v*) ;; + *) version="v${version}" ;; +esac + +case "$(uname -s)-$(uname -m)" in + Linux-x86_64) platform=linux-x86_64 ;; + Linux-aarch64 | Linux-arm64) platform=linux-aarch64 ;; + Darwin-arm64) platform=macos-aarch64 ;; + *) echo "Unsupported Agent host: $(uname -s) $(uname -m)" >&2; exit 1 ;; +esac + +temporary="$(mktemp -d -t agentctl-install.XXXXXXXX)" +source_release="${temporary}/release" +trap 'rm -rf "${temporary}"' EXIT HUP INT TERM + +target="${install_root}/releases/${version}-${platform}" +if [ "${install_mode}" = standalone ] || [ ! -d "${target}" ]; then + if [ -n "${local_archive}" ]; then + archive="$(basename "${local_archive}")" + cp "${local_archive}" "${temporary}/${archive}" + cp "${AGENT_LOCAL_ARCHIVE_SHA256:-${local_archive}.sha256}" "${temporary}/${archive}.sha256" + else + archive="agent-${platform}.tar.gz" + base="https://github.com/${repository}/releases/download/agentctl/${version}" + curl -fsSL "${base}/${archive}" -o "${temporary}/${archive}" + curl -fsSL "${base}/${archive}.sha256" -o "${temporary}/${archive}.sha256" + fi + if command -v sha256sum >/dev/null 2>&1; then + (cd "${temporary}" && sha256sum -c "${archive}.sha256") + else + (cd "${temporary}" && shasum -a 256 -c "${archive}.sha256") + fi + mkdir "${source_release}" + tar -xzf "${temporary}/${archive}" -C "${source_release}" + chmod 0755 "${source_release}/agentctl" "${source_release}/agentd" + if [ "${install_mode}" = standalone ]; then + mkdir -p "${bin_directory}" + install -m 0755 "${source_release}/agentctl" "${bin_directory}/agentctl" + install -m 0755 "${source_release}/agentd" "${bin_directory}/agentd" + echo "Installed standalone agentctl and agentd to ${bin_directory}" + exit 0 + fi + "${source_release}/agentctl" --home "${agent_home}" self __publish-release \ + --install-root "${install_root}" --bin-directory "${bin_directory}" \ + --source-release "${source_release}" --target-version "${version}" +fi + +previous="" +if [ -L "${install_root}/current" ]; then + previous="$(readlink "${install_root}/current")" + case "${previous}" in + /*) ;; + *) previous="${install_root}/${previous}" ;; + esac +fi +set -- --home "${agent_home}" self __complete-update \ + --install-root "${install_root}" --bin-directory "${bin_directory}" \ + --target-release "${target}" \ + --target-version "${version}" --repository "${repository}" +if [ -n "${previous}" ]; then + set -- "$@" --previous-release "${previous}" +fi +"${target}/agentctl" "$@" +echo "Installed agentctl and agentd to ${bin_directory}" diff --git a/agentctl/installation-test.sh b/agentctl/installation-test.sh new file mode 100755 index 0000000..0f89544 --- /dev/null +++ b/agentctl/installation-test.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# Exercises standalone installation and the managed installation and upgrade lifecycle. +set -euo pipefail + +old_version="v0.0.1-dev.upgrade-smoke" +target_version="v0.1.0-preview.2.smoke" +smoke_root="$(mktemp -d /tmp/au.XXXXXXXX)" +export AGENT_SMOKE_ID="${smoke_root##*/}" +# Build outside smoke_root: CI runners keep /tmp on a small tmpfs, and the two +# dev-profile builds below do not fit there. +smoke_target="${CARGO_TARGET_DIR:-$(git rev-parse --show-toplevel)/target}/upgrade-smoke-${AGENT_SMOKE_ID}" +binary_directory="${smoke_target}/debug" +target_binaries="${smoke_root}/target-binaries" +old_archive="${smoke_root}/old.tar.gz" +target_archive="${smoke_root}/target.tar.gz" +export AGENT_HOME="${smoke_root}/home" + +cleanup() { + status=$? + log="${AGENT_HOME}/agentd.log" + if [ "${RUNNER_OS:-}" = "Windows" ]; then + log="$(cygpath -u "${AGENT_HOME}")/agentd.log" + fi + if [ "${status}" -ne 0 ] && [ -f "${log}" ]; then + printf '%s\n' 'agentd.log:' >&2 + cat "${log}" >&2 + fi + if [ "${RUNNER_OS:-}" = "Windows" ]; then + # shellcheck disable=SC2016 # PowerShell expands its own environment variables. + pwsh -NoProfile -Command ' + Get-CimInstance Win32_Process | + Where-Object { + $_.Name -eq "agentd.exe" -and + ($_.CommandLine -like "*$env:AGENT_SMOKE_ID*" -or $_.ExecutablePath -like "*$env:AGENT_SMOKE_ID*") + } | + ForEach-Object { Stop-Process -Id $_.ProcessId -Force } + ' 2>/dev/null || true + else + pkill -f "agentd.*--home ${AGENT_HOME}" 2>/dev/null || true + fi + rm -rf -- "${smoke_root}" "${smoke_target}" +} +trap cleanup EXIT HUP INT TERM + +mkdir -p "${smoke_root}" + +# Build release fixtures + +CARGO_TARGET_DIR="${smoke_target}" CARGO_PROFILE_DEV_DEBUG=0 CARGO_INCREMENTAL=0 \ + AGENT_VERSION="${old_version}" cargo build --locked -p agent --bins +./agentctl/package.sh "${old_archive}" "${binary_directory}" +CARGO_TARGET_DIR="${smoke_target}" CARGO_PROFILE_DEV_DEBUG=0 CARGO_INCREMENTAL=0 \ + AGENT_VERSION="${target_version}" cargo build --locked -p agent --bins +suffix="" +if [ -f "${binary_directory}/agentctl.exe" ]; then + suffix=".exe" +fi +mkdir "${target_binaries}" +mv "${binary_directory}/agentctl${suffix}" "${binary_directory}/agentd${suffix}" "${target_binaries}/" +rm -rf -- "${smoke_target}" +./agentctl/package.sh "${target_archive}" "${target_binaries}" + +if [ "${RUNNER_OS:-}" = "Windows" ]; then + # Standalone installation + + standalone_root="${smoke_root}/standalone" + mkdir -p "${standalone_root}/managed" + printf '{ "phase": "prepared" }\n' > "${standalone_root}/managed/update.json" + export AGENT_INSTALL_MODE=standalone + export AGENT_INSTALL_ROOT="$(cygpath -w "${standalone_root}/managed")" + export AGENT_INSTALL_DIR="$(cygpath -w "${standalone_root}/bin")" + export AGENT_HOME="$(cygpath -w "${standalone_root}/home")" + export AGENT_VERSION="${target_version}" + export AGENT_LOCAL_ARCHIVE="$(cygpath -w "${target_archive}")" + pwsh -NoProfile -File "$(cygpath -w agentctl/install.ps1)" + # shellcheck disable=SC2016 # PowerShell expands its own environment variables. + pwsh -NoProfile -Command ' + $agentctl = Join-Path $env:AGENT_INSTALL_DIR "agentctl.exe" + $agentd = Join-Path $env:AGENT_INSTALL_DIR "agentd.exe" + if (-not (Test-Path $agentctl -PathType Leaf) -or -not (Test-Path $agentd -PathType Leaf)) { + throw "standalone installation did not copy both binaries" + } + $actual = (& $agentctl --version | Out-String).Trim() + if ($actual -ne "agentctl $env:AGENT_VERSION") { + throw "standalone agentctl reports $actual" + } + if (Test-Path (Join-Path $env:AGENT_INSTALL_ROOT "releases")) { + throw "standalone installation created a managed release tree" + } + ' + test "$(cat "${standalone_root}/managed/update.json")" = '{ "phase": "prepared" }' + test ! -e "${standalone_root}/home" + unset AGENT_INSTALL_MODE + + # Managed installation and self-update + + AGENT_INSTALL_ROOT="$(cygpath -w "${smoke_root}/install")" + AGENT_INSTALL_DIR="$(cygpath -w "${smoke_root}/bin")" + AGENT_HOME="$(cygpath -w "${smoke_root}/home")" + export AGENT_VERSION="${old_version}" + AGENT_LOCAL_ARCHIVE="$(cygpath -w "${old_archive}")" + export AGENT_INSTALL_ROOT AGENT_INSTALL_DIR AGENT_HOME AGENT_LOCAL_ARCHIVE + pwsh -NoProfile -File "$(cygpath -w agentctl/install.ps1)" + export AGENT_SMOKE_BIN="${AGENT_INSTALL_DIR}" + AGENT_LOCAL_ARCHIVE="$(cygpath -w "${target_archive}")" + export AGENT_LOCAL_ARCHIVE + export AGENT_TARGET_VERSION="${target_version}" + unset AGENT_INSTALL_ROOT AGENT_INSTALL_DIR + # shellcheck disable=SC2016 # PowerShell expands its own environment variables. + pwsh -NoProfile -Command ' + $agentctl = Join-Path $env:AGENT_SMOKE_BIN "agentctl.cmd" + & $agentctl --home $env:AGENT_HOME self update --version $env:AGENT_TARGET_VERSION + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $actual = (& $agentctl --version | Out-String).Trim() + if ($actual -ne "agentctl $env:AGENT_TARGET_VERSION") { + throw "updated agentctl reports $actual" + } + & $agentctl --home $env:AGENT_HOME self update --version $env:AGENT_TARGET_VERSION + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + ' + AGENT_INSTALL_ROOT="$(cygpath -w "${smoke_root}/install")" + export AGENT_INSTALL_DIR="${AGENT_SMOKE_BIN}" + AGENT_LOCAL_ARCHIVE="$(cygpath -w "${old_archive}")" + export AGENT_VERSION="${old_version}" + export AGENT_INSTALL_ROOT AGENT_LOCAL_ARCHIVE + pwsh -NoProfile -File "$(cygpath -w agentctl/install.ps1)" + # shellcheck disable=SC2016 # PowerShell expands its own environment variables. + pwsh -NoProfile -Command ' + $agentctl = Join-Path $env:AGENT_SMOKE_BIN "agentctl.cmd" + $actual = (& $agentctl --version | Out-String).Trim() + if ($actual -ne "agentctl $env:AGENT_VERSION") { + throw "installer did not replace a newer release with development build $actual" + } + ' +else + # Standalone installation + + standalone_root="${smoke_root}/standalone" + mkdir -p "${standalone_root}/managed" + printf '{ "phase": "prepared" }\n' > "${standalone_root}/managed/update.json" + AGENT_INSTALL_MODE=standalone \ + AGENT_INSTALL_ROOT="${standalone_root}/managed" \ + AGENT_INSTALL_DIR="${standalone_root}/bin" \ + AGENT_HOME="${standalone_root}/home" \ + AGENT_VERSION="${target_version}" \ + AGENT_LOCAL_ARCHIVE="${target_archive}" \ + ./agentctl/install.sh + test -x "${standalone_root}/bin/agentctl" + test -x "${standalone_root}/bin/agentd" + test "$("${standalone_root}/bin/agentctl" --version)" = "agentctl ${target_version}" + test "$(find "${standalone_root}/bin" -type f | wc -l | tr -d ' ')" = 2 + test ! -e "${standalone_root}/managed/releases" + test ! -e "${standalone_root}/home" + test "$(cat "${standalone_root}/managed/update.json")" = '{ "phase": "prepared" }' + + # Managed installation and self-update + + export AGENT_INSTALL_ROOT="${smoke_root}/install" + export AGENT_INSTALL_DIR="${smoke_root}/bin" + export AGENT_HOME="${smoke_root}/home" + export AGENT_VERSION="${old_version}" + export AGENT_LOCAL_ARCHIVE="${old_archive}" + ./agentctl/install.sh + agentctl="${AGENT_INSTALL_DIR}/agentctl" + export AGENT_LOCAL_ARCHIVE="${target_archive}" + unset AGENT_INSTALL_ROOT AGENT_INSTALL_DIR + "${agentctl}" --home "${AGENT_HOME}" self update --version "${target_version}" + test "$("${agentctl}" --version)" = "agentctl ${target_version}" + "${agentctl}" --home "${AGENT_HOME}" self update --version "${target_version}" + export AGENT_INSTALL_ROOT="${smoke_root}/install" + export AGENT_INSTALL_DIR="${smoke_root}/bin" + export AGENT_LOCAL_ARCHIVE="${old_archive}" + export AGENT_VERSION="${old_version}" + ./agentctl/install.sh + test "$("${agentctl}" --version)" = "agentctl ${old_version}" +fi diff --git a/agentctl/make-user-install.ps1 b/agentctl/make-user-install.ps1 new file mode 100644 index 0000000..0591daf --- /dev/null +++ b/agentctl/make-user-install.ps1 @@ -0,0 +1,85 @@ +[CmdletBinding()] +param( + [string]$Version +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Resolve-PathFromRoot([string]$Path, [string]$Root) { + if ([IO.Path]::IsPathRooted($Path)) { + return [IO.Path]::GetFullPath($Path) + } + return [IO.Path]::GetFullPath((Join-Path $Root $Path)) +} + +function Restore-ProcessEnvironment([string]$Name, [AllowNull()][string]$Value) { + if ($null -eq $Value) { + Remove-Item "Env:$Name" -ErrorAction SilentlyContinue + } else { + Set-Item "Env:$Name" $Value + } +} + +$Root = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..")) +if (-not $Version) { + $Version = "v0.0.1-dev.$([DateTime]::UtcNow.ToString('yyyyMMddHHmmss'))" +} elseif (-not $Version.StartsWith("v")) { + $Version = "v$Version" +} + +$TargetRoot = if ($env:CARGO_TARGET_DIR) { + Resolve-PathFromRoot $env:CARGO_TARGET_DIR $Root +} else { + Resolve-PathFromRoot "target" $Root +} +$ReleaseBinDirectory = Join-Path $TargetRoot "release" +$ArchiveDirectory = Join-Path $Root "build/user-install" +$ArchiveName = "agent-$Version.tar.gz" +$Archive = Join-Path $ArchiveDirectory $ArchiveName +$Installer = Join-Path $Root "agentctl/install.ps1" +$PreviousVersion = [Environment]::GetEnvironmentVariable("AGENT_VERSION", "Process") +$PreviousArchive = [Environment]::GetEnvironmentVariable("AGENT_LOCAL_ARCHIVE", "Process") +$PreviousChecksum = [Environment]::GetEnvironmentVariable("AGENT_LOCAL_ARCHIVE_SHA256", "Process") + +Push-Location $Root +try { + Write-Host "Building agentctl $Version..." + $env:AGENT_VERSION = $Version + & cargo build --release --locked -p agent --bins + if ($LASTEXITCODE -ne 0) { + throw "Cargo build exited with code $LASTEXITCODE" + } + + foreach ($Binary in @("agentctl.exe", "agentd.exe")) { + $BinaryPath = Join-Path $ReleaseBinDirectory $Binary + if (-not (Test-Path -LiteralPath $BinaryPath -PathType Leaf)) { + throw "Missing Agent binary: $BinaryPath" + } + } + + New-Item -ItemType Directory -Force -Path $ArchiveDirectory | Out-Null + if (Test-Path -LiteralPath $Archive) { + Remove-Item -LiteralPath $Archive -Force + } + & tar.exe -czf $Archive -C $ReleaseBinDirectory agentctl.exe agentd.exe + if ($LASTEXITCODE -ne 0) { + throw "Agent packaging exited with code $LASTEXITCODE" + } + + $Digest = (Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash.ToLowerInvariant() + $Checksum = "$Digest $ArchiveName`n" + [IO.File]::WriteAllText("$Archive.sha256", $Checksum, [Text.UTF8Encoding]::new($false)) + + $env:AGENT_LOCAL_ARCHIVE = $Archive + $env:AGENT_LOCAL_ARCHIVE_SHA256 = "$Archive.sha256" + & $Installer + if ($LASTEXITCODE -ne 0) { + throw "Agent installer exited with code $LASTEXITCODE" + } +} finally { + Pop-Location + Restore-ProcessEnvironment "AGENT_VERSION" $PreviousVersion + Restore-ProcessEnvironment "AGENT_LOCAL_ARCHIVE" $PreviousArchive + Restore-ProcessEnvironment "AGENT_LOCAL_ARCHIVE_SHA256" $PreviousChecksum +} diff --git a/agentctl/notices/about.hbs b/agentctl/notices/about.hbs new file mode 100644 index 0000000..e3c47c2 --- /dev/null +++ b/agentctl/notices/about.hbs @@ -0,0 +1,14 @@ +{{#each licenses}} +### {{{name}}} + +Used by: + +{{#each used_by}} +- {{{crate.name}}} {{{crate.version}}} +{{/each}} + +```text +{{{text}}} +``` + +{{/each}} diff --git a/agentctl/notices/about.toml b/agentctl/notices/about.toml new file mode 100644 index 0000000..55b77db --- /dev/null +++ b/agentctl/notices/about.toml @@ -0,0 +1,17 @@ +accepted = [ + "0BSD", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "MPL-2.0", + "Unicode-3.0", + "Unlicense", + "Zlib", +] +ignore-build-dependencies = true +ignore-dev-dependencies = true diff --git a/agentctl/notices/license-files.py b/agentctl/notices/license-files.py new file mode 100755 index 0000000..3bc6ba9 --- /dev/null +++ b/agentctl/notices/license-files.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Print the license files that the dependencies of an agentctl release ship. + +Reads a `cargo about generate --format json` report and, for every crate from a +registry or Git source, reproduces the license, copying, copyright and notice +files in its package root verbatim. cargo-about identifies license texts +automatically and falls back to canonical texts without the crate's copyright +notices when it cannot, so this keeps every notice a dependency ships. +Identical files are printed once with the crates that ship them. +""" + +import hashlib +import json +import sys +from pathlib import Path + +NAME_PREFIXES = ("license", "licence", "copying", "copyright", "notice", "unlicense") + + +def license_files(package_dir): + files = [] + for entry in sorted(package_dir.iterdir()): + if entry.is_file() and entry.name.lower().startswith(NAME_PREFIXES): + files.append(entry) + return files + + +def main(paths): + crates = {} + for path in paths: + with open(path, encoding="utf-8") as report: + for crate in json.load(report)["crates"]: + package = crate["package"] + manifest = Path(package["manifest_path"]) + # Workspace crates are covered by this repository's LICENSE. + if "registry" not in manifest.parts and "git" not in manifest.parts: + continue + crates[(package["name"], package["version"])] = manifest.parent + + texts = {} + for (name, version), package_dir in sorted(crates.items()): + for file in license_files(package_dir): + text = file.read_bytes().decode("utf-8", errors="replace").strip() + digest = hashlib.sha256(text.encode()).hexdigest() + entry = texts.setdefault(digest, {"text": text, "users": []}) + entry["users"].append(f"{name} {version} ({file.name})") + + if not texts: + sys.exit("no dependency license files found; check the cargo-about report") + + for entry in sorted(texts.values(), key=lambda entry: entry["users"][0]): + print("Shipped by:\n") + for user in entry["users"]: + print(f"- {user}") + print("\n```text") + print(entry["text"].replace("```", "'''")) + print("```\n") + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/agentctl/notices/third-party-notices.sh b/agentctl/notices/third-party-notices.sh new file mode 100755 index 0000000..e37ba29 --- /dev/null +++ b/agentctl/notices/third-party-notices.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# third-party-notices.sh — write the third-party notices for an agentctl release. +# +# Usage: +# agentctl/notices/third-party-notices.sh +# +# Covers the Rust dependencies of the `agentctl` and `agentd` binaries for every +# release target, followed by the license files the dependencies ship. Requires +# cargo-about and Python 3. + +set -euo pipefail + +if (( $# != 1 )); then + echo "Usage: $0 " >&2 + exit 2 +fi + +output=$(realpath -m -- "$1") +cd "$(git rev-parse --show-toplevel)" + +config=agentctl/notices/about.toml +template=agentctl/notices/about.hbs +about_args=( + --manifest-path agentctl/Cargo.toml + --target x86_64-unknown-linux-gnu --target aarch64-unknown-linux-gnu + --target aarch64-apple-darwin + --target x86_64-pc-windows-msvc --target aarch64-pc-windows-msvc +) +reports=$(mktemp -d) +trap 'rm -rf "$reports"' EXIT +cargo about generate --locked --config "$config" --format json "${about_args[@]}" > "$reports/agent.json" + +{ + cat <<'NOTICE' +# Third-party notices + +This release of agentctl contains the `agentctl` and `agentd` binaries. agentctl +is licensed under MIT; see `LICENSE`. This file lists the third-party components +the binaries contain and their licenses. + +## Rust dependencies + +NOTICE + cargo about generate --locked --config "$config" "${about_args[@]}" "$template" + cat <<'NOTICE' + +## License files shipped by the dependencies + +The license texts above are identified automatically and do not always reproduce +the copyright notices of each dependency. The license, copying and notice files +that the dependencies ship are reproduced here verbatim; identical files are +listed once. + +NOTICE + python3 agentctl/notices/license-files.py "$reports/agent.json" +} > "$output" + +echo "Wrote $output" diff --git a/agentctl/package.sh b/agentctl/package.sh new file mode 100755 index 0000000..dd62f9b --- /dev/null +++ b/agentctl/package.sh @@ -0,0 +1,36 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 ARCHIVE BINARY_DIRECTORY" >&2 + exit 1 +fi + +archive="$1" +binary_directory="$2" +executable_suffix="" +if [ -f "${binary_directory}/agentctl.exe" ] && [ -f "${binary_directory}/agentd.exe" ]; then + executable_suffix=".exe" +fi + +for binary in agentctl agentd; do + if [ ! -f "${binary_directory}/${binary}${executable_suffix}" ]; then + echo "Missing Agent binary: ${binary_directory}/${binary}${executable_suffix}" >&2 + exit 1 + fi +done + +archive_directory="$(dirname "${archive}")" +archive_name="$(basename "${archive}")" +mkdir -p "${archive_directory}" +temporary="$(mktemp -d -t agentctl-package.XXXXXXXX)" +trap 'rm -rf "${temporary}"' EXIT HUP INT TERM + +cp "${binary_directory}/agentctl${executable_suffix}" "${binary_directory}/agentd${executable_suffix}" "${temporary}/" +tar -czf "${archive}" -C "${temporary}" "agentctl${executable_suffix}" "agentd${executable_suffix}" +if command -v sha256sum >/dev/null 2>&1; then + digest="$(sha256sum "${archive}" | awk '{ print $1 }')" +else + digest="$(shasum -a 256 "${archive}" | awk '{ print $1 }')" +fi +printf '%s %s\n' "${digest}" "${archive_name}" > "${archive}.sha256" diff --git a/agentctl/src/authorization/agent_policy.rs b/agentctl/src/authorization/agent_policy.rs new file mode 100644 index 0000000..ca4c981 --- /dev/null +++ b/agentctl/src/authorization/agent_policy.rs @@ -0,0 +1,265 @@ +//! Agent implementation of `sandbox_authorization::PolicyEngine`. + +use std::{cell::RefCell, collections::BTreeMap}; + +use sandbox_authorization::{ + AuthorizationDecision, AuthorizationRequest, PolicyEngine, + vocabulary::{action, context, principal_kind}, +}; + +use crate::Agent; +use sandbox::SandboxName; + +/// Live policy registry keyed by stable Agent/Sandbox name. +#[derive(Default)] +pub struct AgentPolicyEngine { + agents: RefCell>, + platform_endpoint: RefCell>, +} + +struct PlatformEndpoint { + host: String, + port: u16, +} + +impl AgentPolicyEngine { + /// Creates an empty, fail-closed policy registry. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Registers the one Sandbox-reachable host endpoint exposed by the platform. + /// + /// Host-destined traffic (the Network Backend's host alias, and raw dials + /// into host-reserved address ranges) is denied except for exactly this + /// endpoint; without a registered endpoint it is denied entirely. + pub fn set_platform_endpoint(&self, host: impl Into, port: u16) { + *self.platform_endpoint.borrow_mut() = Some(PlatformEndpoint { + host: host.into(), + port, + }); + } + + /// Replaces the policy for one desired Agent generation. + pub fn set_agent( + &self, + sandbox: &SandboxName, + agent: &Agent, + managed_secrets: impl IntoIterator)>, + ) { + let secrets = agent + .spec + .secrets + .iter() + .map(|secret| (secret.environment.clone(), secret.allowed_hosts.clone())) + .chain(managed_secrets) + .collect(); + self.agents.borrow_mut().insert( + sandbox.as_str().into(), + AgentPolicy { + denied_hosts: agent.spec.network.deny.clone(), + secrets, + }, + ); + } + + /// Removes policy for a released Agent. + pub fn remove_agent(&self, sandbox: &SandboxName) { + self.agents.borrow_mut().remove(sandbox.as_str()); + } +} + +struct AgentPolicy { + denied_hosts: Vec, + secrets: BTreeMap>, +} + +impl PolicyEngine for AgentPolicyEngine { + fn evaluate( + &self, + request: AuthorizationRequest, + ) -> sandbox_authorization::LocalFuture<'_, Result> { + Box::pin(async move { Ok(self.evaluate_request(&request)) }) + } +} + +impl AgentPolicyEngine { + fn evaluate_request(&self, request: &AuthorizationRequest) -> AuthorizationDecision { + if request.principal.kind != principal_kind::SANDBOX { + return AuthorizationDecision::Deny; + } + let Some(agent_name) = request + .context + .get(context::SANDBOX_NAME) + .and_then(|value| value.as_str()) + else { + return AuthorizationDecision::Deny; + }; + let agents = self.agents.borrow(); + let Some(policy) = agents.get(agent_name) else { + return AuthorizationDecision::Deny; + }; + let host = request_host(request); + if let Some(host_destined) = self.host_destined_decision(request, host) { + return host_destined; + } + match request.action.as_str() { + action::NETWORK_CONNECT | action::DNS_QUERY | action::HTTP_REQUEST => host + .map_or(AuthorizationDecision::Deny, |host| { + decision(!policy.denied_hosts.iter().any(|pattern| host_matches(pattern, host))) + }), + action::SECRET_USE => { + let Some(host) = host else { + return AuthorizationDecision::Deny; + }; + decision( + policy + .secrets + .get(&request.resource.id) + .is_some_and(|patterns| patterns.iter().any(|pattern| host_matches(pattern, host))), + ) + } + _ => AuthorizationDecision::Deny, + } + } +} + +impl AgentPolicyEngine { + /// Decides host-destined traffic: the Network Backend rewrites its host + /// alias (and only its gateway addresses) to host loopback, so anything + /// aimed at the host must match the registered Platform API endpoint exactly. + /// + /// The trusted Network Backend reports host-destined flows through the + /// `network.destinationIsHost` attribute, set from the same gateway-to- + /// loopback rewrite it applies at dial time — this is the authoritative + /// signal. The host-reserved range check is kept as defense in depth for + /// requests that predate the flag or arrive without it. Returns `None` for + /// traffic that is not host-destined. + fn host_destined_decision( + &self, + request: &AuthorizationRequest, + host: Option<&str>, + ) -> Option { + let action = request.action.as_str(); + if !matches!( + action, + action::NETWORK_CONNECT | action::DNS_QUERY | action::HTTP_REQUEST + ) { + return None; + } + let endpoint = self.platform_endpoint.borrow(); + let alias = endpoint + .as_ref() + .zip(host) + .is_some_and(|(endpoint, host)| host.eq_ignore_ascii_case(&endpoint.host)); + let destination = destination_address(request); + let flagged = request + .context + .get(context::NETWORK_DESTINATION_IS_HOST) + .and_then(sandbox_authorization::AuthorizationValue::as_bool) + .unwrap_or(false); + let reserved = destination.is_some_and(|address| is_host_reserved(address.ip())); + if !alias && !flagged && !reserved { + return None; + } + if action == action::DNS_QUERY { + // Resolving the alias only reveals the gateway address. + return Some(AuthorizationDecision::Allow); + } + let port = destination + .map(|address| address.port()) + .or_else(|| authority_port(request)); + let allowed = alias && endpoint.as_ref().is_some_and(|endpoint| port == Some(endpoint.port)); + Some(decision(allowed)) + } +} + +/// Destination socket address reported by the Network Backend, when present. +fn destination_address(request: &AuthorizationRequest) -> Option { + request + .context + .get(context::NETWORK_DESTINATION_ADDRESS) + .and_then(|value| value.as_str()) + .and_then(|value| value.parse().ok()) +} + +/// Port carried by the HTTP authority, when present. +fn authority_port(request: &AuthorizationRequest) -> Option { + request + .context + .get(context::HTTP_AUTHORITY) + .and_then(|value| value.as_str()) + .and_then(|authority| authority.rsplit_once(':')) + .and_then(|(_, port)| port.parse().ok()) +} + +/// Address ranges that can carry the Network Backend's gateway, plus ranges +/// that never name a legitimate upstream from inside a Sandbox. +const fn is_host_reserved(address: std::net::IpAddr) -> bool { + match address { + std::net::IpAddr::V4(v4) => { + v4.is_loopback() + || v4.is_link_local() + || v4.is_unspecified() + // CGNAT 100.64.0.0/10 carries the per-Sandbox gateway. + || (v4.octets()[0] == 100 && (v4.octets()[1] & 0b1100_0000) == 64) + } + std::net::IpAddr::V6(v6) => { + v6.is_loopback() + || v6.is_unspecified() + // ULA fc00::/7 carries the per-Sandbox gateway. + || (v6.octets()[0] & 0b1111_1110) == 0xfc + // Link-local fe80::/10. + || (v6.octets()[0] == 0xfe && (v6.octets()[1] & 0b1100_0000) == 0x80) + } + } +} + +fn request_host(request: &AuthorizationRequest) -> Option<&str> { + request + .context + .get(context::HTTP_AUTHORITY) + .and_then(|value| value.as_str()) + .or_else(|| { + request + .context + .get(context::NETWORK_HOSTNAME) + .and_then(|value| value.as_str()) + }) + .or_else(|| (request.action.as_str() == action::DNS_QUERY).then_some(request.resource.id.as_str())) + .map(without_port) +} + +fn without_port(authority: &str) -> &str { + if let Some(bracketed) = authority.strip_prefix('[') + && let Some(end) = bracketed.find(']') + { + return &bracketed[..end]; + } + authority + .rsplit_once(':') + .filter(|(_, port)| !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit())) + .map_or(authority, |(host, _)| host) +} + +fn host_matches(pattern: &str, host: &str) -> bool { + let pattern = pattern.to_ascii_lowercase(); + let host = host.trim_end_matches('.').to_ascii_lowercase(); + pattern.strip_prefix("*.").map_or_else( + || host == pattern, + |suffix| { + host.len() > suffix.len() + && host.ends_with(suffix) + && host.as_bytes()[host.len() - suffix.len() - 1] == b'.' + }, + ) +} + +const fn decision(allowed: bool) -> AuthorizationDecision { + if allowed { + AuthorizationDecision::Allow + } else { + AuthorizationDecision::Deny + } +} diff --git a/agentctl/src/authorization/mod.rs b/agentctl/src/authorization/mod.rs new file mode 100644 index 0000000..7c0d5e4 --- /dev/null +++ b/agentctl/src/authorization/mod.rs @@ -0,0 +1,5 @@ +//! Authorization for operations originating inside Agent Sandboxes. + +mod agent_policy; + +pub use agent_policy::AgentPolicyEngine; diff --git a/agentctl/src/bin/agentctl/format.rs b/agentctl/src/bin/agentctl/format.rs new file mode 100644 index 0000000..f2d7753 --- /dev/null +++ b/agentctl/src/bin/agentctl/format.rs @@ -0,0 +1,409 @@ +use agent::{Agent, ConditionStatus}; + +/// Lists the declared access capabilities, or `-` when there are none. +pub(crate) fn format_access(spec: &agent::Spec) -> String { + if spec.access.is_empty() { + return "-".into(); + } + spec.access + .iter() + .map(|capability| match capability { + agent::AccessSpec::Ssh {} => "ssh", + agent::AccessSpec::Vnc {} => "vnc", + }) + .collect::>() + .join(",") +} + +/// Renders an SSH access descriptor as aligned `key: value` lines. +pub(crate) fn ssh_access_lines(access: &agent::ssh::AccessInfo) -> Vec { + vec![ + format!("Type: {}", access.kind), + format!("Agent: {}", access.agent), + format!("Agent ID: {}", access.agent_id), + format!("Alias: {}", access.alias), + format!("User: {}", access.user), + format!("Identity: {}", access.identity_file.display()), + format!("Known hosts: {}", access.known_hosts_file.display()), + format!("Config: {}", access.config_file.display()), + format!("Proxy: {}", access.proxy_command), + format!("Directory: {}", access.working_directory), + format!("Connect: ssh -F {} {}", access.config_file.display(), access.alias), + ] +} + +/// Renders a VNC access descriptor as aligned `key: value` lines. +pub(crate) fn vnc_access_lines(access: &agent::vnc::AccessInfo) -> Vec { + vec![ + format!("Type: {}", access.kind), + format!("Agent: {}", access.agent), + format!("Agent ID: {}", access.agent_id), + format!("Guest port: {}", access.guest_port), + access.web_guest_port.map_or_else( + || "Web port: - (none, or unknown until the Agent is next reconciled)".to_owned(), + |port| format!("Web port: {port} (agentctl vnc --web {})", access.agent), + ), + format!("Forward: {}", access.forward_command), + format!("Connect: agentctl vnc {}", access.agent), + ] +} + +pub(crate) fn describe_agent_lines(agent: &Agent) -> Vec { + let provider = agent + .status + .sandbox + .as_ref() + .map_or("-", |assignment| assignment.provider().as_str()); + let sandbox = agent + .status + .sandbox + .as_ref() + .and_then(agent::sandbox::Assignment::id) + .map_or_else(|| "-".into(), ToString::to_string); + + let source = agent.status.provenance.as_ref().map_or_else( + || "-".into(), + |provenance| { + provenance + .manifest_path + .as_ref() + .unwrap_or(&provenance.source_directory) + .display() + .to_string() + }, + ); + + let secrets = if agent.spec.secrets.is_empty() { + "-".to_owned() + } else { + agent.status.provenance.as_ref().map_or_else( + || "-".into(), + |provenance| { + provenance + .env_file + .clone() + .unwrap_or_else(|| provenance.source_directory.join(agent::control_plane::ENV_FILE)) + .display() + .to_string() + }, + ) + }; + + let mut lines = vec![ + format!("Name: {}", agent.metadata.name), + format!("Generation: {}", agent.metadata.generation), + format!("Run state: {:?}", agent.spec.run_state()), + format!("Source: {source}"), + format!("Secrets: {secrets}"), + format!("Harnesses: {}", format_harnesses(&agent.spec)), + format!("Access: {}", format_access(&agent.spec)), + format!("Provider: {provider}"), + format!("Sandbox: {sandbox}"), + ]; + if let Some(failure) = agent.status.failure { + lines.push(format!("Failure: {}", failure_kind(failure))); + } + if let Some(provisioning) = &agent.status.progress { + let progress = &provisioning.progress; + lines.extend(provisioning_lines( + progress, + progress.output().lines().map(|line| line.text.as_str()), + )); + } + lines.push("Conditions:".to_owned()); + if agent.status.conditions.is_empty() { + lines.push(" None".to_owned()); + return lines; + } + let rows = agent + .status + .conditions + .iter() + .map(|condition| { + vec![ + condition.kind.clone(), + condition_status(condition.status).into(), + condition.reason.clone(), + condition.last_transition_time.map_or_else(|| "-".into(), format_age), + condition.message.clone(), + ] + }) + .collect::>(); + lines.extend(table_lines(&["TYPE", "STATUS", "REASON", "AGE", "MESSAGE"], &rows)); + lines +} + +/// An Agent's readiness and, after a failed pass, whether it is retried. +pub(crate) fn readiness_lines(status: &agent::Status) -> Vec { + let ready = status.ready_condition().map_or_else( + || "Unknown".to_owned(), + |ready| match ready.status { + ConditionStatus::True => "True".to_owned(), + condition => format!("{} ({})", condition_status(condition), ready.detail().trim_end()), + }, + ); + let mut lines = vec![format!("Ready: {ready}")]; + if let Some(failure) = status.failure { + lines.push(format!("Failure: {}", failure_kind(failure))); + } + lines +} + +/// Renders a pass: its phases with the steps they still retain, the step in +/// progress, the failure detail when it failed, then `output` under its own +/// heading. +pub(crate) fn provisioning_lines<'a>( + progress: &sandbox::progress::Progress, + output: impl IntoIterator, +) -> Vec { + let mut lines = vec!["Provisioning:".to_owned()]; + for phase in progress.finished() { + lines.push(format!( + " {} {} ({})", + outcome_mark(phase.outcome), + phase.phase.label, + crate::progress::duration(phase.elapsed_ms) + )); + lines.extend(phase.steps.iter().map(step_line)); + } + if let Some(current) = progress.current() { + lines.push(format!( + " → {} ({})", + current.phase.label, + format_age(current.started_at) + )); + lines.extend(current.finished_steps.iter().map(step_line)); + if let Some(step) = progress.current_step() { + lines.push(format!(" {}{}", step.name, measurement(step.measurement))); + } + } + if let sandbox::progress::OperationStatus::Failed { detail } = progress.status() { + lines.push(format!(" Failed: {detail}")); + } + let mut output = output.into_iter().peekable(); + if output.peek().is_some() { + lines.push(" Output:".to_owned()); + lines.extend(output.map(|line| format!(" {line}"))); + } + lines +} + +fn step_line(step: &sandbox::progress::FinishedStep) -> String { + format!( + " {} {}{} ({})", + outcome_mark(step.outcome), + step.name, + measurement(step.measurement), + crate::progress::duration(step.elapsed_ms) + ) +} + +const fn outcome_mark(outcome: sandbox::Outcome) -> &'static str { + match outcome { + sandbox::Outcome::Failed => "✗", + _ => "✓", + } +} + +fn measurement(measurement: Option) -> String { + measurement.map_or_else(String::new, |measurement| { + format!(": {}", crate::progress::format_measurement(measurement)) + }) +} + +pub(crate) const fn failure_kind(kind: agent::FailureKind) -> &'static str { + match kind { + agent::FailureKind::Invalid => "Invalid (change the Agent to continue)", + agent::FailureKind::Transient => "Transient (retrying in the background)", + } +} + +pub(crate) const fn condition_status(status: ConditionStatus) -> &'static str { + match status { + ConditionStatus::True => "True", + ConditionStatus::False => "False", + ConditionStatus::Unknown => "Unknown", + } +} + +pub(crate) fn format_harnesses(spec: &agent::Spec) -> String { + spec.harnesses + .iter() + .map(|harness| { + let suffix = if spec.harnesses.len() == 1 || harness.default { + " (default)" + } else { + "" + }; + let version = harness + .version + .as_deref() + .map(|version| format!(" {version}")) + .unwrap_or_default(); + format!("{}{version}{suffix}", harness.kind.as_str()) + }) + .collect::>() + .join(", ") +} + +pub(crate) const fn session_state(state: agent::sessions::State) -> &'static str { + match state { + agent::sessions::State::Starting => "Starting", + agent::sessions::State::Working => "Working", + agent::sessions::State::WaitingForInput => "WaitingForInput", + agent::sessions::State::Idle => "Idle", + agent::sessions::State::Archiving => "Archiving", + agent::sessions::State::Archived => "Archived", + agent::sessions::State::Failed => "Failed", + } +} + +pub(crate) fn format_age(created_at: time::OffsetDateTime) -> String { + let seconds = (time::OffsetDateTime::now_utc() - created_at).whole_seconds().max(0); + match seconds { + 0..60 => format!("{seconds}s"), + 60..3600 => format!("{}m", seconds / 60), + 3600..86_400 => format!("{}h", seconds / 3600), + _ => format!("{}d", seconds / 86_400), + } +} + +/// Renders turns as `agentctl turns` prints them: a heading per turn, then one +/// line per text part or tool call, marked with its author. +pub(crate) fn turn_lines(turns: &[agent::sessions::Turn]) -> Vec { + use agent::sessions::{Part, Role}; + let mut lines = Vec::new(); + for (index, turn) in turns.iter().enumerate() { + if index > 0 { + lines.push(String::new()); + } + lines.push(format!("=== turn {} ===", index + 1)); + for message in &turn.messages { + let who = match message.role { + Role::User => "user", + Role::Assistant => "assistant", + }; + for part in &message.parts { + lines.push(match part { + Part::Text { text } => format!("[{who}] {text}"), + Part::ToolCall { name, failed } => { + let mark = if *failed { " (failed)" } else { "" }; + format!("[{who}] -> {name}{mark}") + } + }); + } + } + } + lines +} + +pub(crate) fn table_lines(headers: &[&str], rows: &[Vec]) -> Vec { + let widths = headers + .iter() + .enumerate() + .map(|(index, header)| { + rows.iter() + .filter_map(|row| row.get(index)) + .map(String::len) + .max() + .unwrap_or_default() + .max(header.len()) + }) + .collect::>(); + let mut lines = vec![row_line( + &headers.iter().map(|value| (*value).to_owned()).collect::>(), + &widths, + )]; + lines.extend(rows.iter().map(|row| row_line(row, &widths))); + lines +} + +fn row_line(values: &[String], widths: &[usize]) -> String { + let mut line = String::new(); + for (index, value) in values.iter().enumerate() { + line.push_str(value); + if index + 1 < values.len() { + let width = widths.get(index).copied().unwrap_or_default(); + for _ in value.len()..width + 2 { + line.push(' '); + } + } + } + line +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn describe_shows_the_failure_class_and_condition_age() { + let mut agent: Agent = + serde_yaml_ng::from_str(include_str!("../../../examples/minimal/agent.yaml")).expect("example manifest"); + agent.status.failure = Some(agent::FailureKind::Transient); + agent.status.conditions = vec![agent::Condition { + kind: "Ready".into(), + status: ConditionStatus::False, + reason: "SandboxReconcileFailed".into(), + message: "registry unavailable".into(), + last_transition_time: Some(time::OffsetDateTime::now_utc() - time::Duration::minutes(3)), + }]; + + let mut progress = sandbox::progress::Progress::new(); + let step = sandbox::StepId::generate(); + progress.apply(&sandbox::ProgressEvent::PhaseStarted { + phase: sandbox::SandboxPhase::ImageResolve.phase(), + }); + progress.apply(&sandbox::ProgressEvent::StepStarted { + id: step.clone(), + name: "Pull OCI image".into(), + unit: None, + total: None, + }); + progress.apply(&sandbox::ProgressEvent::StepOutput { + id: step, + stream: sandbox::OutputStream::Stderr, + bytes: b"connection reset\n".to_vec().into(), + }); + progress.fail("registry unavailable"); + agent.status.progress = Some(agent::progress::Provisioning { + pass: agent::resources::Changes::new().revision(), + progress, + }); + + let lines = describe_agent_lines(&agent); + assert!(lines.contains(&"Failure: Transient (retrying in the background)".to_owned())); + for expected in [ + "Provisioning:", + " Failed: registry unavailable", + " Output:", + " connection reset", + ] { + assert!( + lines.iter().any(|line| line == expected), + "missing {expected:?} in {lines:#?}" + ); + } + assert!(lines.iter().any(|line| line.starts_with(" ✗ Resolve Sandbox Image ("))); + assert!(lines.iter().any(|line| line.contains(" AGE "))); + assert!( + lines + .iter() + .any(|line| line.contains("SandboxReconcileFailed") && line.contains(" 3m ")) + ); + } + + #[test] + fn session_state_output_does_not_depend_on_debug_names() { + assert_eq!(session_state(agent::sessions::State::Starting), "Starting"); + assert_eq!(session_state(agent::sessions::State::Working), "Working"); + assert_eq!( + session_state(agent::sessions::State::WaitingForInput), + "WaitingForInput" + ); + assert_eq!(session_state(agent::sessions::State::Idle), "Idle"); + assert_eq!(session_state(agent::sessions::State::Archiving), "Archiving"); + assert_eq!(session_state(agent::sessions::State::Archived), "Archived"); + assert_eq!(session_state(agent::sessions::State::Failed), "Failed"); + } +} diff --git a/agentctl/src/bin/agentctl/launch.rs b/agentctl/src/bin/agentctl/launch.rs new file mode 100644 index 0000000..4baba3a --- /dev/null +++ b/agentctl/src/bin/agentctl/launch.rs @@ -0,0 +1,343 @@ +//! Hands addresses and editor sessions to applications on the machine running `agentctl`. +//! +//! Everything here is best effort: no portable viewer or editor exists. A +//! launcher that fails at once is reported; one that fails later, after it +//! has handed over to the application, cannot be observed. + +use std::{ + ffi::{OsStr, OsString}, + path::{Path, PathBuf}, + process::Stdio, + time::Duration, +}; + +/// How long a launcher gets to fail before it is taken to have launched. +const LAUNCH_GRACE: Duration = Duration::from_secs(2); + +/// Windows `CREATE_NO_WINDOW`: a console launcher such as `code.cmd` opens no console window. +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +/// An editor that opens an Agent's working directory over SSH. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Editor { + VsCode, + Zed, +} + +impl Editor { + pub(crate) const ALL: [Self; 2] = [Self::VsCode, Self::Zed]; + + pub(crate) const fn label(self) -> &'static str { + match self { + Self::VsCode => "VS Code", + Self::Zed => "Zed", + } + } + + /// Executable names the editor's command-line launcher is installed as, most common first. + /// Some distribution packages install Zed's as `zeditor`. + const fn executables(self) -> &'static [&'static str] { + match self { + Self::VsCode if cfg!(windows) => &["code.cmd"], + Self::VsCode => &["code"], + Self::Zed if cfg!(windows) => &["zed.exe"], + Self::Zed => &["zed", "zeditor"], + } + } + + /// Finds the editor's launcher on `path`, a `PATH`-style variable. + pub(crate) fn locate(self, path: Option<&OsStr>) -> Option { + let directories = path + .map(std::env::split_paths) + .into_iter() + .flatten() + .collect::>(); + self.executables().iter().find_map(|name| { + directories + .iter() + .filter(|directory| directory.is_absolute()) + .map(|directory| directory.join(name)) + .find(|candidate| candidate.is_file()) + }) + } + + /// How to open `directory` in the Agent reached as the OpenSSH `alias`, given the launcher + /// found on `PATH`. VS Code falls back to its URL handler; Zed has none to fall back to. + pub(crate) fn launch(self, launcher: Option<&Path>, alias: &str, directory: &str) -> Option { + match (self, launcher) { + (Self::VsCode, Some(program)) => Some(Launch::Command { + program: program.to_path_buf(), + arguments: vec!["--remote".into(), format!("ssh-remote+{alias}"), directory.to_owned()], + }), + (Self::VsCode, None) => Some(Launch::Url(format!( + "vscode://vscode-remote/ssh-remote+{alias}{directory}" + ))), + (Self::Zed, Some(program)) => Some(Launch::Command { + program: program.to_path_buf(), + arguments: vec![format!("ssh://{alias}{directory}")], + }), + (Self::Zed, None) => None, + } + } + + /// Why the editor cannot be launched without its command-line launcher, if it needs one. + pub(crate) fn missing_launcher(self) -> Option { + match self { + Self::VsCode => None, + Self::Zed => Some(format!("not found on PATH ({})", self.executables().join(", "))), + } + } +} + +/// One way to hand something to a local application. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum Launch { + /// Runs a program with arguments, detached from the terminal. + Command { program: PathBuf, arguments: Vec }, + /// Opens an address with whichever application handles its scheme. + Url(String), +} + +impl Launch { + /// Starts the launch, detached from the caller's terminal, and waits + /// briefly for it to fail. + /// + /// # Errors + /// + /// Returns why the program could not be started or failed at once, such as + /// an opener finding no application for the address. + pub(crate) async fn start(&self) -> Result<(), String> { + match self { + Self::Command { program, arguments } => run_detached(program.as_os_str(), arguments, true).await, + Self::Url(url) => open_url(url).await, + } + } +} + +/// Hands `url` to the program that opens addresses on this operating system. +/// +/// # Errors +/// +/// Returns why the opener could not be started or failed at once. +pub(crate) async fn open_url(url: &str) -> Result<(), String> { + // Explorer exits with 1 even when it opened the address, so there only a + // failure to start it tells. + run_detached(OsStr::new(opener()), &[url.to_owned()], !cfg!(windows)).await +} + +/// The program that opens addresses on this operating system. +pub(crate) const fn opener() -> &'static str { + if cfg!(target_os = "macos") { + "open" + } else if cfg!(target_os = "windows") { + "explorer" + } else { + "xdg-open" + } +} + +/// Runs a launcher outside the caller's terminal: its output is discarded, +/// and it gets a process group of its own, so closing the terminal does not +/// close the application. One still running after [`LAUNCH_GRACE`] is taken +/// to have launched and is left running; Tokio reaps it when it exits. With +/// `exit_tells` unset, an exit status is not taken as a failure. +async fn run_detached(program: &OsStr, arguments: &[String], exit_tells: bool) -> Result<(), String> { + let name = program.to_string_lossy(); + let mut command = tokio::process::Command::new(program); + command + .args(arguments) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(unix)] + command.process_group(0); + #[cfg(windows)] + command.creation_flags(CREATE_NO_WINDOW); + let mut child = command + .spawn() + .map_err(|error| format!("could not run {name}: {error}"))?; + match tokio::time::timeout(LAUNCH_GRACE, child.wait()).await { + Ok(Ok(status)) if exit_tells && !status.success() => Err(format!("{name} failed ({status})")), + Ok(Err(error)) => Err(format!("could not wait for {name}: {error}")), + Ok(Ok(_)) | Err(_) => Ok(()), + } +} + +/// The address that opens a forward listening at `local` to `guest_port`: a +/// VNC client for the desktop's RFB port, a browser for anything else. +pub(crate) fn forward_url(local: impl std::fmt::Display, guest_port: u16) -> String { + if guest_port == agent::vnc::GUEST_PORT { + format!("vnc://{local}") + } else { + format!("http://{local}/") + } +} + +/// Names a forward to one of the desktop's ports, however it was made. +pub(crate) const fn forward_label(guest_port: u16) -> Option<&'static str> { + match guest_port { + agent::vnc::WEB_GUEST_PORT => Some("desktop"), + agent::vnc::GUEST_PORT => Some("vnc"), + _ => None, + } +} + +/// Why an application started here would not appear in front of the person, +/// when it would not. `AGENTCTL_OPEN=launch` or `copy` overrides the guess. +/// +/// On Linux a window needs a display; that also covers `ssh -X` and remote +/// shells that are not SSH. macOS and Windows have no display variable, and +/// open windows on the machine's own screen, so there a terminal reached over +/// SSH is taken to be elsewhere. +pub(crate) fn launch_blocked(variable: impl Fn(&str) -> Option) -> Option { + let set = |name: &str| variable(name).is_some_and(|value| !value.is_empty()); + match variable("AGENTCTL_OPEN").as_deref().and_then(OsStr::to_str) { + Some("launch") => return None, + Some("copy") => return Some("AGENTCTL_OPEN=copy asks to copy instead of opening".into()), + _ => {} + } + if cfg!(any(target_os = "macos", windows)) { + (set("SSH_CONNECTION") || set("SSH_TTY")) + .then(|| "this terminal is reached over SSH, so it would open on that machine".into()) + } else { + (!set("DISPLAY") && !set("WAYLAND_DISPLAY")).then(|| "this terminal has no display to open windows on".into()) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::*; + + #[test] + fn vs_code_uses_its_launcher_or_else_its_url_handler() { + let launcher = Path::new("/opt/bin/code"); + assert_eq!( + Editor::VsCode.launch(Some(launcher), "agentctl-worker", "/srv/work"), + Some(Launch::Command { + program: launcher.to_path_buf(), + arguments: vec![ + "--remote".into(), + "ssh-remote+agentctl-worker".into(), + "/srv/work".into() + ], + }) + ); + assert_eq!( + Editor::VsCode.launch(None, "agentctl-worker", "/srv/work"), + Some(Launch::Url( + "vscode://vscode-remote/ssh-remote+agentctl-worker/srv/work".into() + )) + ); + assert_eq!(Editor::VsCode.missing_launcher(), None); + } + + #[test] + fn zed_needs_its_launcher() { + let launcher = Path::new("/opt/bin/zeditor"); + assert_eq!( + Editor::Zed.launch(Some(launcher), "agentctl-worker", "/srv/work"), + Some(Launch::Command { + program: launcher.to_path_buf(), + arguments: vec!["ssh://agentctl-worker/srv/work".into()], + }) + ); + assert_eq!(Editor::Zed.launch(None, "agentctl-worker", "/srv/work"), None); + let names = if cfg!(windows) { "zed.exe" } else { "zed, zeditor" }; + assert_eq!( + Editor::Zed.missing_launcher(), + Some(format!("not found on PATH ({names})")) + ); + } + + #[cfg(unix)] + #[test] + fn launchers_are_found_under_any_of_their_names_in_path_order() { + let first = tempfile::tempdir().expect("directory"); + let second = tempfile::tempdir().expect("directory"); + std::fs::write(second.path().join("zeditor"), "").expect("zeditor"); + let path = std::env::join_paths([first.path(), Path::new("relative"), second.path()]).expect("PATH"); + + assert_eq!(Editor::Zed.locate(Some(&path)), Some(second.path().join("zeditor"))); + std::fs::write(first.path().join("zed"), "").expect("zed"); + assert_eq!(Editor::Zed.locate(Some(&path)), Some(first.path().join("zed"))); + assert_eq!(Editor::VsCode.locate(Some(&path)), None); + assert_eq!(Editor::VsCode.locate(None), None); + } + + fn only(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option { + move |variable: &str| { + pairs + .iter() + .find(|(name, _)| *name == variable) + .map(|(_, value)| OsString::from(value)) + } + } + + #[test] + fn forwards_open_in_the_application_for_their_guest_port() { + assert_eq!(forward_url("127.0.0.1:53817", 5900), "vnc://127.0.0.1:53817"); + assert_eq!(forward_url("127.0.0.1:53817", 6080), "http://127.0.0.1:53817/"); + } + + #[test] + fn forwards_to_the_desktop_are_named_by_their_port() { + assert_eq!(forward_label(6080), Some("desktop")); + assert_eq!(forward_label(5900), Some("vnc")); + assert_eq!(forward_label(3000), None); + } + + #[test] + fn the_override_decides_whatever_the_terminal() { + assert_eq!(launch_blocked(only(&[("AGENTCTL_OPEN", "launch")])), None); + assert!( + launch_blocked(only(&[("AGENTCTL_OPEN", "copy"), ("DISPLAY", ":0")])) + .is_some_and(|reason| reason.contains("AGENTCTL_OPEN=copy")) + ); + } + + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn on_linux_a_window_needs_a_display_whether_or_not_ssh_is_involved() { + assert_eq!(launch_blocked(only(&[("DISPLAY", ":0")])), None); + assert_eq!(launch_blocked(only(&[("WAYLAND_DISPLAY", "wayland-0")])), None); + assert_eq!( + launch_blocked(only(&[("DISPLAY", "localhost:10.0"), ("SSH_TTY", "/dev/pts/1")])), + None, + "ssh -X shows windows on the person's own screen" + ); + assert!(launch_blocked(only(&[("SSH_TTY", "/dev/pts/1")])).is_some()); + assert!(launch_blocked(only(&[("DISPLAY", "")])).is_some()); + } + + #[cfg(any(target_os = "macos", windows))] + #[test] + fn elsewhere_a_terminal_reached_over_ssh_is_somewhere_else() { + assert!(launch_blocked(only(&[("SSH_CONNECTION", "10.0.0.1 5000 10.0.0.2 22")])).is_some()); + assert_eq!(launch_blocked(only(&[])), None); + } + + #[cfg(unix)] + #[tokio::test(flavor = "local")] + async fn a_launcher_that_fails_at_once_is_reported_and_one_that_runs_is_launched() { + assert!(run_detached(OsStr::new("false"), &[], true).await.is_err()); + assert_eq!( + run_detached(OsStr::new("false"), &[], false).await, + Ok(()), + "an opener whose exit status says nothing" + ); + assert_eq!(run_detached(OsStr::new("true"), &[], true).await, Ok(())); + assert_eq!( + run_detached(OsStr::new("sleep"), &["5".into()], true).await, + Ok(()), + "still running after the grace period" + ); + assert!( + run_detached(OsStr::new("agentctl-no-such-launcher"), &[], true) + .await + .is_err_and(|error| error.starts_with("could not run")) + ); + } +} diff --git a/agentctl/src/bin/agentctl/main.rs b/agentctl/src/bin/agentctl/main.rs new file mode 100644 index 0000000..1eb1536 --- /dev/null +++ b/agentctl/src/bin/agentctl/main.rs @@ -0,0 +1,2298 @@ +use std::{ + io::IsTerminal as _, + path::{Path, PathBuf}, + process::{Child, Command as ProcessCommand, ExitCode, Stdio}, + time::Duration, +}; + +use agent::{ + Agent, AgentVariantName, Error, RunState, + control_api::Client, + control_plane::ApplyRequest, + control_plane::WaitPolicy, + local::home::ControlPlaneHome, + manifest, + sandbox::forward, + sessions::{Session, SessionName, SessionRequest}, +}; +use clap::{Parser, Subcommand, ValueEnum}; + +mod format; +mod launch; +mod progress; +mod self_update; +mod tui; + +use format::{condition_status, format_age, format_harnesses, session_state}; +use futures_util::StreamExt as _; +use sandbox::{execution::ExecutionEvent, terminal::TerminalAttachOutcome}; +use tokio::io::AsyncWriteExt as _; +use tokio::runtime::LocalRuntime; + +#[derive(Parser)] +#[command(name = "agentctl", about = "Manage the per-user Agent control plane", version = agent::build_version())] +struct Arguments { + /// Agent control-plane home. + #[arg(long, global = true)] + home: Option, + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Manage the Agent CLI installation. + Self_ { + #[command(subcommand)] + command: self_update::SelfCommand, + }, + /// Manage Claude Code harness authentication. + Claude { + #[command(subcommand)] + command: ClaudeCommand, + }, + /// Manage Codex CLI harness authentication. + Codex { + #[command(subcommand)] + command: CodexCommand, + }, + /// Create a Session and wait until its harness is ready, without attaching. + Create { + #[command(flatten)] + target: SessionTarget, + #[command(flatten)] + selection: SessionSelection, + /// Maximum wait, written as seconds, minutes, or hours. + #[arg(long, default_value = "10m", value_parser = parse_duration)] + timeout: Duration, + /// First prompt, handed to the harness at launch. + #[command(flatten)] + input: PromptInput, + }, + /// Deliver a prompt to a running Session's harness. + Prompt { + #[command(flatten)] + target: SessionTarget, + #[command(flatten)] + input: PromptInput, + #[command(flatten)] + completion: CompletionOptions, + }, + /// Read a Session's conversation as turns. + Turns { + #[command(flatten)] + target: SessionTarget, + /// Print only the last N turns. + #[arg(long)] + last: Option, + }, + /// Create or update an Agent from a manifest. + Apply { + /// Agent manifest path; defaults to ./agent.yaml. + #[arg(short = 'f', long = "filename", conflicts_with = "variant")] + filename: Option, + /// Variant of the Agent in the current directory. + #[arg(long, value_parser = parse_variant_name, conflicts_with = "filename")] + variant: Option, + /// Override metadata.name so one manifest can create multiple Agents. + #[arg(long)] + name: Option, + /// File supplying declared manifest environment and secret values; defaults to `.env` + /// beside the manifest. Keep files containing secrets outside bind-mounted directories. + #[arg(long)] + env_file: Option, + /// Stay attached after applying and show provisioning progress until the Agent is Ready. + #[arg(long)] + wait: bool, + /// Maximum wait with `--wait`, written as seconds, minutes, or hours (for example `10m`). + #[arg(long, default_value = "10m", value_parser = parse_duration, requires = "wait")] + timeout: Duration, + }, + /// Display one or more resources. + Get { + /// Resource kind, optionally combined with a name (for example `agent/worker`). + resource: String, + /// Optional resource name when it is not part of `resource`. + name: Option, + /// Owning Agent for Session resources; inferred from the current directory when omitted. + #[arg(long, conflicts_with = "variant")] + agent: Option, + /// Select the closest Agent by its applied leaf variant. + #[arg(long, value_parser = parse_variant_name, conflicts_with = "agent")] + variant: Option, + /// List Sessions across every Agent instead of resolving one owner. + #[arg(short = 'A', long, conflicts_with_all = ["agent", "variant"])] + all_agents: bool, + /// Include archived Sessions in a Session listing. + #[arg(long)] + archived: bool, + /// Output format. + #[arg(short = 'o', long, default_value = "table", value_enum)] + output: OutputFormat, + }, + /// Show detailed state and conditions for one resource. + Describe { + /// Agent resource, optionally combined with its name (for example `agent/worker`). + resource: String, + /// Optional Agent name when it is not part of `resource`. + name: Option, + /// Output format. + #[arg(short = 'o', long, default_value = "table", value_enum)] + output: OutputFormat, + }, + /// Request deletion of a resource. + Delete { + /// Resource kind, optionally combined with a name (for example `agent/worker`). + resource: String, + /// Optional resource name when it is not part of `resource`. + name: Option, + /// Owning Agent for Session resources; inferred from the current directory when omitted. + #[arg(long, conflicts_with = "variant")] + agent: Option, + /// Select the closest Agent by its applied leaf variant. + #[arg(long, value_parser = parse_variant_name, conflicts_with = "agent")] + variant: Option, + }, + /// Stop an Agent's Sandbox VM, keeping its disk, so attaching to a Session after a start resumes it. + /// + /// Running harnesses are stopped with the VM. Applying the manifest again keeps the Agent stopped. + Stop { + #[command(flatten)] + target: RunStateTarget, + }, + /// Start a stopped Agent's Sandbox VM on its kept disk and wait until the Agent is Ready. + Start { + #[command(flatten)] + target: RunStateTarget, + }, + /// Archive a Session: stop its harness and hide it from listings, keeping its name and conversation. + Archive { + #[command(flatten)] + target: SessionTarget, + }, + /// Unarchive a Session; the next attach resumes its conversation. + Unarchive { + #[command(flatten)] + target: SessionTarget, + }, + /// Create or attach to a named Session in an Agent sandbox. + Attach { + /// Session resource, optionally combined with its name (for example `session/s1`). + resource: String, + /// Optional Session name when it is not part of `resource`. + name: Option, + /// Owning Agent; inferred from the current directory when omitted. + #[arg(long, conflicts_with = "variant")] + agent: Option, + /// Select the closest Agent by its applied leaf variant. + #[arg(long, value_parser = parse_variant_name, conflicts_with = "agent")] + variant: Option, + #[command(flatten)] + selection: SessionSelection, + }, + /// Execute a command in an Agent sandbox. + Exec { + /// Pass stdin to an allocated terminal. + #[arg(short = 'i', long, requires = "tty")] + stdin: bool, + /// Allocate a terminal; currently used together with --stdin. + #[arg(short = 't', long, requires = "stdin")] + tty: bool, + /// Agent resource or name; inferred from the current directory when omitted. + resource: Option, + /// Agent name, as an alternative to the positional resource. + #[arg(long, conflicts_with_all = ["resource", "variant"])] + agent: Option, + /// Select the closest Agent by its applied leaf variant. + #[arg(long, value_parser = parse_variant_name, conflicts_with_all = ["agent", "resource"])] + variant: Option, + /// Command and arguments to execute after `--`. + #[arg(last = true, required = true, num_args = 1..)] + command: Vec, + }, + /// Forward local ports to a running Agent sandbox until interrupted. + PortForward { + /// Agent name, as an alternative to a leading Agent argument. + #[arg(long, conflicts_with = "variant")] + agent: Option, + /// Select the closest Agent by its applied leaf variant. + #[arg(long, value_parser = parse_variant_name, conflicts_with = "agent")] + variant: Option, + /// Optional leading Agent resource or name, followed by port mappings + /// written as GUEST, LOCAL:GUEST, or ADDRESS:LOCAL:GUEST. An empty + /// local port (`:GUEST`) selects an ephemeral local port. The Agent is + /// inferred from the current directory when no leading Agent is given. + #[arg(required = true, num_args = 1..)] + arguments: Vec, + }, + /// Open an OpenSSH session to an Agent through `agentctl ssh-proxy`. + Ssh { + /// Agent name, as an alternative to the positional resource. + #[arg(long, conflicts_with_all = ["resource", "variant"])] + agent: Option, + /// Select the closest Agent by its applied leaf variant. + #[arg(long, value_parser = parse_variant_name, conflicts_with_all = ["agent", "resource"])] + variant: Option, + /// Agent resource or name; inferred from the current directory when omitted. + resource: Option, + /// Remote command and arguments after `--`; an interactive shell when omitted. + #[arg(last = true)] + command: Vec, + }, + /// Relay one connection to an Agent's SSH server over standard input and output. + /// + /// The generated OpenSSH client configuration runs this as its `ProxyCommand`. + SshProxy { + /// Agent resource or name. + resource: String, + }, + /// Manage the OpenSSH client configuration for Agents. + SshConfig { + #[command(subcommand)] + command: SshConfigCommand, + }, + /// Describe how to reach an Agent over SSH. + SshInfo { + /// Agent resource or name; inferred from the current directory when omitted. + resource: Option, + /// Agent name, as an alternative to the positional resource. + #[arg(long, conflicts_with_all = ["resource", "variant"])] + agent: Option, + /// Select the closest Agent by its applied leaf variant. + #[arg(long, value_parser = parse_variant_name, conflicts_with_all = ["agent", "resource"])] + variant: Option, + /// Output format. + #[arg(short = 'o', long, default_value = "table", value_enum)] + output: OutputFormat, + }, + /// Forward an Agent's desktop to a local VNC port until interrupted. + Vnc { + /// Agent name, as an alternative to the positional resource. + #[arg(long, conflicts_with_all = ["resource", "variant"])] + agent: Option, + /// Select the closest Agent by its applied leaf variant. + #[arg(long, value_parser = parse_variant_name, conflicts_with_all = ["agent", "resource"])] + variant: Option, + /// Agent resource or name; inferred from the current directory when omitted. + resource: Option, + /// Local port to listen on. Defaults to a free port, which is printed. + #[arg(long)] + port: Option, + /// Forward the browser-based viewer instead of the raw RFB port, so no VNC client is needed. + #[arg(long)] + web: bool, + /// Hand the address to the local browser or VNC handler instead of only printing it. + #[arg(long)] + open: bool, + }, + /// Relay one connection to an Agent's desktop over standard input and output. + VncProxy { + /// Agent resource or name. + resource: String, + }, + /// Describe how to reach an Agent's desktop over VNC. + VncInfo { + /// Agent resource or name; inferred from the current directory when omitted. + resource: Option, + /// Agent name, as an alternative to the positional resource. + #[arg(long, conflicts_with_all = ["resource", "variant"])] + agent: Option, + /// Select the closest Agent by its applied leaf variant. + #[arg(long, value_parser = parse_variant_name, conflicts_with_all = ["agent", "resource"])] + variant: Option, + /// Output format. + #[arg(short = 'o', long, default_value = "table", value_enum)] + output: OutputFormat, + }, + /// Open the interactive terminal UI. + Tui, + /// Wait for a resource condition. + Wait { + /// Condition expression. Only `condition=Ready` is currently supported. + #[arg(long = "for", default_value = "condition=Ready")] + condition: String, + /// Maximum wait, written as seconds, minutes, or hours (for example `30s` or `10m`). + #[arg(long, default_value = "10m", value_parser = parse_duration)] + timeout: Duration, + /// Agent resource, optionally combined with its name (for example `agent/worker`). + resource: String, + /// Optional Agent name when it is not part of `resource`. + name: Option, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Resource { + Agent, + Session, +} + +/// The Agent `stop` or `start` acts on: `agent/NAME` or `agent NAME`, and how long to wait. +#[derive(clap::Args)] +struct RunStateTarget { + /// Agent resource, optionally combined with its name (for example `agent/worker`). + resource: String, + /// Optional Agent name when it is not part of `resource`. + name: Option, + /// Maximum wait, written as seconds, minutes, or hours (for example `2m`). + #[arg(long, default_value = "10m", value_parser = parse_duration)] + timeout: Duration, +} + +/// The Session a verb acts on: `session/NAME` or `session NAME`, plus its owning Agent. +#[derive(clap::Args)] +struct SessionTarget { + /// Session resource, optionally combined with its name (for example `session/s1`). + resource: String, + /// Optional Session name when it is not part of `resource`. + name: Option, + /// Owning Agent; inferred from the current directory when omitted. + #[arg(long, conflicts_with = "variant")] + agent: Option, + /// Select the closest Agent by its applied leaf variant. + #[arg(long, value_parser = parse_variant_name, conflicts_with = "agent")] + variant: Option, +} + +/// Selections fixed when a command creates a Session. An existing Session keeps +/// its recorded values; naming different ones is an error. +#[derive(Default, clap::Args)] +struct SessionSelection { + /// Harness installation to bind when creating the Session. + #[arg(long, value_parser = parse_harness)] + harness: Option, + /// Model the harness launches with, in the harness's own spelling (for example + /// `fable` for Claude Code). Defaults to the installation's manifest `model`, + /// else the harness default. + #[arg(long, value_parser = parse_model)] + model: Option, + /// Effort level the harness launches with, in the harness's own spelling (for + /// example `high`). Defaults to the installation's manifest `effort`, else the + /// harness default. + #[arg(long, value_parser = parse_effort)] + effort: Option, +} + +impl SessionSelection { + fn request(self, initial_prompt: Option) -> SessionRequest { + SessionRequest { + harness: self.harness, + model_selection: agent::ModelSelection { + model: self.model, + effort: self.effort, + }, + initial_prompt, + } + } +} + +/// Whether a prompt waits for the next turn completion. +#[derive(clap::Args)] +struct CompletionOptions { + /// Wait for a turn completion and identical waiting activity in two polls + /// 250 ms apart, following newly observed work. Inspect output with `turns`. + #[arg(long)] + wait: bool, + /// Completion wait after submission, excluding setup and delivery; seconds, minutes, or hours. + #[arg(long, default_value = "10m", value_parser = parse_duration, requires = "wait")] + timeout: Duration, +} + +/// Prompt text from --prompt, --file, or piped standard input. +#[derive(clap::Args)] +struct PromptInput { + /// Prompt text. Read from a file with --file, or from standard input when + /// neither is given and stdin is piped. + #[arg(long, conflicts_with = "file", allow_hyphen_values = true)] + prompt: Option, + /// Read the prompt from a file instead of --prompt. + #[arg(short = 'f', long, conflicts_with = "prompt")] + file: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +enum OutputFormat { + Table, + Json, +} + +#[derive(Debug, thiserror::Error)] +enum CommandError { + #[error(transparent)] + Agent(#[from] Error), + #[error("{0}")] + Message(String), +} + +type CommandResult = Result; + +#[derive(Subcommand)] +enum ClaudeCommand { + /// Mint a long-lived Claude token on the host and store it for agents. + Login { + /// Read an existing credential from standard input instead of signing in. Inside an Agent + /// this accepts the mediated placeholder the Session holds as `AGENT_CLAUDE_ACCESS_TOKEN`, + /// so a nested `agentd` chains through the outer mediation without ever holding a real + /// credential. + #[arg(long)] + from_stdin: bool, + }, +} + +#[derive(Subcommand)] +enum SshConfigCommand { + /// Include the generated Agent configuration from `~/.ssh/config`, once, at the top. + Install, +} + +#[derive(Subcommand)] +enum CodexCommand { + /// Sign in with `ChatGPT` and store an Agent-only grant. + Login { + /// Read the harness's credential file from standard input instead of signing in. Inside + /// an Agent this accepts the file the harness already has, whose placeholders let a nested + /// `agentd` chain through the outer mediation without ever holding a real credential. + #[arg(long)] + from_stdin: bool, + }, +} + +fn main() -> ExitCode { + match run() { + Ok(code) => code, + // The daemon rejected the desired state; the message is the whole story. + Err(CommandError::Agent(Error::Rpc(error))) if error.is_invalid_params() => { + eprintln!("agentctl: {}", error.message); + ExitCode::FAILURE + } + Err(error) => { + eprintln!("agentctl: {error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> CommandResult { + let arguments = Arguments::parse(); + let home = ControlPlaneHome::resolve(arguments.home.as_deref())?; + let client = Client::for_path(home.socket_path()); + LocalRuntime::new().map_err(Error::from)?.block_on(async move { + if !matches!(arguments.command, Command::Self_ { .. }) { + self_update::resume_pending_before_command(&home)?; + } + if !matches!( + arguments.command, + Command::Create { .. } | Command::Prompt { .. } | Command::Self_ { .. } | Command::SshConfig { .. } + ) { + ensure_daemon(&home, &client).await?; + } + execute(arguments.command, &home, &client).await + }) +} + +#[allow( + clippy::too_many_lines, + reason = "keep command dispatch together; behavior lives in the handlers" +)] +async fn execute(command: Command, home: &ControlPlaneHome, client: &Client) -> CommandResult { + match command { + Command::Self_ { command } => self_update::execute(command, home).await?, + Command::Claude { + command: ClaudeCommand::Login { from_stdin }, + } => { + let token = if from_stdin { + read_token_from_stdin()? + } else { + agent::harness::acquire_host_credential(agent::Harness::ClaudeCode, home.path())? + }; + let imported = client + .auth_login(agent::Harness::ClaudeCode, token.to_string(), from_stdin) + .await?; + println!("{} authentication stored", imported.provider); + } + Command::Codex { + command: CodexCommand::Login { from_stdin }, + } => { + let credential = if from_stdin { + read_stdin_to_end()? + } else { + agent::harness::acquire_host_credential(agent::Harness::Codex, home.path())? + }; + let imported = client + .auth_login(agent::Harness::Codex, credential.to_string(), from_stdin) + .await?; + println!("{} authentication stored", imported.provider); + } + Command::Apply { + filename, + variant, + name, + env_file, + wait, + timeout, + } => { + let filename = apply_manifest_path(filename, variant)?; + let mut request = read_apply_request(filename, env_file)?; + if let Some(name) = name { + request.agent.metadata.name = name; + } + let applied = client.apply(request).await?; + let name = applied.metadata.name; + println!("agent/{name} applied"); + if wait { + let converged = wait_until_converged(client, &name, timeout).await?; + let outcome = if converged.spec.is_stopped() { + "stopped" + } else { + "ready" + }; + println!("agent/{name} {outcome}"); + } + } + Command::Get { + resource, + name, + agent, + variant, + all_agents, + archived, + output, + } => get_resources(client, &resource, name, agent, variant, all_agents, archived, output).await?, + Command::Describe { resource, name, output } => describe(client, &resource, name, output).await?, + Command::Delete { + resource, + name, + agent, + variant, + } => { + let (resource, name) = resource_reference(&resource, name)?; + if resource == Resource::Agent { + reject_session_scope(agent.as_deref(), variant.as_ref(), false)?; + let name = require_name(name, "Agent")?; + client.delete(&name).await?; + println!("agent/{name} deleted"); + } else { + let name = SessionName::new(require_name(name, "Session")?)?; + let agent = resolve_agent_name(client, agent, variant).await?; + client.delete_session(&agent, name.clone()).await?; + println!("session/{agent}/{name} deleted"); + } + } + Command::Stop { target } => set_run_state(client, target, RunState::Stopped).await?, + Command::Start { target } => set_run_state(client, target, RunState::Running).await?, + Command::Archive { target } => set_archived(client, target, true).await?, + Command::Unarchive { target } => set_archived(client, target, false).await?, + Command::Attach { + resource, + name, + agent, + variant, + selection, + } => attach(home, client, &resource, name, agent, variant, selection).await?, + Command::Exec { + stdin, + tty, + resource, + agent, + variant, + command, + } => return exec_command(home, client, resource, agent, variant, &command, stdin, tty).await, + Command::PortForward { + agent, + variant, + arguments, + } => { + return port_forward(home, client, agent, variant, &arguments).await; + } + Command::Ssh { + agent, + variant, + resource, + command, + } => return ssh(client, resource, agent, variant, &command).await, + Command::SshProxy { resource } => return ssh_proxy(home, client, resource).await, + Command::SshConfig { + command: SshConfigCommand::Install, + } => install_ssh_config(home)?, + Command::SshInfo { + resource, + agent, + variant, + output, + } => ssh_info(client, resource, agent, variant, output).await?, + Command::Vnc { + agent, + variant, + resource, + port, + web, + open, + } => { + let options = VncOptions { port, web, open }; + return vnc(home, client, resource, agent, variant, options).await; + } + Command::VncProxy { resource } => return vnc_proxy(home, client, resource).await, + Command::VncInfo { + resource, + agent, + variant, + output, + } => vnc_info(client, resource, agent, variant, output).await?, + Command::Create { + target, + selection, + input, + timeout, + } => create_session(home, client, target, selection, input, timeout).await?, + Command::Prompt { + target, + input, + completion, + } => prompt_session(home, client, target, input, completion).await?, + Command::Turns { target, last } => turns(client, target, last).await?, + Command::Tui => return tui::run(home, client).await, + Command::Wait { + condition, + timeout, + resource, + name, + } => wait(client, &condition, timeout, &resource, name).await?, + } + Ok(ExitCode::SUCCESS) +} + +#[allow(clippy::too_many_arguments, reason = "mirrors the get command's flags")] +async fn get_resources( + client: &Client, + resource: &str, + name: Option, + agent: Option, + variant: Option, + all_agents: bool, + archived: bool, + output: OutputFormat, +) -> CommandResult<()> { + let (resource, name) = resource_reference(resource, name)?; + match resource { + Resource::Agent => { + reject_session_scope(agent.as_deref(), variant.as_ref(), all_agents)?; + let agents = if let Some(name) = name { + vec![client.get(&name).await?] + } else { + client.list_agents().await? + }; + match output { + OutputFormat::Json => print_json(&agents)?, + OutputFormat::Table => print_agents(&agents), + } + } + Resource::Session => { + let sessions = if name.is_some() { + if all_agents { + return Err(Error::Invalid( + "a named Session requires --agent or current-directory inference".into(), + ) + .into()); + } + let agent = resolve_agent_name(client, agent, variant).await?; + vec![ + client + .get_session(&agent, SessionName::new(require_name(name, "Session")?)?) + .await?, + ] + } else { + let sessions = if all_agents { + client.list_sessions(None).await? + } else { + let agent = resolve_agent_name(client, agent, variant).await?; + client.list_sessions(Some(&agent)).await? + }; + sessions + .into_iter() + .filter(|session| archived || !session.is_archived()) + .collect() + }; + match output { + OutputFormat::Json => print_json(&sessions)?, + OutputFormat::Table => print_sessions(&sessions, all_agents), + } + } + } + Ok(()) +} + +fn print_json(value: &T) -> CommandResult<()> { + println!( + "{}", + serde_json::to_string_pretty(value).map_err(|error| Error::Invalid(error.to_string()))? + ); + Ok(()) +} + +async fn attach( + home: &ControlPlaneHome, + client: &Client, + resource: &str, + name: Option, + agent: Option, + variant: Option, + selection: SessionSelection, +) -> CommandResult<()> { + let (resource, name) = resource_reference(resource, name)?; + if resource != Resource::Session { + return Err(Error::Invalid("attach requires a Session resource".into()).into()); + } + let session = SessionName::new(require_name(name, "Session")?)?; + let agent = resolve_agent_name(client, agent, variant).await?; + let wait = progress::Wait::start(); + let target = wait + .until( + client, + &agent, + client.ensure_session(&agent, session, selection.request(None), WaitPolicy::UntilConverged), + ) + .await?; + agent::sessions::attach(home.path(), &target).await?; + Ok(()) +} + +#[allow( + clippy::too_many_arguments, + reason = "command flags remain explicit at the execution boundary" +)] +async fn exec_command( + home: &ControlPlaneHome, + client: &Client, + resource: Option, + agent: Option, + variant: Option, + command: &[String], + stdin: bool, + tty: bool, +) -> CommandResult { + let agent = resolve_execution_agent(client, resource, agent, variant).await?; + if tty && (!std::io::stdin().is_terminal() || !std::io::stdout().is_terminal()) { + return Err(Error::Invalid("-it requires an interactive local terminal".into()).into()); + } + let wait = progress::Wait::start(); + let target = wait + .until( + client, + &agent, + client.ensure_execution(&agent, WaitPolicy::UntilConverged), + ) + .await?; + let spec = agent::sandbox::platform::execution_spec(&target.operating_system, command, tty)?; + let status = if stdin && tty { + match agent::sandbox::attach_terminal( + home.path(), + &target.sandbox, + ::sandbox::terminal::AttachTerminalRequest::new(spec), + ) + .await? + { + TerminalAttachOutcome::Exited(status) => status, + TerminalAttachOutcome::Detached => return Ok(ExitCode::SUCCESS), + _ => return Err(Error::Session("terminal execution returned an unsupported outcome".into()).into()), + } + } else { + let execution = agent::sandbox::start_execution(home.path(), &target, spec).await?; + stream_execution(execution).await? + }; + Ok(exit_code(status.code)) +} + +/// Splits a leading Agent reference from the port mappings. +/// +/// A first argument containing ':' or made only of digits is a port mapping; +/// anything else names the Agent. Agent names cannot contain ':' and port +/// mappings cannot contain letters, so the shapes never overlap. +fn split_forward_arguments(arguments: &[String]) -> (Option, &[String]) { + match arguments.split_first() { + Some((first, rest)) if !first.contains(':') && !first.bytes().all(|byte| byte.is_ascii_digit()) => { + (Some(first.clone()), rest) + } + _ => (None, arguments), + } +} + +async fn port_forward( + home: &ControlPlaneHome, + client: &Client, + agent: Option, + variant: Option, + arguments: &[String], +) -> CommandResult { + let (resource, ports) = split_forward_arguments(arguments); + if agent.is_some() && resource.is_some() { + return Err(Error::Invalid("the Agent was supplied both as an argument and with --agent".into()).into()); + } + if variant.is_some() && resource.is_some() { + return Err(Error::Invalid("the Agent was supplied both as an argument and with --variant".into()).into()); + } + if ports.is_empty() { + return Err(Error::Invalid("at least one port mapping is required".into()).into()); + } + let specs = ports + .iter() + .map(|port| forward::ForwardSpec::parse(port)) + .collect::, String>>() + .map_err(CommandError::Message)?; + let agent = resolve_execution_agent(client, resource, agent, variant).await?; + let wait = progress::Wait::start(); + let target = wait + .until( + client, + &agent, + client.ensure_execution(&agent, WaitPolicy::UntilConverged), + ) + .await?; + let mut forwards = Vec::new(); + for spec in specs { + let forward = forward::PortForward::start(home.path().to_path_buf(), target.sandbox.clone(), spec).await?; + println!( + "Forwarding from {} -> {} (agent {agent:?})", + forward.local_address(), + forward.spec().guest_port + ); + forwards.push(forward); + } + hold_forwards(&forwards).await +} + +/// Holds forwards open until interrupted, reporting each connection failure +/// once, and fails when every forward has stopped serving. +async fn hold_forwards(forwards: &[forward::PortForward]) -> CommandResult { + let mut reported = vec![None; forwards.len()]; + let mut poll = tokio::time::interval(Duration::from_secs(1)); + loop { + tokio::select! { + result = tokio::signal::ctrl_c() => { + result.map_err(Error::from)?; + return Ok(ExitCode::SUCCESS); + } + _ = poll.tick() => { + for (forward, reported) in forwards.iter().zip(reported.iter_mut()) { + let status = forward.status(); + if status != *reported { + if let Some(message) = &status { + eprintln!("{} -> {}: {message}", forward.local_address(), forward.spec().guest_port); + } + *reported = status; + } + } + if forwards.iter().all(forward::PortForward::finished) { + eprintln!("every port forward has stopped"); + return Ok(ExitCode::FAILURE); + } + } + } + } +} + +/// Opens the local OpenSSH client against the Agent's generated alias. +/// +/// The Agent is converged first, so the server and the key material exist by +/// the time `ssh` runs the `ProxyCommand`. +async fn ssh( + client: &Client, + resource: Option, + agent: Option, + variant: Option, + command: &[String], +) -> CommandResult { + let agent = resolve_execution_agent(client, resource, agent, variant).await?; + let wait = progress::Wait::start(); + wait.until( + client, + &agent, + client.ensure_execution(&agent, WaitPolicy::UntilConverged), + ) + .await?; + let access = client.ssh_access(&agent).await?; + let mut ssh = ProcessCommand::new(ssh_client_executable()); + ssh.args(ssh_client_arguments(&access)).args(command); + run_ssh_client(ssh) +} + +fn ssh_client_executable() -> String { + format!("ssh{}", std::env::consts::EXE_SUFFIX) +} + +/// Arguments that point the OpenSSH client at an Agent's generated alias. +fn ssh_client_arguments(access: &agent::ssh::AccessInfo) -> [&std::ffi::OsStr; 3] { + ["-F".as_ref(), access.config_file.as_os_str(), access.alias.as_ref()] +} + +#[cfg(unix)] +fn run_ssh_client(mut ssh: ProcessCommand) -> CommandResult { + use std::os::unix::process::CommandExt as _; + + Err(ssh_client_error(&ssh.exec())) +} + +#[cfg(not(unix))] +fn run_ssh_client(mut ssh: ProcessCommand) -> CommandResult { + let status = ssh.status().map_err(|error| ssh_client_error(&error))?; + Ok(status.code().map_or(ExitCode::FAILURE, exit_code)) +} + +fn ssh_client_error(error: &std::io::Error) -> CommandError { + CommandError::Message(ssh_client_failure(error)) +} + +/// Why the OpenSSH client could not be started. +fn ssh_client_failure(error: &std::io::Error) -> String { + if error.kind() == std::io::ErrorKind::NotFound { + "the OpenSSH client `ssh` was not found on PATH; install OpenSSH".into() + } else { + format!("could not run the OpenSSH client: {error}") + } +} + +/// Relays one SSH connection over standard input and output; the `ProxyCommand` entry point. +/// +/// Progress and errors go to standard error, which `ssh` shows to the user; +/// standard output carries only the SSH byte stream. +async fn ssh_proxy(home: &ControlPlaneHome, client: &Client, resource: String) -> CommandResult { + let agent = resolve_execution_agent(client, Some(resource), None, None).await?; + let wait = progress::Wait::start(); + let target = wait + .until( + client, + &agent, + client.ensure_execution(&agent, WaitPolicy::UntilConverged), + ) + .await?; + forward::relay_guest_port( + home.path(), + &target.sandbox, + agent::ssh::GUEST_PORT, + tokio::io::stdin(), + tokio::io::stdout(), + ) + .await?; + // A blocked standard-input read would keep the runtime from shutting down; + // the relay is finished, so leave immediately. + std::process::exit(0) +} + +fn install_ssh_config(home: &ControlPlaneHome) -> CommandResult<()> { + let include = agent::ssh::UserInclude::for_home(home)?; + let (line, user_config) = (&include.line, include.user_config.display()); + match include.install()? { + agent::ssh::IncludeOutcome::Installed => println!("added `{line}` at the top of {user_config}"), + agent::ssh::IncludeOutcome::AlreadyInstalled => println!("{user_config} already contains `{line}`"), + } + Ok(()) +} + +async fn ssh_info( + client: &Client, + resource: Option, + agent: Option, + variant: Option, + output: OutputFormat, +) -> CommandResult<()> { + let agent = resolve_execution_agent(client, resource, agent, variant).await?; + let access = client.ssh_access(&agent).await?; + match output { + OutputFormat::Json => print_json(&access)?, + OutputFormat::Table => { + for line in format::ssh_access_lines(&access) { + println!("{line}"); + } + } + } + Ok(()) +} + +/// How one `agentctl vnc` invocation should expose the desktop. +struct VncOptions { + /// Local port to listen on; a free port when omitted. + port: Option, + /// Forward the browser-based viewer rather than the raw RFB port. + web: bool, + /// Also hand the address to whichever local application handles its scheme. + open: bool, +} + +/// Forwards the Agent's desktop to a local port and holds it open. +/// +/// The Agent is converged first, so the platform-owned bridge from the guest +/// port to the image's display socket exists before anything dials it. +async fn vnc( + home: &ControlPlaneHome, + client: &Client, + resource: Option, + agent: Option, + variant: Option, + options: VncOptions, +) -> CommandResult { + let VncOptions { port, web, open } = options; + let agent = resolve_execution_agent(client, resource, agent, variant).await?; + // Refuses early, with the remedy, when the Agent declares no VNC access. + client.vnc_access(&agent).await?; + let wait = progress::Wait::start(); + let target = wait + .until( + client, + &agent, + client.ensure_execution(&agent, WaitPolicy::UntilConverged), + ) + .await?; + // Read again now the Agent is Ready: which ports its image offers is something a + // reconciliation pass observes, so before converging the browser viewer's port is unknown + // rather than absent. + let access = client.vnc_access(&agent).await?; + let guest_port = if web { + access.web_guest_port.ok_or_else(|| { + Error::Invalid(format!( + "the image of Agent {agent:?} serves no browser viewer; use `agentctl vnc {agent}` with a VNC client" + )) + })? + } else { + access.guest_port + }; + let spec = forward::ForwardSpec { + address: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + // A free port by default: 5900 and 6080 are often taken locally, by a screen-sharing + // server or by another `agentctl vnc`, and the address is printed either way. + local_port: port.unwrap_or(0), + guest_port, + }; + let forward = forward::PortForward::start(home.path().to_path_buf(), target.sandbox.clone(), spec).await?; + let address = forward.local_address(); + // The image decides what its viewer port serves and where the root redirects, so the caller is + // pointed at the root rather than a path this side would have to keep in step with it. + let url = launch::forward_url(address, guest_port); + println!("Desktop of agent {agent:?} is at {url}"); + if web { + println!("Open that address in a browser; nothing needs installing."); + } else { + println!("Open it with any VNC viewer, for example `vncviewer {address}`, or pass --web for a browser."); + } + if open { + open_locally(&url).await; + } + hold_forwards(std::slice::from_ref(&forward)).await +} + +/// Hands the address to whichever local application handles its scheme. +/// +/// Best effort by design: there is no portable VNC viewer, the address is +/// already printed, and a missing handler must not fail the forward. +async fn open_locally(url: &str) { + match launch::open_url(url).await { + Ok(()) => println!("Asked {} to open {url}.", launch::opener()), + Err(error) => eprintln!("{error}"), + } +} + +/// Relays one desktop connection over standard input and output. +/// +/// This is the seam for a viewer that dials through a command rather than a +/// port, and for tooling that wants the RFB stream without a listening socket. +async fn vnc_proxy(home: &ControlPlaneHome, client: &Client, resource: String) -> CommandResult { + let agent = resolve_execution_agent(client, Some(resource), None, None).await?; + let access = client.vnc_access(&agent).await?; + let wait = progress::Wait::start(); + let target = wait + .until( + client, + &agent, + client.ensure_execution(&agent, WaitPolicy::UntilConverged), + ) + .await?; + forward::relay_guest_port( + home.path(), + &target.sandbox, + access.guest_port, + tokio::io::stdin(), + tokio::io::stdout(), + ) + .await?; + // A blocked standard-input read would keep the runtime from shutting down; + // the relay is finished, so leave immediately. + std::process::exit(0) +} + +async fn vnc_info( + client: &Client, + resource: Option, + agent: Option, + variant: Option, + output: OutputFormat, +) -> CommandResult<()> { + let agent = resolve_execution_agent(client, resource, agent, variant).await?; + let access = client.vnc_access(&agent).await?; + match output { + OutputFormat::Json => print_json(&access)?, + OutputFormat::Table => { + for line in format::vnc_access_lines(&access) { + println!("{line}"); + } + } + } + Ok(()) +} + +/// Resolves a [`SessionTarget`] into the owning Agent and Session name. +async fn session_target(client: &Client, target: SessionTarget) -> CommandResult<(String, SessionName)> { + let (resource, name) = resource_reference(&target.resource, target.name)?; + if resource != Resource::Session { + return Err(Error::Invalid("this command requires a Session resource".into()).into()); + } + let session = SessionName::new(require_name(name, "Session")?)?; + let agent = resolve_agent_name(client, target.agent, target.variant).await?; + Ok((agent, session)) +} + +async fn create_session( + home: &ControlPlaneHome, + client: &Client, + target: SessionTarget, + selection: SessionSelection, + input: PromptInput, + timeout: Duration, +) -> CommandResult<()> { + let resource = target.resource.clone(); + let request = selection.request(read_prompt_arg(input)?); + let deadline = tokio::time::Instant::now() + timeout; + let timed_out = || { + CommandError::Message(format!( + "timed out creating {resource}; Agent resolution or provisioning did not finish; provisioning may continue" + )) + }; + let (agent, session) = tokio::time::timeout_at(deadline, async { + ensure_daemon(home, client).await?; + session_target(client, target).await + }) + .await + .map_err(|_| timed_out())??; + let wait = progress::Wait::start(); + wait.until( + client, + &agent, + tokio::time::timeout_at( + deadline, + client.ensure_session(&agent, session.clone(), request, WaitPolicy::UntilConverged), + ), + ) + .await + .map_err(|_| timed_out())??; + println!("session/{agent}/{session} ready"); + Ok(()) +} + +async fn prompt_session( + home: &ControlPlaneHome, + client: &Client, + target: SessionTarget, + input: PromptInput, + completion: CompletionOptions, +) -> CommandResult<()> { + ensure_daemon(home, client).await?; + let (agent, session) = session_target(client, target).await?; + let prompt = read_prompt_arg(input)?.ok_or_else(|| Error::Invalid("a prompt is required".into()))?; + client + .prompt_session( + &agent, + session.clone(), + prompt, + completion.wait, + completion.wait.then_some(completion.timeout), + ) + .await?; + println!("session/{agent}/{session} prompted"); + Ok(()) +} + +async fn set_archived(client: &Client, target: SessionTarget, archived: bool) -> CommandResult<()> { + let (agent, name) = session_target(client, target).await?; + let session = client.set_session_archived(&agent, name.clone(), archived).await?; + if !archived { + println!("session/{agent}/{name} unarchived"); + } else if session.status.state == agent::sessions::State::Archived { + println!("session/{agent}/{name} archived"); + } else { + println!("session/{agent}/{name} archived; its harness stops once it is idle"); + } + Ok(()) +} + +async fn turns(client: &Client, target: SessionTarget, last: Option) -> CommandResult<()> { + let (agent, session) = session_target(client, target).await?; + print_turns(&client.session_turns(&agent, session, last).await?); + Ok(()) +} + +/// Resolves the prompt from --prompt, --file, or piped standard input. +/// +/// With neither flag and an interactive terminal there is no prompt. +fn read_prompt_arg(input: PromptInput) -> CommandResult> { + if let Some(prompt) = input.prompt { + return Ok(Some(prompt)); + } + if let Some(file) = input.file { + return Ok(Some(std::fs::read_to_string(&file).map_err(Error::from)?)); + } + if !std::io::stdin().is_terminal() { + let mut buffer = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut buffer).map_err(Error::from)?; + if !buffer.is_empty() { + return Ok(Some(buffer)); + } + } + Ok(None) +} + +fn print_turns(turns: &[agent::sessions::Turn]) { + if turns.is_empty() { + eprintln!("No turns yet."); + } + for line in format::turn_lines(turns) { + println!("{line}"); + } +} + +async fn resolve_execution_agent( + client: &Client, + resource: Option, + explicit: Option, + variant: Option, +) -> CommandResult { + if let Some(explicit) = explicit { + return Ok(explicit); + } + let Some(resource) = resource else { + return resolve_agent_name(client, None, variant).await; + }; + if !resource.contains('/') { + return Ok(resource); + } + let (kind, name) = resource_reference(&resource, None)?; + if kind != Resource::Agent { + return Err(Error::Invalid("this command requires an Agent resource".into()).into()); + } + require_name(name, "Agent").map_err(CommandError::from) +} + +async fn stream_execution( + mut execution: ::sandbox::execution::StartedExecution, +) -> Result<::sandbox::execution::ExitStatus, Error> { + let id = execution.id.clone(); + let mut stdout = tokio::io::stdout(); + let mut stderr = tokio::io::stderr(); + while let Some(event) = execution.events.next().await { + match event? { + ExecutionEvent::Started { .. } => {} + ExecutionEvent::Stdout(bytes) => stdout.write_all(&bytes).await?, + ExecutionEvent::Stderr(bytes) => stderr.write_all(&bytes).await?, + ExecutionEvent::Exited(status) => { + stdout.flush().await?; + stderr.flush().await?; + return Ok(status); + } + ExecutionEvent::Failed { message } => { + return Err(::sandbox::Error::ExecutionFailed { id, message }.into()); + } + _ => { + return Err(Error::Sandbox(::sandbox::Error::Backend( + "unsupported Execution event".into(), + ))); + } + } + } + Err(::sandbox::Error::ExecutionStreamEnded { id }.into()) +} + +fn exit_code(code: i32) -> ExitCode { + u8::try_from(code).map_or(ExitCode::FAILURE, ExitCode::from) +} + +async fn describe(client: &Client, resource: &str, name: Option, output: OutputFormat) -> CommandResult<()> { + let (resource, name) = resource_reference(resource, name)?; + if resource != Resource::Agent { + return Err(Error::Invalid("describe currently supports only Agent resources".into()).into()); + } + let agent = client.get(&require_name(name, "Agent")?).await?; + match output { + OutputFormat::Json => print_json(&agent)?, + OutputFormat::Table => print_agent_description(&agent), + } + Ok(()) +} + +async fn wait( + client: &Client, + condition: &str, + timeout: Duration, + resource: &str, + name: Option, +) -> CommandResult<()> { + let (resource, name) = resource_reference(resource, name)?; + if resource != Resource::Agent { + return Err(Error::Invalid("wait currently supports only Agent resources".into()).into()); + } + if condition != "condition=Ready" { + return Err(Error::Invalid("only --for=condition=Ready is supported".into()).into()); + } + let name = require_name(name, "Agent")?; + // A stopped Agent is never Ready, also while its stop is still in progress. + if client.get(&name).await?.spec.is_stopped() + || wait_until_converged(client, &name, timeout).await?.spec.is_stopped() + { + return Err(Error::Stopped(name).into()); + } + println!("agent/{name} condition met"); + Ok(()) +} + +/// Resolves `agent/NAME` or `agent NAME` for a verb that acts only on Agents. +fn agent_reference(resource: &str, name: Option) -> Result { + let (resource, name) = resource_reference(resource, name)?; + if resource != Resource::Agent { + return Err(Error::Invalid("this command requires an Agent resource".into())); + } + require_name(name, "Agent") +} + +fn resource_reference(resource: &str, name: Option) -> Result<(Resource, Option), Error> { + let (kind, embedded_name) = resource.split_once('/').map_or((resource, None), |(kind, name)| { + (kind, (!name.is_empty()).then(|| name.to_owned())) + }); + if resource.matches('/').count() > 1 || (resource.contains('/') && embedded_name.is_none()) { + return Err(Error::Invalid("resource references must use TYPE/NAME".into())); + } + if embedded_name.is_some() && name.is_some() { + return Err(Error::Invalid("resource name was supplied twice".into())); + } + let resource = match kind.to_ascii_lowercase().as_str() { + "agent" | "agents" | "ag" => Resource::Agent, + "session" | "sessions" => Resource::Session, + _ => return Err(Error::Invalid(format!("unknown resource type {kind:?}"))), + }; + Ok((resource, embedded_name.or(name))) +} + +fn require_name(name: Option, resource: &str) -> Result { + name.ok_or_else(|| Error::Invalid(format!("{resource} name is required"))) +} + +fn reject_session_scope( + agent: Option<&str>, + variant: Option<&AgentVariantName>, + all_agents: bool, +) -> Result<(), Error> { + if agent.is_some() || variant.is_some() || all_agents { + Err(Error::Invalid( + "--agent, --variant, and --all-agents apply only to Session resources".into(), + )) + } else { + Ok(()) + } +} + +async fn resolve_agent_name( + client: &Client, + explicit: Option, + variant: Option, +) -> CommandResult { + if let Some(agent) = explicit { + return Ok(agent); + } + let directory = std::env::current_dir().map_err(Error::from)?; + match client.resolve_agent_variant(directory, variant).await { + Ok(agent) => Ok(agent.metadata.name), + Err(error) => Err(inference_error(error)), + } +} + +fn inference_error(error: Error) -> CommandError { + match error { + Error::Rpc(error) if error.is_not_found() => CommandError::Message( + "no Agent was applied from the current directory; specify --agent or --variant".into(), + ), + Error::Rpc(error) => CommandError::Message(error.message), + error => error.into(), + } +} + +/// Follows an Agent's convergence with live progress until it has its desired +/// run state, Ready or stopped, and returns it then; or until a terminal +/// error, the timeout, or Ctrl-C. +async fn wait_until_converged(client: &Client, name: &str, timeout: Duration) -> CommandResult { + let waited = progress::Wait::start() + .until(client, name, tokio::time::timeout(timeout, client.converge(name))) + .await; + let Ok(converged) = waited else { + return Err(CommandError::Message(match client.get(name).await.ok() { + Some(agent) if agent.spec.is_stopped() => { + format!("timed out waiting for Agent {name:?} to stop; agentd keeps stopping it") + } + agent => wait_timeout_message(name, agent.as_ref().and_then(|agent| agent.status.ready_condition())), + })); + }; + converged.map_err(CommandError::from) +} + +/// Stops or starts an Agent and waits until it is stopped or Ready. +async fn set_run_state(client: &Client, target: RunStateTarget, state: RunState) -> CommandResult<()> { + let name = agent_reference(&target.resource, target.name)?; + client.set_run_state(&name, state).await?; + let converged = wait_until_converged(client, &name, target.timeout).await?; + match (state, converged.spec.run_state()) { + (RunState::Stopped, RunState::Stopped) => println!("agent/{name} stopped"), + (RunState::Running, RunState::Running) => println!("agent/{name} started"), + (_, current) => { + return Err(CommandError::Message(format!( + "Agent {name:?} was set to {current:?} again before it finished" + ))); + } + } + Ok(()) +} + +fn wait_timeout_message(name: &str, ready: Option<&agent::Condition>) -> String { + let Some(ready) = ready else { + return format!("timed out waiting for Agent {name:?} to become Ready; no Ready condition was reported"); + }; + format!( + "timed out waiting for Agent {name:?} to become Ready: {}", + ready.summary() + ) +} + +fn parse_duration(value: &str) -> Result { + let (number, multiplier) = match value.as_bytes().last() { + Some(b's') => (&value[..value.len() - 1], 1), + Some(b'm') => (&value[..value.len() - 1], 60), + Some(b'h') => (&value[..value.len() - 1], 60 * 60), + _ => return Err("duration must end in s, m, or h".into()), + }; + let number = number + .parse::() + .map_err(|_| "duration must contain a positive whole number".to_string())?; + if number == 0 { + return Err("duration must be greater than zero".into()); + } + Ok(Duration::from_secs(number.saturating_mul(multiplier))) +} + +fn parse_harness(value: &str) -> Result { + value.parse().map_err(|error: Error| error.to_string()) +} + +fn parse_model(value: &str) -> Result { + value.parse().map_err(|error: Error| error.to_string()) +} + +fn parse_effort(value: &str) -> Result { + value.parse().map_err(|error: Error| error.to_string()) +} + +fn parse_variant_name(value: &str) -> Result { + value.parse().map_err(|error: Error| error.to_string()) +} + +fn print_agents(agents: &[Agent]) { + let rows = agents + .iter() + .map(|agent| { + let ready = agent.status.ready_condition(); + let ready_value = ready.map_or("Unknown", |condition| condition_status(condition.status)); + let status = if agent.metadata.deletion_timestamp.is_some() { + "Terminating" + } else { + ready.map_or("Pending", |condition| condition.reason.as_str()) + }; + let harnesses = format_harnesses(&agent.spec); + let provider = agent + .status + .sandbox + .as_ref() + .map_or("-", |assignment| assignment.provider().as_str()); + vec![ + agent.metadata.name.clone(), + ready_value.into(), + status.into(), + harnesses, + provider.into(), + ] + }) + .collect::>(); + print_table(&["NAME", "READY", "STATUS", "HARNESSES", "PROVIDER"], &rows); +} + +fn print_agent_description(agent: &Agent) { + for line in format::describe_agent_lines(agent) { + println!("{line}"); + } +} + +fn print_sessions(sessions: &[Session], show_agent: bool) { + let rows = sessions + .iter() + .map(|session| { + let mut row = Vec::new(); + if show_agent { + row.push(session.agent.clone()); + } + row.extend([ + session.name.as_str().to_owned(), + session.harness.as_str().into(), + session.model_selection.model_str().unwrap_or("-").into(), + session.model_selection.effort_str().unwrap_or("-").into(), + session_state(session.status.state).into(), + format_age(session.created_at), + ]); + row + }) + .collect::>(); + let headers = if show_agent { + vec!["AGENT", "NAME", "HARNESS", "MODEL", "EFFORT", "STATE", "AGE"] + } else { + vec!["NAME", "HARNESS", "MODEL", "EFFORT", "STATE", "AGE"] + }; + print_table(&headers, &rows); +} + +fn print_table(headers: &[&str], rows: &[Vec]) { + if rows.is_empty() { + eprintln!("No resources found."); + return; + } + for line in format::table_lines(headers, rows) { + println!("{line}"); + } +} + +async fn ensure_daemon(home: &ControlPlaneHome, client: &Client) -> Result<(), Error> { + if let Ok(daemon) = client.health().await { + return daemon.require_compatible(); + } + let mut daemon = spawn_daemon(home)?; + for _ in 0..100 { + tokio::time::sleep(Duration::from_millis(100)).await; + if let Ok(daemon) = client.health().await { + return daemon.require_compatible(); + } + if let Some(status) = daemon.try_wait()? { + return Err(Error::Daemon(format!( + "automatic startup exited with {status}; {}", + daemon_startup_diagnostics(home) + ))); + } + } + Err(Error::Daemon(format!( + "automatic startup did not become ready within 10 seconds; {}", + daemon_startup_diagnostics(home) + ))) +} + +fn daemon_startup_diagnostics(home: &ControlPlaneHome) -> String { + let log = home.daemon_log_path(); + let marker = home.pending_session_relaunch_path(); + if marker.exists() { + format!( + "see {}; pending post-upgrade Session relaunch: {}", + log.display(), + marker.display() + ) + } else { + format!("see {}", log.display()) + } +} + +fn spawn_daemon(home: &ControlPlaneHome) -> Result { + home.prepare()?; + let log = home.open_daemon_log()?; + let executable = daemon_executable(&std::env::current_exe()?); + let mut command = ProcessCommand::new(executable); + command + .arg("--home") + .arg(home.path()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(log); + agent::local::process::configure_detached(&mut command); + agent::local::process::configure_logging(&mut command); + command.spawn().map_err(Error::from) +} + +fn daemon_executable(agentctl: &Path) -> PathBuf { + agentctl.with_file_name(format!("agentd{}", std::env::consts::EXE_SUFFIX)) +} + +fn apply_manifest_path(filename: Option, variant: Option) -> Result { + match (filename, variant) { + (Some(filename), None) => Ok(filename), + (None, Some(variant)) => Ok(PathBuf::from(variant.filename())), + (None, None) => Ok(PathBuf::from(manifest::MANIFEST_FILE)), + (Some(_), Some(_)) => Err(Error::Invalid("--filename and --variant are mutually exclusive".into())), + } +} + +fn read_apply_request(filename: PathBuf, env_file: Option) -> Result { + let filename = absolute(filename)?; + let env_file = env_file.map(absolute).transpose()?; + let agent = manifest::resolve(&filename)?.agent; + let source_directory = filename + .parent() + .ok_or_else(|| Error::Invalid("manifest path has no parent directory".into()))? + .to_path_buf(); + Ok(ApplyRequest { + source_directory, + manifest_path: Some(filename), + env_file, + create_only: false, + agent, + }) +} + +fn read_token_from_stdin() -> Result, Error> { + let mut line = zeroize::Zeroizing::new(String::new()); + std::io::stdin() + .read_line(&mut line) + .map_err(|error| Error::Invalid(format!("could not read the token from standard input: {error}")))?; + let token = zeroize::Zeroizing::new(line.trim().to_owned()); + if token.is_empty() { + return Err(Error::Invalid("no token was provided on standard input".into())); + } + Ok(token) +} + +fn read_stdin_to_end() -> Result, Error> { + use std::io::Read as _; + + let mut text = zeroize::Zeroizing::new(String::new()); + std::io::stdin() + .read_to_string(&mut text) + .map_err(|error| Error::Invalid(format!("could not read the credential from standard input: {error}")))?; + if text.trim().is_empty() { + return Err(Error::Invalid("no credential was provided on standard input".into())); + } + Ok(text) +} + +fn absolute(path: PathBuf) -> Result { + if path.is_absolute() { + Ok(path) + } else { + Ok(std::env::current_dir()?.join(path)) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::*; + + struct StalledConnector { + healthy: bool, + } + + struct PreviewOneConnector; + + impl agent::control_api::Connector for PreviewOneConnector { + fn connect(&self) -> sandbox::LocalFuture<'_, Result, Error>> { + Box::pin(async { + use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _}; + let (client, server) = tokio::io::duplex(4096); + tokio::task::spawn_local(async move { + let mut server = tokio::io::BufReader::new(server); + let mut request = String::new(); + server.read_line(&mut request).await.expect("request"); + let request: serde_json::Value = serde_json::from_str(&request).expect("RPC"); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": {"protocolVersion": "v1"} + }); + server + .write_all(format!("{response}\n").as_bytes()) + .await + .expect("response"); + }); + Ok(Box::new(client) as Box) + }) + } + } + + #[tokio::test(flavor = "local")] + async fn incompatible_daemon_is_reported_without_starting_another() { + let directory = tempfile::TempDir::new().expect("temporary home"); + let home = ControlPlaneHome::resolve(Some(directory.path())).expect("home"); + let client = Client::new(std::rc::Rc::new(PreviewOneConnector)); + let error = ensure_daemon(&home, &client) + .await + .expect_err("preview daemon is incompatible"); + assert!(error.to_string().contains("protocol Some(\"v1\")")); + assert!(!home.daemon_log_path().exists(), "no second daemon was spawned"); + } + + #[test] + fn startup_diagnostics_identify_a_pending_session_relaunch() { + let directory = tempfile::TempDir::new().expect("temporary home"); + let home = ControlPlaneHome::resolve(Some(directory.path())).expect("home"); + std::fs::write(home.pending_session_relaunch_path(), "pending").expect("marker"); + + let diagnostic = daemon_startup_diagnostics(&home); + + assert!(diagnostic.contains(&home.daemon_log_path().display().to_string())); + assert!(diagnostic.contains(&home.pending_session_relaunch_path().display().to_string())); + } + + impl agent::control_api::Connector for StalledConnector { + fn connect(&self) -> sandbox::LocalFuture<'_, Result, Error>> { + Box::pin(async move { + use tokio::io::AsyncBufReadExt as _; + if !self.healthy { + return std::future::pending().await; + } + let (client, server) = tokio::io::duplex(4096); + tokio::task::spawn_local(async move { + let mut server = tokio::io::BufReader::new(server); + let mut line = String::new(); + server.read_line(&mut line).await.expect("request"); + let request: serde_json::Value = serde_json::from_str(&line).expect("RPC"); + if request["method"] == "control.v1.health" { + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": { + "protocolVersion": agent::control_api::PROTOCOL_VERSION, + "buildVersion": agent::build_version() + } + }); + server + .write_all(format!("{response}\n").as_bytes()) + .await + .expect("health response"); + } else { + std::future::pending::<()>().await; + drop(server); + } + }); + Ok(Box::new(client) as Box) + }) + } + } + + #[tokio::test(flavor = "local", start_paused = true)] + async fn create_stops_waiting_at_its_deadline() { + for (owner, healthy) in [(Some("worker"), false), (Some("worker"), true), (None, true)] { + let client = Client::new(std::rc::Rc::new(StalledConnector { healthy })); + let directory = tempfile::TempDir::new().expect("temporary home"); + let home = ControlPlaneHome::resolve(Some(directory.path())).expect("home"); + let result = tokio::time::timeout( + Duration::from_secs(2), + create_session( + &home, + &client, + SessionTarget { + resource: "session/s1".into(), + name: None, + agent: owner.map(str::to_owned), + variant: None, + }, + SessionSelection::default(), + PromptInput { + prompt: Some("go".into()), + file: None, + }, + Duration::from_secs(1), + ), + ) + .await + .expect("the command's own deadline must include Agent inference"); + assert!(matches!(result, Err(CommandError::Message(message)) if message.contains("timed out"))); + } + } + + struct DelayedHealthConnector { + remaining: std::rc::Rc>>, + } + + impl agent::control_api::Connector for DelayedHealthConnector { + fn connect(&self) -> sandbox::LocalFuture<'_, Result, Error>> { + Box::pin(async move { + use tokio::io::AsyncBufReadExt as _; + let (client, server) = tokio::io::duplex(4096); + let remaining = self.remaining.clone(); + tokio::task::spawn_local(async move { + let mut server = tokio::io::BufReader::new(server); + let mut line = String::new(); + server.read_line(&mut line).await.expect("request"); + let request: serde_json::Value = serde_json::from_str(&line).expect("RPC"); + if request["method"] == "control.v1.health" { + tokio::time::sleep(Duration::from_millis(600)).await; + } else { + assert_eq!(request["method"], "sessions.v1.prompt"); + remaining.set(Some( + serde_json::from_value(request["params"]["timeout"].clone()).expect("timeout"), + )); + } + let result = if request["method"] == "control.v1.health" { + serde_json::json!({ + "protocolVersion": agent::control_api::PROTOCOL_VERSION, + "buildVersion": agent::build_version() + }) + } else { + serde_json::json!({}) + }; + let response = serde_json::json!({"jsonrpc":"2.0", "id":request["id"], "result":result}); + server + .write_all(format!("{response}\n").as_bytes()) + .await + .expect("response"); + }); + Ok(Box::new(client) as Box) + }) + } + } + + #[tokio::test(flavor = "local", start_paused = true)] + async fn prompt_setup_does_not_consume_the_completion_timeout() { + let remaining = std::rc::Rc::new(std::cell::Cell::new(None)); + let client = Client::new(std::rc::Rc::new(DelayedHealthConnector { + remaining: remaining.clone(), + })); + let directory = tempfile::TempDir::new().expect("home"); + let home = ControlPlaneHome::resolve(Some(directory.path())).expect("home"); + prompt_session( + &home, + &client, + SessionTarget { + resource: "session/s1".into(), + name: None, + agent: Some("worker".into()), + variant: None, + }, + PromptInput { + prompt: Some("go".into()), + file: None, + }, + CompletionOptions { + wait: true, + timeout: Duration::from_secs(1), + }, + ) + .await + .expect("prompt"); + assert_eq!(remaining.get(), Some(Duration::from_secs(1))); + } + + #[test] + fn create_accepts_a_bounded_wait() { + assert!(Arguments::try_parse_from(["agentctl", "create", "session/s1", "--timeout", "1s"]).is_ok()); + } + + #[test] + fn session_creation_commands_accept_optional_model_and_effort() { + for verb in ["create", "attach"] { + let arguments = Arguments::try_parse_from([ + "agentctl", + verb, + "session/s1", + "--model", + "claude-fable-5", + "--effort", + "xhigh", + ]) + .expect("model and effort parse"); + let (Command::Create { selection, .. } | Command::Attach { selection, .. }) = arguments.command else { + panic!("expected a Session creation command"); + }; + let request = selection.request(None); + assert_eq!(request.model_selection.model_str(), Some("claude-fable-5")); + assert_eq!(request.model_selection.effort_str(), Some("xhigh")); + assert_eq!(request.harness, None); + + let omitted = Arguments::try_parse_from(["agentctl", verb, "session/s1"]).expect("omitted selections"); + let (Command::Create { selection, .. } | Command::Attach { selection, .. }) = omitted.command else { + panic!("expected a Session creation command"); + }; + assert_eq!(selection.request(None), SessionRequest::default()); + + let Err(error) = Arguments::try_parse_from(["agentctl", verb, "session/s1", "--model", ""]) else { + panic!("an empty model is rejected before reaching the daemon"); + }; + assert!(error.to_string().contains("model must be 1-128"), "{error}"); + assert!(Arguments::try_parse_from(["agentctl", verb, "session/s1", "--effort", "very high"]).is_err()); + } + } + + #[test] + fn resource_references_follow_kubectl_shapes_and_aliases() { + assert_eq!( + resource_reference("agents", None).expect("Agent collection"), + (Resource::Agent, None) + ); + assert_eq!( + resource_reference("ag/worker", None).expect("Agent reference"), + (Resource::Agent, Some("worker".into())) + ); + assert_eq!( + resource_reference("session", Some("s1".into())).expect("Session reference"), + (Resource::Session, Some("s1".into())) + ); + assert!(resource_reference("agent/worker", Some("other".into())).is_err()); + assert!(resource_reference("pods", None).is_err()); + } + + #[test] + fn exec_accepts_kubectl_style_interactive_and_inferred_shapes() { + let explicit = Arguments::try_parse_from(["agentctl", "exec", "-it", "agent/worker", "--", "bash", "-l"]) + .expect("interactive exec arguments"); + let Command::Exec { + stdin, + tty, + resource, + agent, + command, + .. + } = explicit.command + else { + panic!("expected exec command"); + }; + assert!(stdin); + assert!(tty); + assert_eq!(resource.as_deref(), Some("agent/worker")); + assert!(agent.is_none()); + assert_eq!(command, ["bash", "-l"]); + + let inferred = Arguments::try_parse_from(["agentctl", "exec", "--", "pwd"]).expect("inferred exec arguments"); + let Command::Exec { resource, command, .. } = inferred.command else { + panic!("expected exec command"); + }; + assert!(resource.is_none()); + assert_eq!(command, ["pwd"]); + } + + #[test] + fn port_forward_accepts_kubectl_shapes_and_inference() { + let explicit = Arguments::try_parse_from(["agentctl", "port-forward", "agent/worker", "9090:80", ":5432"]) + .expect("explicit port-forward arguments"); + let Command::PortForward { agent, arguments, .. } = explicit.command else { + panic!("expected port-forward command"); + }; + assert!(agent.is_none()); + assert_eq!( + split_forward_arguments(&arguments), + (Some("agent/worker".into()), &arguments[1..]) + ); + + let inferred = + Arguments::try_parse_from(["agentctl", "port-forward", "8080"]).expect("inferred port-forward arguments"); + let Command::PortForward { arguments, .. } = inferred.command else { + panic!("expected port-forward command"); + }; + assert_eq!(split_forward_arguments(&arguments), (None, arguments.as_slice())); + + let flagged = Arguments::try_parse_from(["agentctl", "port-forward", "--agent", "worker", "0.0.0.0:80:80"]) + .expect("flagged port-forward arguments"); + let Command::PortForward { agent, arguments, .. } = flagged.command else { + panic!("expected port-forward command"); + }; + assert_eq!(agent.as_deref(), Some("worker")); + assert_eq!(split_forward_arguments(&arguments), (None, arguments.as_slice())); + } + + #[test] + fn vnc_commands_accept_kubectl_shapes_and_a_chosen_local_port() { + let explicit = Arguments::try_parse_from(["agentctl", "vnc", "agent/worker"]).expect("vnc"); + let Command::Vnc { + agent, + variant, + resource, + port, + web, + open, + } = explicit.command + else { + panic!("expected vnc command"); + }; + assert_eq!(resource.as_deref(), Some("agent/worker")); + assert!(agent.is_none() && variant.is_none()); + assert!(port.is_none(), "a free local port is chosen unless one is asked for"); + assert!(!web, "the raw RFB port is forwarded unless a browser is asked for"); + assert!(!open, "a viewer is launched only when asked for"); + + let chosen = Arguments::try_parse_from(["agentctl", "vnc", "--port", "5901", "--open"]).expect("chosen port"); + assert!( + matches!( + chosen.command, + Command::Vnc { + port: Some(5901), + open: true, + resource: None, + .. + } + ), + "the Agent is inferred and a chosen local port is kept" + ); + + let browser = Arguments::try_parse_from(["agentctl", "vnc", "--web"]).expect("web vnc"); + assert!( + matches!( + browser.command, + Command::Vnc { + web: true, + port: None, + .. + } + ), + "--web forwards the viewer port instead of the RFB port" + ); + + // The Agent is named once, the same rule the ssh commands follow. + assert!(Arguments::try_parse_from(["agentctl", "vnc", "--agent", "worker", "agent/other"]).is_err()); + + let proxy = Arguments::try_parse_from(["agentctl", "vnc-proxy", "agent/worker"]).expect("vnc-proxy"); + assert!(matches!(proxy.command, Command::VncProxy { resource } if resource == "agent/worker")); + assert!(Arguments::try_parse_from(["agentctl", "vnc-proxy"]).is_err()); + + let info = Arguments::try_parse_from(["agentctl", "vnc-info", "worker", "-o", "json"]).expect("vnc-info"); + assert!(matches!( + info.command, + Command::VncInfo { + resource: Some(resource), + output: OutputFormat::Json, + .. + } if resource == "worker" + )); + } + + #[test] + fn ssh_commands_accept_kubectl_shapes_and_remote_commands() { + let explicit = Arguments::try_parse_from(["agentctl", "ssh", "agent/worker", "--", "uptime", "-p"]) + .expect("ssh with a remote command"); + let Command::Ssh { + agent, + resource, + command, + .. + } = explicit.command + else { + panic!("expected ssh command"); + }; + assert!(agent.is_none()); + assert_eq!(resource.as_deref(), Some("agent/worker")); + assert_eq!(command, ["uptime", "-p"]); + + let inferred = Arguments::try_parse_from(["agentctl", "ssh"]).expect("inferred ssh"); + assert!(matches!( + inferred.command, + Command::Ssh { + agent: None, + resource: None, + command, + .. + } if command.is_empty() + )); + assert!(Arguments::try_parse_from(["agentctl", "ssh", "--agent", "worker", "agent/other"]).is_err()); + + let proxy = Arguments::try_parse_from(["agentctl", "ssh-proxy", "agent/worker"]).expect("ssh-proxy"); + assert!(matches!(proxy.command, Command::SshProxy { resource } if resource == "agent/worker")); + assert!(Arguments::try_parse_from(["agentctl", "ssh-proxy"]).is_err()); + + let install = Arguments::try_parse_from(["agentctl", "ssh-config", "install"]).expect("ssh-config install"); + assert!(matches!( + install.command, + Command::SshConfig { + command: SshConfigCommand::Install + } + )); + + let info = Arguments::try_parse_from(["agentctl", "ssh-info", "worker", "-o", "json"]).expect("ssh-info"); + assert!(matches!( + info.command, + Command::SshInfo { + resource: Some(resource), + agent: None, + output: OutputFormat::Json, + .. + } if resource == "worker" + )); + } + + #[test] + fn codex_login_uses_an_isolated_chatgpt_grant() { + let arguments = + Arguments::try_parse_from(["agentctl", "codex", "login"]).expect("Codex ChatGPT login arguments"); + assert!(matches!( + arguments.command, + Command::Codex { + command: CodexCommand::Login { from_stdin: false } + } + )); + assert!(Arguments::try_parse_from(["agentctl", "codex", "login", "--with-api-key"]).is_err()); + let nested = Arguments::try_parse_from(["agentctl", "codex", "login", "--from-stdin"]) + .expect("Codex credential-file login arguments"); + assert!(matches!( + nested.command, + Command::Codex { + command: CodexCommand::Login { from_stdin: true } + } + )); + } + + #[test] + fn claude_login_accepts_a_token_on_standard_input() { + let arguments = + Arguments::try_parse_from(["agentctl", "claude", "login", "--from-stdin"]).expect("Claude login arguments"); + assert!(matches!( + arguments.command, + Command::Claude { + command: ClaudeCommand::Login { from_stdin: true } + } + )); + } + + #[test] + fn apply_accepts_a_secret_file_outside_the_manifest_directory() { + let arguments = Arguments::try_parse_from([ + "agentctl", + "apply", + "-f", + "agent.yaml", + "--env-file", + "/srv/secrets/worker.env", + ]) + .expect("apply arguments"); + assert!(matches!( + arguments.command, + Command::Apply { env_file: Some(path), .. } if path == Path::new("/srv/secrets/worker.env") + )); + } + + #[test] + fn apply_wait_is_opt_in_and_owns_the_timeout() { + let plain = Arguments::try_parse_from(["agentctl", "apply", "-f", "agent.yaml"]).expect("plain apply"); + assert!(matches!(plain.command, Command::Apply { wait: false, .. })); + let waited = Arguments::try_parse_from(["agentctl", "apply", "-f", "agent.yaml", "--wait", "--timeout", "2m"]) + .expect("apply --wait"); + assert!(matches!( + waited.command, + Command::Apply { wait: true, timeout, .. } if timeout == Duration::from_mins(2) + )); + assert!(Arguments::try_parse_from(["agentctl", "apply", "-f", "agent.yaml", "--timeout", "2m"]).is_err()); + } + + #[test] + fn apply_defaults_to_agent_yaml_and_accepts_only_variant_names() { + let default = Arguments::try_parse_from(["agentctl", "apply"]).expect("default apply"); + let Command::Apply { filename, variant, .. } = default.command else { + panic!("expected apply command"); + }; + assert!(filename.is_none()); + assert!(variant.is_none()); + assert_eq!( + apply_manifest_path(filename, variant).expect("default path"), + Path::new("agent.yaml") + ); + + let nested = + Arguments::try_parse_from(["agentctl", "apply", "--variant", "nested-build"]).expect("variant apply"); + let Command::Apply { filename, variant, .. } = nested.command else { + panic!("expected apply command"); + }; + assert_eq!( + apply_manifest_path(filename, variant).expect("variant path"), + Path::new("agent.nested-build.yaml") + ); + assert!(Arguments::try_parse_from(["agentctl", "apply", "-f", "agent.yaml", "--variant", "nested"]).is_err()); + assert!(Arguments::try_parse_from(["agentctl", "apply", "--variant", "../nested"]).is_err()); + assert!( + Arguments::try_parse_from([ + "agentctl", + "exec", + "--agent", + "worker", + "--variant", + "nested", + "--", + "true" + ]) + .is_err() + ); + } + + #[test] + fn wait_durations_are_bounded_and_explicit() { + assert_eq!(parse_duration("30s").expect("seconds"), Duration::from_secs(30)); + assert_eq!(parse_duration("10m").expect("minutes"), Duration::from_mins(10)); + assert_eq!(parse_duration("2h").expect("hours"), Duration::from_hours(2)); + assert!(parse_duration("0s").is_err()); + assert!(parse_duration("forever").is_err()); + } + + #[test] + fn wait_timeout_reports_the_last_ready_diagnostic() { + let condition = agent::Condition { + kind: "Ready".into(), + status: agent::ConditionStatus::False, + reason: "SecretMissing".into(), + message: ".env does not define required variable \"GITHUB_TOKEN\"".into(), + last_transition_time: None, + }; + + assert_eq!( + wait_timeout_message("worker", Some(&condition)), + "timed out waiting for Agent \"worker\" to become Ready: SecretMissing: .env does not define required variable \"GITHUB_TOKEN\"" + ); + } + + #[test] + fn inference_errors_have_one_actionable_message() { + let ambiguous = inference_error(Error::Rpc(agent::control_api::ResponseError { + code: -32602, + message: "multiple Agents were applied from this directory; specify --agent or --variant".into(), + })); + assert_eq!( + ambiguous.to_string(), + "multiple Agents were applied from this directory; specify --agent or --variant" + ); + + let missing = inference_error(Error::Rpc(agent::control_api::ResponseError { + code: -32004, + message: "Agent not found".into(), + })); + assert_eq!( + missing.to_string(), + "no Agent was applied from the current directory; specify --agent or --variant" + ); + } + + #[test] + fn apply_source_is_resolved_in_the_client_working_directory() { + let directory = tempfile::tempdir().expect("temporary directory"); + let manifest_path = directory.path().join("agent.yaml"); + std::fs::copy( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/minimal/agent.yaml"), + &manifest_path, + ) + .expect("copy example manifest"); + let original_directory = std::env::current_dir().expect("current directory"); + std::env::set_current_dir(directory.path()).expect("enter temporary directory"); + + let result = read_apply_request(PathBuf::from("agent.yaml"), None); + + std::env::set_current_dir(original_directory).expect("restore current directory"); + let request = result.expect("read apply request"); + let actual_directory = std::fs::canonicalize(&request.source_directory).expect("canonical source directory"); + let expected_directory = std::fs::canonicalize(directory.path()).expect("canonical temporary directory"); + assert_eq!(actual_directory, expected_directory); + } + + #[test] + fn daemon_binary_is_resolved_beside_agentctl() { + let directory = Path::new("opt").join("agent").join("bin"); + let agentctl = directory.join(format!("agentctl{}", std::env::consts::EXE_SUFFIX)); + let agentd = directory.join(format!("agentd{}", std::env::consts::EXE_SUFFIX)); + assert_eq!(daemon_executable(&agentctl), agentd); + } +} diff --git a/agentctl/src/bin/agentctl/progress.rs b/agentctl/src/bin/agentctl/progress.rs new file mode 100644 index 0000000..4ac3cec --- /dev/null +++ b/agentctl/src/bin/agentctl/progress.rs @@ -0,0 +1,870 @@ +//! Following an Agent's provisioning from a plain terminal. +//! +//! [`Wait`] runs one Control API call while it follows the Agent's progress +//! with `agents.v1.progress` and renders it to stderr. On a terminal the +//! current phase and step live on one updating line and only work that took +//! noticeable time leaves a permanent line, so a warm ensure prints nothing. +//! Without a terminal every completion and output line is printed once. + +use std::{ + cell::RefCell, + collections::VecDeque, + io::{self, IsTerminal as _, Write}, + pin::pin, + time::{Duration, Instant}, +}; + +use agent::{ + FailureKind, + control_api::Client, + progress::{AgentProgress, OutputPosition}, + resources::Revision, +}; +use sandbox::progress::{Measurement, OperationStatus, Outcome, ProgressCursor, ProgressUnit, Update}; + +/// Minimum interval between redraws of the updating line. +const REDRAW_INTERVAL: Duration = Duration::from_millis(50); +/// Completed phases faster than this leave no line on a terminal. +const NOTABLE_PHASE_MS: u64 = 100; +/// Completed steps faster than this leave no line on a terminal. +const NOTABLE_STEP_MS: u64 = 500; +const FALLBACK_WIDTH: usize = 80; +/// Output lines of the failed step shown in the failure report. +const RECENT_OUTPUT_LINES: usize = 5; +/// Longest wait for the state reached when the followed call returned. +const FINAL_READ_TIMEOUT: Duration = Duration::from_millis(500); +/// Pause before following again after a failed progress read. +const RETRY_INTERVAL: Duration = Duration::from_millis(250); + +/// One followed call: owns the renderer for its duration. +/// +/// Ctrl-C is deliberately not handled here. The default SIGINT disposition ends +/// `agentctl` with status 130, the daemon keeps reconciling regardless, and the +/// renderer already tells the user so on the first failure. A handler would +/// outlive the wait (tokio cannot uninstall it) and every later phase of the +/// process would have to emulate the default by hand. +/// +/// ```ignore +/// let wait = Wait::start(); +/// let target = wait +/// .until(client, &agent, client.ensure_execution(&agent, WaitPolicy::UntilConverged)) +/// .await?; +/// ``` +pub(crate) struct Wait { + renderer: RefCell, +} + +impl Wait { + pub(crate) fn start() -> Self { + Self { + renderer: RefCell::new(Renderer::stderr()), + } + } + + /// Runs `call` to completion while rendering `agent`'s progress, then + /// renders the final state and settles the terminal. + pub(crate) async fn until(&self, client: &Client, agent: &str, call: impl Future) -> T { + let mut call = pin!(call); + loop { + let (after, output) = self.renderer.borrow().position(); + let follow = async { + let progress = client.agent_progress(agent, after, output).await.ok(); + if progress.is_none() { + // The Agent may not be stored yet, or the daemon is restarting. + tokio::time::sleep(RETRY_INTERVAL).await; + } + progress + }; + tokio::select! { + biased; + result = &mut call => { + let (_, output) = self.renderer.borrow().position(); + let latest = tokio::time::timeout(FINAL_READ_TIMEOUT, client.agent_progress(agent, None, output)); + if let Ok(Ok(progress)) = latest.await { + self.renderer.borrow_mut().render(&progress); + } + self.renderer.borrow_mut().finish(); + return result; + } + progress = follow => { + if let Some(progress) = progress { + self.renderer.borrow_mut().render(&progress); + } + } + } + } + } +} + +/// Where the renderer writes, which decides between an updating line and plain lines. +#[derive(Clone, Copy)] +enum Mode { + /// An interactive terminal of the given width. + Terminal { width: usize }, + /// A pipe or file: every completion is printed once, nothing is redrawn. + Plain, +} + +pub(crate) struct Renderer { + output: W, + mode: Mode, + active_line: bool, + last_redraw: Option, + last_error: Option, + /// Consecutive failed passes with the current error. + failures: u32, + revision: Option, + /// Pass being rendered and the position in its progress. + pass: Option, + cursor: ProgressCursor, + /// Pass whose failure was already reported. + reported_failure: Option, + /// A step of the current phase left a permanent line on the terminal. + printed_step: bool, + /// Output of the step in progress, shown when it fails. + recent_output: VecDeque, + /// Ready condition already considered, so a standing failure is shown once. + seen_condition: Option, + started: bool, +} + +impl Renderer { + pub(crate) fn stderr() -> Self { + let output = io::stderr(); + let mode = if output.is_terminal() { + Mode::Terminal { + width: crossterm::terminal::size().map_or(FALLBACK_WIDTH, |(columns, _)| usize::from(columns)), + } + } else { + Mode::Plain + }; + Self::new(output, mode) + } +} + +impl Renderer { + const fn new(output: W, mode: Mode) -> Self { + Self { + output, + mode, + active_line: false, + last_redraw: None, + last_error: None, + failures: 0, + revision: None, + pass: None, + cursor: ProgressCursor::new(), + reported_failure: None, + printed_step: false, + recent_output: VecDeque::new(), + seen_condition: None, + started: false, + } + } + + /// Revision to follow from and the output already rendered. + fn position(&self) -> (Option, Option) { + let output = self.pass.map(|pass| OutputPosition { + pass, + sequence: self.cursor.output_sequence(), + }); + (self.revision, output) + } + + pub(crate) fn render(&mut self, progress: &AgentProgress) { + let _ignored = self.render_inner(progress); + } + + pub(crate) fn finish(&mut self) { + let _ignored = self.clear_active_line(); + } + + const fn interactive(&self) -> bool { + matches!(self.mode, Mode::Terminal { .. }) + } + + fn render_inner(&mut self, progress: &AgentProgress) -> io::Result<()> { + let following_began = !self.started; + self.revision = Some(progress.revision); + self.condition(progress)?; + let Some(provisioning) = &progress.provisioning else { + return Ok(()); + }; + if self.pass != Some(provisioning.pass) { + self.pass = Some(provisioning.pass); + self.cursor = ProgressCursor::new(); + self.printed_step = false; + self.recent_output.clear(); + // The latest pass may have ended long before following began. It is + // history, not progress: take its position without printing it. The + // command asks for a pass of its own, which reports its outcome. + if following_began + && matches!( + provisioning.progress.status(), + OperationStatus::Succeeded | OperationStatus::Failed { .. } + ) + { + let _ = self.cursor.updates(&provisioning.progress); + self.reported_failure = Some(provisioning.pass); + return Ok(()); + } + } + let pass = &provisioning.progress; + let mut latest_output = None; + for update in self.cursor.updates(pass) { + match update { + Update::OutputSkipped(count) => self.skipped(count)?, + Update::Output(line) => { + self.step_output(&line.text)?; + latest_output = Some(line.text.trim()); + } + Update::StepFinished(step) => { + latest_output = None; + // A failed step keeps its output for the failure report. + if step.outcome != Outcome::Failed { + self.recent_output.clear(); + self.step_completed(&step.name, step.elapsed_ms)?; + } + } + Update::PhaseFinished(phase) if phase.outcome != Outcome::Failed => { + self.phase_completed(&phase.phase.label, phase.outcome, phase.elapsed_ms)?; + } + // A failed phase is reported with its pass's failure below. + Update::PhaseFinished(_) => {} + } + } + match pass.status() { + OperationStatus::Failed { detail } if self.reported_failure != Some(provisioning.pass) => { + self.reported_failure = Some(provisioning.pass); + let (phase, elapsed_ms) = pass + .finished() + .iter() + .rfind(|phase| phase.outcome == Outcome::Failed) + .map_or(("Provision Sandbox", 0), |phase| { + (phase.phase.label.as_ref(), phase.elapsed_ms) + }); + // A stalled guest ends the wait like an invalid Agent does; the + // command reports both itself. + let retried = progress.status.failure.unwrap_or(FailureKind::Transient) == FailureKind::Transient + && progress.status.unresponsive().is_none(); + self.phase_failed(phase, detail, retried, elapsed_ms) + } + OperationStatus::Running => { + let Some(current) = pass.current() else { + return Ok(()); + }; + let mut line = format!("→ {}", current.phase.label); + let Some(step) = pass.current_step() else { + return self.show_line(&line); + }; + line.push_str(": "); + line.push_str(&step.name); + let detail = step + .measurement + .map(format_measurement) + .or_else(|| latest_output.map(str::to_owned)); + match detail { + Some(detail) => self.show_line_throttled(&format!("{line}: {detail}")), + None => self.show_line(&line), + } + } + _ => Ok(()), + } + } + + /// Reports a transient failure recorded outside a Sandbox pass, including + /// one that already stood when following started. Readiness itself is the + /// command's outcome and reported by the command. + fn condition(&mut self, progress: &AgentProgress) -> io::Result<()> { + let started = std::mem::replace(&mut self.started, true); + let Some(ready) = progress.status.ready_condition() else { + return Ok(()); + }; + let detail = ready.detail(); + if self.seen_condition.as_deref() == Some(detail.as_str()) { + return Ok(()); + } + self.seen_condition = Some(detail.clone()); + let failing = progress.status.failure == Some(FailureKind::Transient); + // A failed pass reports the failure itself, also when following begins after it. + let explained_by_pass = progress.provisioning.as_ref().is_some_and(|provisioning| { + started || matches!(provisioning.progress.status(), OperationStatus::Failed { .. }) + }); + if failing && !explained_by_pass { + self.clear_active_line()?; + self.error(&detail)?; + } + Ok(()) + } + + fn phase_completed(&mut self, label: &str, outcome: Outcome, elapsed_ms: u64) -> io::Result<()> { + self.clear_active_line()?; + let printed_step = std::mem::take(&mut self.printed_step); + if !self.interactive() { + return if outcome == Outcome::Reused { + writeln!(self.output, "✓ {label} (reused)") + } else { + writeln!(self.output, "✓ {label} ({})", duration(elapsed_ms)) + }; + } + if outcome != Outcome::Reused && (elapsed_ms >= NOTABLE_PHASE_MS || printed_step) { + writeln!(self.output, "✓ {label} ({})", duration(elapsed_ms))?; + } + Ok(()) + } + + fn step_completed(&mut self, name: &str, elapsed_ms: u64) -> io::Result<()> { + self.clear_active_line()?; + if !self.interactive() || elapsed_ms >= NOTABLE_STEP_MS { + self.printed_step = true; + writeln!(self.output, " ✓ {name} ({})", duration(elapsed_ms))?; + } + Ok(()) + } + + /// Closes the failed phase. + /// + /// An invalid configuration fails the command, which reports the error + /// itself. A transient failure is explained once, with the failed step's + /// last output; on a terminal further identical failures only advance a + /// counter on the updating line, because the daemon retries every pass. + fn phase_failed(&mut self, label: &str, detail: &str, retried: bool, elapsed_ms: u64) -> io::Result<()> { + self.clear_active_line()?; + if !retried { + return writeln!(self.output, "✗ {label} ({})", duration(elapsed_ms)); + } + // The first failed pass this command observes is always explained in full, + // even when the standing condition already named the error. + let explain = self.failures == 0 || self.last_error.as_deref() != Some(detail); + if explain { + self.failures = 0; + writeln!(self.output, "✗ {label} ({})", duration(elapsed_ms))?; + self.last_error = None; + self.error(detail)?; + let recent = std::mem::take(&mut self.recent_output); + let skip = recent.len().saturating_sub(RECENT_OUTPUT_LINES); + for line in recent.iter().skip(skip) { + writeln!(self.output, " {line}")?; + } + writeln!( + self.output, + " agentd keeps retrying in the background; press Ctrl-C to stop waiting" + )?; + } + self.failures += 1; + if !self.interactive() { + if !explain { + writeln!(self.output, "✗ {label} ({})", duration(elapsed_ms))?; + } + return Ok(()); + } + let count = self.failures; + self.show_line(&format!( + "✗ {label} failed {count}× (last {}); waiting for the next retry", + duration(elapsed_ms) + )) + } + + fn step_output(&mut self, line: &str) -> io::Result<()> { + self.recent_output.push_back(line.trim().to_owned()); + if self.interactive() { + return Ok(()); + } + writeln!(self.output, "{line}") + } + + fn skipped(&mut self, count: u64) -> io::Result<()> { + if self.interactive() { + return Ok(()); + } + writeln!(self.output, "… {count} output lines skipped") + } + + /// Prints one `error:` line, suppressing a repeat of the previous diagnostic. + fn error(&mut self, diagnostic: &str) -> io::Result<()> { + if self.last_error.as_deref() == Some(diagnostic) { + return Ok(()); + } + self.last_error = Some(diagnostic.to_owned()); + writeln!(self.output, "error: {diagnostic}") + } + + fn show_line_throttled(&mut self, line: &str) -> io::Result<()> { + if self.last_redraw.is_some_and(|last| last.elapsed() < REDRAW_INTERVAL) { + return Ok(()); + } + self.show_line(line) + } + + /// Replaces the updating line, truncated to the terminal width; no-op without a terminal. + fn show_line(&mut self, line: &str) -> io::Result<()> { + let Mode::Terminal { width } = self.mode else { + return Ok(()); + }; + let line = truncate(line, width.saturating_sub(1)); + write!(self.output, "\r\x1b[2K{line}")?; + self.output.flush()?; + self.active_line = true; + self.last_redraw = Some(Instant::now()); + Ok(()) + } + + fn clear_active_line(&mut self) -> io::Result<()> { + if self.interactive() && self.active_line { + write!(self.output, "\r\x1b[2K")?; + self.output.flush()?; + self.active_line = false; + } + Ok(()) + } +} + +fn truncate(text: &str, width: usize) -> String { + let count = text.chars().count(); + if count <= width || width < 2 { + return text.to_owned(); + } + let mut kept: String = text.chars().take(width - 1).collect(); + kept.push('…'); + kept +} + +pub(crate) fn format_measurement(Measurement { unit, completed, total }: Measurement) -> String { + match (unit, total) { + (ProgressUnit::Bytes, Some(total)) => format!("{} / {}", bytes(completed), bytes(total)), + (ProgressUnit::Bytes, None) => bytes(completed), + (ProgressUnit::Items, Some(total)) => format!("{completed} / {total}"), + _ => completed.to_string(), + } +} + +fn bytes(value: u64) -> String { + const KIB: u64 = 1_024; + const MIB: u64 = KIB * 1_024; + const GIB: u64 = MIB * 1_024; + if value >= GIB { + scaled(value, GIB, "GiB") + } else if value >= MIB { + scaled(value, MIB, "MiB") + } else if value >= KIB { + scaled(value, KIB, "KiB") + } else { + format!("{value} B") + } +} + +fn scaled(value: u64, unit: u64, suffix: &str) -> String { + let whole = value / unit; + let decimal = (value % unit).saturating_mul(10) / unit; + format!("{whole}.{decimal} {suffix}") +} + +pub(crate) fn duration(milliseconds: u64) -> String { + if milliseconds >= 60_000 { + format!("{}m {:02}s", milliseconds / 60_000, milliseconds % 60_000 / 1_000) + } else if milliseconds >= 1_000 { + let seconds = milliseconds / 1_000; + let tenths = milliseconds % 1_000 / 100; + format!("{seconds}.{tenths}s") + } else { + format!("{milliseconds}ms") + } +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, time::Duration}; + + use agent::{Condition, ConditionStatus, Status, progress::Provisioning, resources::Changes}; + use sandbox::{OutputStream, ProgressEvent, SandboxPhase, StepId, progress::Progress}; + + use super::*; + + /// Builds successive snapshots of one Agent's progress, as the daemon reports them. + struct Daemon { + changes: Changes, + pass: Revision, + progress: Progress, + status: Status, + steps: HashMap<&'static str, StepId>, + } + + impl Daemon { + fn new() -> Self { + let changes = Changes::new(); + Self { + pass: changes.revision(), + changes, + progress: Progress::new(), + status: Status::default(), + steps: HashMap::new(), + } + } + + fn apply(&mut self, event: &ProgressEvent) -> &mut Self { + self.progress.apply(event); + self + } + + fn phase(&mut self, phase: SandboxPhase) -> &mut Self { + self.apply(&ProgressEvent::PhaseStarted { phase: phase.phase() }) + } + + fn end_phase(&mut self, phase: SandboxPhase, outcome: Outcome, elapsed_ms: u64) -> &mut Self { + self.apply(&ProgressEvent::PhaseEnded { + phase: phase.phase(), + outcome, + elapsed: Duration::from_millis(elapsed_ms), + }) + } + + fn step(&mut self, name: &'static str) -> &mut Self { + let id = StepId::generate(); + self.steps.insert(name, id.clone()); + self.apply(&ProgressEvent::StepStarted { + id, + name: name.into(), + unit: None, + total: None, + }) + } + + fn output(&mut self, name: &'static str, text: &str) -> &mut Self { + let id = self.steps[name].clone(); + self.apply(&ProgressEvent::StepOutput { + id, + stream: OutputStream::Stderr, + bytes: text.as_bytes().to_vec().into(), + }) + } + + fn end_step(&mut self, name: &'static str, elapsed_ms: u64) -> &mut Self { + let id = self.steps[name].clone(); + self.apply(&ProgressEvent::StepEnded { + id, + outcome: Outcome::Completed, + elapsed: Duration::from_millis(elapsed_ms), + }) + } + + fn fail(&mut self, detail: &str) -> &mut Self { + // End the phase in progress at a fixed time: left to the fold, its + // duration is measured on the clock and differs between machines. + if let Some(phase) = self.progress.current().map(|current| current.phase.clone()) { + self.apply(&ProgressEvent::PhaseEnded { + phase, + outcome: Outcome::Failed, + elapsed: Duration::ZERO, + }); + } + self.progress.fail(detail); + self.status.failure = Some(FailureKind::Transient); + self.status.conditions = vec![Condition { + kind: Condition::READY.into(), + status: ConditionStatus::False, + reason: "SandboxReconcileFailed".into(), + message: detail.into(), + last_transition_time: None, + }]; + self + } + + fn succeed(&mut self) -> &mut Self { + self.progress.succeed(); + self.status.failure = None; + self.status.conditions = vec![Condition { + kind: Condition::READY.into(), + status: ConditionStatus::True, + reason: "SandboxReady".into(), + message: String::new(), + last_transition_time: None, + }]; + self + } + + fn retry(&mut self) -> &mut Self { + self.changes.bump(); + self.pass = self.changes.revision(); + self.progress = Progress::new(); + self + } + + fn snapshot(&self) -> AgentProgress { + self.changes.bump(); + AgentProgress { + revision: self.changes.revision(), + status: self.status.clone(), + provisioning: Some(Provisioning { + pass: self.pass, + progress: self.progress.clone(), + }), + } + } + } + + fn lines(renderer: Renderer>) -> Vec { + String::from_utf8(renderer.output) + .expect("utf-8") + .split(['\r', '\n']) + .map(|part| part.trim_start_matches("\x1b[2K")) + .filter(|part| !part.is_empty()) + .map(str::to_owned) + .collect() + } + + fn renderer(interactive: bool) -> Renderer> { + let mode = if interactive { + Mode::Terminal { width: 80 } + } else { + Mode::Plain + }; + Renderer::new(Vec::new(), mode) + } + + #[test] + fn a_warm_ensure_leaves_no_permanent_lines_on_a_terminal() { + let mut renderer = renderer(true); + let mut daemon = Daemon::new(); + renderer.render(&daemon.phase(SandboxPhase::Lookup).snapshot()); + renderer.render( + &daemon + .end_phase(SandboxPhase::Lookup, Outcome::Reused, 0) + .phase(SandboxPhase::Inspect) + .snapshot(), + ); + renderer.render( + &daemon + .end_phase(SandboxPhase::Inspect, Outcome::Completed, 3) + .snapshot(), + ); + renderer.finish(); + assert_eq!( + lines(renderer), + vec!["→ Look up Sandbox", "→ Inspect Sandbox"], + "only transient redraws" + ); + } + + #[test] + fn noticeable_work_and_failures_leave_lines_on_a_terminal() { + let mut renderer = renderer(true); + let mut daemon = Daemon::new(); + daemon + .phase(SandboxPhase::ImageResolve) + .step("Check Docker Engine") + .end_step("Check Docker Engine", 2) + .step("Build Docker image"); + renderer.render(&daemon.snapshot()); + daemon + .end_step("Build Docker image", 74_000) + .end_phase(SandboxPhase::ImageResolve, Outcome::Completed, 87_000) + .phase(SandboxPhase::SandboxStart) + .step("Start Microsandbox VM") + .output("Start Microsandbox VM", "opening disk\nno such file\n"); + renderer.render(&daemon.snapshot()); + daemon.fail("VMDK missing"); + renderer.render(&daemon.snapshot()); + daemon.retry().phase(SandboxPhase::SandboxStart).fail("VMDK missing"); + renderer.render(&daemon.snapshot()); + renderer.finish(); + + let lines = lines(renderer); + let permanent: Vec<_> = lines.iter().filter(|line| !line.starts_with('→')).collect(); + assert_eq!( + permanent, + vec![ + " ✓ Build Docker image (1m 14s)", + "✓ Resolve Sandbox Image (1m 27s)", + "✗ Start Sandbox (0ms)", + "error: VMDK missing", + " opening disk", + " no such file", + " agentd keeps retrying in the background; press Ctrl-C to stop waiting", + "✗ Start Sandbox failed 1× (last 0ms); waiting for the next retry", + "✗ Start Sandbox failed 2× (last 0ms); waiting for the next retry", + ] + ); + } + + #[test] + fn without_a_terminal_every_completion_and_output_line_is_printed_once() { + let mut renderer = renderer(false); + let mut daemon = Daemon::new(); + renderer.render(&daemon.phase(SandboxPhase::Lookup).snapshot()); + daemon + .end_phase(SandboxPhase::Lookup, Outcome::Reused, 0) + .phase(SandboxPhase::SandboxStart) + .step("Create Microsandbox VM") + .output("Create Microsandbox VM", "created\n"); + renderer.render(&daemon.snapshot()); + renderer.render(&daemon.snapshot()); + daemon + .end_step("Create Microsandbox VM", 366) + .end_phase(SandboxPhase::SandboxStart, Outcome::Completed, 367); + renderer.render(&daemon.snapshot()); + renderer.finish(); + assert_eq!( + lines(renderer), + vec![ + "✓ Look up Sandbox (reused)", + "created", + " ✓ Create Microsandbox VM (366ms)", + "✓ Start Sandbox (367ms)", + ] + ); + } + + #[test] + fn a_pass_that_succeeded_before_following_began_is_not_replayed() { + let mut renderer = renderer(false); + let mut daemon = Daemon::new(); + daemon + .phase(SandboxPhase::SandboxStart) + .step("Create Microsandbox VM") + .output("Create Microsandbox VM", "created\n") + .end_step("Create Microsandbox VM", 366) + .end_phase(SandboxPhase::SandboxStart, Outcome::Completed, 367) + .succeed(); + renderer.render(&daemon.snapshot()); + renderer.render(&daemon.snapshot()); + daemon + .retry() + .phase(SandboxPhase::SandboxStart) + .step("Create Microsandbox VM") + .end_step("Create Microsandbox VM", 12) + .end_phase(SandboxPhase::SandboxStart, Outcome::Completed, 13); + renderer.render(&daemon.snapshot()); + renderer.finish(); + assert_eq!( + lines(renderer), + vec![" ✓ Create Microsandbox VM (12ms)", "✓ Start Sandbox (13ms)"], + "only the pass that ran while following is printed" + ); + } + + #[test] + fn a_pass_that_failed_before_following_began_is_not_replayed() { + let mut renderer = renderer(false); + let mut daemon = Daemon::new(); + daemon.phase(SandboxPhase::SandboxStart).fail("VMDK missing"); + renderer.render(&daemon.snapshot()); + daemon + .retry() + .phase(SandboxPhase::SandboxStart) + .fail("VMDK still missing"); + renderer.render(&daemon.snapshot()); + renderer.finish(); + let lines = lines(renderer); + assert!(!lines.iter().any(|line| line == "error: VMDK missing"), "{lines:#?}"); + assert_eq!( + lines.first().map(String::as_str), + Some("✗ Start Sandbox (0ms)"), + "the command's own pass reports its failure: {lines:#?}" + ); + } + + #[test] + fn a_stalled_guest_ends_the_wait_without_a_retry_hint() { + let mut renderer = renderer(false); + let mut daemon = Daemon::new(); + renderer.render(&daemon.snapshot()); + daemon + .retry() + .phase(SandboxPhase::SandboxStart) + .fail("the guest has not reported progress for more than 15s"); + daemon.status.conditions.push(Condition { + kind: Condition::SANDBOX_RESPONSIVE.into(), + status: ConditionStatus::False, + reason: "HeartbeatStale".into(), + message: "the guest has not reported progress for more than 15s".into(), + last_transition_time: None, + }); + renderer.render(&daemon.snapshot()); + renderer.finish(); + assert_eq!(lines(renderer), ["✗ Start Sandbox (0ms)"]); + } + + #[test] + fn a_standing_failure_is_reported_once_when_following_starts() { + let mut renderer = renderer(false); + let mut daemon = Daemon::new(); + daemon.fail("registry unavailable"); + let mut standing = daemon.snapshot(); + standing.provisioning = None; + renderer.render(&standing); + renderer.render(&standing); + assert_eq!(lines(renderer), vec!["error: registry unavailable"]); + } + + #[test] + fn the_updating_line_is_truncated_to_the_terminal_width() { + let mut renderer = Renderer::new(Vec::new(), Mode::Terminal { width: 24 }); + let mut daemon = Daemon::new(); + daemon.phase(SandboxPhase::ImageResolve).step("Build Docker image"); + renderer.render(&daemon.snapshot()); + let lines = lines(renderer); + assert_eq!(lines.last().map(String::as_str), Some("→ Resolve Sandbox Imag…")); + } + + #[test] + fn replies_trimmed_to_unseen_output_render_nothing_twice_over_the_wire() { + let mut renderer = renderer(false); + let mut daemon = Daemon::new(); + daemon + .phase(SandboxPhase::ImageResolve) + .step("Build") + .output("Build", "one\n") + .end_step("Build", 700) + .step("Import"); + renderer.render(&daemon.snapshot()); + for completed in [1_u64, 2, 3] { + let id = daemon.steps["Import"].clone(); + daemon.apply(&ProgressEvent::StepOutput { + id, + stream: OutputStream::Stdout, + bytes: format!("layer {completed}\n").into_bytes().into(), + }); + let (_, output) = renderer.position(); + let mut reply = daemon.snapshot(); + if let (Some(provisioning), Some(output)) = (reply.provisioning.as_mut(), output) { + provisioning.progress = provisioning.progress.output_from(output.sequence); + } + // Replies reach the renderer through the wire format. + let reply = serde_json::from_value(serde_json::to_value(&reply).expect("reply JSON")).expect("reply"); + renderer.render(&reply); + let (_, output) = renderer.position(); + let mut quiet = daemon.snapshot(); + if let (Some(provisioning), Some(output)) = (quiet.provisioning.as_mut(), output) { + provisioning.progress = provisioning.progress.output_from(output.sequence); + } + let quiet = serde_json::from_value(serde_json::to_value(&quiet).expect("reply JSON")).expect("reply"); + renderer.render(&quiet); + } + assert_eq!( + lines(renderer), + vec!["one", " ✓ Build (700ms)", "layer 1", "layer 2", "layer 3"] + ); + } + + #[test] + fn the_follow_position_names_the_rendered_pass_and_output() { + let mut renderer = renderer(false); + let mut daemon = Daemon::new(); + daemon + .phase(SandboxPhase::ImageResolve) + .step("Build") + .output("Build", "one\ntwo\n"); + let snapshot = daemon.snapshot(); + renderer.render(&snapshot); + assert_eq!( + renderer.position(), + ( + Some(snapshot.revision), + Some(OutputPosition { + pass: daemon.pass, + sequence: 2 + }) + ) + ); + } +} diff --git a/agentctl/src/bin/agentctl/self_update.rs b/agentctl/src/bin/agentctl/self_update.rs new file mode 100644 index 0000000..cf9acd9 --- /dev/null +++ b/agentctl/src/bin/agentctl/self_update.rs @@ -0,0 +1,526 @@ +use std::{ + path::{Path, PathBuf}, + process::{Command as ProcessCommand, Stdio}, + time::{Duration, Instant}, +}; + +use agent::{ + Error, + control_api::{Client, DaemonInfo, PROTOCOL_VERSION}, + local::home::{ControlPlaneHome, Lock}, + upgrade::{self, InstallMetadata, InstallPaths, Release, UpdateJournal, UpdatePhase}, +}; + +use super::CommandResult; + +const DEFAULT_REPOSITORY: &str = "digdir/digdir-agents"; +const DAEMON_STOP_TIMEOUT: Duration = Duration::from_secs(65); +const LIFECYCLE_REQUEST_TIMEOUT: Duration = Duration::from_secs(65); +const TARGET_VERIFY_TIMEOUT: Duration = Duration::from_secs(75); + +struct Completion { + paths: InstallPaths, + target_release: PathBuf, + target_version: String, + previous_release: Option, + repository: String, +} + +impl Completion { + fn from_journal(paths: InstallPaths, journal: UpdateJournal) -> Result { + let repository = repository(&paths)?; + Ok(Self { + paths, + target_release: journal.target_release, + target_version: journal.target_version, + previous_release: journal.previous_release, + repository, + }) + } + + fn run_target(&self, home: &ControlPlaneHome) -> Result<(), Error> { + let mut command = ProcessCommand::new( + self.target_release + .join(format!("agentctl{}", std::env::consts::EXE_SUFFIX)), + ); + command + .arg("--home") + .arg(home.path()) + .args(["self", "__complete-update", "--install-root"]) + .arg(self.paths.root()) + .arg("--bin-directory") + .arg(self.paths.bin()) + .arg("--target-release") + .arg(&self.target_release) + .arg("--target-version") + .arg(&self.target_version) + .arg("--repository") + .arg(&self.repository); + if let Some(previous) = &self.previous_release { + command.arg("--previous-release").arg(previous); + } + let status = command.status()?; + if !status.success() { + return Err(Error::Daemon(format!("target updater exited with {status}"))); + } + Ok(()) + } +} + +#[derive(clap::Subcommand)] +pub(super) enum SelfCommand { + /// Check for or install a released Agent update. + Update { + /// Install this exact release version. + #[arg(long)] + version: Option, + /// Report whether an update is available without downloading it. + #[arg(long)] + check: bool, + }, + /// Complete a target-owned package and state transition. + #[command(name = "__complete-update", hide = true)] + CompleteUpdate { + #[arg(long)] + install_root: PathBuf, + #[arg(long)] + bin_directory: PathBuf, + #[arg(long)] + target_release: PathBuf, + #[arg(long)] + target_version: String, + #[arg(long)] + previous_release: Option, + #[arg(long, default_value = DEFAULT_REPOSITORY)] + repository: String, + }, + /// Publish an extracted release through the platform install lock. + #[command(name = "__publish-release", hide = true)] + PublishRelease { + #[arg(long)] + install_root: PathBuf, + #[arg(long)] + bin_directory: PathBuf, + #[arg(long)] + source_release: PathBuf, + #[arg(long)] + target_version: String, + }, +} + +pub(super) async fn execute(command: SelfCommand, home: &ControlPlaneHome) -> CommandResult<()> { + match command { + SelfCommand::Update { version, check } => update(home, version.as_deref(), check).await, + SelfCommand::CompleteUpdate { + install_root, + bin_directory, + target_release, + target_version, + previous_release, + repository, + } => { + complete( + Completion { + paths: InstallPaths::new(install_root, bin_directory)?, + target_release, + target_version, + previous_release, + repository, + }, + home, + ) + .await + } + SelfCommand::PublishRelease { + install_root, + bin_directory, + source_release, + target_version, + } => { + validate_source_process(&source_release)?; + upgrade::publish_release( + &InstallPaths::new(install_root, bin_directory)?, + &source_release, + &target_version, + ) + .await?; + Ok(()) + } + } +} + +pub(super) fn resume_pending_before_command(home: &ControlPlaneHome) -> CommandResult<()> { + let paths = InstallPaths::resolve()?; + let Some(journal) = UpdateJournal::read(&paths)?.filter(|journal| journal.phase != UpdatePhase::Complete) else { + return Ok(()); + }; + let target_version = journal.target_version.clone(); + Completion::from_journal(paths, journal)?.run_target(home)?; + if !same_version(agent::build_version(), &target_version)? { + return Err(Error::Daemon(format!( + "Agent update to {target_version} completed; rerun this command with the current agentctl" + )) + .into()); + } + Ok(()) +} + +async fn update(home: &ControlPlaneHome, version: Option<&str>, check: bool) -> CommandResult<()> { + let paths = InstallPaths::resolve()?; + if let Some(journal) = UpdateJournal::read(&paths)?.filter(|journal| journal.phase != UpdatePhase::Complete) { + Completion::from_journal(paths, journal)?.run_target(home)?; + return Ok(()); + } + if version.is_none() && agent::release_version().is_none() { + return Err(Error::Invalid("this development build needs an explicit self update --version".into()).into()); + } + let repository = repository(&paths)?; + println!("Resolve release"); + let release = Release::resolve(version, repository).await?; + let previous = upgrade::current_release(&paths)?; + let current_version = previous + .as_deref() + .map(upgrade::managed_release_version) + .transpose()? + .unwrap_or_else(|| agent::build_version().to_owned()); + compare_versions(¤t_version, &release.version)?; + if previous.is_some() && same_version(¤t_version, &release.version)? { + println!("Agent {} is already installed", release.version); + return Ok(()); + } + if check { + println!("Agent {} is available (current {})", release.version, current_version); + return Ok(()); + } + + println!("Download and verify package"); + let staged = upgrade::stage_release(&paths, release).await?; + Completion { + paths, + target_release: staged.path, + target_version: staged.release.version, + previous_release: previous, + repository: staged.release.repository, + } + .run_target(home) + .map_err(Into::into) +} + +async fn complete(completion: Completion, home: &ControlPlaneHome) -> CommandResult<()> { + // Release ordering belongs to `self update`. Installers enter here directly + // so a local development build may replace a higher published preview. + let Completion { + paths, + target_release, + target_version, + previous_release, + repository, + } = completion; + let _install_lock = paths.lock().await?; + validate_target_process(&paths, &target_release, &target_version)?; + let (mut journal, journal_is_new) = + if let Some(journal) = UpdateJournal::read(&paths)?.filter(|journal| journal.phase != UpdatePhase::Complete) { + if journal.target_release != target_release + || journal.target_version != target_version + || journal.previous_release != previous_release + { + return Err(Error::Invalid("arguments do not match the unfinished Agent update".into()).into()); + } + (journal, false) + } else { + ( + UpdateJournal::new(previous_release.clone(), target_release.clone(), target_version.clone()), + true, + ) + }; + let client = Client::for_path(home.socket_path()); + if journal.phase < UpdatePhase::Migrated { + println!("Check Agent activity"); + match tokio::time::timeout(Duration::from_secs(2), client.health()).await { + Ok(Ok(info)) => { + if is_preview_1(&info) { + return Err(Error::Daemon(preview_stop_instruction().into()).into()); + } + println!("Stop agentd"); + let warnings = tokio::time::timeout(LIFECYCLE_REQUEST_TIMEOUT, client.shutdown_for_upgrade()) + .await + .map_err(|_| Error::Daemon("timed out waiting for agentd to prepare for upgrade".into()))??; + for warning in warnings { + eprintln!("Warning: {warning}"); + } + } + Err(_) => { + return Err(Error::Daemon( + "agentd did not answer its health check; retry the update or stop agentd".into(), + ) + .into()); + } + Ok(Err(_)) => {} + } + } + let home_lock = if journal.phase < UpdatePhase::Activated { + Some(acquire_home_lock(home).await?) + } else { + None + }; + if journal_is_new { + journal.advance(&paths, UpdatePhase::Prepared)?; + } + if journal.phase < UpdatePhase::Migrated { + println!("Migrate Agent state"); + if let Err(error) = migrate_state(&paths, &journal, &home.path().join("agent.db")) { + drop(home_lock); + if let Some(previous_release) = &previous_release { + let _ = start_daemon(previous_release, home); + } + return Err(error.into()); + } + upgrade::create_session_relaunch_marker(home, &target_version)?; + journal.advance(&paths, UpdatePhase::Migrated)?; + } + + if journal.phase < UpdatePhase::Activated { + println!("Activate target package"); + InstallMetadata::new(repository, paths.bin().to_path_buf()).write(&paths)?; + upgrade::activate_release(&paths, &target_release)?; + journal.advance(&paths, UpdatePhase::Activated)?; + } + drop(home_lock); + + println!("Start and verify target daemon"); + if !target_ready(&client, home, &target_version).await { + start_daemon(&target_release, home)?; + } + verify_target(&client, home, &target_version).await?; + journal.advance(&paths, UpdatePhase::Complete)?; + upgrade::prune_releases(&paths, previous_release.as_deref())?; + println!("Agent updated to {target_version}"); + Ok(()) +} + +fn migrate_state(paths: &InstallPaths, journal: &UpdateJournal, database: &Path) -> Result<(), Error> { + match agent::persistence::Database::migrate(database) { + Ok(()) => Ok(()), + Err(error) => { + if let Err(discard_error) = journal.discard_before_migration(paths) { + return Err(Error::Database(format!( + "{error}; failed to discard the pre-migration update journal: {discard_error}" + ))); + } + Err(error) + } + } +} + +fn validate_target_process(paths: &InstallPaths, target: &Path, version: &str) -> Result<(), Error> { + let target = canonical_target(paths, target)?; + upgrade::validate_release_directory(&target, version)?; + validate_release_process(&target, "update completion must run from the target release") +} + +fn canonical_target(paths: &InstallPaths, target: &Path) -> Result { + if !target.is_absolute() { + return Err(Error::Invalid("target updater paths are inconsistent".into())); + } + let releases = std::fs::canonicalize(paths.releases())?; + let target = std::fs::canonicalize(target)?; + if target.parent() != Some(releases.as_path()) { + return Err(Error::Invalid("target updater paths are inconsistent".into())); + } + Ok(target) +} + +fn validate_source_process(source: &Path) -> Result<(), Error> { + validate_release_process(source, "release publication must run from the source package") +} + +fn validate_release_process(release: &Path, mismatch: &str) -> Result<(), Error> { + let executable = std::fs::canonicalize(std::env::current_exe()?)?; + let expected = std::fs::canonicalize(release.join(format!("agentctl{}", std::env::consts::EXE_SUFFIX)))?; + if executable != expected { + return Err(Error::Invalid(mismatch.into())); + } + Ok(()) +} + +async fn acquire_home_lock(home: &ControlPlaneHome) -> Result { + let deadline = Instant::now() + DAEMON_STOP_TIMEOUT; + loop { + match home.acquire_lock() { + Ok(lock) => return Ok(lock), + Err(Error::Io(error)) if error.kind() == std::io::ErrorKind::WouldBlock && Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(100)).await; + } + Err(error) => return Err(error), + } + } +} + +fn start_daemon(release: &Path, home: &ControlPlaneHome) -> Result<(), Error> { + let log = home.open_daemon_log()?; + let mut command = ProcessCommand::new(release.join(format!("agentd{}", std::env::consts::EXE_SUFFIX))); + command + .arg("--home") + .arg(home.path()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(log); + agent::local::process::configure_detached(&mut command); + agent::local::process::configure_logging(&mut command); + command.spawn()?; + Ok(()) +} + +async fn verify_target(client: &Client, home: &ControlPlaneHome, target_version: &str) -> Result<(), Error> { + let deadline = Instant::now() + TARGET_VERIFY_TIMEOUT; + while Instant::now() < deadline { + if target_ready(client, home, target_version).await { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + Err(Error::Daemon(format!( + "target agentd {target_version:?} did not become healthy and finish Session relaunch within {} seconds; {}", + TARGET_VERIFY_TIMEOUT.as_secs(), + super::daemon_startup_diagnostics(home) + ))) +} + +async fn target_ready(client: &Client, home: &ControlPlaneHome, target_version: &str) -> bool { + matches!( + tokio::time::timeout(Duration::from_secs(1), client.health()).await, + Ok(Ok(info)) + if info.protocol_version.as_deref() == Some(PROTOCOL_VERSION) + && info.build_version.as_deref() == Some(target_version) + && !home.pending_session_relaunch_path().exists() + ) +} + +fn compare_versions(current: &str, target: &str) -> Result<(), Error> { + let current = parse_version(current); + let target = parse_version(target).map_err(|error| Error::Invalid(format!("invalid target version: {error}")))?; + if let Ok(ref current) = current + && target < *current + { + return Err(Error::Invalid(format!( + "downgrading Agent from v{current} to v{target} is not supported" + ))); + } + Ok(()) +} + +fn same_version(current: &str, target: &str) -> Result { + let current = + parse_version(current).map_err(|error| Error::Invalid(format!("invalid current version: {error}")))?; + let target = parse_version(target).map_err(|error| Error::Invalid(format!("invalid target version: {error}")))?; + Ok(current == target) +} + +fn parse_version(version: &str) -> Result { + semver::Version::parse(version.strip_prefix('v').unwrap_or(version)) +} + +fn repository(paths: &InstallPaths) -> Result { + match InstallMetadata::read(paths) { + Ok(metadata) => Ok(metadata.repository().to_owned()), + Err(Error::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(std::env::var("AGENT_GITHUB_REPOSITORY").unwrap_or_else(|_| DEFAULT_REPOSITORY.into())) + } + Err(error) => Err(error), + } +} + +const fn preview_stop_instruction() -> &'static str { + if cfg!(windows) { + "preview 1 agentd cannot stop itself; finish active turns, run `Stop-Process -Name agentd` in PowerShell, and rerun the installer" + } else { + "preview 1 agentd cannot stop itself; finish active turns, run `pkill -x agentd`, and rerun the installer" + } +} + +fn is_preview_1(info: &DaemonInfo) -> bool { + info.protocol_version.as_deref() == Some("v1") && info.build_version.is_none() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_comparison_rejects_downgrade() { + assert!(compare_versions("v2.0.0", "v1.0.0").is_err()); + assert!( + compare_versions("v0.1.0-preview.2", "v0.0.1-dev.20260914000000").is_err(), + "the released-update policy also rejects a lower development build" + ); + assert!(same_version("1.0.0", "v1.0.0").expect("version")); + } + + #[test] + fn failed_migration_discards_the_prepared_journal() { + let temporary = tempfile::TempDir::new().expect("temporary directory"); + let paths = + InstallPaths::new(temporary.path().join("install"), temporary.path().join("bin")).expect("install paths"); + let target = paths.releases().join(format!("v2.0.0-{}", upgrade::package_target())); + let mut journal = UpdateJournal::new(None, target, "v2.0.0".into()); + journal + .advance(&paths, UpdatePhase::Prepared) + .expect("prepared journal"); + let database = temporary.path().join("agent.db"); + rusqlite::Connection::open(&database) + .expect("database") + .execute_batch("PRAGMA user_version = 999;") + .expect("future schema"); + + let error = migrate_state(&paths, &journal, &database).expect_err("migration failure"); + + assert!(error.to_string().contains("newer than the supported schema"), "{error}"); + assert!( + !paths.journal().exists(), + "failed migration must not poison later commands" + ); + } + + #[test] + fn only_preview_1_requires_manual_daemon_shutdown() { + assert!(is_preview_1(&DaemonInfo { + protocol_version: Some("v1".into()), + build_version: None, + })); + assert!(!is_preview_1(&DaemonInfo { + protocol_version: Some("v1".into()), + build_version: Some("v0.2.0".into()), + })); + assert!(!is_preview_1(&DaemonInfo { + protocol_version: Some("v2".into()), + build_version: Some("v0.3.0".into()), + })); + } + + #[cfg(unix)] + #[test] + fn target_validation_accepts_an_installation_alias() { + use std::os::unix::fs::symlink; + + let temporary = tempfile::TempDir::new().expect("temporary directory"); + let real = temporary.path().join("real"); + let release = real.join("releases/v0.2.0-linux-x86_64"); + std::fs::create_dir_all(&release).expect("release"); + let alias = temporary.path().join("alias"); + symlink(&real, &alias).expect("alias"); + let paths = InstallPaths::new(real, temporary.path().join("bin")).expect("paths"); + + assert_eq!( + canonical_target(&paths, &alias.join("releases/v0.2.0-linux-x86_64")).expect("target"), + std::fs::canonicalize(release).expect("canonical release") + ); + } + + #[test] + fn self_help_hides_completion_command() { + use clap::CommandFactory as _; + let help = super::super::Arguments::command().render_long_help().to_string(); + assert!(!help.contains("__complete-update")); + assert!(!help.contains("__publish-release")); + } +} diff --git a/agentctl/src/bin/agentctl/tui/app.rs b/agentctl/src/bin/agentctl/tui/app.rs new file mode 100644 index 0000000..d007d33 --- /dev/null +++ b/agentctl/src/bin/agentctl/tui/app.rs @@ -0,0 +1,4964 @@ +use std::{ + cell::Cell, + collections::HashSet, + path::{Path, PathBuf}, + time::{Duration, Instant}, +}; + +use agent::{ + Agent, Condition, ConditionStatus, Effort, FailureKind, Harness, HarnessSpec, Model, ModelSelection, RunState, + sessions::{Session, SessionName, State, Turn}, +}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use sandbox::progress::{OperationStatus, Progress}; +use time::OffsetDateTime; + +use super::open::{Environment, MenuEntry, OpenMenu, OpenTarget, SshSetup}; +use crate::{format, forward::ForwardSpec}; + +/// Output lines of a failed pass the Agent side panel shows. +const AGENT_PANEL_OUTPUT_LINES: usize = 10; + +/// How long the header shows the outcome of a Session change. +const NOTICE_DURATION: Duration = Duration::from_secs(5); + +/// A displayed key hint and, when unambiguous, the key emitted by a click. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct Hint { + pub(crate) label: &'static str, + pub(crate) description: &'static str, + pub(crate) key: Option<(KeyCode, KeyModifiers)>, +} + +impl Hint { + pub(crate) const fn key(label: &'static str, description: &'static str, code: KeyCode) -> Self { + Self { + label, + description, + key: Some((code, KeyModifiers::NONE)), + } + } + + pub(crate) const fn modified( + label: &'static str, + description: &'static str, + code: KeyCode, + modifiers: KeyModifiers, + ) -> Self { + Self { + label, + description, + key: Some((code, modifiers)), + } + } + + pub(crate) const fn display(label: &'static str, description: &'static str) -> Self { + Self { + label, + description, + key: None, + } + } +} + +/// Key hints of the new Session form, shared by the modal and the footer. +pub(crate) const NEW_SESSION_HINTS: [Hint; 4] = [ + Hint::key("enter", "create", KeyCode::Enter), + Hint::display("tab/↑/↓", "field"), + Hint::display("←/→", "harness"), + Hint::key("esc", "cancel", KeyCode::Esc), +]; + +/// Key hints of the create Agent form, shared by the modal and the footer. +pub(crate) const CREATE_AGENT_HINTS: [Hint; 4] = [ + Hint::key("enter", "create", KeyCode::Enter), + Hint::display("tab/↑/↓", "field"), + Hint::display("←/→", "select"), + Hint::key("esc", "cancel", KeyCode::Esc), +]; + +pub(crate) const CONFIRM_HINTS: [Hint; 2] = [ + Hint::key("y", "confirm", KeyCode::Char('y')), + Hint::key("n", "cancel", KeyCode::Char('n')), +]; + +/// Key hints while a prompt is typed in the footer. +pub(crate) const PROMPT_HINTS: [Hint; 2] = [ + Hint::key("enter", "send", KeyCode::Enter), + Hint::key("esc", "cancel", KeyCode::Esc), +]; + +/// Key hints while the filter is edited in the footer. +pub(crate) const FILTER_HINTS: [Hint; 2] = [ + Hint::key("enter", "keep", KeyCode::Enter), + Hint::key("esc", "clear", KeyCode::Esc), +]; + +/// Key hints of the open menu, shared by the modal and the footer. +pub(crate) const OPEN_HINTS: [Hint; 3] = [ + Hint::key("enter", "open", KeyCode::Enter), + Hint::display("↑/↓", "select"), + Hint::key("esc", "cancel", KeyCode::Esc), +]; + +/// Adding the line takes Enter, not `y`: in the open menu `y` copies the alias, +/// so a repeated `y` must not write the user's configuration. +pub(crate) const CONFIRM_SSH_SETUP_HINTS: [Hint; 2] = [ + Hint::key("enter", "add", KeyCode::Enter), + Hint::key("esc", "back", KeyCode::Esc), +]; + +/// The SSH setup question for something waiting to open, which can also open +/// without the line: an editor may reach Agents through a configuration of its own. +pub(crate) const CONFIRM_SSH_SETUP_THEN_HINTS: [Hint; 3] = [ + Hint::key("enter", "add", KeyCode::Enter), + Hint::key("o", "open anyway", KeyCode::Char('o')), + Hint::key("esc", "back", KeyCode::Esc), +]; + +pub(crate) const PORT_FORWARD_HINTS: [Hint; 3] = [ + Hint::key("enter", "forward", KeyCode::Enter), + Hint::key("tab", "field", KeyCode::Tab), + Hint::key("esc", "cancel", KeyCode::Esc), +]; + +const DETAIL_HINTS: [Hint; 2] = [ + Hint::display("j/k", "scroll"), + Hint::key("q", "back", KeyCode::Char('q')), +]; + +const FORWARD_VIEW_HINTS: [Hint; 4] = [ + Hint::key("o", "open", KeyCode::Char('o')), + Hint::key("e", "edit", KeyCode::Char('e')), + Hint::modified("ctrl-d", "delete", KeyCode::Char('d'), KeyModifiers::CONTROL), + Hint::key("q", "back", KeyCode::Char('q')), +]; + +// Selection hints come most used first, so a footer too narrow for all of +// them drops the rarest. +const AGENT_HINTS: [Hint; 12] = [ + Hint::key("enter", "fold", KeyCode::Enter), + Hint::key("n", "new session", KeyCode::Char('n')), + Hint::key("o", "open…", KeyCode::Char('o')), + Hint::key("e", "exec", KeyCode::Char('e')), + Hint::key("f", "forward", KeyCode::Char('f')), + Hint::key("d", "delete", KeyCode::Char('d')), + Hint::key("p", "provisioning", KeyCode::Char('p')), + Hint::key("s", "describe", KeyCode::Char('s')), + Hint::key("y", "yaml", KeyCode::Char('y')), + Hint::key("x", "stop", KeyCode::Char('x')), + Hint::key("z", "all", KeyCode::Char('z')), + Hint::key("c", "new agent", KeyCode::Char('c')), +]; + +/// An Agent whose stop is not recorded yet: starting it now would cancel the stop. +const STOPPING_AGENT_HINTS: [Hint; 7] = [ + Hint::key("enter", "fold", KeyCode::Enter), + Hint::key("d", "delete", KeyCode::Char('d')), + Hint::key("p", "provisioning", KeyCode::Char('p')), + Hint::key("s", "describe", KeyCode::Char('s')), + Hint::key("y", "yaml", KeyCode::Char('y')), + Hint::key("z", "all", KeyCode::Char('z')), + Hint::key("c", "new agent", KeyCode::Char('c')), +]; + +/// A stopped Agent runs nothing, so only what works without its Sandbox is offered. +const STOPPED_AGENT_HINTS: [Hint; 8] = [ + Hint::key("x", "start", KeyCode::Char('x')), + Hint::key("enter", "fold", KeyCode::Enter), + Hint::key("d", "delete", KeyCode::Char('d')), + Hint::key("p", "provisioning", KeyCode::Char('p')), + Hint::key("s", "describe", KeyCode::Char('s')), + Hint::key("y", "yaml", KeyCode::Char('y')), + Hint::key("z", "all", KeyCode::Char('z')), + Hint::key("c", "new agent", KeyCode::Char('c')), +]; + +const SESSION_HINTS: [Hint; 9] = [ + Hint::key("enter", "attach", KeyCode::Enter), + Hint::key("p", "prompt", KeyCode::Char('p')), + Hint::key("o", "open…", KeyCode::Char('o')), + Hint::key("a", "archive", KeyCode::Char('a')), + Hint::key("d", "delete", KeyCode::Char('d')), + Hint::key("s", "describe", KeyCode::Char('s')), + Hint::key("y", "yaml", KeyCode::Char('y')), + Hint::key("n", "new session", KeyCode::Char('n')), + Hint::key("c", "new agent", KeyCode::Char('c')), +]; + +const ARCHIVED_SESSION_HINTS: [Hint; 7] = [ + Hint::key("a", "unarchive", KeyCode::Char('a')), + Hint::key("o", "open…", KeyCode::Char('o')), + Hint::key("d", "delete", KeyCode::Char('d')), + Hint::key("s", "describe", KeyCode::Char('s')), + Hint::key("y", "yaml", KeyCode::Char('y')), + Hint::key("n", "new session", KeyCode::Char('n')), + Hint::key("c", "new agent", KeyCode::Char('c')), +]; + +/// A stopped Agent's Session can only be put away or inspected until the Agent starts. +const STOPPED_SESSION_HINTS: [Hint; 5] = [ + Hint::key("a", "archive", KeyCode::Char('a')), + Hint::key("d", "delete", KeyCode::Char('d')), + Hint::key("s", "describe", KeyCode::Char('s')), + Hint::key("y", "yaml", KeyCode::Char('y')), + Hint::key("c", "new agent", KeyCode::Char('c')), +]; + +const STOPPED_ARCHIVED_SESSION_HINTS: [Hint; 5] = [ + Hint::key("a", "unarchive", KeyCode::Char('a')), + Hint::key("d", "delete", KeyCode::Char('d')), + Hint::key("s", "describe", KeyCode::Char('s')), + Hint::key("y", "yaml", KeyCode::Char('y')), + Hint::key("c", "new agent", KeyCode::Char('c')), +]; + +const EMPTY_HINTS: [Hint; 1] = [Hint::key("c", "new agent", KeyCode::Char('c'))]; + +/// Every hint set the tree shows for its selection. The footer is sized for +/// the widest, so moving the selection never moves the tree. +pub(crate) const SELECTION_HINTS: [&[Hint]; 8] = [ + &AGENT_HINTS, + &STOPPING_AGENT_HINTS, + &STOPPED_AGENT_HINTS, + &SESSION_HINTS, + &ARCHIVED_SESSION_HINTS, + &STOPPED_SESSION_HINTS, + &STOPPED_ARCHIVED_SESSION_HINTS, + &EMPTY_HINTS, +]; + +pub(crate) const HELP_HINTS: [Hint; 1] = [Hint::key("esc", "close", KeyCode::Esc)]; + +/// A titled group of keys in the help overlay. +pub(crate) type HelpSection = (&'static str, &'static [(&'static str, &'static str)]); + +/// Every key the terminal UI accepts, in the help overlay's two columns. +pub(crate) const HELP: [&[HelpSection]; 2] = [ + &[ + ( + "Fleet", + &[ + ("tab", "next Session needing you"), + ("j / k", "move"), + ("enter", "fold, or attach a Session"), + ("z", "fold or unfold all"), + ("A", "show or hide archived Sessions"), + ("/", "filter by name or state"), + ("c", "create an Agent"), + ("F", "port forwards"), + ("q", "quit"), + ], + ), + ( + "Views and forms", + &[ + ("j / k", "scroll a detail view"), + ("o", "open, in forwards"), + ("q / esc", "back, or close a form"), + ("ctrl-b d", "detach from a Session"), + ], + ), + ], + &[ + ( + "Selected Agent", + &[ + ("p", "follow provisioning"), + ("s / y", "describe, or show YAML"), + ("n", "new Session"), + ("o", "open in editor, desktop…"), + ("e", "shell in its Sandbox"), + ("f", "forward a port"), + ("x", "stop, or start"), + ("d", "delete"), + ], + ), + ( + "Selected Session", + &[ + ("p", "prompt without attaching"), + ("s / y", "describe, or show YAML"), + ("n", "new Session on its Agent"), + ("o", "open its Agent"), + ("a", "archive, or unarchive"), + ("d", "delete"), + ], + ), + ], +]; + +#[allow( + clippy::struct_excessive_bools, + reason = "independent display switches of one screen, not a state machine" +)] +pub(crate) struct App { + pub(crate) agents: Vec, + pub(crate) sessions: Vec, + pub(crate) groups: Vec, + pub(crate) rows: Vec, + pub(crate) collapsed: HashSet, + /// Shows only Agents and Sessions whose name, state, harness or model + /// contains it, ignoring case; an Agent that matches keeps all its Sessions. + pub(crate) filter: String, + /// Selected tree row, kept by identity so a snapshot that reorders or + /// reshapes the tree leaves it on the same Agent or Session. + pub(crate) selection: Option, + pub(crate) loaded: bool, + /// Why the daemon cannot be watched; the last state it reported stays shown. + pub(crate) connection_error: Option, + /// A failed action, shown until dismissed. + pub(crate) error: Option, + pub(crate) detail: Option, + pub(crate) modal: Option, + pub(crate) forwards: Vec, + pub(crate) view: View, + pub(crate) forward_selected: usize, + pub(crate) creating: usize, + /// Prompts being sent. + pub(crate) prompting: usize, + pub(crate) transcript: Option, + /// The Session whose turns are being loaded and its turn count when they + /// were requested; one load runs at a time. + turns_loading: Option<(String, SessionName, u64)>, + /// The terminal is wide enough for the panel beside the tree, which shows + /// the selected Session's turns or the selected Agent's status. + pub(crate) side_panel: bool, + /// Lists archived Sessions, which are hidden otherwise. + pub(crate) show_archived: bool, + /// The outcome of a Session change and when it was shown. + pub(crate) notice: Option<(String, Instant)>, + pub(crate) discovering: bool, + pub(crate) queued_candidates: Option>, + /// Editors and whether the terminal is remote, which the open menu depends on. + pub(crate) environment: Environment, + /// Whether OpenSSH reaches Agents through the generated configuration, as + /// it resolved the alias of the Agent checked last. + pub(crate) ssh_setup: SshSetup, + /// Whether the SSH setup is to be checked again once an Agent can be. + pub(crate) ssh_check_due: bool, + /// Whether an SSH setup check is running. + pub(crate) ssh_checking: bool, + /// The `Include` SSH setup adds, when the user's home is known. + pub(crate) ssh_include: Option, + /// Opens waiting in the background, at most one per Agent and target. + pub(crate) opening: Vec<(String, OpenTarget)>, +} + +/// Display state of one process-owned port forward. +pub(crate) struct ForwardEntry { + pub(crate) id: u64, + pub(crate) agent: String, + pub(crate) local: String, + pub(crate) guest_port: u16, + pub(crate) status: Option, + /// The forward stopped serving, so its address no longer works. + pub(crate) finished: bool, +} + +impl ForwardEntry { + /// Renders the mapping as `LOCAL:GUEST`, keeping a non-loopback address. + pub(crate) fn mapping(&self) -> String { + let local = self.local.strip_prefix("127.0.0.1:").unwrap_or(&self.local); + let mapping = format!("{local}:{}", self.guest_port); + match self.label() { + Some(label) => format!("{label} {mapping}"), + None => mapping, + } + } + + /// Names a forward to the desktop. + pub(crate) const fn label(&self) -> Option<&'static str> { + crate::launch::forward_label(self.guest_port) + } + + /// The address that opens the forward: a VNC client for the RFB port, a browser otherwise. + pub(crate) fn url(&self) -> String { + crate::launch::forward_url(&self.local, self.guest_port) + } +} + +/// Which main screen the TUI is showing. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum View { + Tree, + Forwards, +} + +pub(crate) struct Group { + pub(crate) agent: usize, + pub(crate) sessions: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Row { + Agent(usize), + Session { group: usize, position: usize }, +} + +pub(crate) struct Detail { + pub(crate) title: String, + pub(crate) lines: Vec, + pub(crate) scroll: usize, + /// Agent whose provisioning the detail follows; its lines are replaced as + /// progress arrives. + pub(crate) follows: Option, + /// Furthest scroll that still fills the view, recorded by the last draw, + /// which wraps long lines into more rows than `lines` has. + pub(crate) scroll_limit: Cell>, +} + +impl Detail { + /// Moves the scroll by `delta` rows within what the last draw can show. + /// Before the first draw, every line may start the view. + fn scroll_by(&mut self, delta: isize) { + let limit = self + .scroll_limit + .get() + .unwrap_or_else(|| self.lines.len().saturating_sub(1)); + self.scroll = offset_clamped(self.scroll.min(limit), limit, delta); + } + + pub(crate) const fn text(title: String, lines: Vec) -> Self { + Self { + title, + lines, + scroll: 0, + follows: None, + scroll_limit: Cell::new(None), + } + } + + fn provisioning(agent: String) -> Self { + Self { + title: format!("agent/{agent} provisioning"), + lines: vec!["Waiting for agentd…".to_owned()], + scroll: 0, + follows: Some(agent), + scroll_limit: Cell::new(None), + } + } +} + +pub(crate) enum Modal { + ConfirmDelete { + agent: String, + sessions: usize, + }, + ConfirmStop { + agent: String, + }, + ConfirmDeleteSession { + agent: String, + session: SessionName, + }, + NewSession(SessionForm), + CreateAgent(CreateForm), + PortForward(ForwardForm), + Filter, + Prompt(PromptForm), + Help, + Open(OpenMenu), + /// Asks before quitting closes the forwards this TUI holds open. + ConfirmQuit, + /// Asks before adding `include` to the user's OpenSSH configuration, then + /// opens `then` in `agent`. + ConfirmSshSetup { + agent: String, + include: agent::ssh::UserInclude, + then: Option, + }, +} + +/// A prompt for a running Session, sent without attaching to it. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PromptForm { + pub(crate) agent: String, + pub(crate) session: SessionName, + pub(crate) input: String, + /// Why the last attempt to send the input failed. + pub(crate) error: Option, +} + +impl PromptForm { + /// Applies one key press; a submitted or cancelled form returns its Action. + fn key(&mut self, key: KeyEvent) -> Option { + match key.code { + KeyCode::Esc => return Some(Action::None), + KeyCode::Enter if self.input.trim().is_empty() => self.error = Some("type a prompt to send".into()), + KeyCode::Enter => return Some(Action::Prompt(self.clone())), + KeyCode::Backspace => { + self.input.pop(); + self.error = None; + } + KeyCode::Char(character) + if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() && !character.is_control() => + { + self.input.push(character); + self.error = None; + } + _ => {} + } + None + } +} + +/// The selected Session's most recent turns, shown beside the tree. +pub(crate) struct Transcript { + pub(crate) agent: String, + pub(crate) session: SessionName, + /// The Session's turn count when the turns were last requested, if they + /// were; a change reloads them. + requested_at: Option, + pub(crate) turns: Vec, + pub(crate) loading: bool, + pub(crate) error: Option, + /// The Session's Agent is stopped, so its turns, which live in the guest, are not read. + pub(crate) stopped: bool, +} + +/// Text field of the new Session form that typing edits. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum SessionField { + Name, + Model, + Effort, +} + +impl SessionField { + const ORDER: [Self; 3] = [Self::Name, Self::Model, Self::Effort]; + + fn next(self) -> Self { + let index = Self::ORDER.iter().position(|field| *field == self).unwrap_or_default(); + Self::ORDER[(index + 1) % Self::ORDER.len()] + } + + fn previous(self) -> Self { + let index = Self::ORDER.iter().position(|field| *field == self).unwrap_or_default(); + Self::ORDER[(index + Self::ORDER.len() - 1) % Self::ORDER.len()] + } +} + +/// New Session form state: a name, optional model and effort, and a harness picker. +/// +/// Empty model and effort fields leave the choice to the daemon, which applies +/// the selected installation's manifest defaults and then the harness's own. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct SessionForm { + pub(crate) agent: String, + pub(crate) name: String, + pub(crate) model: String, + pub(crate) effort: String, + pub(crate) field: SessionField, + pub(crate) harnesses: Vec, + pub(crate) harness: usize, + pub(crate) error: Option, +} + +impl SessionForm { + /// The installation the harness picker currently selects. + pub(crate) fn installation(&self) -> Option<&HarnessSpec> { + self.harnesses.get(self.harness) + } + + /// Manifest default that applies while the model field is empty. + pub(crate) fn model_default(&self) -> Option<&str> { + self.installation() + .and_then(|installation| installation.defaults.model_str()) + } + + /// Manifest default that applies while the effort field is empty. + pub(crate) fn effort_default(&self) -> Option<&str> { + self.installation() + .and_then(|installation| installation.defaults.effort_str()) + } + + /// Applies one key; `Some` closes the form with the returned action. + fn key(&mut self, key: KeyEvent, sessions: &[Session]) -> Option { + match key.code { + KeyCode::Esc => return Some(Action::None), + KeyCode::Enter => match self.submit(sessions) { + Ok(action) => return Some(action), + Err(invalid) => self.error = Some(invalid), + }, + KeyCode::Tab | KeyCode::Down => self.field = self.field.next(), + KeyCode::BackTab | KeyCode::Up => self.field = self.field.previous(), + KeyCode::Right => self.harness = (self.harness + 1) % self.harnesses.len().max(1), + KeyCode::Left => { + self.harness = self + .harness + .checked_sub(1) + .unwrap_or_else(|| self.harnesses.len().saturating_sub(1)); + } + KeyCode::Backspace => { + self.value_mut().pop(); + self.error = None; + } + KeyCode::Char(character) + if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() && self.accepts(character) => + { + self.value_mut().push(character); + self.error = None; + } + _ => {} + } + None + } + + /// Validates the form. An existing name is rejected, because ensuring it + /// would attach to that Session instead of creating one. + fn submit(&self, sessions: &[Session]) -> Result { + let session = SessionName::new(self.name.clone()).map_err(|invalid| invalid.to_string())?; + if sessions + .iter() + .any(|existing| existing.agent == self.agent && existing.name == session) + { + return Err(format!("session {:?} already exists", session.as_str())); + } + let Some(installation) = self.installation() else { + return Ok(Action::None); + }; + let model = (!self.model.is_empty()) + .then(|| Model::new(self.model.clone())) + .transpose() + .map_err(|invalid| invalid.to_string())?; + let effort = (!self.effort.is_empty()) + .then(|| Effort::new(self.effort.clone())) + .transpose() + .map_err(|invalid| invalid.to_string())?; + Ok(Action::CreateSession { + agent: self.agent.clone(), + session, + harness: installation.kind, + model_selection: ModelSelection { model, effort }, + }) + } + + const fn value_mut(&mut self) -> &mut String { + match self.field { + SessionField::Name => &mut self.name, + SessionField::Model => &mut self.model, + SessionField::Effort => &mut self.effort, + } + } + + /// Whether typing `character` into the focused field is accepted. The name + /// field admits only valid characters; a model or effort keeps whatever was + /// typed, so an invalid value is reported on submission instead of being + /// silently reshaped into a different valid one. + fn accepts(&self, character: char) -> bool { + match self.field { + SessionField::Name => { + (character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) && self.name.len() < 64 + } + SessionField::Model => !character.is_control() && self.model.chars().count() < 128, + SessionField::Effort => !character.is_control() && self.effort.chars().count() < 128, + } + } +} + +/// One manifest source offered by the create-agent picker. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ManifestCandidate { + /// Full path of the manifest file. + pub(crate) path: PathBuf, + /// Decoded `metadata.name`, or why the manifest cannot be used. + pub(crate) name: Result, + /// Other path spellings discovered for the same canonical file. + equivalent_paths: Vec, +} + +impl ManifestCandidate { + pub(crate) const fn new(path: PathBuf, name: Result) -> Self { + Self { + path, + name, + equivalent_paths: Vec::new(), + } + } + + pub(crate) fn add_equivalent_path(&mut self, path: PathBuf) { + if self.path != path && !self.equivalent_paths.contains(&path) { + self.equivalent_paths.push(path); + } + } + + fn matches_path(&self, path: &Path) -> bool { + self.path == path || self.equivalent_paths.iter().any(|candidate| candidate == path) + } +} + +/// One Agent's default manifest and variant leaves. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct AgentDefinition { + /// Directory containing the Agent's `agent.yaml`. + pub(crate) directory: PathBuf, + /// Manifest leaves in picker order, with `agent.yaml` first. + pub(crate) variants: Vec, +} + +impl AgentDefinition { + /// User-facing Agent label, taken from the expanded default when possible. + pub(crate) fn label(&self) -> String { + self.variants + .iter() + .find(|candidate| { + candidate + .path + .file_name() + .is_some_and(|name| name == agent::manifest::MANIFEST_FILE) + }) + .or_else(|| self.variants.first()) + .and_then(|candidate| candidate.name.as_ref().ok()) + .cloned() + .or_else(|| { + self.directory + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + }) + .unwrap_or_else(|| self.directory.display().to_string()) + } +} + +/// Focused field of the create-Agent form. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum CreateField { + Agent, + Variant, + Name, + EnvironmentFile, +} + +impl CreateField { + const ORDER: [Self; 4] = [Self::Agent, Self::Variant, Self::Name, Self::EnvironmentFile]; + + fn next(self) -> Self { + let index = Self::ORDER.iter().position(|field| *field == self).unwrap_or_default(); + Self::ORDER[(index + 1) % Self::ORDER.len()] + } + + fn previous(self) -> Self { + let index = Self::ORDER.iter().position(|field| *field == self).unwrap_or_default(); + Self::ORDER[(index + Self::ORDER.len() - 1) % Self::ORDER.len()] + } +} + +/// Create-agent form state: independent Agent and variant pickers plus apply overrides. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CreateForm { + pub(crate) agents: Vec, + pub(crate) agent: usize, + pub(crate) variant: usize, + pub(crate) field: CreateField, + pub(crate) name: String, + pub(crate) env_file: String, + pub(crate) error: Option, +} + +impl CreateForm { + /// Groups discovered leaves by sibling directory and preselects exact provenance. + pub(crate) fn new(candidates: Vec, selected_path: Option<&Path>) -> Self { + let mut agents = Vec::::new(); + for candidate in candidates { + let directory = candidate.path.parent().unwrap_or_else(|| Path::new("")).to_path_buf(); + if let Some(agent) = agents.iter_mut().find(|agent| agent.directory == directory) { + agent.variants.push(candidate); + } else { + agents.push(AgentDefinition { + directory, + variants: vec![candidate], + }); + } + } + for agent in &mut agents { + agent.variants.sort_by_key(|candidate| { + ( + candidate + .path + .file_name() + .is_none_or(|name| name != agent::manifest::MANIFEST_FILE), + candidate.path.clone(), + ) + }); + } + let selected = selected_path.and_then(|selected| { + agents.iter().enumerate().find_map(|(agent_index, agent)| { + agent + .variants + .iter() + .position(|candidate| candidate.matches_path(selected)) + .map(|variant| (agent_index, variant)) + }) + }); + let (agent, variant) = selected.unwrap_or_default(); + Self { + agents, + agent, + variant, + field: CreateField::Agent, + name: String::new(), + env_file: String::new(), + error: None, + } + } + + pub(crate) fn agent(&self) -> Option<&AgentDefinition> { + self.agents.get(self.agent) + } + + pub(crate) fn candidate(&self) -> Option<&ManifestCandidate> { + self.agent()?.variants.get(self.variant) + } + + pub(crate) fn variant_label(&self) -> Option { + let path = &self.candidate()?.path; + if path + .file_name() + .is_some_and(|name| name == agent::manifest::MANIFEST_FILE) + { + Some("default".into()) + } else { + agent::manifest::variant_from_filename(path) + .map(String::from) + .or_else(|| path.file_name().map(|name| name.to_string_lossy().into_owned())) + } + } + + /// Returns the selected manifest's name, shown grayed while nothing is typed. + pub(crate) fn placeholder(&self) -> Option<&str> { + self.candidate()?.name.as_deref().ok() + } + + /// Applies one key press; a submitted or cancelled form returns its Action. + fn key(&mut self, key: KeyEvent, agents: &[Agent]) -> Option { + match key.code { + KeyCode::Esc => return Some(Action::None), + KeyCode::Enter => match self.submission(agents) { + Ok(action) => return Some(action), + Err(invalid) => self.error = Some(invalid), + }, + KeyCode::Tab | KeyCode::Down => { + self.field = self.field.next(); + self.error = None; + } + KeyCode::BackTab | KeyCode::Up => { + self.field = self.field.previous(); + self.error = None; + } + KeyCode::Right => self.select(1), + KeyCode::Left => self.select(-1), + KeyCode::Backspace if matches!(self.field, CreateField::Name | CreateField::EnvironmentFile) => { + match self.field { + CreateField::Name => { + self.name.pop(); + } + CreateField::EnvironmentFile => { + self.env_file.pop(); + } + CreateField::Agent | CreateField::Variant => {} + } + self.error = None; + } + KeyCode::Char(character) + if self.field == CreateField::Name + && key.modifiers.difference(KeyModifiers::SHIFT).is_empty() + && ::sandbox::SandboxName::accepts(character) + && self.name.len() < ::sandbox::MAX_SANDBOX_NAME_BYTES => + { + self.name.push(character); + self.error = None; + } + KeyCode::Char(character) + if self.field == CreateField::EnvironmentFile + && key.modifiers.difference(KeyModifiers::SHIFT).is_empty() + && !character.is_control() + && self.env_file.len() < 4096 => + { + self.env_file.push(character); + self.error = None; + } + _ => {} + } + None + } + + fn select(&mut self, delta: isize) { + match self.field { + CreateField::Agent => { + self.agent = wrapped_index(self.agent, self.agents.len(), delta); + self.variant = 0; + } + CreateField::Variant => { + let length = self.agent().map_or(0, |agent| agent.variants.len()); + self.variant = wrapped_index(self.variant, length, delta); + } + CreateField::Name | CreateField::EnvironmentFile => return, + } + self.error = None; + } + + fn submission(&self, agents: &[Agent]) -> Result { + let candidate = self + .candidate() + .ok_or_else(|| "no Agent manifests found; apply one with agentctl apply".to_owned())?; + let manifest_name = candidate.name.as_ref().map_err(Clone::clone)?; + let name = if self.name.is_empty() { + manifest_name.clone() + } else { + self.name.clone() + }; + ::sandbox::SandboxName::new(name.clone()).map_err(|invalid| format!("name: {invalid}"))?; + if agents.iter().any(|agent| agent.metadata.name == name) { + return Err(format!("agent {name:?} already exists")); + } + Ok(Action::CreateAgent { + manifest: candidate.path.clone(), + name, + env_file: (!self.env_file.is_empty()).then(|| PathBuf::from(&self.env_file)), + form: self.clone(), + }) + } +} + +fn wrapped_index(current: usize, length: usize, delta: isize) -> usize { + if length == 0 { + return 0; + } + let length = isize::try_from(length).unwrap_or(1); + let current = isize::try_from(current).unwrap_or_default(); + usize::try_from((current + delta).rem_euclid(length)).unwrap_or_default() +} + +/// k9s-style port-forward form state. +pub(crate) struct ForwardForm { + pub(crate) agent: String, + pub(crate) address: String, + pub(crate) local: String, + pub(crate) guest: String, + pub(crate) field: ForwardField, + pub(crate) error: Option, + pub(crate) replace: Option, +} + +impl ForwardForm { + /// Reopens the form for a mapping the runtime rejected, keeping its values. + pub(crate) fn rejected(agent: String, spec: &ForwardSpec, replace: Option, error: String) -> Self { + Self { + agent, + address: spec.address.to_string(), + local: if spec.local_port == 0 { + String::new() + } else { + spec.local_port.to_string() + }, + guest: spec.guest_port.to_string(), + field: ForwardField::Address, + error: Some(bind_hint(spec, error)), + replace, + } + } + + /// Applies one key press; a submitted or cancelled form returns its Action. + fn key(&mut self, key: KeyEvent) -> Option { + match key.code { + KeyCode::Esc => return Some(Action::None), + KeyCode::Enter => { + let local = if self.local.is_empty() { + &self.guest + } else { + &self.local + }; + match ForwardSpec::parse(&format!("{}:{local}:{}", self.address, self.guest)) { + Ok(spec) => { + return Some(Action::CreateForward { + agent: self.agent.clone(), + spec, + replace: self.replace, + }); + } + Err(invalid) => self.error = Some(invalid), + } + } + KeyCode::Tab | KeyCode::Down => self.field = self.field.next(), + KeyCode::BackTab | KeyCode::Up => self.field = self.field.previous(), + KeyCode::Backspace => { + self.field_text().pop(); + self.error = None; + } + KeyCode::Char(character) + if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() + && forward_field_accepts(self.field, character) => + { + let text = self.field_text(); + if text.len() < 45 { + text.push(character); + self.error = None; + } + } + _ => {} + } + None + } + + const fn field_text(&mut self) -> &mut String { + match self.field { + ForwardField::Address => &mut self.address, + ForwardField::LocalPort => &mut self.local, + ForwardField::GuestPort => &mut self.guest, + } + } +} + +/// One editable field of the port-forward form. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ForwardField { + Address, + LocalPort, + GuestPort, +} + +/// A semantic interaction emitted by the renderer's hit map. +/// +/// Mouse input uses these instead of terminal coordinates so layout remains +/// entirely owned by the renderer. Keyboard-shaped controls deliberately flow +/// back through `on_key` to keep both input methods equivalent. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum MouseAction { + Key(KeyCode, KeyModifiers), + Select(RowTarget), + Primary(RowTarget), + FoldTree(String), + MoveTree(isize), + MoveForward(isize), + ScrollDetail(isize), + FocusSessionField(SessionField), + SelectHarness(usize), + FocusCreateField(CreateField), + SelectCreate { + field: CreateField, + delta: isize, + }, + FocusForwardField(ForwardField), + /// Chooses the open menu's item at this index. + ChooseOpen(usize), +} + +/// A rendered row whose selection is owned by the application. +/// +/// Targets name the resource rather than its position, so a click acts on the +/// row that was drawn even when a newer snapshot has moved it since. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum RowTarget { + Tree(TreeRowId), + /// A port forward by its stable ID. + Forward(u64), +} + +/// Identity of one tree row. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum TreeRowId { + Agent(String), + Session { agent: String, session: SessionName }, +} + +impl ForwardField { + const fn next(self) -> Self { + match self { + Self::Address => Self::LocalPort, + Self::LocalPort => Self::GuestPort, + Self::GuestPort => Self::Address, + } + } + + const fn previous(self) -> Self { + match self { + Self::Address => Self::GuestPort, + Self::LocalPort => Self::Address, + Self::GuestPort => Self::LocalPort, + } + } +} + +#[derive(Debug, Eq, PartialEq)] +pub(crate) enum Action { + None, + Quit, + Attach { + agent: String, + session: SessionName, + }, + CreateSession { + agent: String, + session: SessionName, + harness: Harness, + model_selection: ModelSelection, + }, + OpenCreate, + CreateAgent { + manifest: PathBuf, + name: String, + env_file: Option, + form: CreateForm, + }, + Exec { + agent: String, + }, + Prompt(PromptForm), + Delete { + agent: String, + }, + SetRunState { + agent: String, + state: RunState, + }, + DeleteSession { + agent: String, + session: SessionName, + }, + SetArchived { + agent: String, + session: SessionName, + archived: bool, + }, + CreateForward { + agent: String, + spec: ForwardSpec, + replace: Option, + }, + DeleteForward { + id: u64, + }, + Open { + agent: String, + target: OpenTarget, + }, + /// Adds `include`, then opens `then`. + SetUpSsh { + include: agent::ssh::UserInclude, + then: Option<(String, OpenTarget)>, + }, + /// Opens an address on this machine. + OpenUrl(String), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Tone { + Green, + Yellow, + Cyan, + Gray, + Red, +} + +/// One tree row as the renderer draws it. +pub(crate) struct RowView { + pub(crate) agent: bool, + /// A Session waits for input, or an Agent has one that does. + pub(crate) attention: bool, + /// An Agent's fold marker or a Session's state glyph, readable without color. + pub(crate) marker: &'static str, + pub(crate) name: String, + pub(crate) state: &'static str, + pub(crate) tone: Tone, + /// How long the row has been in its state, when known. + pub(crate) since: String, + pub(crate) detail: String, + /// The detail is a failure message; when it does not fit, its end, where + /// the cause is, is kept. + pub(crate) detail_keeps_end: bool, + pub(crate) age: String, +} + +/// Sessions by state and Agents provisioning, for the header. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct TriageCounts { + pub(crate) needs_you: usize, + pub(crate) working: usize, + pub(crate) starting: usize, + pub(crate) idle: usize, + pub(crate) failed: usize, + pub(crate) provisioning: usize, + pub(crate) archived: usize, +} + +impl App { + pub(crate) fn new() -> Self { + Self { + agents: Vec::new(), + sessions: Vec::new(), + groups: Vec::new(), + rows: Vec::new(), + collapsed: HashSet::new(), + filter: String::new(), + selection: None, + loaded: false, + connection_error: None, + error: None, + detail: None, + modal: None, + forwards: Vec::new(), + view: View::Tree, + forward_selected: 0, + creating: 0, + prompting: 0, + transcript: None, + turns_loading: None, + side_panel: false, + show_archived: false, + notice: None, + discovering: false, + queued_candidates: None, + environment: Environment::default(), + ssh_setup: SshSetup::Unknown, + ssh_check_due: true, + ssh_checking: false, + ssh_include: None, + opening: Vec::new(), + } + } + + pub(crate) fn apply_snapshot(&mut self, mut agents: Vec, mut sessions: Vec) { + agents.sort_by(|left, right| left.metadata.name.cmp(&right.metadata.name)); + sessions.sort_by(|left, right| left.agent.cmp(&right.agent).then_with(|| left.name.cmp(&right.name))); + // The selection's position is read before the rows it indexes are replaced. + let fallback = self.selected_index().unwrap_or_default(); + self.agents = agents; + self.sessions = sessions; + self.loaded = true; + self.rebuild_from(fallback); + } + + /// Rebuilds the tree. A selected Session hidden by folding its Agent leaves + /// the selection on that Agent; any other selection whose row disappeared + /// falls back to the row now at its former position. + pub(crate) fn rebuild(&mut self) { + self.rebuild_from(self.selected_index().unwrap_or_default()); + } + + fn rebuild_from(&mut self, fallback: usize) { + self.groups = self + .agents + .iter() + .enumerate() + .map(|(index, agent)| Group { + agent: index, + sessions: self + .sessions + .iter() + .enumerate() + .filter(|(_, session)| session.agent == agent.metadata.name) + .map(|(session_index, _)| session_index) + .collect(), + }) + .collect(); + self.rows = self + .groups + .iter() + .enumerate() + .flat_map(|(group_index, group)| { + let Some(agent) = self.agents.get(group.agent) else { + return Vec::new(); + }; + let agent_matches = self.agent_matches(agent); + let sessions = self.listed_positions(group, agent_matches); + if !agent_matches && sessions.is_empty() { + return Vec::new(); + } + let mut rows = vec![Row::Agent(group_index)]; + // Folding hides Sessions only while nothing is filtered, so a match is never hidden. + if self.filter.is_empty() && self.collapsed.contains(&agent.metadata.name) { + return rows; + } + rows.extend(sessions.into_iter().map(|position| Row::Session { + group: group_index, + position, + })); + rows + }) + .collect(); + if self.selected_index().is_none() { + let agent = match &self.selection { + Some(TreeRowId::Session { agent, session }) => self.session_or_agent_near(agent, session), + _ => None, + }; + self.selection = agent.or_else(|| self.tree_id_at(fallback.min(self.rows.len().saturating_sub(1)))); + } + } + + /// Where the selection goes when its Session's row disappeared: the listed + /// Session of the same Agent that follows it by name, the one before it + /// when it was the last, or else the Agent. Archiving or deleting one + /// Session after another then needs no move between them, however many + /// rows a snapshot, the filter or showing archived Sessions removed. + fn session_or_agent_near(&self, agent: &str, name: &SessionName) -> Option { + let listed = (0..self.rows.len()) + .filter_map(|index| match self.tree_id_at(index)? { + TreeRowId::Session { agent: owner, session } if owner == agent => Some(session), + _ => None, + }) + .collect::>(); + listed + .iter() + .find(|session| *session > name) + .or_else(|| listed.iter().rev().find(|session| *session < name)) + .map(|session| TreeRowId::Session { + agent: agent.to_owned(), + session: session.clone(), + }) + .or_else(|| Some(TreeRowId::Agent(agent.to_owned())).filter(|id| self.tree_index(id).is_some())) + } + + fn agent_matches(&self, agent: &Agent) -> bool { + self.matches(&agent.metadata.name) || self.matches(agent_state(agent).label) + } + + /// Positions of the group's Sessions the tree lists, folded or not: the + /// shown ones, and while filtered, those matching or of a matching Agent. + fn listed_positions(&self, group: &Group, agent_matches: bool) -> Vec { + (0..group.sessions.len()) + .filter(|position| { + self.sessions + .get(group.sessions[*position]) + .is_some_and(|session| self.lists(session) && (agent_matches || self.session_matches(session))) + }) + .collect() + } + + /// Whether the tree lists this Session when nothing is filtered or folded. + const fn lists(&self, session: &Session) -> bool { + self.show_archived || !session.is_archived() + } + + fn matches(&self, value: &str) -> bool { + value.to_lowercase().contains(&self.filter.to_lowercase()) + } + + fn session_matches(&self, session: &Session) -> bool { + let (_, _, state) = session_state(session.status.state); + [ + session.name.as_str(), + state, + harness_label(session.harness), + session.model_selection.model_str().unwrap_or_default(), + ] + .into_iter() + .any(|value| self.matches(value)) + } + + /// Selects the next Session waiting for input in tree order, after the + /// selection and wrapping around. The header counts every such Session, so + /// one in a folded Agent is unfolded and one the filter hides clears it. + /// An archived Session is neither counted nor selected, as nobody answers it. + fn select_next_needing_input(&mut self) { + let order = self + .groups + .iter() + .filter_map(|group| { + let agent = self.agents.get(group.agent)?; + let sessions = group.sessions.iter().filter_map(|index| self.sessions.get(*index)); + Some( + std::iter::once((TreeRowId::Agent(agent.metadata.name.clone()), None)).chain(sessions.map( + |session| { + ( + TreeRowId::Session { + agent: session.agent.clone(), + session: session.name.clone(), + }, + Some(session), + ) + }, + )), + ) + }) + .flatten() + .collect::>(); + let start = self + .selection + .as_ref() + .and_then(|selection| order.iter().position(|(id, _)| id == selection)) + .map_or(0, |index| index + 1); + let Some((target, session)) = (0..order.len()) + .map(|step| &order[(start + step) % order.len()]) + .find(|(_, session)| session.is_some_and(needs_you)) + else { + return; + }; + if let Some(session) = session { + if !self.session_matches(session) && !self.matches(&session.agent) { + self.filter.clear(); + } + self.collapsed.remove(&session.agent); + } + self.selection = Some(target.clone()); + self.rebuild(); + } + + pub(crate) fn selected_row(&self) -> Option { + self.rows.get(self.selected_index()?).copied() + } + + pub(crate) fn selected_index(&self) -> Option { + self.tree_index(self.selection.as_ref()?) + } + + /// Identity of the row at `index`. + pub(crate) fn tree_id_at(&self, index: usize) -> Option { + match *self.rows.get(index)? { + Row::Agent(group) => Some(TreeRowId::Agent(self.group_agent(group)?.metadata.name.clone())), + Row::Session { group, position } => { + let session = self.group_session(group, position)?; + Some(TreeRowId::Session { + agent: session.agent.clone(), + session: session.name.clone(), + }) + } + } + } + + fn tree_index(&self, target: &TreeRowId) -> Option { + (0..self.rows.len()).find(|index| self.tree_id_at(*index).as_ref() == Some(target)) + } + + pub(crate) fn select_index(&mut self, index: usize) { + if let Some(target) = self.tree_id_at(index) { + self.selection = Some(target); + } + } + + pub(crate) fn triage_counts(&self) -> TriageCounts { + let mut counts = TriageCounts { + provisioning: self + .agents + .iter() + .filter(|agent| agent_state(agent).label == "Provisioning") + .count(), + ..TriageCounts::default() + }; + for session in &self.sessions { + let count = match session.status.state { + State::WaitingForInput => &mut counts.needs_you, + State::Working => &mut counts.working, + State::Starting => &mut counts.starting, + State::Idle => &mut counts.idle, + State::Failed => &mut counts.failed, + State::Archiving | State::Archived => &mut counts.archived, + }; + *count += 1; + } + counts + } + + pub(crate) const fn idle(&self) -> bool { + self.modal.is_none() && self.detail.is_none() + } + + pub(crate) fn on_key(&mut self, key: KeyEvent) -> Action { + if self.modal.is_some() { + return self.modal_key(key); + } + if self.detail.is_some() { + self.detail_key(key); + return Action::None; + } + if self.error.is_some() { + return self.error_key(key); + } + if self.view == View::Forwards { + return self.forwards_key(key); + } + self.main_key(key) + } + + /// The error screen renders over the tree or forwards view until dismissed. + fn error_key(&mut self, key: KeyEvent) -> Action { + match key.code { + KeyCode::Esc | KeyCode::Enter => self.error = None, + KeyCode::Char('q') => return self.quit(), + _ => {} + } + Action::None + } + + /// Quits, asking first while forwards would close with the TUI. + fn quit(&mut self) -> Action { + if self.forwards.is_empty() { + return Action::Quit; + } + self.modal = Some(Modal::ConfirmQuit); + Action::None + } + + pub(crate) fn on_mouse(&mut self, action: MouseAction) -> Action { + match action { + MouseAction::Key(code, modifiers) => self.on_key(KeyEvent::new(code, modifiers)), + MouseAction::Select(target) => { + self.select_row(&target); + Action::None + } + MouseAction::Primary(target) => { + if !self.select_row(&target) { + return Action::None; + } + let code = match target { + RowTarget::Tree(_) => KeyCode::Enter, + RowTarget::Forward(_) => KeyCode::Char('e'), + }; + self.on_key(KeyEvent::new(code, KeyModifiers::NONE)) + } + MouseAction::FoldTree(agent) => { + if !self.select_row(&RowTarget::Tree(TreeRowId::Agent(agent))) { + return Action::None; + } + self.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + } + MouseAction::MoveTree(delta) => { + self.move_selection_clamped(delta); + Action::None + } + MouseAction::MoveForward(delta) => { + self.move_forward_selection_clamped(delta); + Action::None + } + MouseAction::ScrollDetail(delta) => { + let Some(detail) = self.detail.as_mut() else { + return Action::None; + }; + detail.scroll_by(delta); + Action::None + } + MouseAction::FocusSessionField(field) => { + if let Some(Modal::NewSession(form)) = &mut self.modal { + form.field = field; + } + Action::None + } + MouseAction::SelectHarness(index) => { + if let Some(Modal::NewSession(form)) = &mut self.modal + && index < form.harnesses.len() + { + form.harness = index; + } + Action::None + } + MouseAction::FocusCreateField(field) => { + if let Some(Modal::CreateAgent(form)) = &mut self.modal { + form.field = field; + form.error = None; + } + Action::None + } + MouseAction::SelectCreate { field, delta } => { + if let Some(Modal::CreateAgent(form)) = &mut self.modal { + form.field = field; + form.select(delta); + } + Action::None + } + MouseAction::FocusForwardField(field) => { + if let Some(Modal::PortForward(form)) = &mut self.modal { + form.field = field; + } + Action::None + } + MouseAction::ChooseOpen(index) => { + let Some(Modal::Open(menu)) = self.modal.take() else { + return Action::None; + }; + let chosen = menu + .items + .get(index) + .filter(|item| item.unavailable.is_none()) + .map(|item| item.entry); + if let Some(entry) = chosen { + return self.choose(menu.agent, entry); + } + self.modal = Some(Modal::Open(menu)); + Action::None + } + } + } + + /// Selects the targeted row; `false` when it no longer exists. + fn select_row(&mut self, target: &RowTarget) -> bool { + match target { + RowTarget::Tree(target) => { + if self.tree_index(target).is_none() { + return false; + } + self.selection = Some(target.clone()); + } + RowTarget::Forward(id) => { + let Some(index) = self.forwards.iter().position(|entry| entry.id == *id) else { + return false; + }; + self.forward_selected = index; + } + } + true + } + + fn main_key(&mut self, key: KeyEvent) -> Action { + match key.code { + KeyCode::Esc if !self.filter.is_empty() => { + self.filter.clear(); + self.rebuild(); + } + KeyCode::Esc | KeyCode::Char('q') => return self.quit(), + KeyCode::Char('/') => self.modal = Some(Modal::Filter), + KeyCode::Char('?') => self.modal = Some(Modal::Help), + KeyCode::Tab => self.select_next_needing_input(), + KeyCode::Down | KeyCode::Char('j') => self.move_selection(1), + KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1), + KeyCode::Char('z') => self.toggle_all(), + KeyCode::Char('A') => { + self.show_archived = !self.show_archived; + // Showing or hiding archived Sessions answers a notice about + // archiving, "A to show" included; any other is short-lived. + self.notice = None; + self.rebuild(); + } + KeyCode::Char('F') => self.view = View::Forwards, + KeyCode::Char('c') => return Action::OpenCreate, + _ => { + return match self.selected_row() { + Some(Row::Agent(group)) => self.agent_key(key, group), + Some(Row::Session { group, position }) => self.session_key(key, group, position), + None => Action::None, + }; + } + } + Action::None + } + + fn forwards_key(&mut self, key: KeyEvent) -> Action { + match key.code { + KeyCode::Esc | KeyCode::Char('q' | 'F') => self.view = View::Tree, + KeyCode::Char('?') => self.modal = Some(Modal::Help), + KeyCode::Down | KeyCode::Char('j') => self.move_forward_selection(1), + KeyCode::Up | KeyCode::Char('k') => self.move_forward_selection(-1), + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(entry) = self.forwards.get(self.forward_selected) { + return Action::DeleteForward { id: entry.id }; + } + } + KeyCode::Char('o') => { + if let Some(entry) = self.forwards.get(self.forward_selected) { + return Action::OpenUrl(entry.url()); + } + } + KeyCode::Char('e') => { + if let Some(entry) = self.forwards.get(self.forward_selected) { + let (address, local) = entry + .local + .rsplit_once(':') + .map_or((String::new(), String::new()), |(address, local)| { + (address.to_owned(), local.to_owned()) + }); + self.modal = Some(Modal::PortForward(ForwardForm { + agent: entry.agent.clone(), + address, + local, + guest: entry.guest_port.to_string(), + field: ForwardField::LocalPort, + error: None, + replace: Some(entry.id), + })); + } + } + _ => {} + } + Action::None + } + + fn agent_key(&mut self, key: KeyEvent, group: usize) -> Action { + let Some(agent) = self.group_agent(group) else { + return Action::None; + }; + let name = agent.metadata.name.clone(); + // Nothing runs in a stopped Agent until it is started. + if agent.spec.is_stopped() && matches!(key.code, KeyCode::Char('n' | 'o' | 'e' | 'f')) { + return Action::None; + } + match key.code { + KeyCode::Enter | KeyCode::Char(' ') => self.toggle_fold(&name), + KeyCode::Right => { + if self.collapsed.remove(&name) { + self.rebuild(); + } + } + KeyCode::Left => { + if self.collapsed.insert(name) { + self.rebuild(); + } + } + KeyCode::Char('p') => self.detail = Some(Detail::provisioning(name)), + KeyCode::Char('s') => { + self.detail = Some(Detail::text( + format!("agent/{name}"), + format::describe_agent_lines(agent), + )); + } + KeyCode::Char('y') => self.detail = Some(Detail::text(format!("agent/{name} yaml"), yaml_lines(agent))), + KeyCode::Char('d') => { + let sessions = self.groups.get(group).map_or(0, |group| group.sessions.len()); + self.modal = Some(Modal::ConfirmDelete { agent: name, sessions }); + } + // Starting before the stop is recorded would cancel it, so wait for it. + KeyCode::Char('x') if stop_pending(agent) => {} + KeyCode::Char('x') if agent.spec.is_stopped() => { + return Action::SetRunState { + agent: name, + state: RunState::Running, + }; + } + KeyCode::Char('x') => self.modal = Some(Modal::ConfirmStop { agent: name }), + KeyCode::Char('n') => self.open_new_session(group), + KeyCode::Char('o') => self.open_menu(&name), + KeyCode::Char('e') => return Action::Exec { agent: name }, + KeyCode::Char('f') => { + self.modal = Some(Modal::PortForward(ForwardForm { + agent: name, + address: "127.0.0.1".into(), + local: String::new(), + guest: String::new(), + field: ForwardField::GuestPort, + error: None, + replace: None, + })); + } + _ => {} + } + Action::None + } + + fn session_key(&mut self, key: KeyEvent, group: usize, position: usize) -> Action { + let Some(session) = self.group_session(group, position) else { + return Action::None; + }; + // An archived Session cannot be attached or prompted until it is unarchived. + if session.is_archived() && matches!(key.code, KeyCode::Enter | KeyCode::Char('p')) { + return Action::None; + } + // Nothing runs in a stopped Agent until it is started. + if self.group_agent(group).is_some_and(|agent| agent.spec.is_stopped()) + && matches!(key.code, KeyCode::Enter | KeyCode::Char('p' | 'n' | 'o')) + { + return Action::None; + } + match key.code { + KeyCode::Enter => { + return Action::Attach { + agent: session.agent.clone(), + session: session.name.clone(), + }; + } + KeyCode::Left => { + let agent = session.agent.clone(); + self.collapsed.insert(agent.clone()); + self.selection = Some(TreeRowId::Agent(agent)); + self.rebuild(); + } + KeyCode::Char('s') => self.detail = Some(session_detail(session)), + KeyCode::Char('y') => { + self.detail = Some(Detail::text( + format!("session/{}/{} yaml", session.agent, session.name.as_str()), + yaml_lines(session), + )); + } + KeyCode::Char('a') => { + return Action::SetArchived { + agent: session.agent.clone(), + session: session.name.clone(), + archived: !session.is_archived(), + }; + } + KeyCode::Char('d') => { + self.modal = Some(Modal::ConfirmDeleteSession { + agent: session.agent.clone(), + session: session.name.clone(), + }); + } + KeyCode::Char('n') => self.open_new_session(group), + KeyCode::Char('o') => { + let agent = session.agent.clone(); + self.open_menu(&agent); + } + KeyCode::Char('p') => { + self.modal = Some(Modal::Prompt(PromptForm { + agent: session.agent.clone(), + session: session.name.clone(), + input: String::new(), + error: None, + })); + } + _ => {} + } + Action::None + } + + fn detail_key(&mut self, key: KeyEvent) { + let Some(detail) = self.detail.as_mut() else { + return; + }; + match key.code { + KeyCode::Esc | KeyCode::Char('q') => self.detail = None, + KeyCode::Down | KeyCode::Char('j') => detail.scroll_by(1), + KeyCode::Up | KeyCode::Char('k') => detail.scroll_by(-1), + KeyCode::PageDown => detail.scroll_by(10), + KeyCode::PageUp => detail.scroll_by(-10), + _ => {} + } + } + + fn modal_key(&mut self, key: KeyEvent) -> Action { + match self.modal.take() { + Some(Modal::Help) => { + if !matches!(key.code, KeyCode::Esc | KeyCode::Char('q' | '?')) { + self.modal = Some(Modal::Help); + } + Action::None + } + Some(Modal::ConfirmDelete { agent, sessions }) => match key.code { + KeyCode::Char('y') => Action::Delete { agent }, + KeyCode::Esc | KeyCode::Char('n' | 'q') => Action::None, + _ => { + self.modal = Some(Modal::ConfirmDelete { agent, sessions }); + Action::None + } + }, + Some(Modal::ConfirmStop { agent }) => match key.code { + KeyCode::Char('y') => Action::SetRunState { + agent, + state: RunState::Stopped, + }, + KeyCode::Esc | KeyCode::Char('n' | 'q') => Action::None, + _ => { + self.modal = Some(Modal::ConfirmStop { agent }); + Action::None + } + }, + Some(Modal::ConfirmDeleteSession { agent, session }) => match key.code { + KeyCode::Char('y') => Action::DeleteSession { agent, session }, + KeyCode::Esc | KeyCode::Char('n' | 'q') => Action::None, + _ => { + self.modal = Some(Modal::ConfirmDeleteSession { agent, session }); + Action::None + } + }, + Some(Modal::NewSession(mut form)) => { + if let Some(action) = form.key(key, &self.sessions) { + return action; + } + self.modal = Some(Modal::NewSession(form)); + Action::None + } + Some(Modal::CreateAgent(mut form)) => { + if let Some(action) = form.key(key, &self.agents) { + return action; + } + self.modal = Some(Modal::CreateAgent(form)); + Action::None + } + Some(Modal::PortForward(mut form)) => { + if let Some(action) = form.key(key) { + return action; + } + self.modal = Some(Modal::PortForward(form)); + Action::None + } + Some(Modal::Prompt(mut form)) => { + if let Some(action) = form.key(key) { + return action; + } + self.modal = Some(Modal::Prompt(form)); + Action::None + } + Some(Modal::Open(menu)) => self.open_menu_key(menu, key), + Some(Modal::ConfirmQuit) => match key.code { + KeyCode::Char('y') => Action::Quit, + KeyCode::Esc | KeyCode::Char('n' | 'q') => Action::None, + _ => { + self.modal = Some(Modal::ConfirmQuit); + Action::None + } + }, + Some(Modal::ConfirmSshSetup { agent, include, then }) => { + self.confirm_ssh_setup_key(agent, include, then, key) + } + Some(Modal::Filter) => { + match key.code { + KeyCode::Enter => return Action::None, + KeyCode::Esc => { + self.filter.clear(); + self.rebuild(); + return Action::None; + } + KeyCode::Backspace => { + self.filter.pop(); + self.rebuild(); + } + KeyCode::Char(character) + if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() && !character.is_control() => + { + self.filter.push(character); + self.rebuild(); + } + _ => {} + } + self.modal = Some(Modal::Filter); + Action::None + } + None => Action::None, + } + } + + /// Opens the create-agent modal for finished discovery, or queues the + /// candidates while another view is open. + pub(crate) fn manifests_discovered(&mut self, candidates: Vec) { + if !std::mem::take(&mut self.discovering) { + return; + } + if self.idle() { + self.open_create(candidates); + } else { + self.queued_candidates = Some(candidates); + } + } + + /// Opens the create-agent modal for candidates queued behind another view once it closes. + pub(crate) fn open_queued_create(&mut self) { + if self.idle() + && let Some(candidates) = self.queued_candidates.take() + { + self.open_create(candidates); + } + } + + /// Opens the create-agent modal, preselecting the highlighted Agent's manifest. + pub(crate) fn open_create(&mut self, candidates: Vec) { + let manifest = match self.selected_row() { + Some(Row::Agent(group) | Row::Session { group, .. }) => self + .group_agent(group) + .and_then(|agent| agent.status.provenance.as_ref()) + .map(agent::Provenance::manifest_or_default), + None => None, + }; + self.modal = Some(Modal::CreateAgent(CreateForm::new(candidates, manifest.as_deref()))); + } + + /// Shows an Agent this TUI just created, ahead of the watch reply that will + /// report it, selects it and follows its provisioning. + pub(crate) fn agent_applied(&mut self, agent: Agent) { + let name = agent.metadata.name.clone(); + let mut agents = std::mem::take(&mut self.agents); + agents.retain(|existing| existing.metadata.name != name); + agents.push(agent); + let sessions = std::mem::take(&mut self.sessions); + self.apply_snapshot(agents, sessions); + self.selection = Some(TreeRowId::Agent(name.clone())); + self.detail = Some(Detail::provisioning(name)); + } + + /// The selected Session whose turns need loading: newly selected, or with + /// more turns than when they were last requested. + /// The selected Session whose turns the side panel needs loaded, if any. + /// + /// Turns are read from the Session's Sandbox, so they load only while the + /// panel is shown, one load at a time: moving through Sessions loads the one + /// the selection rests on. They reload when the Session finishes a turn. + pub(crate) fn transcript_request(&mut self) -> Option<(String, SessionName)> { + let Some(TreeRowId::Session { agent, session }) = self.selection.as_ref().filter(|_| self.side_panel) else { + self.transcript = None; + return None; + }; + let turns = self + .sessions + .iter() + .find(|candidate| candidate.agent == *agent && candidate.name == *session) + .map_or(0, |session| session.status.reported.activity.turns); + let stopped = self + .agents + .iter() + .any(|candidate| candidate.metadata.name == *agent && candidate.spec.is_stopped()); + let transcript = match &mut self.transcript { + Some(transcript) if transcript.agent == *agent && transcript.session == *session => transcript, + _ => self.transcript.insert(Transcript { + agent: agent.clone(), + session: session.clone(), + requested_at: None, + turns: Vec::new(), + loading: true, + error: None, + stopped, + }), + }; + transcript.stopped = stopped; + if stopped || self.turns_loading.is_some() || transcript.requested_at == Some(turns) { + return None; + } + transcript.requested_at = Some(turns); + self.turns_loading = Some((agent.clone(), session.clone(), turns)); + Some((agent.clone(), session.clone())) + } + + pub(crate) fn transcript_loaded(&mut self, agent: &str, session: &SessionName, turns: Result, String>) { + let requested_at = self.turns_loading.take().map(|(_, _, turns)| turns); + let Some(transcript) = self + .transcript + .as_mut() + .filter(|transcript| transcript.agent == agent && transcript.session == *session) + else { + return; + }; + // The selection may have left the Session and come back while it loaded. + transcript.requested_at = requested_at; + transcript.loading = false; + match turns { + Ok(turns) => { + transcript.turns = turns; + transcript.error = None; + } + Err(error) => transcript.error = Some(error), + } + } + + /// Reopens a prompt that could not be sent with its input and the reason, + /// unless another form is open by now. + pub(crate) fn prompt_failed(&mut self, mut form: PromptForm, error: String) { + if self.modal.is_some() { + self.error = Some(error); + } else { + form.error = Some(error); + self.modal = Some(Modal::Prompt(form)); + } + } + + /// Applies an archive or unarchive as the daemon recorded it, so the tree + /// changes with the notice instead of on the next watch reply, which + /// confirms it. The notice tells what happened, since an archived Session + /// leaves the tree while archived Sessions are hidden. It is kept short to + /// fit the header; the row reads Archiving while the harness still runs. + pub(crate) fn archive_changed(&mut self, session: Session, now: Instant) { + let name = session.name.as_str(); + let notice = match (session.is_archived(), self.show_archived) { + (false, _) => format!("{name} unarchived"), + (true, true) => format!("{name} archived"), + (true, false) => format!("{name} archived · A to show"), + }; + self.notice = Some((notice, now)); + if let Some(listed) = self + .sessions + .iter_mut() + .find(|listed| listed.agent == session.agent && listed.name == session.name) + { + *listed = session; + self.rebuild(); + } + } + + pub(crate) fn expire_notice(&mut self, now: Instant) { + if self + .notice + .as_ref() + .is_some_and(|(_, shown)| now.saturating_duration_since(*shown) >= NOTICE_DURATION) + { + self.notice = None; + } + } + + /// The selected Agent's status for the side panel: readiness, and the pass + /// in progress or the one that failed with its last output. + pub(crate) fn agent_panel_lines(&self, name: &str) -> Vec { + let Some(agent) = self.agents.iter().find(|agent| agent.metadata.name == name) else { + return Vec::new(); + }; + let mut lines = format::readiness_lines(&agent.status); + if let Some(provisioning) = &agent.status.progress { + let progress = &provisioning.progress; + lines.extend(format::provisioning_lines( + progress, + progress + .output() + .tail(AGENT_PANEL_OUTPUT_LINES) + .map(|line| line.text.as_str()), + )); + } + lines.extend([String::new(), "Connect · o open…".to_owned()]); + let desktop = self + .forwards + .iter() + .find(|entry| entry.agent == name && entry.label().is_some() && !entry.finished) + .map(ForwardEntry::url); + lines.extend(super::open::connect_lines( + agent, + &self.environment, + self.ssh_setup, + desktop.as_deref(), + )); + lines + } + + /// The Agent whose provisioning the open detail follows. + pub(crate) fn followed_agent(&self) -> Option<&str> { + self.detail.as_ref()?.follows.as_deref() + } + + /// Replaces the lines of the detail following `agent`'s provisioning. + pub(crate) fn provisioning_followed(&mut self, agent: &str, lines: Vec) { + if let Some(detail) = self + .detail + .as_mut() + .filter(|detail| detail.follows.as_deref() == Some(agent)) + { + detail.scroll = detail.scroll.min(lines.len().saturating_sub(1)); + detail.lines = lines; + // The next draw measures the new lines. + detail.scroll_limit.set(None); + } + } + + /// Applies one key to the open menu: moving, choosing by row or by the item's own key. + fn open_menu_key(&mut self, mut menu: OpenMenu, key: KeyEvent) -> Action { + let chosen = match key.code { + KeyCode::Esc | KeyCode::Char('q') => return Action::None, + KeyCode::Enter => menu.chosen(), + KeyCode::Down | KeyCode::Char('j') | KeyCode::Tab => { + menu.move_selection(1); + None + } + KeyCode::Up | KeyCode::Char('k') | KeyCode::BackTab => { + menu.move_selection(-1); + None + } + KeyCode::Char(character) => menu.by_key(character), + _ => None, + }; + if let Some(entry) = chosen { + return self.choose(menu.agent, entry); + } + self.modal = Some(Modal::Open(menu)); + Action::None + } + + /// Applies one key to the SSH setup question; declining returns to the + /// menu, and `o` opens what waits without the line. + fn confirm_ssh_setup_key( + &mut self, + agent: String, + include: agent::ssh::UserInclude, + then: Option, + key: KeyEvent, + ) -> Action { + match (key.code, then) { + (KeyCode::Enter, then) => Action::SetUpSsh { + include, + then: then.map(|target| (agent, target)), + }, + (KeyCode::Char('o'), Some(target)) => Action::Open { agent, target }, + (KeyCode::Esc | KeyCode::Char('n' | 'q'), _) => { + self.open_menu(&agent); + Action::None + } + _ => { + self.modal = Some(Modal::ConfirmSshSetup { agent, include, then }); + Action::None + } + } + } + + /// Opens the open menu for the named Agent, and checks the SSH setup + /// again, since the user may have changed their configuration since. + fn open_menu(&mut self, agent: &str) { + if let Some(agent) = self.agents.iter().find(|candidate| candidate.metadata.name == agent) { + self.modal = Some(Modal::Open(OpenMenu::new(agent, &self.environment, self.ssh_setup))); + self.ssh_check_due = true; + } + } + + /// The Agent whose alias the SSH setup is to be checked with next, when a + /// check is due and none is running: the open menu's Agent, or else any + /// Agent with SSH access, since OpenSSH resolves only aliases it has. + pub(crate) fn ssh_check_request(&mut self) -> Option { + if self.ssh_checking || !self.ssh_check_due { + return None; + } + let menu = match &self.modal { + Some(Modal::Open(menu)) => Some(menu.agent.as_str()), + _ => None, + }; + let agent = self + .agents + .iter() + .filter(|agent| agent.spec.ssh_access()) + .min_by_key(|agent| Some(agent.metadata.name.as_str()) != menu)? + .metadata + .name + .clone(); + self.ssh_check_due = false; + self.ssh_checking = true; + Some(agent) + } + + /// Records how OpenSSH resolved `agent`'s alias, and updates the open + /// menu, whose SSH setup row follows it. + pub(crate) fn ssh_checked(&mut self, agent: &str, setup: SshSetup) { + self.ssh_checking = false; + self.ssh_setup = setup; + let Some(Modal::Open(menu)) = &self.modal else { + return; + }; + if menu.agent != agent { + return; + } + let chosen = menu.chosen(); + let Some(listed) = self.agents.iter().find(|candidate| candidate.metadata.name == agent) else { + return; + }; + let mut rebuilt = OpenMenu::new(listed, &self.environment, setup); + if let Some(index) = rebuilt + .items + .iter() + .position(|item| Some(item.entry) == chosen && item.unavailable.is_none()) + { + rebuilt.selected = index; + } + self.modal = Some(Modal::Open(rebuilt)); + } + + /// Chooses `entry` for `agent`, asking first for the SSH setup the entry + /// needs while it is missing. Setup is only ever missing once the + /// include is known, so there is always a line to ask about. + fn choose(&mut self, agent: String, entry: MenuEntry) -> Action { + let (target, missing) = match entry { + MenuEntry::SetUpSsh => (None, true), + MenuEntry::Open(target) => ( + Some(target), + target.needs_include() && self.ssh_setup == SshSetup::Missing, + ), + }; + match (target, self.ssh_include.clone().filter(|_| missing)) { + (target, Some(include)) => { + self.modal = Some(Modal::ConfirmSshSetup { + agent, + include, + then: target, + }); + Action::None + } + (Some(target), None) => Action::Open { agent, target }, + (None, None) => Action::None, + } + } + + /// Records the SSH setup's outcome, the configuration it wrote or why it + /// could not, and continues to what it was set up for. + pub(crate) fn ssh_set_up( + &mut self, + result: Result, + then: Option<(String, OpenTarget)>, + now: Instant, + ) -> Option { + match result { + Ok(user_config) => { + self.ssh_setup = SshSetup::Installed; + // A check still running began before the line was added. + self.ssh_check_due = true; + self.notice = Some((format!("SSH set up in {}", user_config.display()), now)); + then.map(|(agent, target)| Action::Open { agent, target }) + } + Err(error) => { + self.error = Some(error); + None + } + } + } + + /// Starts waiting for `target` in `agent`, unless it already waits. + pub(crate) fn start_opening(&mut self, agent: &str, target: OpenTarget, now: Instant) -> bool { + if self + .opening + .iter() + .any(|(listed, waiting)| listed == agent && *waiting == target) + { + self.notice = Some((format!("already opening {} on {agent}", target.name()), now)); + return false; + } + self.opening.push((agent.to_owned(), target)); + true + } + + /// Shows how a background open ended, ending the wait for what it opened. + pub(crate) fn opened( + &mut self, + waiting: Option<(String, OpenTarget)>, + result: Result, + now: Instant, + ) { + if let Some(waiting) = waiting { + self.opening.retain(|listed| *listed != waiting); + } + match result { + Ok(notice) => self.notice = Some((notice, now)), + Err(error) => self.error = Some(error), + } + } + + fn open_new_session(&mut self, group: usize) { + let Some(agent) = self.group_agent(group) else { + return; + }; + let harness = agent + .spec + .harnesses + .iter() + .position(|spec| spec.default) + .unwrap_or_default(); + self.modal = Some(Modal::NewSession(SessionForm { + agent: agent.metadata.name.clone(), + name: String::new(), + model: String::new(), + effort: String::new(), + field: SessionField::Name, + harnesses: agent.spec.harnesses.clone(), + harness, + error: None, + })); + } + + fn toggle_fold(&mut self, name: &str) { + if !self.collapsed.remove(name) { + self.collapsed.insert(name.to_owned()); + } + self.rebuild(); + } + + fn toggle_all(&mut self) { + if self.collapsed.len() == self.agents.len() { + self.collapsed.clear(); + } else { + self.collapsed = self.agents.iter().map(|agent| agent.metadata.name.clone()).collect(); + } + self.rebuild(); + } + + fn move_forward_selection(&mut self, delta: isize) { + if self.forwards.is_empty() { + return; + } + let length = isize::try_from(self.forwards.len()).unwrap_or(1); + let current = isize::try_from(self.forward_selected).unwrap_or_default(); + self.forward_selected = usize::try_from((current + delta).rem_euclid(length)).unwrap_or_default(); + } + + fn move_forward_selection_clamped(&mut self, delta: isize) { + if !self.forwards.is_empty() { + self.forward_selected = offset_clamped(self.forward_selected, self.forwards.len() - 1, delta); + } + } + + /// Replaces the forward display list, keeping the selected forward by its ID. + pub(crate) fn set_forwards(&mut self, forwards: Vec) { + let selected = self.forwards.get(self.forward_selected).map(|entry| entry.id); + self.forwards = forwards; + self.forward_selected = selected + .and_then(|id| self.forwards.iter().position(|entry| entry.id == id)) + .unwrap_or(self.forward_selected) + .min(self.forwards.len().saturating_sub(1)); + } + + fn move_selection(&mut self, delta: isize) { + if self.rows.is_empty() { + return; + } + let length = self.rows.len(); + let current = isize::try_from(self.selected_index().unwrap_or_default()).unwrap_or_default(); + let next = (current + delta).rem_euclid(isize::try_from(length).unwrap_or(1)); + self.select_index(usize::try_from(next).unwrap_or_default()); + } + + fn move_selection_clamped(&mut self, delta: isize) { + if !self.rows.is_empty() { + let current = self.selected_index().unwrap_or_default(); + self.select_index(offset_clamped(current, self.rows.len() - 1, delta)); + } + } + + fn group_agent(&self, group: usize) -> Option<&Agent> { + self.agents.get(self.groups.get(group)?.agent) + } + + fn group_session(&self, group: usize, position: usize) -> Option<&Session> { + self.sessions.get(*self.groups.get(group)?.sessions.get(position)?) + } + + pub(crate) fn render_rows(&self) -> Vec { + self.rows + .iter() + .filter_map(|row| match *row { + Row::Agent(group) => { + let agent = self.group_agent(group)?; + let attention = self + .groups + .get(group)? + .sessions + .iter() + .filter_map(|index| self.sessions.get(*index)) + .any(needs_you); + let marker = if self.collapsed.contains(&agent.metadata.name) { + "▸" + } else { + "▾" + }; + let AgentState { + tone, + label: state, + detail: status, + failure, + since, + } = agent_state(agent); + // The Sessions listed under it, as unfolding would show them. + let listed = self.listed_positions(self.groups.get(group)?, self.agent_matches(agent)); + let count = match listed.len() { + 0 => String::new(), + 1 => "1 session".to_owned(), + count => format!("{count} sessions"), + }; + let forwards = self + .forwards + .iter() + .filter(|entry| entry.agent == agent.metadata.name) + .map(ForwardEntry::mapping) + .collect::>(); + let ports = if forwards.is_empty() { + String::new() + } else { + format!("ports: {}", forwards.join(" ")) + }; + let detail = [status, count, ports] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join(" · "); + Some(RowView { + agent: true, + attention, + marker, + name: agent.metadata.name.clone(), + state, + tone, + since: since.map_or_else(String::new, format::format_age), + detail, + detail_keeps_end: failure, + age: String::new(), + }) + } + Row::Session { group, position } => { + let session = self.group_session(group, position)?; + let (tone, marker, state) = session_state(session.status.state); + let harness = harness_label(session.harness); + let identity = session + .model_selection + .model_str() + .map_or_else(|| harness.to_owned(), |model| format!("{harness} · {model}")); + // A Session held or failed says why, such as an Agent whose guest stalled. + let detail = match (&session.status.state, &session.status.lifecycle.failure) { + (State::Starting | State::Failed, Some(reason)) => format!("{identity} · {reason}"), + _ => identity, + }; + Some(RowView { + agent: false, + attention: needs_you(session), + marker, + name: session.name.as_str().to_owned(), + state, + tone, + since: session.status.state_since.map_or_else(String::new, format::format_age), + detail, + detail_keeps_end: false, + age: format::format_age(session.created_at), + }) + } + }) + .collect() + } + + pub(crate) fn hints(&self) -> &'static [Hint] { + if let Some(modal) = &self.modal { + return match modal { + Modal::ConfirmDelete { .. } + | Modal::ConfirmStop { .. } + | Modal::ConfirmDeleteSession { .. } + | Modal::ConfirmQuit => &CONFIRM_HINTS, + Modal::NewSession(_) => &NEW_SESSION_HINTS, + Modal::CreateAgent { .. } => &CREATE_AGENT_HINTS, + Modal::PortForward { .. } => &PORT_FORWARD_HINTS, + Modal::Filter => &FILTER_HINTS, + Modal::Prompt(_) => &PROMPT_HINTS, + Modal::Help => &HELP_HINTS, + Modal::Open(_) => &OPEN_HINTS, + Modal::ConfirmSshSetup { then: Some(_), .. } => &CONFIRM_SSH_SETUP_THEN_HINTS, + Modal::ConfirmSshSetup { then: None, .. } => &CONFIRM_SSH_SETUP_HINTS, + }; + } + if self.detail.is_some() { + return &DETAIL_HINTS; + } + if self.view == View::Forwards { + return &FORWARD_VIEW_HINTS; + } + match self.selected_row() { + Some(Row::Agent(group)) => match self.group_agent(group) { + Some(agent) if stop_pending(agent) => &STOPPING_AGENT_HINTS, + Some(agent) if agent.spec.is_stopped() => &STOPPED_AGENT_HINTS, + _ => &AGENT_HINTS, + }, + Some(Row::Session { group, position }) => { + let archived = self.group_session(group, position).is_some_and(Session::is_archived); + let stopped = self.group_agent(group).is_some_and(|agent| agent.spec.is_stopped()); + match (stopped, archived) { + (false, false) => &SESSION_HINTS, + (false, true) => &ARCHIVED_SESSION_HINTS, + (true, false) => &STOPPED_SESSION_HINTS, + (true, true) => &STOPPED_ARCHIVED_SESSION_HINTS, + } + } + None => &EMPTY_HINTS, + } + } +} + +fn offset_clamped(current: usize, limit: usize, delta: isize) -> usize { + if delta.is_negative() { + current.saturating_sub(delta.unsigned_abs()) + } else { + current.saturating_add(delta.unsigned_abs()).min(limit) + } +} + +/// An Agent row's state, the detail beside it and when it entered the state. +struct AgentState { + tone: Tone, + label: &'static str, + detail: String, + /// The detail is a failure, whose cause is at its end. + failure: bool, + since: Option, +} + +/// Reads an Agent's state from its typed status: deletion first, then the +/// class of the last failure, the pass in progress and readiness. +/// +/// A failure holds while its generation is current, so an Agent that is +/// retrying stays Retrying through each retry, and time in state is how long +/// `Ready` has been in its current state. A pass for a newer generation is +/// provisioning the change, and its time in state is the pass's own. +fn agent_state(agent: &Agent) -> AgentState { + let state = |tone, label, detail, since| AgentState { + tone, + label, + detail, + failure: false, + since, + }; + let failed = |tone, label, detail, since| AgentState { + failure: true, + ..state(tone, label, detail, since) + }; + if let Some(deleted) = agent.metadata.deletion_timestamp { + return state(Tone::Red, "Terminating", String::new(), Some(deleted)); + } + let ready = agent.status.ready_condition(); + let entered = ready.and_then(|ready| ready.last_transition_time); + let message = || ready.map_or_else(String::new, |ready| ready.detail().trim_end().to_owned()); + let failure = agent + .status + .failure + .filter(|_| agent.status.observed_generation == agent.metadata.generation); + let changing = if agent.spec.is_stopped() { + if !stop_pending(agent) { + return state(Tone::Gray, "Stopped", String::new(), entered); + } + // A failed stop reads as Retrying below, like any other failed pass. + failure.is_none().then_some("Stopping") + } else { + ready + .is_some_and(|ready| ready.reason == Condition::REASON_STARTING) + .then_some("Starting") + }; + if let Some(label) = changing { + let detail = provisioning(agent).map_or_else(String::new, progress_summary); + return state(Tone::Cyan, label, detail, entered); + } + // Retried like any transient failure, but nothing reaches the guest until it responds again. + if agent.status.unresponsive().is_some() { + return failed(Tone::Red, "Unresponsive", message(), entered); + } + match (failure, provisioning(agent)) { + (Some(FailureKind::Invalid), _) => failed(Tone::Red, "Failed", message(), entered), + (Some(FailureKind::Transient), Some(progress)) => { + state(Tone::Yellow, "Retrying", progress_summary(progress), entered) + } + (Some(FailureKind::Transient), None) => failed(Tone::Yellow, "Retrying", message(), entered), + (None, Some(progress)) => state( + Tone::Cyan, + "Provisioning", + progress_summary(progress), + Some(pass_started(progress)), + ), + (None, None) => match ready.map(|ready| ready.status) { + None => state(Tone::Gray, "Pending", String::new(), None), + Some(ConditionStatus::True) => state(Tone::Green, "Ready", String::new(), entered), + Some(_) => failed(Tone::Cyan, "Starting", message(), entered), + }, + } +} + +/// Whether the Agent was asked to stop and no pass for that generation has +/// recorded it stopped yet. +fn stop_pending(agent: &Agent) -> bool { + agent.spec.is_stopped() + && !(agent.status.observed_generation == agent.metadata.generation && agent.status.is_stopped()) +} + +/// When a pass started: before the phase in progress by the time its +/// finished phases took. +fn pass_started(progress: &Progress) -> OffsetDateTime { + let finished = progress.finished().iter().map(|phase| phase.elapsed_ms).sum::(); + let end = progress + .current() + .map_or_else(OffsetDateTime::now_utc, |phase| phase.started_at); + end - time::Duration::milliseconds(i64::try_from(finished).unwrap_or(i64::MAX)) +} + +/// The Agent's pass while it is running. +fn provisioning(agent: &Agent) -> Option<&Progress> { + let progress = &agent.status.progress.as_ref()?.progress; + (*progress.status() == OperationStatus::Running).then_some(progress) +} + +/// The phase in progress and its current step, with the step's measurement. +fn progress_summary(progress: &Progress) -> String { + let Some(phase) = progress.current() else { + return String::new(); + }; + let mut summary = phase.phase.label.to_string(); + if let Some(step) = progress.current_step() { + summary.push_str(" · "); + summary.push_str(&step.name); + if let Some(measurement) = step.measurement { + summary.push_str(": "); + summary.push_str(&crate::progress::format_measurement(measurement)); + } + } + summary +} + +/// Whether a Session waits for its user. An archived one never does: it reads +/// Archiving until its harness stops, as nobody answers it. +const fn needs_you(session: &Session) -> bool { + matches!(session.status.state, State::WaitingForInput) +} + +/// A Session state's tone, glyph and label. +const fn session_state(state: State) -> (Tone, &'static str, &'static str) { + match state { + State::WaitingForInput => (Tone::Yellow, "!", "Needs you"), + State::Working => (Tone::Green, "*", "Working"), + State::Starting => (Tone::Cyan, "~", "Starting"), + State::Idle => (Tone::Gray, "-", "Idle"), + State::Archiving => (Tone::Gray, "_", "Archiving"), + State::Archived => (Tone::Gray, "_", "Archived"), + State::Failed => (Tone::Red, "x", "Failed"), + } +} + +pub(crate) const fn harness_label(harness: Harness) -> &'static str { + match harness { + Harness::ClaudeCode => "Claude Code", + Harness::Codex => "Codex", + } +} + +fn bind_hint(spec: &ForwardSpec, error: String) -> String { + let low_port_on_specific_address = spec.local_port != 0 && spec.local_port < 1024 && !spec.address.is_unspecified(); + if cfg!(target_os = "macos") && low_port_on_specific_address && error.contains("Permission denied") { + format!("{error} — macOS allows ports below 1024 only on 0.0.0.0") + } else { + error + } +} + +const fn forward_field_accepts(field: ForwardField, character: char) -> bool { + match field { + ForwardField::Address => character.is_ascii_digit() || character == '.', + ForwardField::LocalPort | ForwardField::GuestPort => character.is_ascii_digit(), + } +} + +fn yaml_lines(value: &T) -> Vec { + serde_yaml_ng::to_string(value).map_or_else( + |error| vec![format!("failed to render YAML: {error}")], + |yaml| yaml.lines().map(str::to_owned).collect(), + ) +} + +fn session_detail(session: &Session) -> Detail { + let lines = vec![ + format!("Name: {}", session.name.as_str()), + format!("Agent: {}", session.agent), + format!("Harness: {}", session.harness.as_str()), + format!("Model: {}", session.model_selection.model_str().unwrap_or("-")), + format!("Effort: {}", session.model_selection.effort_str().unwrap_or("-")), + format!("State: {}", format::session_state(session.status.state)), + format!("Turns: {}", session.status.reported.activity.turns), + format!("Age: {}", format::format_age(session.created_at)), + format!( + "Failure: {}", + session.status.lifecycle.failure.as_deref().unwrap_or("-") + ), + format!( + "Harness ID: {}", + session.status.reported.harness_session_id.as_deref().unwrap_or("-") + ), + format!("ID: {}", session.id), + ]; + Detail::text(format!("session/{}/{}", session.agent, session.name.as_str()), lines) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::*; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn agent_named(name: &str, harnesses: &str) -> Agent { + let yaml = format!( + "apiVersion: agents.platform/v1alpha1\n\ + kind: Agent\n\ + metadata:\n\ + \x20 name: {name}\n\ + spec:\n\ + \x20 sandbox:\n\ + \x20 image:\n\ + \x20 type: build\n\ + \x20 context: .\n\ + \x20 dockerfile: Dockerfile\n\ + \x20 platform:\n\ + \x20 os: linux\n\ + \x20 resources:\n\ + \x20 cpu: \"1\"\n\ + \x20 memory: \"1Gi\"\n\ + \x20 rootFilesystem:\n\ + \x20 capacity: \"8Gi\"\n\ + \x20 mode: layered\n\ + \x20 home:\n\ + \x20 source: home\n\ + \x20 harnesses:\n\ + {harnesses}\ + \x20 secrets: []\n\ + \x20 network:\n\ + \x20 mode: mediated\n\ + \x20 allow: all\n" + ); + agent::manifest::decode(yaml.as_bytes()).expect("test manifest should decode") + } + + fn agent(name: &str) -> Agent { + agent_named( + name, + "\x20 - type: claudeCode\n\x20 version: \"1.0.0\"\n\x20 auth: mediated\n", + ) + } + + fn ready_agent(name: &str) -> Agent { + let mut agent = agent(name); + agent.status.conditions.push(agent::Condition { + kind: "Ready".into(), + status: ConditionStatus::True, + reason: "SandboxReady".into(), + message: String::new(), + last_transition_time: None, + }); + agent + } + + fn session(agent: &str, name: &str, state: &str) -> Session { + let lifecycle = match state { + "working" | "waitingForInput" => "running", + other => other, + }; + serde_json::from_value(serde_json::json!({ + "id": "00000000-0000-0000-0000-000000000001", + "agentId": "00000000-0000-0000-0000-000000000002", + "agent": agent, + "name": name, + "harness": "claudeCode", + "createdAt": "2026-08-25T00:00:00Z", + "status": {"state": state, "lifecycle": {"state": lifecycle}} + })) + .expect("test session should deserialize") + } + + fn populated() -> App { + let mut app = App::new(); + app.apply_snapshot( + vec![agent("worker"), agent("builder")], + vec![ + session("worker", "s2", "working"), + session("worker", "s1", "idle"), + session("builder", "b1", "starting"), + ], + ); + app + } + + #[test] + fn snapshot_groups_sessions_under_sorted_agents() { + let app = populated(); + assert_eq!( + app.rows, + vec![ + Row::Agent(0), + Row::Session { group: 0, position: 0 }, + Row::Agent(1), + Row::Session { group: 1, position: 0 }, + Row::Session { group: 1, position: 1 }, + ] + ); + let views = app.render_rows(); + assert_eq!(views[0].name, "builder"); + assert_eq!(views[1].name, "b1"); + assert_eq!(views[2].name, "worker"); + assert_eq!(views[3].name, "s1"); + assert_eq!(views[4].name, "s2"); + assert_eq!( + app.triage_counts(), + TriageCounts { + working: 1, + starting: 1, + idle: 1, + ..TriageCounts::default() + } + ); + } + + fn type_text(app: &mut App, text: &str) { + for character in text.chars() { + app.on_key(key(KeyCode::Char(character))); + } + } + + #[test] + fn the_filter_matches_an_agents_state() { + let mut app = App::new(); + app.apply_snapshot( + vec![ready_agent("alive"), failed_agent("broken", FailureKind::Transient)], + Vec::new(), + ); + app.on_key(key(KeyCode::Char('/'))); + type_text(&mut app, "retry"); + assert_eq!( + app.render_rows() + .iter() + .map(|row| row.name.as_str()) + .collect::>(), + ["broken"] + ); + } + + #[test] + fn the_filter_matches_names_states_harnesses_and_models_and_shows_folded_matches() { + let mut app = populated(); + app.on_key(key(KeyCode::Enter)); + assert_eq!(app.rows.len(), 4, "builder is folded"); + app.on_key(key(KeyCode::Char('/'))); + + type_text(&mut app, "B1"); + assert_eq!(app.rows, [Row::Agent(0), Row::Session { group: 0, position: 0 }]); + app.on_key(key(KeyCode::Backspace)); + app.on_key(key(KeyCode::Backspace)); + type_text(&mut app, "idle"); + assert_eq!( + app.render_rows() + .iter() + .map(|row| row.name.as_str()) + .collect::>(), + ["worker", "s1"] + ); + app.filter.clear(); + type_text(&mut app, "work"); + assert_eq!(app.rows.len(), 3, "an Agent that matches keeps all its Sessions"); + + assert_eq!(app.on_key(key(KeyCode::Enter)), Action::None); + assert!(app.modal.is_none()); + assert_eq!(app.filter, "work", "enter keeps the filter"); + assert_eq!( + app.on_key(key(KeyCode::Esc)), + Action::None, + "esc clears the filter first" + ); + assert!(app.filter.is_empty()); + assert_eq!(app.rows.len(), 4, "folding applies again"); + assert_eq!(app.on_key(key(KeyCode::Esc)), Action::Quit); + } + + #[test] + fn tab_selects_the_next_session_needing_input_and_wraps() { + let mut app = App::new(); + app.apply_snapshot( + vec![agent("first"), agent("second")], + vec![ + session("first", "a", "waitingForInput"), + session("first", "b", "working"), + session("second", "c", "waitingForInput"), + ], + ); + app.on_key(key(KeyCode::Tab)); + assert_eq!(app.selected_index(), Some(1)); + app.on_key(key(KeyCode::Tab)); + assert_eq!(app.selected_index(), Some(4)); + app.on_key(key(KeyCode::Tab)); + assert_eq!(app.selected_index(), Some(1), "the jump wraps around"); + } + + #[test] + fn tab_unfolds_and_clears_the_filter_to_reach_every_session_the_header_counts() { + let mut app = App::new(); + app.apply_snapshot( + vec![agent("first"), agent("second")], + vec![ + session("first", "a", "working"), + session("second", "c", "waitingForInput"), + ], + ); + app.on_key(key(KeyCode::Char('z'))); + assert_eq!(app.rows.len(), 2, "both Agents are folded"); + app.on_key(key(KeyCode::Tab)); + assert_eq!( + app.selection, + Some(TreeRowId::Session { + agent: "second".into(), + session: SessionName::new("c").expect("name"), + }) + ); + assert!(!app.collapsed.contains("second"), "the Agent holding it unfolds"); + + app.filter = "first".into(); + app.select_index(0); + app.rebuild(); + app.on_key(key(KeyCode::Tab)); + assert!(app.filter.is_empty(), "a filter hiding it is cleared"); + assert_eq!(app.selected_row(), Some(Row::Session { group: 1, position: 0 })); + } + + #[test] + fn a_removed_row_leaves_the_selection_on_its_neighbour() { + let mut app = App::new(); + app.apply_snapshot(vec![agent("a"), agent("b"), agent("c")], Vec::new()); + app.select_index(2); + app.apply_snapshot(vec![agent("a"), agent("b")], Vec::new()); + assert_eq!( + app.selection, + Some(TreeRowId::Agent("b".into())), + "the last row falls back to the one above" + ); + + app.apply_snapshot(vec![agent("a"), agent("b"), agent("c")], Vec::new()); + app.select_index(1); + app.apply_snapshot(vec![agent("a"), agent("c")], Vec::new()); + assert_eq!( + app.selection, + Some(TreeRowId::Agent("c".into())), + "a middle row falls back to the one now in its place" + ); + } + + #[test] + fn folding_hides_sessions_and_expand_all_restores_them() { + let mut app = populated(); + assert_eq!(app.on_key(key(KeyCode::Enter)), Action::None); + assert_eq!(app.rows.len(), 4); + app.on_key(key(KeyCode::Char('z'))); + assert_eq!(app.rows.len(), 2); + app.on_key(key(KeyCode::Char('z'))); + assert_eq!(app.rows.len(), 5); + } + + #[test] + fn folding_moves_the_selection_from_a_hidden_session_to_its_agent() { + let mut app = populated(); + app.select_index(1); + assert_eq!( + app.selection, + Some(TreeRowId::Session { + agent: "builder".into(), + session: SessionName::new("b1").expect("name"), + }) + ); + app.on_key(key(KeyCode::Char('z'))); + assert_eq!( + app.selection, + Some(TreeRowId::Agent("builder".into())), + "not worker, which folding moved to the Session's former position" + ); + app.on_key(key(KeyCode::Char('z'))); + assert_eq!( + app.selection, + Some(TreeRowId::Agent("builder".into())), + "unfolding keeps it on the Agent" + ); + } + + #[test] + fn question_mark_opens_help_over_the_tree_and_the_forwards() { + let mut app = populated(); + app.on_key(key(KeyCode::Char('?'))); + assert!(matches!(app.modal, Some(Modal::Help))); + assert_eq!(app.hints(), &HELP_HINTS); + app.on_key(key(KeyCode::Char('j'))); + assert!(matches!(app.modal, Some(Modal::Help)), "other keys leave it open"); + assert_eq!( + app.on_key(key(KeyCode::Char('q'))), + Action::None, + "q closes help instead of quitting" + ); + assert!(app.modal.is_none()); + + app.on_key(key(KeyCode::Char('F'))); + app.on_key(key(KeyCode::Char('?'))); + assert!(matches!(app.modal, Some(Modal::Help))); + app.on_key(key(KeyCode::Esc)); + assert!(app.modal.is_none()); + assert_eq!(app.view, View::Forwards); + } + + #[test] + fn selection_wraps_and_clamps_after_shrink() { + let mut app = populated(); + app.on_key(key(KeyCode::Up)); + assert_eq!(app.selected_index(), Some(4)); + app.on_key(key(KeyCode::Down)); + assert_eq!(app.selected_index(), Some(0)); + app.select_index(4); + app.apply_snapshot(vec![agent("worker")], Vec::new()); + assert_eq!(app.selected_index(), Some(0)); + } + + #[test] + fn enter_on_a_session_attaches_to_it() { + let mut app = populated(); + app.select_index(1); + let action = app.on_key(key(KeyCode::Enter)); + assert_eq!( + action, + Action::Attach { + agent: "builder".into(), + session: SessionName::new("b1").expect("valid name"), + } + ); + } + + fn session_target(agent: &str, session: &str) -> RowTarget { + RowTarget::Tree(TreeRowId::Session { + agent: agent.into(), + session: SessionName::new(session).expect("valid Session name"), + }) + } + + #[test] + fn mouse_row_selection_is_separate_from_primary_actions() { + let mut app = populated(); + + assert_eq!( + app.on_mouse(MouseAction::Select(session_target("worker", "s2"))), + Action::None + ); + assert_eq!(app.selected_index(), Some(4)); + assert_eq!( + app.on_mouse(MouseAction::Primary(session_target("worker", "s2"))), + Action::Attach { + agent: "worker".into(), + session: SessionName::new("s2").expect("valid Session name"), + } + ); + + assert_eq!(app.rows.len(), 5); + assert_eq!(app.on_mouse(MouseAction::FoldTree("builder".into())), Action::None); + assert_eq!(app.rows.len(), 4); + } + + #[test] + fn selection_and_mouse_targets_follow_the_resource_when_rows_move() { + let mut app = populated(); + let rendered = session_target("worker", "s1"); + app.on_mouse(MouseAction::Select(rendered.clone())); + assert_eq!(app.selected_index(), Some(3)); + + app.apply_snapshot( + vec![agent("worker"), agent("builder"), agent("analyst")], + vec![ + session("worker", "s2", "working"), + session("worker", "s1", "idle"), + session("analyst", "a1", "working"), + session("builder", "b1", "starting"), + ], + ); + assert_eq!(app.selected_index(), Some(5), "the new Agent moved the Session down"); + assert_eq!( + app.on_mouse(MouseAction::Primary(rendered)), + Action::Attach { + agent: "worker".into(), + session: SessionName::new("s1").expect("valid Session name"), + } + ); + assert_eq!( + app.on_mouse(MouseAction::Select(session_target("worker", "gone"))), + Action::None + ); + assert_eq!( + app.selected_index(), + Some(5), + "a vanished target leaves the selection alone" + ); + } + + #[test] + fn mouse_wheel_selection_and_detail_scrolling_clamp_at_the_ends() { + let mut app = populated(); + app.select_index(app.rows.len() - 1); + + app.on_mouse(MouseAction::MoveTree(1)); + assert_eq!(app.selected_index(), Some(app.rows.len() - 1)); + app.on_mouse(MouseAction::MoveTree(-100)); + assert_eq!(app.selected_index(), Some(0)); + + app.detail = Some(Detail::text( + "detail".into(), + vec!["one".into(), "two".into(), "three".into()], + )); + app.on_mouse(MouseAction::ScrollDetail(100)); + assert_eq!(app.detail.as_ref().map(|detail| detail.scroll), Some(2)); + app.on_mouse(MouseAction::ScrollDetail(-100)); + assert_eq!(app.detail.as_ref().map(|detail| detail.scroll), Some(0)); + } + + #[test] + fn clickable_actions_keep_confirmation_and_terminal_action_semantics() { + let mut app = populated(); + assert_eq!( + app.on_mouse(MouseAction::Key(KeyCode::Char('e'), KeyModifiers::NONE)), + Action::Exec { + agent: "builder".into() + } + ); + + assert_eq!( + app.on_mouse(MouseAction::Key(KeyCode::Char('d'), KeyModifiers::NONE)), + Action::None + ); + assert!(matches!(app.modal, Some(Modal::ConfirmDelete { .. }))); + assert_eq!( + app.on_mouse(MouseAction::Key(KeyCode::Char('y'), KeyModifiers::NONE)), + Action::Delete { + agent: "builder".into() + } + ); + } + + #[test] + fn archived_sessions_are_hidden_until_shown_and_toggle_from_their_row() { + let mut app = populated(); + let b1 = SessionName::new("b1").expect("name"); + let b1_session = app.sessions.iter_mut().find(|session| session.name == b1).expect("b1"); + *b1_session = archived(b1_session.clone()); + app.rebuild(); + let b1_row = TreeRowId::Session { + agent: "builder".into(), + session: b1.clone(), + }; + assert_eq!(app.tree_index(&b1_row), None, "archived Sessions are hidden"); + assert_eq!(app.triage_counts().archived, 1, "but counted"); + assert_eq!(app.triage_counts().starting, 0); + + assert_eq!(app.on_key(key(KeyCode::Char('A'))), Action::None); + assert!(app.tree_index(&b1_row).is_some(), "A shows them"); + app.selection = Some(b1_row); + assert_eq!(app.hints().first().map(|hint| hint.description), Some("unarchive")); + assert_eq!( + app.on_key(key(KeyCode::Enter)), + Action::None, + "an archived Session is not attached" + ); + assert!(app.modal.is_none(), "nor prompted"); + assert_eq!(app.on_key(key(KeyCode::Char('p'))), Action::None); + assert!(app.modal.is_none()); + assert_eq!( + app.on_key(key(KeyCode::Char('a'))), + Action::SetArchived { + agent: "builder".into(), + session: b1, + archived: false, + }, + "a unarchives an archived Session" + ); + + app.selection = Some(TreeRowId::Session { + agent: "worker".into(), + session: SessionName::new("s1").expect("name"), + }); + assert!(matches!( + app.on_key(key(KeyCode::Char('a'))), + Action::SetArchived { archived: true, .. } + )); + } + + /// Archives a test Session as the daemon reports it: Archiving until its + /// harness has stopped. + fn archived(mut session: Session) -> Session { + session.archived_at = Some(time::OffsetDateTime::UNIX_EPOCH); + if session.status.state != State::Archived { + session.status.state = State::Archiving; + } + session + } + + fn session_row(agent: &str, name: &str) -> TreeRowId { + TreeRowId::Session { + agent: agent.into(), + session: SessionName::new(name).expect("name"), + } + } + + #[test] + fn an_agent_counts_the_sessions_listed_under_it() { + let mut app = App::new(); + app.apply_snapshot( + vec![agent("worker")], + vec![ + archived(session("worker", "old", "waitingForInput")), + session("worker", "main", "working"), + session("worker", "review", "idle"), + ], + ); + let detail = |app: &App| app.render_rows()[0].detail.clone(); + assert_eq!(detail(&app), "2 sessions", "a hidden archived Session is not counted"); + assert!(!app.render_rows()[0].attention, "nor does it ask for attention"); + + app.on_key(key(KeyCode::Char('A'))); + assert_eq!(detail(&app), "3 sessions"); + + app.filter = "main".into(); + app.rebuild(); + assert_eq!(detail(&app), "1 session", "the filter narrows the count"); + + app.filter.clear(); + app.collapsed.insert("worker".into()); + app.rebuild(); + assert_eq!(detail(&app), "3 sessions", "a folded Agent tells what unfolding shows"); + } + + #[test] + fn a_session_leaving_the_tree_selects_its_neighbour_before_its_agent() { + let mut app = App::new(); + let snapshot = |archived_names: &[&str]| { + ["s1", "s2", "s3"] + .into_iter() + .map(|name| { + let session = session("worker", name, "idle"); + if archived_names.contains(&name) { + archived(session) + } else { + session + } + }) + .collect::>() + }; + app.apply_snapshot(vec![agent("builder"), agent("worker")], snapshot(&[])); + app.selection = Some(session_row("worker", "s2")); + + app.apply_snapshot(vec![agent("builder"), agent("worker")], snapshot(&["s2"])); + assert_eq!( + app.selection, + Some(session_row("worker", "s3")), + "the next one takes its place" + ); + + app.apply_snapshot(vec![agent("builder"), agent("worker")], snapshot(&["s2", "s3"])); + assert_eq!( + app.selection, + Some(session_row("worker", "s1")), + "the last one gives way to the one above" + ); + + app.apply_snapshot(vec![agent("builder"), agent("worker")], snapshot(&["s1", "s2", "s3"])); + assert_eq!(app.selection, Some(TreeRowId::Agent("worker".into())), "then its Agent"); + } + + #[test] + fn a_selection_whose_neighbours_also_leave_goes_to_the_nearest_listed_session() { + let mut app = App::new(); + app.show_archived = true; + app.apply_snapshot( + vec![agent("alpha"), agent("worker")], + vec![ + archived(session("alpha", "a1", "archived")), + archived(session("alpha", "a2", "archived")), + archived(session("worker", "s1", "archived")), + archived(session("worker", "s2", "archived")), + archived(session("worker", "s3", "archived")), + session("worker", "s4", "idle"), + ], + ); + app.selection = Some(session_row("worker", "s3")); + app.on_key(key(KeyCode::Char('A'))); + assert_eq!( + app.selection, + Some(session_row("worker", "s4")), + "rows above, of its own and of another Agent, left with it" + ); + + app.selection = Some(session_row("worker", "s4")); + app.on_key(key(KeyCode::Char('A'))); + app.selection = Some(session_row("worker", "s4")); + app.filter = "s1".into(); + app.rebuild(); + assert_eq!( + app.selection, + Some(session_row("worker", "s1")), + "the filter keeps only one before it" + ); + } + + #[test] + fn a_snapshot_that_adds_and_removes_sessions_selects_by_name_not_position() { + let mut app = App::new(); + app.apply_snapshot( + vec![agent("worker")], + vec![session("worker", "s2", "idle"), session("worker", "s3", "idle")], + ); + app.selection = Some(session_row("worker", "s2")); + app.apply_snapshot( + vec![agent("worker")], + vec![ + session("worker", "s1", "idle"), + archived(session("worker", "s2", "archived")), + session("worker", "s3", "idle"), + ], + ); + assert_eq!(app.selection, Some(session_row("worker", "s3"))); + } + + #[test] + fn hiding_archived_sessions_moves_the_selection_to_a_listed_neighbour() { + let mut app = App::new(); + app.show_archived = true; + app.apply_snapshot( + vec![agent("worker")], + vec![ + archived(session("worker", "s1", "archived")), + session("worker", "s2", "idle"), + ], + ); + app.selection = Some(session_row("worker", "s1")); + app.on_key(key(KeyCode::Char('A'))); + assert_eq!(app.selection, Some(session_row("worker", "s2"))); + } + + #[test] + fn tab_never_selects_a_hidden_archived_session() { + let mut app = App::new(); + app.apply_snapshot( + vec![agent("worker")], + vec![ + archived(session("worker", "s1", "waitingForInput")), + session("worker", "s2", "idle"), + ], + ); + app.selection = Some(session_row("worker", "s2")); + app.on_key(key(KeyCode::Tab)); + assert_eq!( + app.selection, + Some(session_row("worker", "s2")), + "nothing listed needs you" + ); + assert_eq!(app.triage_counts().needs_you, 0); + + app.on_key(key(KeyCode::Char('A'))); + app.on_key(key(KeyCode::Tab)); + assert_eq!( + app.selection, + Some(session_row("worker", "s2")), + "nor a shown one, which nobody answers" + ); + } + + #[test] + fn an_archived_session_reads_archiving_until_its_harness_stops() { + let mut app = App::new(); + app.show_archived = true; + app.apply_snapshot( + vec![agent("worker")], + vec![ + archived(session("worker", "s1", "waitingForInput")), + archived(session("worker", "s2", "archived")), + ], + ); + let rows = app.render_rows(); + assert_eq!((rows[1].name.as_str(), rows[1].state), ("s1", "Archiving")); + assert!(!rows[1].attention, "an archived Session never asks for attention"); + assert_eq!((rows[2].name.as_str(), rows[2].state), ("s2", "Archived")); + app.filter = "archiving".into(); + app.rebuild(); + assert_eq!(app.render_rows().len(), 2, "the filter matches the shown label"); + } + + #[test] + fn an_archive_applies_to_the_tree_as_recorded_and_a_clears_its_notice() { + let mut app = App::new(); + app.apply_snapshot( + vec![agent("worker")], + vec![session("worker", "s1", "working"), session("worker", "s2", "idle")], + ); + app.selection = Some(session_row("worker", "s1")); + assert_eq!( + app.on_key(key(KeyCode::Char('a'))), + Action::SetArchived { + agent: "worker".into(), + session: SessionName::new("s1").expect("name"), + archived: true, + } + ); + app.archive_changed(archived(session("worker", "s1", "working")), Instant::now()); + assert_eq!( + app.tree_index(&session_row("worker", "s1")), + None, + "gone before the watch replies" + ); + assert_eq!(app.selection, Some(session_row("worker", "s2"))); + assert_eq!(app.triage_counts().archived, 1); + + app.on_key(key(KeyCode::Char('A'))); + assert!(app.notice.is_none(), "showing archived Sessions answers the notice"); + assert_eq!(app.render_rows()[1].state, "Archiving"); + + app.archive_changed(archived(session("worker", "gone", "idle")), Instant::now()); + assert_eq!(app.sessions.len(), 2, "a Session the tree does not list is not added"); + } + + #[test] + fn archiving_tells_what_happened_for_a_while() { + let mut app = App::new(); + let now = Instant::now(); + app.archive_changed(archived(session("worker", "s1", "archived")), now); + assert_eq!( + app.notice.as_ref().map(|(text, _)| text.as_str()), + Some("s1 archived · A to show") + ); + app.show_archived = true; + app.archive_changed(archived(session("worker", "s2", "working")), now); + assert_eq!(app.notice.as_ref().map(|(text, _)| text.as_str()), Some("s2 archived")); + app.archive_changed(session("worker", "s1", "idle"), now); + assert_eq!( + app.notice.as_ref().map(|(text, _)| text.as_str()), + Some("s1 unarchived") + ); + app.expire_notice(now + NOTICE_DURATION.saturating_sub(Duration::from_millis(1))); + assert!(app.notice.is_some()); + app.expire_notice(now + NOTICE_DURATION); + assert!(app.notice.is_none()); + } + + #[test] + fn deleting_a_session_row_confirms_first_and_leaves_the_agent_alone() { + let mut app = populated(); + app.selection = Some(TreeRowId::Session { + agent: "builder".into(), + session: SessionName::new("b1").expect("name"), + }); + + assert_eq!(app.on_key(key(KeyCode::Char('d'))), Action::None); + let Some(Modal::ConfirmDeleteSession { agent, session }) = &app.modal else { + panic!("deleting a Session must ask for confirmation"); + }; + assert_eq!((agent.as_str(), session.as_str()), ("builder", "b1")); + + assert_eq!(app.on_key(key(KeyCode::Char('n'))), Action::None); + assert!(app.modal.is_none(), "cancelling must not delete anything"); + + app.on_key(key(KeyCode::Char('d'))); + assert_eq!( + app.on_key(key(KeyCode::Char('y'))), + Action::DeleteSession { + agent: "builder".into(), + session: SessionName::new("b1").expect("name"), + } + ); + } + + #[test] + fn mouse_forward_actions_select_edit_and_delete_the_target() { + let mut app = App::new(); + app.view = View::Forwards; + app.set_forwards(vec![ + ForwardEntry { + id: 10, + agent: "first".into(), + local: "127.0.0.1:8000".into(), + guest_port: 80, + status: None, + finished: false, + }, + ForwardEntry { + id: 20, + agent: "second".into(), + local: "127.0.0.1:9000".into(), + guest_port: 90, + status: None, + finished: false, + }, + ]); + + assert_eq!(app.on_mouse(MouseAction::Select(RowTarget::Forward(20))), Action::None); + assert_eq!(app.forward_selected, 1); + assert_eq!(app.on_mouse(MouseAction::Primary(RowTarget::Forward(20))), Action::None); + assert!(matches!( + app.modal, + Some(Modal::PortForward(ForwardForm { replace: Some(20), .. })) + )); + + app.modal = None; + assert_eq!( + app.on_mouse(MouseAction::Key(KeyCode::Char('d'), KeyModifiers::CONTROL)), + Action::DeleteForward { id: 20 } + ); + } + + #[test] + fn deleting_an_agent_requires_confirmation() { + let mut app = populated(); + assert_eq!(app.on_key(key(KeyCode::Char('d'))), Action::None); + assert!(matches!(app.modal, Some(Modal::ConfirmDelete { .. }))); + assert_eq!(app.on_key(key(KeyCode::Char('n'))), Action::None); + assert!(app.modal.is_none()); + app.on_key(key(KeyCode::Char('d'))); + assert_eq!( + app.on_key(key(KeyCode::Char('y'))), + Action::Delete { + agent: "builder".into() + } + ); + assert!(app.modal.is_none()); + } + + #[test] + fn new_session_modal_validates_the_name_and_creates_on_enter() { + let mut app = populated(); + app.on_key(key(KeyCode::Char('n'))); + assert!(matches!(app.modal, Some(Modal::NewSession(_)))); + assert_eq!(app.on_key(key(KeyCode::Enter)), Action::None); + assert!(matches!(&app.modal, Some(Modal::NewSession(form)) if form.error.is_some())); + app.on_key(key(KeyCode::Char('s'))); + app.on_key(key(KeyCode::Char('!'))); + app.on_key(key(KeyCode::Char('1'))); + let action = app.on_key(key(KeyCode::Enter)); + assert_eq!( + action, + Action::CreateSession { + agent: "builder".into(), + session: SessionName::new("s1").expect("valid name"), + harness: Harness::ClaudeCode, + model_selection: ModelSelection::default(), + } + ); + assert!(app.modal.is_none()); + } + + #[test] + fn new_session_modal_rejects_an_existing_name_instead_of_attaching() { + let mut app = populated(); + app.select_index(2); + app.on_key(key(KeyCode::Char('n'))); + type_text(&mut app, "s1"); + assert_eq!(app.on_key(key(KeyCode::Enter)), Action::None); + assert!(matches!( + &app.modal, + Some(Modal::NewSession(form)) if form.error.as_deref() == Some("session \"s1\" already exists") + )); + } + + #[test] + fn new_session_form_types_model_and_effort_and_shows_manifest_defaults() { + let mut app = App::new(); + app.apply_snapshot( + vec![agent_named( + "worker", + "\x20 - type: claudeCode\n\x20 auth: mediated\n\x20 defaults:\n\x20 model: fable\n\x20 effort: high\n", + )], + Vec::new(), + ); + app.on_key(key(KeyCode::Char('n'))); + let form = |app: &App| match &app.modal { + Some(Modal::NewSession(form)) => form.clone(), + _ => panic!("expected the NewSession modal"), + }; + assert_eq!(form(&app).field, SessionField::Name); + assert_eq!(form(&app).model_default(), Some("fable")); + assert_eq!(form(&app).effort_default(), Some("high")); + assert_eq!(app.hints(), &NEW_SESSION_HINTS); + + app.on_key(key(KeyCode::Char('s'))); + app.on_key(key(KeyCode::Tab)); + assert_eq!(form(&app).field, SessionField::Model); + for character in "gpt 5.4".chars() { + app.on_key(key(KeyCode::Char(character))); + } + assert_eq!(form(&app).model, "gpt 5.4", "typed input is kept as typed"); + assert_eq!(app.on_key(key(KeyCode::Enter)), Action::None); + let error = form(&app).error.expect("an invalid model is reported, not reshaped"); + assert!(error.contains("model must be 1-128"), "{error}"); + app.on_key(key(KeyCode::Backspace)); + app.on_key(key(KeyCode::Backspace)); + app.on_key(key(KeyCode::Backspace)); + app.on_key(key(KeyCode::Backspace)); + for character in "5.4".chars() { + app.on_key(key(KeyCode::Char(character))); + } + assert_eq!(form(&app).model, "gpt5.4"); + assert_eq!(form(&app).error, None, "editing clears the error"); + app.on_key(key(KeyCode::Down)); + assert_eq!(form(&app).field, SessionField::Effort); + app.on_key(key(KeyCode::Char('x'))); + app.on_key(key(KeyCode::Backspace)); + app.on_key(key(KeyCode::BackTab)); + app.on_key(key(KeyCode::Up)); + assert_eq!(form(&app).field, SessionField::Name); + assert_eq!(form(&app).name, "s"); + + assert_eq!( + app.on_key(key(KeyCode::Enter)), + Action::CreateSession { + agent: "worker".into(), + session: SessionName::new("s").expect("valid name"), + harness: Harness::ClaudeCode, + model_selection: ModelSelection { + model: Some(Model::new("gpt5.4").expect("model")), + effort: None, + }, + }, + "an empty effort leaves the manifest default to the daemon" + ); + } + + #[test] + fn new_session_preselects_the_default_harness_and_cycles() { + let mut app = App::new(); + app.apply_snapshot( + vec![agent_named( + "worker", + "\x20 - type: claudeCode\n\x20 version: \"1.0.0\"\n\x20 auth: mediated\n\ + \x20 - type: codex\n\x20 version: \"1.0.0\"\n\x20 auth: mediated\n\ + \x20 default: true\n", + )], + Vec::new(), + ); + app.on_key(key(KeyCode::Char('n'))); + let Some(Modal::NewSession(form)) = &app.modal else { + panic!("expected the NewSession modal"); + }; + assert_eq!(form.harness, 1); + app.on_key(key(KeyCode::Right)); + let Some(Modal::NewSession(form)) = &app.modal else { + panic!("expected the NewSession modal"); + }; + assert_eq!(form.harness, 0); + app.on_key(key(KeyCode::Left)); + app.on_key(key(KeyCode::Left)); + let Some(Modal::NewSession(form)) = &app.modal else { + panic!("expected the NewSession modal"); + }; + assert_eq!(form.harness, 0, "the picker wraps in both directions"); + app.on_key(key(KeyCode::Char('s'))); + app.on_key(key(KeyCode::Char('1'))); + assert_eq!( + app.on_key(key(KeyCode::Enter)), + Action::CreateSession { + agent: "worker".into(), + session: SessionName::new("s1").expect("valid name"), + harness: Harness::ClaudeCode, + model_selection: ModelSelection::default(), + } + ); + } + + fn candidates(entries: &[(&str, &str)]) -> Vec { + entries + .iter() + .map(|(directory, name)| { + ManifestCandidate::new(PathBuf::from(directory).join("agent.yaml"), Ok((*name).to_owned())) + }) + .collect() + } + + fn create_form(app: &App) -> &CreateForm { + let Some(Modal::CreateAgent(form)) = &app.modal else { + panic!("expected the CreateAgent modal"); + }; + form + } + + #[test] + fn create_key_requests_manifest_discovery_even_without_agents() { + let mut app = App::new(); + app.apply_snapshot(Vec::new(), Vec::new()); + assert_eq!(app.hints(), &EMPTY_HINTS); + assert_eq!(app.on_key(key(KeyCode::Char('c'))), Action::OpenCreate); + let mut app = populated(); + assert_eq!(app.on_key(key(KeyCode::Char('c'))), Action::OpenCreate); + app.select_index(1); + assert_eq!(app.on_key(key(KeyCode::Char('c'))), Action::OpenCreate); + } + + #[test] + fn discovery_results_wait_for_an_open_view_to_close() { + let mut app = populated(); + app.manifests_discovered(candidates(&[("/sources/stale", "stale")])); + assert!(app.modal.is_none()); + + app.discovering = true; + app.on_key(key(KeyCode::Char('s'))); + app.manifests_discovered(candidates(&[("/sources/worker", "worker")])); + assert!(!app.discovering); + assert!(app.modal.is_none()); + app.open_queued_create(); + assert!(app.modal.is_none()); + + app.on_key(key(KeyCode::Esc)); + app.open_queued_create(); + assert_eq!(create_form(&app).placeholder(), Some("worker")); + assert!(app.queued_candidates.is_none()); + + let mut app = populated(); + app.discovering = true; + app.manifests_discovered(candidates(&[("/sources/worker", "worker")])); + assert_eq!(create_form(&app).placeholder(), Some("worker")); + } + + #[test] + fn open_create_preselects_the_highlighted_agents_manifest() { + let mut app = populated(); + for agent in &mut app.agents { + if agent.metadata.name == "worker" { + agent.status.provenance = Some(agent::Provenance { + source_directory: PathBuf::from("/sources/worker"), + manifest_path: Some(PathBuf::from("/sources/worker/agent.nested.yaml")), + env_file: None, + }); + } + } + app.select_index(3); + let mut discovered = candidates(&[("/sources/builder", "builder"), ("/sources/worker", "worker")]); + discovered.push(ManifestCandidate::new( + PathBuf::from("/sources/worker/agent.nested.yaml"), + Ok("worker-nested".into()), + )); + app.open_create(discovered); + let form = create_form(&app); + assert_eq!(form.agent, 1); + assert_eq!(form.variant, 1); + assert_eq!(form.variant_label(), Some("nested".into())); + assert_eq!(form.placeholder(), Some("worker-nested")); + assert_eq!(app.hints(), &CREATE_AGENT_HINTS); + } + + #[test] + fn create_form_submits_the_placeholder_name_when_nothing_is_typed() { + let mut app = populated(); + app.open_create(candidates(&[("/sources/fresh", "fresh")])); + let Action::CreateAgent { + manifest, + name, + env_file, + .. + } = app.on_key(key(KeyCode::Enter)) + else { + panic!("expected a CreateAgent action"); + }; + assert_eq!(manifest, PathBuf::from("/sources/fresh/agent.yaml")); + assert_eq!(name, "fresh"); + assert_eq!(env_file, None); + assert!(app.modal.is_none()); + } + + #[test] + fn create_form_accepts_an_environment_file_path() { + let mut app = populated(); + app.open_create(candidates(&[("/sources/fresh", "fresh")])); + app.on_key(key(KeyCode::Tab)); + app.on_key(key(KeyCode::Tab)); + app.on_key(key(KeyCode::Tab)); + assert_eq!(create_form(&app).field, CreateField::EnvironmentFile); + for character in "../private/fresh.env".chars() { + app.on_key(key(KeyCode::Char(character))); + } + app.on_key(key(KeyCode::Char('x'))); + app.on_key(key(KeyCode::Backspace)); + + let Action::CreateAgent { env_file, .. } = app.on_key(key(KeyCode::Enter)) else { + panic!("expected a CreateAgent action"); + }; + assert_eq!(env_file, Some(PathBuf::from("../private/fresh.env"))); + } + + #[test] + fn create_form_placeholder_follows_selection_and_typed_names_win() { + let mut app = populated(); + app.open_create(candidates(&[("/a", "alpha"), ("/b", "beta")])); + assert_eq!(create_form(&app).placeholder(), Some("alpha")); + app.on_key(key(KeyCode::Right)); + assert_eq!(create_form(&app).placeholder(), Some("beta")); + app.on_key(key(KeyCode::Tab)); + app.on_key(key(KeyCode::Tab)); + app.on_key(key(KeyCode::Char('m'))); + app.on_key(key(KeyCode::Char('E'))); + app.on_key(key(KeyCode::Char('y'))); + assert_eq!(create_form(&app).name, "my"); + let Action::CreateAgent { manifest, name, .. } = app.on_key(key(KeyCode::Enter)) else { + panic!("expected a CreateAgent action"); + }; + assert_eq!(manifest, PathBuf::from("/b/agent.yaml")); + assert_eq!(name, "my"); + } + + #[test] + fn create_form_reports_duplicates_and_invalid_names_on_submit() { + let mut app = populated(); + app.open_create(candidates(&[("/sources/worker", "worker")])); + assert_eq!(app.on_key(key(KeyCode::Enter)), Action::None); + assert_eq!( + create_form(&app).error.as_deref(), + Some("agent \"worker\" already exists") + ); + app.on_key(key(KeyCode::Tab)); + app.on_key(key(KeyCode::Tab)); + app.on_key(key(KeyCode::Char('-'))); + assert_eq!(app.on_key(key(KeyCode::Enter)), Action::None); + let error = create_form(&app).error.as_deref().expect("invalid name error"); + assert!(error.starts_with("name:")); + app.on_key(key(KeyCode::Backspace)); + app.on_key(key(KeyCode::Char('w'))); + app.on_key(key(KeyCode::Char('2'))); + let Action::CreateAgent { name, .. } = app.on_key(key(KeyCode::Enter)) else { + panic!("expected a CreateAgent action"); + }; + assert_eq!(name, "w2"); + } + + #[test] + fn create_form_blocks_unreadable_manifests_and_empty_pickers() { + let mut app = populated(); + app.open_create(vec![ManifestCandidate::new( + PathBuf::from("/gone/agent.yaml"), + Err("manifest cannot be decoded".into()), + )]); + assert_eq!(create_form(&app).placeholder(), None); + app.on_key(key(KeyCode::Enter)); + assert_eq!(create_form(&app).error.as_deref(), Some("manifest cannot be decoded")); + app.on_key(key(KeyCode::Esc)); + assert!(app.modal.is_none()); + app.open_create(Vec::new()); + app.on_key(key(KeyCode::Enter)); + assert!(create_form(&app).error.is_some()); + } + + #[test] + fn create_form_keeps_invalid_variants_visible_but_blocks_submission() { + let mut app = populated(); + app.open_create(vec![ + ManifestCandidate::new(PathBuf::from("/sources/full/agent.yaml"), Ok("full".into())), + ManifestCandidate::new( + PathBuf::from("/sources/full/agent.broken.yaml"), + Err("agent.broken.yaml: missing base".into()), + ), + ]); + app.on_key(key(KeyCode::Tab)); + app.on_key(key(KeyCode::Right)); + let form = create_form(&app); + assert_eq!(form.variant_label(), Some("broken".into())); + assert_eq!(form.placeholder(), None); + + assert_eq!(app.on_key(key(KeyCode::Enter)), Action::None); + assert_eq!( + create_form(&app).error.as_deref(), + Some("agent.broken.yaml: missing base") + ); + } + + #[test] + fn create_form_selection_wraps_and_clears_errors() { + let mut app = populated(); + let mut discovered = candidates(&[("/a", "builder"), ("/b", "beta")]); + discovered.push(ManifestCandidate::new( + PathBuf::from("/a/agent.nested.yaml"), + Ok("builder-nested".into()), + )); + app.open_create(discovered); + app.on_key(key(KeyCode::Enter)); + assert!(create_form(&app).error.is_some()); + app.on_key(key(KeyCode::Left)); + let form = create_form(&app); + assert_eq!(form.agent, 1); + assert_eq!(form.variant, 0); + assert_eq!(form.error, None); + app.on_key(key(KeyCode::Tab)); + assert_eq!(create_form(&app).field, CreateField::Variant); + app.on_key(key(KeyCode::Right)); + assert_eq!(create_form(&app).variant, 0); + app.on_key(key(KeyCode::BackTab)); + app.on_key(key(KeyCode::Right)); + assert_eq!(create_form(&app).agent, 0); + app.on_key(key(KeyCode::Tab)); + app.on_key(key(KeyCode::Right)); + assert_eq!(create_form(&app).variant, 1); + assert_eq!(create_form(&app).variant_label(), Some("nested".into())); + assert_eq!(create_form(&app).placeholder(), Some("builder-nested")); + } + + #[test] + fn create_form_cycles_fields_with_arrows_and_tab() { + let mut app = populated(); + app.open_create(candidates(&[("/sources/fresh", "fresh")])); + assert_eq!(create_form(&app).field, CreateField::Agent); + + app.on_key(key(KeyCode::Down)); + assert_eq!(create_form(&app).field, CreateField::Variant); + app.on_key(key(KeyCode::Down)); + assert_eq!(create_form(&app).field, CreateField::Name); + app.on_key(key(KeyCode::Up)); + assert_eq!(create_form(&app).field, CreateField::Variant); + app.on_key(key(KeyCode::Tab)); + assert_eq!(create_form(&app).field, CreateField::Name); + app.on_key(key(KeyCode::BackTab)); + assert_eq!(create_form(&app).field, CreateField::Variant); + } + + #[test] + fn a_created_agent_is_shown_and_selected_before_the_watch_reports_it() { + let mut app = populated(); + app.agent_applied(agent("analyst")); + assert_eq!(app.selected_index(), Some(0)); + assert_eq!(app.agents.len(), 3); + + app.apply_snapshot(vec![agent("worker"), agent("builder"), agent("analyst")], Vec::new()); + assert_eq!(app.selected_index(), Some(0), "the watch reply keeps the selection"); + } + + fn failed_agent(name: &str, failure: FailureKind) -> Agent { + let mut agent = agent(name); + agent.status.conditions.push(agent::Condition { + kind: "Ready".into(), + status: ConditionStatus::False, + reason: "ImageBuildFailed".into(), + message: "Dockerfile not found".into(), + last_transition_time: None, + }); + agent.status.failure = Some(failure); + agent + } + + fn provisioning_agent(name: &str, failure: Option) -> Agent { + let phase = sandbox::SandboxPhase::ImageResolve.phase(); + let step = sandbox::StepId::generate(); + let mut progress = Progress::new(); + for event in [ + sandbox::ProgressEvent::PhaseStarted { phase }, + sandbox::ProgressEvent::StepStarted { + id: step.clone(), + name: "Pull OCI image".into(), + unit: Some(sandbox::ProgressUnit::Bytes), + total: Some(2048), + }, + sandbox::ProgressEvent::StepProgress { + id: step, + completed: 1024, + total: None, + }, + ] { + progress.apply(&event); + } + let mut agent = failed_agent(name, FailureKind::Transient); + agent.status.failure = failure; + agent.status.progress = Some(agent::progress::Provisioning { + pass: agent::resources::Changes::new().revision(), + progress, + }); + agent + } + + #[test] + fn agent_state_comes_from_typed_status_and_the_pass_in_progress() { + let mut terminating = ready_agent("done"); + terminating.metadata.deletion_timestamp = Some(time::OffsetDateTime::now_utc()); + let mut updating = provisioning_agent("updating", Some(FailureKind::Invalid)); + updating.metadata.generation = 2; + updating.status.observed_generation = 1; + let mut app = App::new(); + app.apply_snapshot( + vec![ + ready_agent("alive"), + terminating, + agent("fresh"), + failed_agent("broken", FailureKind::Invalid), + failed_agent("flaky", FailureKind::Transient), + provisioning_agent("pulling", None), + provisioning_agent("retrying", Some(FailureKind::Transient)), + updating, + ], + Vec::new(), + ); + let states = app + .render_rows() + .into_iter() + .map(|row| (row.name, row.state, row.tone, row.detail)) + .collect::>(); + assert_eq!( + states, + [ + ("alive".into(), "Ready", Tone::Green, String::new()), + ("broken".into(), "Failed", Tone::Red, "Dockerfile not found".into()), + ("done".into(), "Terminating", Tone::Red, String::new()), + ("flaky".into(), "Retrying", Tone::Yellow, "Dockerfile not found".into()), + ("fresh".into(), "Pending", Tone::Gray, String::new()), + ( + "pulling".into(), + "Provisioning", + Tone::Cyan, + "Resolve Sandbox Image · Pull OCI image: 1.0 KiB / 2.0 KiB".into() + ), + ( + "retrying".into(), + "Retrying", + Tone::Yellow, + "Resolve Sandbox Image · Pull OCI image: 1.0 KiB / 2.0 KiB".into() + ), + ( + "updating".into(), + "Provisioning", + Tone::Cyan, + "Resolve Sandbox Image · Pull OCI image: 1.0 KiB / 2.0 KiB".into() + ), + ] + ); + assert_eq!( + app.triage_counts().provisioning, + 2, + "the header counts the rows shown as Provisioning" + ); + } + + #[test] + fn a_held_session_says_why() { + let mut held = session("worker", "s1", "starting"); + held.status.lifecycle.failure = Some("Agent \"worker\" is not ready: the guest stalled".into()); + let mut app = App::new(); + app.apply_snapshot(vec![ready_agent("worker")], vec![held]); + let rows = app.render_rows(); + assert_eq!( + rows[1].detail, + "Claude Code · Agent \"worker\" is not ready: the guest stalled" + ); + } + + #[test] + fn a_stalled_guest_is_shown_as_unresponsive_even_while_a_pass_retries() { + let mut stalled = provisioning_agent("stalled", Some(FailureKind::Transient)); + stalled.status.conditions = vec![ + agent::Condition { + kind: agent::Condition::SANDBOX_RESPONSIVE.into(), + status: ConditionStatus::False, + reason: "HeartbeatStale".into(), + message: "the guest has not reported progress for more than 15s".into(), + last_transition_time: None, + }, + agent::Condition { + kind: "Ready".into(), + status: ConditionStatus::False, + reason: "SandboxUnresponsive".into(), + message: "Agent Sandbox is not responding: the guest has not reported progress for more than 15s" + .into(), + last_transition_time: None, + }, + ]; + let mut app = App::new(); + app.apply_snapshot(vec![stalled], Vec::new()); + let rows = app.render_rows(); + assert_eq!( + (rows[0].state, rows[0].tone, rows[0].detail.as_str()), + ( + "Unresponsive", + Tone::Red, + "Agent Sandbox is not responding: the guest has not reported progress for more than 15s" + ) + ); + } + + fn not_ready_agent(name: &str, run_state: RunState, reason: &str) -> Agent { + let mut agent = agent(name); + agent.spec.run_state = Some(run_state); + agent.status.conditions.push(agent::Condition { + kind: agent::Condition::READY.into(), + status: ConditionStatus::False, + reason: reason.into(), + message: String::new(), + last_transition_time: None, + }); + agent + } + + #[test] + fn a_stopped_agent_reads_stopped_and_one_changing_reads_stopping_or_starting() { + let mut app = App::new(); + app.apply_snapshot( + vec![ + not_ready_agent("a-stopped", RunState::Stopped, agent::Condition::REASON_STOPPED), + not_ready_agent("b-stopping", RunState::Stopped, agent::Condition::REASON_STOPPING), + // Asked to stop, but the last pass still recorded it Ready. + { + let mut agent = ready_agent("c-asked"); + agent.spec.run_state = Some(RunState::Stopped); + agent + }, + not_ready_agent("d-starting", RunState::Running, agent::Condition::REASON_STARTING), + ], + Vec::new(), + ); + let states = app + .render_rows() + .into_iter() + .map(|row| (row.name, row.state, row.tone)) + .collect::>(); + assert_eq!( + states, + [ + ("a-stopped".into(), "Stopped", Tone::Gray), + ("b-stopping".into(), "Stopping", Tone::Cyan), + ("c-asked".into(), "Stopping", Tone::Cyan), + ("d-starting".into(), "Starting", Tone::Cyan), + ] + ); + } + + #[test] + fn x_stops_an_agent_once_confirmed_and_starts_a_stopped_one_at_once() { + let mut app = App::new(); + app.apply_snapshot( + vec![ + not_ready_agent("idle", RunState::Stopped, agent::Condition::REASON_STOPPED), + ready_agent("worker"), + ], + Vec::new(), + ); + + app.select_index(1); + assert_eq!(app.hints(), &AGENT_HINTS); + assert_eq!(app.on_key(key(KeyCode::Char('x'))), Action::None); + assert!(matches!(&app.modal, Some(Modal::ConfirmStop { agent }) if agent == "worker")); + assert_eq!(app.hints(), &CONFIRM_HINTS); + assert_eq!(app.on_key(key(KeyCode::Char('n'))), Action::None, "declined"); + assert!(app.modal.is_none()); + app.on_key(key(KeyCode::Char('x'))); + assert_eq!( + app.on_key(key(KeyCode::Char('y'))), + Action::SetRunState { + agent: "worker".into(), + state: RunState::Stopped, + } + ); + + app.select_index(0); + assert_eq!(app.hints(), &STOPPED_AGENT_HINTS); + assert_eq!( + app.on_key(key(KeyCode::Char('x'))), + Action::SetRunState { + agent: "idle".into(), + state: RunState::Running, + }, + "a start interrupts nothing, so it is not confirmed" + ); + assert_eq!( + app.on_key(key(KeyCode::Char('e'))), + Action::None, + "nothing runs in a stopped Agent" + ); + app.on_key(key(KeyCode::Char('n'))); + assert!(app.modal.is_none(), "no Session is created in a stopped Agent"); + } + + #[test] + fn x_does_not_start_an_agent_whose_stop_is_not_recorded_yet() { + let mut app = App::new(); + app.apply_snapshot( + vec![{ + let mut agent = ready_agent("worker"); + agent.spec.run_state = Some(RunState::Stopped); + agent + }], + Vec::new(), + ); + app.select_index(0); + assert_eq!(app.hints(), &STOPPING_AGENT_HINTS); + assert_eq!( + app.on_key(key(KeyCode::Char('x'))), + Action::None, + "starting would cancel the stop" + ); + assert!(app.modal.is_none()); + } + + #[test] + fn a_stopped_agents_session_turns_are_not_read() { + let mut app = App::new(); + app.apply_snapshot( + vec![not_ready_agent( + "idle", + RunState::Stopped, + agent::Condition::REASON_STOPPED, + )], + vec![session("idle", "s1", "idle")], + ); + app.side_panel = true; + app.select_index(1); + assert_eq!(app.transcript_request(), None, "its turns live in the stopped guest"); + assert!(app.transcript.as_ref().is_some_and(|transcript| transcript.stopped)); + } + + #[test] + fn a_stopped_agents_session_offers_only_what_works_without_its_sandbox() { + let mut app = App::new(); + app.apply_snapshot( + vec![not_ready_agent( + "idle", + RunState::Stopped, + agent::Condition::REASON_STOPPED, + )], + vec![session("idle", "s1", "idle")], + ); + app.select_index(1); + assert_eq!(app.hints(), &STOPPED_SESSION_HINTS); + for code in [ + KeyCode::Enter, + KeyCode::Char('p'), + KeyCode::Char('n'), + KeyCode::Char('o'), + ] { + assert_eq!(app.on_key(key(code)), Action::None, "{code:?}"); + assert!(app.modal.is_none(), "{code:?} opens nothing"); + } + assert!(matches!( + app.on_key(key(KeyCode::Char('a'))), + Action::SetArchived { archived: true, .. } + )); + } + + #[test] + fn time_in_state_is_readys_for_failures_and_the_passs_while_provisioning() { + let entered = OffsetDateTime::now_utc() - time::Duration::minutes(5); + let mut retrying = provisioning_agent("retrying", Some(FailureKind::Transient)); + retrying.status.conditions[0].last_transition_time = Some(entered); + let mut updating = provisioning_agent("updating", None); + updating.status.conditions[0].last_transition_time = Some(entered); + let mut app = App::new(); + app.apply_snapshot(vec![retrying, updating], Vec::new()); + let since = app + .render_rows() + .into_iter() + .map(|row| (row.name, row.since)) + .collect::>(); + assert_eq!( + since, + [("retrying".into(), "5m".into()), ("updating".into(), "0s".into())], + "a retry keeps the time the Agent started failing, and a pass shows its own" + ); + } + + #[test] + fn every_session_state_has_its_own_glyph_and_input_needs_attention() { + let mut app = App::new(); + app.apply_snapshot( + vec![ready_agent("fleet")], + ["waitingForInput", "working", "starting", "idle", "failed"] + .iter() + .enumerate() + .map(|(index, state)| session("fleet", &format!("s{index}"), state)) + .collect(), + ); + let rows = app.render_rows(); + assert!( + rows[0].attention, + "the Agent shows that one of its Sessions needs input" + ); + assert_eq!(rows[0].detail, "5 sessions"); + let sessions = rows[1..] + .iter() + .map(|row| (row.marker, row.state, row.attention)) + .collect::>(); + assert_eq!( + sessions, + [ + ("!", "Needs you", true), + ("*", "Working", false), + ("~", "Starting", false), + ("-", "Idle", false), + ("x", "Failed", false), + ] + ); + assert_eq!(rows[1].detail, "Claude Code"); + } + + #[test] + fn forward_form_mirrors_an_empty_local_port_and_creates_on_enter() { + let mut app = populated(); + assert_eq!(app.on_key(key(KeyCode::Char('f'))), Action::None); + assert!(matches!(app.modal, Some(Modal::PortForward { .. }))); + app.on_key(key(KeyCode::Char('8'))); + app.on_key(key(KeyCode::Char('0'))); + let action = app.on_key(key(KeyCode::Enter)); + assert_eq!( + action, + Action::CreateForward { + agent: "builder".into(), + spec: ForwardSpec { + address: std::net::IpAddr::from([127, 0, 0, 1]), + local_port: 80, + guest_port: 80, + }, + replace: None, + } + ); + assert!(app.modal.is_none()); + } + + #[test] + fn forward_form_cycles_fields_and_reports_invalid_input() { + let mut app = populated(); + app.on_key(key(KeyCode::Char('f'))); + app.on_key(key(KeyCode::Enter)); + assert!(matches!( + app.modal, + Some(Modal::PortForward(ForwardForm { error: Some(_), .. })) + )); + app.on_key(key(KeyCode::Tab)); + let Some(Modal::PortForward(form)) = &app.modal else { + panic!("expected the PortForward modal"); + }; + assert_eq!(form.field, ForwardField::Address); + app.on_key(key(KeyCode::Tab)); + app.on_key(key(KeyCode::Char('9'))); + app.on_key(key(KeyCode::Tab)); + app.on_key(key(KeyCode::Char('8'))); + app.on_key(key(KeyCode::Char('0'))); + let action = app.on_key(key(KeyCode::Enter)); + assert_eq!( + action, + Action::CreateForward { + agent: "builder".into(), + spec: ForwardSpec { + address: std::net::IpAddr::from([127, 0, 0, 1]), + local_port: 9, + guest_port: 80, + }, + replace: None, + } + ); + } + + #[test] + fn forwards_view_lists_deletes_and_edits_like_k9s() { + let mut app = populated(); + app.set_forwards(vec![ForwardEntry { + id: 7, + agent: "worker".into(), + local: "127.0.0.1:9090".into(), + guest_port: 80, + status: None, + finished: false, + }]); + app.on_key(key(KeyCode::Char('F'))); + assert_eq!(app.view, View::Forwards); + assert_eq!( + app.on_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)), + Action::DeleteForward { id: 7 } + ); + app.on_key(key(KeyCode::Char('e'))); + let Some(Modal::PortForward(form)) = &app.modal else { + panic!("expected the PortForward modal"); + }; + assert_eq!(form.replace, Some(7)); + assert_eq!(form.local, "9090"); + assert_eq!(form.guest, "80"); + app.on_key(key(KeyCode::Esc)); + assert!(app.modal.is_none()); + app.on_key(key(KeyCode::Char('q'))); + assert_eq!(app.view, View::Tree); + } + + #[test] + fn error_screen_keys_win_over_an_open_forwards_view() { + let mut app = populated(); + app.on_key(key(KeyCode::Char('F'))); + app.error = Some("control plane unreachable".into()); + assert_eq!(app.on_key(key(KeyCode::Char('q'))), Action::Quit); + assert_eq!(app.on_key(key(KeyCode::Esc)), Action::None); + assert!(app.error.is_none()); + assert_eq!(app.view, View::Forwards, "dismissing the error returns to the forwards"); + app.on_key(key(KeyCode::Char('q'))); + assert_eq!(app.view, View::Tree); + } + + #[test] + fn rejected_forwards_reopen_the_form_with_their_values() { + let spec = ForwardSpec { + address: std::net::IpAddr::from([127, 0, 0, 1]), + local_port: 0, + guest_port: 5432, + }; + let form = ForwardForm::rejected("worker".into(), &spec, Some(3), "boom".into()); + assert_eq!(form.agent, "worker"); + assert_eq!(form.address, "127.0.0.1"); + assert_eq!(form.local, ""); + assert_eq!(form.guest, "5432"); + assert_eq!(form.field, ForwardField::Address); + assert_eq!(form.error.as_deref(), Some("boom")); + assert_eq!(form.replace, Some(3)); + } + + #[cfg(target_os = "macos")] + #[test] + fn low_loopback_ports_get_the_wildcard_bind_hint() { + let spec = ForwardSpec { + address: std::net::IpAddr::from([127, 0, 0, 1]), + local_port: 80, + guest_port: 80, + }; + let form = ForwardForm::rejected( + "worker".into(), + &spec, + None, + "bind: Permission denied (os error 13)".into(), + ); + let error = form.error.expect("rejected form should keep its error"); + assert!(error.contains("macOS allows ports below 1024 only on 0.0.0.0")); + + let wildcard = ForwardSpec { + address: std::net::IpAddr::from([0, 0, 0, 0]), + ..spec + }; + let form = ForwardForm::rejected("worker".into(), &wildcard, None, "Permission denied".into()); + assert_eq!(form.error.as_deref(), Some("Permission denied")); + } + + #[test] + fn agent_badges_list_forward_mappings() { + let mut app = populated(); + app.set_forwards(vec![ + ForwardEntry { + id: 1, + agent: "worker".into(), + local: "127.0.0.1:9090".into(), + guest_port: 80, + status: None, + finished: false, + }, + ForwardEntry { + id: 2, + agent: "worker".into(), + local: "0.0.0.0:80".into(), + guest_port: 80, + status: None, + finished: false, + }, + ]); + let views = app.render_rows(); + assert!(!views[0].detail.contains("ports:")); + assert!(views[2].detail.contains("ports: 9090:80 0.0.0.0:80:80")); + } + + #[test] + fn a_created_agents_provisioning_is_followed_until_its_detail_closes() { + let mut app = populated(); + app.agent_applied(agent("analyst")); + assert_eq!(app.followed_agent(), Some("analyst")); + + app.provisioning_followed("worker", vec!["stale".into()]); + app.provisioning_followed("analyst", vec!["Ready: True".into()]); + assert_eq!( + app.detail.as_ref().map(|detail| detail.lines.clone()), + Some(vec!["Ready: True".into()]) + ); + + app.on_key(key(KeyCode::Char('q'))); + assert_eq!(app.followed_agent(), None); + app.select_index(1); + app.on_key(key(KeyCode::Char('p'))); + assert_eq!(app.followed_agent(), Some("builder"), "p follows the selected Agent"); + } + + #[test] + fn a_prompt_is_sent_in_place_and_a_failure_brings_its_input_back() { + let mut app = populated(); + app.select_index(1); + app.on_key(key(KeyCode::Char('p'))); + assert_eq!( + app.on_key(key(KeyCode::Enter)), + Action::None, + "an empty prompt is not sent" + ); + assert!(matches!(&app.modal, Some(Modal::Prompt(form)) if form.error.is_some())); + type_text(&mut app, "go on"); + let Action::Prompt(form) = app.on_key(key(KeyCode::Enter)) else { + panic!("expected the prompt to be sent"); + }; + assert_eq!( + (form.agent.as_str(), form.session.as_str(), form.input.as_str()), + ("builder", "b1", "go on") + ); + assert!(app.modal.is_none()); + + app.prompt_failed(form, "session is not running".into()); + assert!(matches!( + &app.modal, + Some(Modal::Prompt(form)) if form.input == "go on" && form.error.as_deref() == Some("session is not running") + )); + } + + #[test] + fn turns_load_for_the_selected_session_and_again_when_it_finishes_a_turn() { + let mut app = populated(); + app.select_index(1); + assert_eq!( + app.transcript_request(), + None, + "no turns load while the panel is hidden" + ); + app.side_panel = true; + app.select_index(0); + assert_eq!(app.transcript_request(), None, "an Agent row has no turns"); + app.select_index(1); + let target = app.transcript_request().expect("the selected Session's turns"); + assert_eq!(app.transcript_request(), None, "one request at a time"); + app.select_index(3); + assert_eq!( + app.transcript_request(), + None, + "another Session waits for the load in flight" + ); + app.select_index(1); + assert_eq!(app.transcript_request(), None, "coming back waits for the same load"); + app.transcript_loaded(&target.0, &target.1, Ok(Vec::new())); + assert_eq!(app.transcript_request(), None, "nothing changed"); + + let mut sessions = app.sessions.clone(); + sessions[0].status.reported.activity.turns += 1; + let agents = app.agents.clone(); + app.apply_snapshot(agents, sessions); + assert_eq!(app.transcript_request(), Some(target)); + + app.select_index(0); + assert_eq!(app.transcript_request(), None); + assert!(app.transcript.is_none()); + } + + #[test] + fn describe_opens_a_detail_view_that_scrolls_and_closes() { + let mut app = populated(); + app.on_key(key(KeyCode::Char('s'))); + let detail = app.detail.as_ref().expect("agent detail"); + assert_eq!(detail.title, "agent/builder"); + app.on_key(key(KeyCode::Char('j'))); + assert_eq!(app.detail.as_ref().expect("agent detail").scroll, 1); + app.on_key(key(KeyCode::Char('q'))); + assert!(app.detail.is_none()); + app.select_index(1); + app.on_key(key(KeyCode::Char('s'))); + assert_eq!(app.detail.as_ref().expect("session detail").title, "session/builder/b1"); + } + + #[test] + fn yaml_views_render_the_full_resource() { + let mut app = populated(); + app.on_key(key(KeyCode::Char('y'))); + let detail = app.detail.as_ref().expect("agent yaml"); + assert_eq!(detail.title, "agent/builder yaml"); + assert!(detail.lines.iter().any(|line| line == "kind: Agent")); + assert!(detail.lines.iter().any(|line| line.contains("apiVersion:"))); + assert!(detail.lines.iter().any(|line| line.contains("harnesses:"))); + app.on_key(key(KeyCode::Char('q'))); + app.select_index(1); + app.on_key(key(KeyCode::Char('y'))); + let detail = app.detail.as_ref().expect("session yaml"); + assert_eq!(detail.title, "session/builder/b1 yaml"); + assert!(detail.lines.iter().any(|line| line.contains("harness: claudeCode"))); + assert!(detail.lines.iter().any(|line| line.contains("name: b1"))); + } + + fn ssh_app(setup: SshSetup) -> App { + let mut worker = ready_agent("worker"); + worker.spec.access = vec![agent::AccessSpec::Ssh {}]; + let mut app = App::new(); + app.ssh_setup = setup; + app.ssh_include = Some(agent::ssh::UserInclude { + user_config: "/tmp/user/.ssh/config".into(), + line: "Include ~/.agent/ssh/config".into(), + }); + app.apply_snapshot(vec![worker], vec![session("worker", "main", "working")]); + app + } + + #[test] + fn o_opens_the_menu_of_the_selected_agent_or_of_a_sessions_agent() { + let mut app = ssh_app(SshSetup::Installed); + app.selection = Some(TreeRowId::Agent("worker".into())); + assert_eq!(app.on_key(key(KeyCode::Char('o'))), Action::None); + assert!(matches!(&app.modal, Some(Modal::Open(menu)) if menu.agent == "worker")); + assert_eq!(app.hints(), &OPEN_HINTS); + + app.modal = None; + app.selection = Some(TreeRowId::Session { + agent: "worker".into(), + session: SessionName::new("main").expect("name"), + }); + app.on_key(key(KeyCode::Char('o'))); + assert!(matches!(&app.modal, Some(Modal::Open(menu)) if menu.agent == "worker")); + assert_eq!(app.on_key(key(KeyCode::Esc)), Action::None); + assert!(app.modal.is_none()); + } + + #[test] + fn choosing_an_item_opens_it_in_the_agent() { + let mut app = ssh_app(SshSetup::Installed); + app.selection = Some(TreeRowId::Agent("worker".into())); + app.on_key(key(KeyCode::Char('o'))); + assert_eq!( + app.on_key(key(KeyCode::Char('c'))), + Action::Open { + agent: "worker".into(), + target: OpenTarget::Editor(crate::launch::Editor::VsCode), + } + ); + app.on_key(key(KeyCode::Char('o'))); + assert_eq!( + app.on_key(key(KeyCode::Enter)), + Action::Open { + agent: "worker".into(), + target: OpenTarget::Shell, + }, + "the shell is selected first" + ); + } + + #[test] + fn an_editor_asks_for_the_missing_ssh_setup_and_then_opens() { + let mut app = ssh_app(SshSetup::Missing); + app.selection = Some(TreeRowId::Agent("worker".into())); + app.on_key(key(KeyCode::Char('o'))); + assert_eq!(app.on_key(key(KeyCode::Char('c'))), Action::None); + let editor = OpenTarget::Editor(crate::launch::Editor::VsCode); + assert!(matches!(&app.modal, Some(Modal::ConfirmSshSetup { then: Some(target), .. }) if *target == editor)); + assert_eq!(app.hints(), &CONFIRM_SSH_SETUP_THEN_HINTS); + assert_eq!( + app.on_key(key(KeyCode::Char('y'))), + Action::None, + "y never writes the file" + ); + + // Declining returns to the menu, which still offers the setup. + assert_eq!(app.on_key(key(KeyCode::Esc)), Action::None); + assert!(matches!(&app.modal, Some(Modal::Open(menu)) + if menu.items.iter().any(|item| item.entry == MenuEntry::SetUpSsh))); + + app.on_key(key(KeyCode::Char('c'))); + let then = Some(("worker".to_owned(), editor)); + let Action::SetUpSsh { include, then: next } = app.on_key(key(KeyCode::Enter)) else { + panic!("expected SetUpSsh"); + }; + assert_eq!(include.line, "Include ~/.agent/ssh/config"); + assert_eq!(next, then); + assert_eq!( + app.ssh_set_up(Ok(include.user_config), next, Instant::now()), + Some(Action::Open { + agent: "worker".into(), + target: editor, + }) + ); + assert_eq!(app.ssh_setup, SshSetup::Installed); + assert_eq!( + app.notice.as_ref().map(|(text, _)| text.as_str()), + Some("SSH set up in /tmp/user/.ssh/config") + ); + + // Once set up, the editor opens directly. + app.on_key(key(KeyCode::Char('o'))); + assert!(matches!(app.on_key(key(KeyCode::Char('c'))), Action::Open { .. })); + } + + #[test] + fn setting_up_ssh_from_its_own_entry_opens_nothing_after() { + let mut app = ssh_app(SshSetup::Missing); + app.selection = Some(TreeRowId::Agent("worker".into())); + app.on_key(key(KeyCode::Char('o'))); + let Some(Modal::Open(menu)) = &app.modal else { + panic!("expected the open menu"); + }; + let setup = menu + .items + .iter() + .position(|item| item.entry == MenuEntry::SetUpSsh) + .expect("setup entry"); + app.on_mouse(MouseAction::ChooseOpen(setup)); + assert!(matches!(app.modal, Some(Modal::ConfirmSshSetup { then: None, .. }))); + assert_eq!(app.hints(), &CONFIRM_SSH_SETUP_HINTS); + assert_eq!( + app.on_key(key(KeyCode::Char('o'))), + Action::None, + "nothing waits to open" + ); + assert!(matches!(app.modal, Some(Modal::ConfirmSshSetup { then: None, .. }))); + let Action::SetUpSsh { include, then } = app.on_key(key(KeyCode::Enter)) else { + panic!("expected SetUpSsh"); + }; + assert_eq!(then, None); + assert_eq!(app.ssh_set_up(Ok(include.user_config), then, Instant::now()), None); + } + + #[test] + fn what_waits_on_the_ssh_setup_opens_without_it_on_request() { + let mut app = ssh_app(SshSetup::Missing); + app.selection = Some(TreeRowId::Agent("worker".into())); + app.on_key(key(KeyCode::Char('o'))); + app.on_key(key(KeyCode::Char('y'))); + assert!(matches!(app.modal, Some(Modal::ConfirmSshSetup { .. }))); + assert_eq!( + app.on_key(key(KeyCode::Char('o'))), + Action::Open { + agent: "worker".into(), + target: OpenTarget::CopyAlias, + } + ); + assert!(app.modal.is_none()); + assert_eq!(app.ssh_setup, SshSetup::Missing, "nothing was written"); + } + + #[test] + fn the_ssh_setup_is_checked_with_an_agent_openssh_knows_and_again_on_each_menu() { + let mut app = App::new(); + app.apply_snapshot(vec![ready_agent("builder")], Vec::new()); + assert_eq!(app.ssh_check_request(), None, "no Agent has an alias to resolve"); + + let mut app = ssh_app(SshSetup::Unknown); + let mut other = ready_agent("alpha"); + other.spec.access = vec![agent::AccessSpec::Ssh {}]; + let worker = app.agents[0].clone(); + app.apply_snapshot(vec![other, worker], Vec::new()); + assert_eq!(app.ssh_check_request().as_deref(), Some("alpha")); + assert_eq!(app.ssh_check_request(), None, "one check at a time"); + app.ssh_checked("alpha", SshSetup::Missing); + assert_eq!(app.ssh_setup, SshSetup::Missing); + assert_eq!(app.ssh_check_request(), None, "checked until a menu opens"); + + app.selection = Some(TreeRowId::Agent("worker".into())); + app.on_key(key(KeyCode::Char('o'))); + assert_eq!( + app.ssh_check_request().as_deref(), + Some("worker"), + "the menu's Agent first" + ); + } + + #[test] + fn a_finished_ssh_check_updates_the_open_menu_and_keeps_its_selection() { + let mut app = ssh_app(SshSetup::Unknown); + app.selection = Some(TreeRowId::Agent("worker".into())); + app.on_key(key(KeyCode::Char('o'))); + app.on_key(key(KeyCode::Down)); + let setup_offered = |app: &App| { + matches!(&app.modal, Some(Modal::Open(menu)) + if menu.items.iter().any(|item| item.entry == MenuEntry::SetUpSsh)) + }; + assert!(!setup_offered(&app)); + + assert_eq!(app.ssh_check_request().as_deref(), Some("worker")); + app.ssh_checked("worker", SshSetup::Missing); + assert!(setup_offered(&app)); + let Some(Modal::Open(menu)) = &app.modal else { + panic!("expected the open menu"); + }; + assert_eq!( + menu.chosen(), + Some(MenuEntry::Open(OpenTarget::Editor(crate::launch::Editor::VsCode))) + ); + + app.ssh_checked("worker", SshSetup::Installed); + assert!(!setup_offered(&app)); + } + + #[test] + fn clicking_an_unavailable_item_keeps_the_menu_open() { + let mut app = ssh_app(SshSetup::Installed); + app.selection = Some(TreeRowId::Agent("worker".into())); + app.on_key(key(KeyCode::Char('o'))); + let Some(Modal::Open(menu)) = &app.modal else { + panic!("expected the open menu"); + }; + let zed = menu + .items + .iter() + .position(|item| item.unavailable.is_some()) + .expect("Zed has no launcher in tests"); + assert_eq!(app.on_mouse(MouseAction::ChooseOpen(zed)), Action::None); + assert!(matches!(app.modal, Some(Modal::Open(_)))); + } + + #[test] + fn a_failed_ssh_setup_is_reported_and_opens_nothing() { + let mut app = ssh_app(SshSetup::Missing); + let then = Some(("worker".to_owned(), OpenTarget::CopyAlias)); + + assert_eq!( + app.ssh_set_up(Err("read-only file system".into()), then, Instant::now()), + None + ); + assert_eq!(app.error.as_deref(), Some("read-only file system")); + assert_eq!(app.ssh_setup, SshSetup::Missing); + } + + #[test] + fn the_agent_panel_shows_how_to_connect() { + let app = ssh_app(SshSetup::Missing); + let lines = app.agent_panel_lines("worker"); + let connect = lines + .iter() + .position(|line| line == "Connect · o open…") + .expect("Connect section"); + assert_eq!(lines[connect + 1], " shell in this terminal"); + assert!(lines.contains(&" ! SSH not set up; o offers it".to_owned())); + assert_eq!(lines.last().map(String::as_str), Some(" ssh alias agentctl-worker")); + assert!( + lines.iter().all(|line| !line.contains("agentctl ")), + "the panel offers keys, not commands: {lines:?}" + ); + } + + #[test] + fn one_open_waits_per_agent_and_target_and_ends_in_a_notice_or_an_error() { + let mut app = ssh_app(SshSetup::Installed); + let now = Instant::now(); + let vs_code = OpenTarget::Editor(crate::launch::Editor::VsCode); + assert!(app.start_opening("worker", vs_code, now)); + assert!( + !app.start_opening("worker", vs_code, now), + "a repeat waits for the first" + ); + assert_eq!( + app.notice.as_ref().map(|(text, _)| text.as_str()), + Some("already opening VS Code on worker") + ); + assert!(app.start_opening("worker", OpenTarget::Editor(crate::launch::Editor::Zed), now)); + + app.opened( + Some(("worker".into(), vs_code)), + Ok("opening VS Code on worker".into()), + now, + ); + assert_eq!( + app.notice.as_ref().map(|(text, _)| text.as_str()), + Some("opening VS Code on worker") + ); + app.opened( + Some(("worker".into(), OpenTarget::Editor(crate::launch::Editor::Zed))), + Err("zed failed".into()), + now, + ); + assert_eq!(app.error.as_deref(), Some("zed failed")); + assert!(app.opening.is_empty()); + } + + fn desktop_forward(id: u64, agent: &str, guest_port: u16) -> ForwardEntry { + ForwardEntry { + id, + agent: agent.into(), + local: format!("127.0.0.1:{}", 50000 + id), + guest_port, + status: None, + finished: false, + } + } + + #[test] + fn quitting_asks_first_while_forwards_would_close() { + let mut app = ssh_app(SshSetup::Installed); + assert_eq!(app.on_key(key(KeyCode::Char('q'))), Action::Quit, "nothing to lose"); + + app.set_forwards(vec![desktop_forward(1, "worker", 6080)]); + assert_eq!(app.on_key(key(KeyCode::Char('q'))), Action::None); + assert!(matches!(app.modal, Some(Modal::ConfirmQuit))); + assert_eq!(app.hints(), &CONFIRM_HINTS); + assert_eq!(app.on_key(key(KeyCode::Char('n'))), Action::None); + assert!(app.modal.is_none(), "n stays"); + app.on_key(key(KeyCode::Esc)); + assert_eq!(app.on_key(key(KeyCode::Char('y'))), Action::Quit); + } + + #[test] + fn a_forward_opens_in_the_application_for_its_port() { + let mut app = ssh_app(SshSetup::Installed); + app.set_forwards(vec![ + desktop_forward(1, "worker", 6080), + desktop_forward(2, "worker", agent::vnc::GUEST_PORT), + ]); + app.view = View::Forwards; + assert_eq!( + app.on_key(key(KeyCode::Char('o'))), + Action::OpenUrl("http://127.0.0.1:50001/".into()) + ); + app.on_key(key(KeyCode::Char('j'))); + assert_eq!( + app.on_key(key(KeyCode::Char('o'))), + Action::OpenUrl("vnc://127.0.0.1:50002".into()) + ); + } + + #[test] + fn labeled_forwards_name_themselves_and_the_panel_shows_the_open_desktop() { + let mut app = ssh_app(SshSetup::Installed); + let mut desktop = ready_agent("desk"); + desktop.spec.access = vec![agent::AccessSpec::Ssh {}, agent::AccessSpec::Vnc {}]; + let mut agents = std::mem::take(&mut app.agents); + agents.push(desktop); + let sessions = std::mem::take(&mut app.sessions); + app.apply_snapshot(agents, sessions); + app.set_forwards(vec![desktop_forward(1, "desk", 6080)]); + + let desk = app + .render_rows() + .into_iter() + .find(|row| row.name == "desk") + .expect("desk row"); + assert!(desk.detail.contains("ports: desktop 50001:6080"), "{}", desk.detail); + assert!( + app.agent_panel_lines("desk") + .contains(&" desktop open at http://127.0.0.1:50001/".to_owned()) + ); + + let mut stopped = desktop_forward(1, "desk", 6080); + stopped.finished = true; + app.set_forwards(vec![stopped]); + assert!( + app.agent_panel_lines("desk") + .contains(&" desktop browser · VNC client".to_owned()), + "a stopped forward's address no longer opens anything" + ); + } +} diff --git a/agentctl/src/bin/agentctl/tui/mod.rs b/agentctl/src/bin/agentctl/tui/mod.rs new file mode 100644 index 0000000..b9115b0 --- /dev/null +++ b/agentctl/src/bin/agentctl/tui/mod.rs @@ -0,0 +1,1548 @@ +mod app; +mod open; +mod provisioning; +mod terminal; +mod view; + +use std::{ + collections::HashMap, + io::IsTerminal as _, + path::{Path, PathBuf}, + process::ExitCode, + time::{Duration, Instant}, +}; + +use agent::{ + Agent, Error, control_api::Client, control_plane::WaitPolicy, local::home::ControlPlaneHome, manifest, + resources::Resources, sessions::Session, sessions::SessionName, sessions::SessionRequest, sessions::Turn, +}; +use crossterm::event::{ + Event, EventStream, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; +use futures_util::StreamExt as _; +use ignore::WalkBuilder; +use sandbox::terminal::TerminalAttachOutcome; + +use crate::CommandResult; +use crate::forward::{ForwardSpec, PortForward}; +use crate::progress::Wait; +use agent::manifest::MANIFEST_FILE; +use app::{ + Action, App, CreateForm, ForwardEntry, ForwardForm, ManifestCandidate, Modal, MouseAction, PromptForm, RowTarget, +}; +use open::{DesktopViewer, OpenTarget, SshSetup}; +use terminal::Tui; +use view::{HitMap, HitTarget, WheelTarget}; + +/// Turns of the selected Session shown beside the tree. +const TRANSCRIPT_TURNS: usize = 3; +/// Pause before watching again after the daemon could not be reached. +const RECONNECT_INTERVAL: Duration = Duration::from_secs(1); +/// How often the screen is redrawn without input, so times keep moving. +const REDRAW_INTERVAL: Duration = Duration::from_secs(1); +const DOUBLE_CLICK_INTERVAL: Duration = Duration::from_millis(500); +/// Deepest directory level below the working directory searched for manifests. +const DISCOVERY_DEPTH: usize = 8; + +enum Input { + Event(Option>), + /// A watch reply, or why the daemon could not be watched. + Resources(Result), + /// The lines of an Agent's followed provisioning. + Provisioning { + agent: String, + lines: Vec, + }, + TranscriptLoaded { + agent: String, + session: SessionName, + turns: Result, String>, + }, + PromptSent(PromptForm, Result<(), String>), + /// A background Session change failed; its success shows through the watch. + SessionChangeFailed(String), + /// A Session was archived or unarchived, as recorded. + ArchiveChanged(Session), + ForwardCreated(CreateOutcome), + ManifestsDiscovered(Vec), + /// An action a finished step leads to, run in turn with the input already waiting. + Then(Action), + /// OpenSSH resolved an Agent's alias. + SshChecked { + agent: String, + setup: SshSetup, + }, + /// A background open finished; `waiting` is what it ends the wait for. + Opened { + waiting: Option<(String, OpenTarget)>, + outcome: Result, + }, +} + +/// Sends the event loop what background work finished. +type Inputs = tokio::sync::mpsc::UnboundedSender; + +/// Completion of one background forward creation. +type CreateOutcome = (String, ForwardSpec, Option, Result); + +#[derive(Default)] +struct MouseInput { + last_row: Option<(RowTarget, Instant)>, + position: Option<(u16, u16)>, +} + +impl MouseInput { + fn reset(&mut self) { + self.last_row = None; + } + + const fn position(&self) -> Option<(u16, u16)> { + self.position + } + + fn double_click(&mut self, row: &RowTarget, now: Instant) -> bool { + let double = self + .last_row + .as_ref() + .is_some_and(|(previous, at)| previous == row && now.duration_since(*at) <= DOUBLE_CLICK_INTERVAL); + if double { + self.reset(); + } else { + self.last_row = Some((row.clone(), now)); + } + double + } + + fn action(&mut self, event: MouseEvent, hit_map: &HitMap, app: &mut App, now: Instant) -> Action { + self.position = Some((event.column, event.row)); + if !event.modifiers.is_empty() { + self.reset(); + return Action::None; + } + match event.kind { + MouseEventKind::Down(MouseButton::Left) => { + let Some(target) = hit_map.click_at(event.column, event.row) else { + self.reset(); + return Action::None; + }; + match target { + HitTarget::Action(action) => { + self.reset(); + app.on_mouse(action) + } + HitTarget::Row(row) => { + if self.double_click(&row, now) { + app.on_mouse(MouseAction::Primary(row)) + } else { + app.on_mouse(MouseAction::Select(row)) + } + } + } + } + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => { + self.reset(); + let delta = if event.kind == MouseEventKind::ScrollUp { -1 } else { 1 }; + match hit_map.wheel_at(event.column, event.row) { + Some(WheelTarget::Tree) => app.on_mouse(MouseAction::MoveTree(delta)), + Some(WheelTarget::Forwards) => app.on_mouse(MouseAction::MoveForward(delta)), + Some(WheelTarget::Detail) => app.on_mouse(MouseAction::ScrollDetail(delta)), + None => Action::None, + } + } + MouseEventKind::Down(_) | MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight => { + self.reset(); + Action::None + } + MouseEventKind::Up(_) | MouseEventKind::Drag(_) | MouseEventKind::Moved => Action::None, + } + } +} + +#[allow(clippy::too_many_lines)] +pub(crate) async fn run(home: &ControlPlaneHome, client: &Client) -> CommandResult { + if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { + return Err(Error::Invalid("tui requires an interactive local terminal".into()).into()); + } + let mut app = App::new(); + app.environment = open::Environment::detect(); + app.ssh_include = agent::ssh::UserInclude::for_home(home).ok(); + let mut forwards = ActiveForwards::default(); + let (inputs, mut background) = tokio::sync::mpsc::unbounded_channel(); + let mut tui = Tui::enter()?; + let mut events = EventStream::new(); + let mut mouse = MouseInput::default(); + let mut follow = Follow::default(); + let mut redraw = tokio::time::interval(REDRAW_INTERVAL); + redraw.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + spawn_watch(home.socket_path(), inputs.clone()); + loop { + app.open_queued_create(); + app.set_forwards(forwards.entries()); + follow.sync(app.followed_agent(), home.socket_path(), &inputs); + app.side_panel = view::shows_side_panel(tui.width()); + app.expire_notice(Instant::now()); + if let Some((agent, session)) = app.transcript_request() { + spawn_transcript(home.socket_path(), inputs.clone(), agent, session); + } + if let Some(agent) = app.ssh_check_request() { + spawn_ssh_check(inputs.clone(), agent); + } + let hit_map = tui.draw(&app)?; + tui.set_pointer_for(&hit_map, mouse.position())?; + let input = tokio::select! { + event = events.next() => Input::Event(event), + Some(input) = background.recv() => input, + // Times in state and elapsed step times move without new input. + _ = redraw.tick() => continue, + }; + let action = match input { + Input::Resources(Ok(resources)) => { + app.connection_error = None; + forwards.prune(&resources.agents); + app.apply_snapshot(resources.agents, resources.sessions); + continue; + } + Input::Resources(Err(error)) => { + app.connection_error = Some(error); + continue; + } + Input::Provisioning { agent, lines } => { + app.provisioning_followed(&agent, lines); + continue; + } + Input::TranscriptLoaded { agent, session, turns } => { + app.transcript_loaded(&agent, &session, turns); + continue; + } + Input::PromptSent(form, result) => { + app.prompting = app.prompting.saturating_sub(1); + if let Err(error) = result { + app.prompt_failed(form, error); + } + continue; + } + Input::SessionChangeFailed(error) => { + app.error = Some(error); + continue; + } + Input::ArchiveChanged(session) => { + app.archive_changed(session, Instant::now()); + continue; + } + Input::ForwardCreated(outcome) => { + mouse.reset(); + forward_created(&mut app, &mut forwards, outcome); + continue; + } + Input::ManifestsDiscovered(candidates) => { + mouse.reset(); + app.manifests_discovered(candidates); + continue; + } + Input::Then(action) => action, + Input::SshChecked { agent, setup } => { + app.ssh_checked(&agent, setup); + continue; + } + Input::Opened { waiting, outcome } => { + open_finished(&mut app, &mut forwards, waiting, outcome); + continue; + } + Input::Event(None) => { + tui.restore()?; + return Ok(ExitCode::SUCCESS); + } + Input::Event(Some(Err(error))) => { + tui.restore()?; + return Err(Error::from(error).into()); + } + Input::Event(Some(Ok(Event::Key(key)))) if key.kind == KeyEventKind::Press => { + mouse.reset(); + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + tui.restore()?; + return Ok(ExitCode::SUCCESS); + } + app.on_key(key) + } + Input::Event(Some(Ok(Event::Mouse(event)))) => mouse.action(event, &hit_map, &mut app, Instant::now()), + Input::Event(Some(Ok(_))) => { + mouse.reset(); + continue; + } + }; + match action { + Action::None => {} + Action::Quit => { + tui.restore()?; + return Ok(ExitCode::SUCCESS); + } + Action::Delete { agent } => { + if let Err(error) = client.delete(&agent).await { + app.error = Some(error.to_string()); + } + } + Action::SetRunState { agent, state } => { + if let Err(error) = client.set_run_state(&agent, state).await { + app.error = Some(error.to_string()); + } + } + Action::OpenCreate => { + if !app.discovering { + app.discovering = true; + spawn_discovery(inputs.clone(), app.agents.clone()); + } + } + Action::CreateAgent { + manifest, + name, + env_file, + form, + } => create(&mut app, client, manifest, name, env_file, form).await, + Action::CreateForward { agent, spec, replace } => { + if let Some(id) = replace { + forwards.remove(id); + } + app.creating += 1; + spawn_create(home, inputs.clone(), agent, spec, replace); + } + Action::DeleteSession { agent, session } => { + spawn_session_delete(home.socket_path(), inputs.clone(), agent, session); + } + Action::SetArchived { + agent, + session, + archived, + } => spawn_session_archive(home.socket_path(), inputs.clone(), agent, session, archived), + Action::DeleteForward { id } => forwards.remove(id), + Action::Prompt(form) => { + app.prompting += 1; + spawn_prompt(home.socket_path(), inputs.clone(), form); + } + Action::Open { + agent, + target: target @ OpenTarget::Editor(editor), + } => { + if app.start_opening(&agent, target, Instant::now()) { + let launcher = app.environment.launcher(editor).map(Path::to_path_buf); + let outside = Outside::Editor { + agent: agent.clone(), + editor, + launcher, + }; + spawn_open(home, inputs.clone(), Some((agent, target)), outside, false); + } + } + Action::Open { + agent, + target: OpenTarget::CopyAlias, + } => { + let alias = agent::ssh::alias(&agent); + terminal::copy_to_clipboard(&alias)?; + app.notice = Some((format!("copied {alias}"), Instant::now())); + } + Action::SetUpSsh { include, then } => { + // One line in one small file: written at once, so nothing can + // ask for the setup again while it is being written. + let result = include + .install() + .map(|_| include.user_config.clone()) + .map_err(|error| error.to_string()); + if let Some(next) = app.ssh_set_up(result, then, Instant::now()) { + let _ = inputs.send(Input::Then(next)); + } + } + Action::Open { + agent, + target: target @ OpenTarget::Desktop(viewer), + } => { + if app.start_opening(&agent, target, Instant::now()) { + // An Agent's desktop already forwarded is opened again rather than twice. + let outside = forwards.desktop(&agent, viewer).map_or_else( + || Outside::Desktop { + agent: agent.clone(), + viewer, + }, + Outside::Forward, + ); + let copy = app.environment.launch_blocked.is_some(); + spawn_open(home, inputs.clone(), Some((agent, target)), outside, copy); + } + } + Action::OpenUrl(url) => { + let copy = app.environment.launch_blocked.is_some(); + spawn_open(home, inputs.clone(), None, Outside::Forward(url), copy); + } + action => { + drop(events); + suspended(&mut app, &mut tui, home, client, action).await?; + events = EventStream::new(); + } + } + } +} + +/// Whether `forward` dials the Sandbox the Agent named `agent` has now. An +/// Agent whose Sandbox is not materialized yet keeps what it has. +fn dials_current_sandbox(agents: &[Agent], agent: &str, forward: &PortForward) -> bool { + agents.iter().any(|listed| { + listed.metadata.name == agent + && listed + .status + .sandbox + .as_ref() + .and_then(agent::sandbox::Assignment::id) + .is_none_or(|id| Some(id) == forward.assignment().id()) + }) +} + +/// Process-owned port forwards keyed by a stable per-run identity. +#[derive(Default)] +struct ActiveForwards { + next_id: u64, + active: Vec<(u64, String, PortForward)>, +} + +impl ActiveForwards { + /// Holds `forward` while its Agent still has the Sandbox it dials. A + /// forward that finished starting after its Agent was deleted or re-created + /// is stopped at once, and `false` says so. + fn push(&mut self, agent: String, forward: PortForward, agents: &[Agent]) -> bool { + if !dials_current_sandbox(agents, &agent, &forward) { + return false; + } + let id = self.next_id; + self.next_id += 1; + self.active.push((id, agent, forward)); + true + } + + fn remove(&mut self, id: u64) { + self.active.retain(|(entry, _, _)| *entry != id); + } + + /// Stops forwards whose Sandbox their Agent no longer has: the Agent was + /// deleted, or re-created under the same name with a Sandbox of its own. + /// An Agent whose Sandbox is not materialized yet keeps its forwards. + fn prune(&mut self, agents: &[Agent]) { + self.active + .retain(|(_, name, forward)| dials_current_sandbox(agents, name, forward)); + } + + /// The address that opens the Agent's forward to `viewer`, while it still serves. + fn desktop(&self, agent: &str, viewer: DesktopViewer) -> Option { + self.active + .iter() + .map(|(_, name, forward)| (name, forward)) + .find(|(name, forward)| { + *name == agent && forward.spec().guest_port == viewer.guest_port() && !forward.finished() + }) + .map(|(_, forward)| crate::launch::forward_url(forward.local_address(), forward.spec().guest_port)) + } + + fn entries(&self) -> Vec { + self.active + .iter() + .map(|(id, agent, forward)| ForwardEntry { + id: *id, + agent: agent.clone(), + local: forward.local_address().to_string(), + guest_port: forward.spec().guest_port, + status: forward.status(), + finished: forward.finished(), + }) + .collect() + } +} + +/// Follows every Agent and Session, sending each state the daemon reports. +/// +/// Each reply is the complete current state, so a reply lost to a reconnect +/// needs no recovery: the next one supersedes it. +fn spawn_watch(socket_path: PathBuf, inputs: Inputs) { + tokio::task::spawn_local(async move { + let client = Client::for_path(socket_path); + let mut after = None; + loop { + let reply = match client.watch_resources(after).await { + Ok(resources) => { + after = Some(resources.revision); + Ok(resources) + } + // An upgraded daemon may no longer speak this client's protocol, + // which explains the failure better than the failed call does. + Err(error) => Err(client.require_compatible_daemon().await.err().unwrap_or(error)), + }; + let failed = reply.is_err(); + if inputs + .send(Input::Resources(reply.map_err(|error| error.to_string()))) + .is_err() + { + return; + } + if failed { + tokio::time::sleep(RECONNECT_INTERVAL).await; + } + } + }); +} + +/// The task following the provisioning an open detail shows, at most one. +#[derive(Default)] +struct Follow { + task: Option<(String, tokio::task::JoinHandle<()>)>, +} + +impl Follow { + /// Follows `agent`, stopping the previous task when the agent changes. + fn sync(&mut self, agent: Option<&str>, socket_path: PathBuf, inputs: &Inputs) { + if self.task.as_ref().map(|(followed, _)| followed.as_str()) == agent { + return; + } + if let Some((_, task)) = self.task.take() { + task.abort(); + } + self.task = agent.map(|agent| { + ( + agent.to_owned(), + spawn_follow(socket_path, agent.to_owned(), inputs.clone()), + ) + }); + } +} + +/// Follows one Agent's provisioning through `agents.v1.progress`, sending the +/// lines to show after every reply. +fn spawn_follow(socket_path: PathBuf, agent: String, inputs: Inputs) -> tokio::task::JoinHandle<()> { + tokio::task::spawn_local(async move { + let client = Client::for_path(socket_path); + let mut followed = provisioning::Followed::default(); + loop { + let (after, output) = followed.position(); + let lines = match client.agent_progress(&agent, after, output).await { + Ok(progress) => { + followed.apply(progress); + followed.lines() + } + Err(error) => { + tokio::time::sleep(RECONNECT_INTERVAL).await; + vec![format!("Cannot follow provisioning: {error}")] + } + }; + let agent = agent.clone(); + if inputs.send(Input::Provisioning { agent, lines }).is_err() { + return; + } + } + }) +} + +/// Checks in the background how OpenSSH resolves `agent`'s alias. +fn spawn_ssh_check(inputs: Inputs, agent: String) { + tokio::task::spawn_local(async move { + let setup = SshSetup::check(&agent).await; + let _ = inputs.send(Input::SshChecked { agent, setup }); + }); +} + +/// What `o` opens outside this terminal. +enum Outside { + Editor { + agent: String, + editor: crate::launch::Editor, + launcher: Option, + }, + /// The Agent's desktop, through a new forward. + Desktop { agent: String, viewer: DesktopViewer }, + /// The address of a forward that is already open. + Forward(String), +} + +/// What a background open leaves for the event loop. +struct OpenOutcome { + notice: String, + /// A forward the open started, for the TUI to hold, with its Agent. + forward: Option<(String, PortForward)>, + /// An address to copy instead of opening, since this terminal cannot show windows. + copy: Option, +} + +/// Opens `outside`, first waiting for its Agent to be Ready when it has one, +/// so an editor's first connection does not wait behind provisioning and time +/// out, and ends the wait for `waiting`. With `copy`, since nothing can open +/// here, an address is copied instead. +fn spawn_open( + home: &ControlPlaneHome, + inputs: Inputs, + waiting: Option<(String, OpenTarget)>, + outside: Outside, + copy: bool, +) { + let home_path = home.path().to_path_buf(); + let socket_path = home.socket_path(); + tokio::task::spawn_local(async move { + let client = Client::for_path(socket_path); + let outcome = async { + let (launch, what, forward) = match outside { + Outside::Editor { + agent, + editor, + launcher, + } => { + client + .ensure_execution(&agent, WaitPolicy::UntilConverged) + .await + .map_err(|error| error.to_string())?; + let access = client.ssh_access(&agent).await.map_err(|error| error.to_string())?; + let launch = editor + .launch(launcher.as_deref(), &access.alias, &access.working_directory) + .ok_or_else(|| { + format!("{}: {}", editor.label(), editor.missing_launcher().unwrap_or_default()) + })?; + (launch, format!("{} on {agent}", editor.label()), None) + } + Outside::Desktop { agent, viewer } => { + let forward = desktop_forward(&client, home_path, &agent, viewer).await?; + let url = crate::launch::forward_url(forward.local_address(), forward.spec().guest_port); + (crate::launch::Launch::Url(url.clone()), url, Some((agent, forward))) + } + Outside::Forward(url) => (crate::launch::Launch::Url(url.clone()), url, None), + }; + hand_over(launch, &what, forward, copy).await + } + .await; + let _ = inputs.send(Input::Opened { waiting, outcome }); + }); +} + +/// Launches what an open resolved to, or copies its address where nothing can +/// open here. Every address is a forward's, so when launching it fails the +/// forward is kept, since its address still works, and the address is copied. +async fn hand_over( + launch: crate::launch::Launch, + what: &str, + forward: Option<(String, PortForward)>, + copy: bool, +) -> Result { + let url = match &launch { + crate::launch::Launch::Url(url) => Some(url.clone()), + crate::launch::Launch::Command { .. } => None, + }; + if let (Some(url), true) = (&url, copy) { + return Ok(OpenOutcome { + notice: format!("copied {url}, reachable from the machine running agentctl"), + forward, + copy: Some(url.clone()), + }); + } + launched(launch.start().await, what, url, forward) +} + +/// How an open ends once its launch was tried. +fn launched( + started: Result<(), String>, + what: &str, + url: Option, + forward: Option<(String, PortForward)>, +) -> Result { + match (started, url, forward) { + (Ok(()), _, forward) => Ok(OpenOutcome { + notice: format!("opening {what}"), + forward, + copy: None, + }), + (Err(error), Some(url), forward) => Ok(OpenOutcome { + notice: format!("could not open {url}, so it is copied: {error}"), + forward, + copy: Some(url), + }), + (Err(error), _, _) => Err(format!("opening {what} failed: {error}")), + } +} + +/// Applies a finished background open: keeps a forward it started, copies an +/// address it left, and shows how it ended. A failed copy is shown like any +/// other failure, so the forwards the TUI holds stay open. +fn open_finished( + app: &mut App, + forwards: &mut ActiveForwards, + waiting: Option<(String, OpenTarget)>, + outcome: Result, +) { + let result = outcome.and_then(|opened| { + if let Some((agent, forward)) = opened.forward + && !forwards.push(agent.clone(), forward, &app.agents) + { + return Err(format!( + "{agent} was deleted or re-created while it opened, so its address no longer works" + )); + } + if let Some(url) = &opened.copy { + terminal::copy_to_clipboard(url).map_err(|error| format!("could not copy {url}: {error}"))?; + } + Ok(opened.notice) + }); + app.opened(waiting, result, Instant::now()); +} + +/// Forwards the Agent's desktop to a free local port once it is Ready. The +/// browser viewer's port is known only after a pass has seen what the image +/// declares, so it is read after converging. +async fn desktop_forward( + client: &Client, + home: PathBuf, + agent: &str, + viewer: DesktopViewer, +) -> Result { + let target = client + .ensure_execution(agent, WaitPolicy::UntilConverged) + .await + .map_err(|error| error.to_string())?; + let access = client.vnc_access(agent).await.map_err(|error| error.to_string())?; + let guest_port = match viewer { + DesktopViewer::Browser => access + .web_guest_port + .ok_or_else(|| format!("the image of {agent} serves no browser viewer; open it in a VNC client"))?, + DesktopViewer::VncClient => access.guest_port, + }; + let spec = ForwardSpec { + address: std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + local_port: 0, + guest_port, + }; + PortForward::start(home, target.sandbox, spec) + .await + .map_err(|error| error.to_string()) +} + +/// Loads the most recent turns of the selected Session. +fn spawn_transcript(socket_path: PathBuf, inputs: Inputs, agent: String, session: SessionName) { + tokio::task::spawn_local(async move { + let turns = Client::for_path(socket_path) + .session_turns(&agent, session.clone(), Some(TRANSCRIPT_TURNS)) + .await + .map_err(|error| error.to_string()); + let _ = inputs.send(Input::TranscriptLoaded { agent, session, turns }); + }); +} + +/// Sends a prompt to a running Session without waiting for its turn to start. +fn spawn_prompt(socket_path: PathBuf, inputs: Inputs, form: PromptForm) { + tokio::task::spawn_local(async move { + let result = Client::for_path(socket_path) + .prompt_session(&form.agent, form.session.clone(), form.input.clone(), false, None) + .await + .map_err(|error| error.to_string()); + let _ = inputs.send(Input::PromptSent(form, result)); + }); +} + +/// Deletes a Session off the event loop: the call returns only once its harness +/// is stopped, and the watch removes the row. +fn spawn_session_delete(socket_path: PathBuf, inputs: Inputs, agent: String, session: SessionName) { + tokio::task::spawn_local(async move { + if let Err(error) = Client::for_path(socket_path).delete_session(&agent, session).await { + let _ = inputs.send(Input::SessionChangeFailed(error.to_string())); + } + }); +} + +/// Archives or unarchives a Session off the event loop, which stopping its harness would block. +fn spawn_session_archive(socket_path: PathBuf, inputs: Inputs, agent: String, session: SessionName, archived: bool) { + tokio::task::spawn_local(async move { + let _ = inputs.send( + match Client::for_path(socket_path) + .set_session_archived(&agent, session, archived) + .await + { + Ok(session) => Input::ArchiveChanged(session), + Err(error) => Input::SessionChangeFailed(error.to_string()), + }, + ); + }); +} + +/// Creates a forward off the event loop so provisioning never freezes the UI. +fn spawn_create(home: &ControlPlaneHome, inputs: Inputs, agent: String, spec: ForwardSpec, replace: Option) { + let home_path = home.path().to_path_buf(); + let socket_path = home.socket_path(); + tokio::task::spawn_local(async move { + let client = Client::for_path(socket_path); + let result = async { + // The TUI has no place to render progress while on screen, so a failing + // first pass is reported instead of waited through. + let target = client.ensure_execution(&agent, WaitPolicy::FirstPass).await?; + PortForward::start(home_path, target.sandbox, spec.clone()).await + } + .await; + let _ = inputs.send(Input::ForwardCreated((agent, spec, replace, result))); + }); +} + +/// Discovers create-agent candidates off the event loop so a slow filesystem never freezes the UI. +fn spawn_discovery(inputs: Inputs, agents: Vec) { + tokio::task::spawn_local(async move { + let candidates = manifest_candidates(std::env::current_dir().ok(), &agents).await; + let _ = inputs.send(Input::ManifestsDiscovered(candidates)); + }); +} + +/// Assembles create-agent candidates from the working tree and recorded Agent manifests. +/// +/// Every manifest below the working directory is offered, or below the repository +/// root when the working directory is inside a git repository, skipping hidden and +/// ignored directories; a recorded manifest that is unreadable stays listed so its +/// error is visible. +async fn manifest_candidates(current_directory: Option, agents: &[Agent]) -> Vec { + let agents = agents.to_vec(); + tokio::task::spawn_blocking(move || manifest_candidates_blocking(current_directory.as_deref(), &agents)) + .await + .unwrap_or_default() +} + +fn manifest_candidates_blocking(current_directory: Option<&Path>, agents: &[Agent]) -> Vec { + let mut recorded: Vec = agents + .iter() + .filter_map(|agent| agent.status.provenance.as_ref()) + .map(agent::Provenance::manifest_or_default) + .collect(); + recorded.sort(); + recorded.dedup(); + let found = current_directory.map_or_else(Vec::new, working_tree_manifests); + let paths = found + .into_iter() + .map(|path| (path, false)) + .chain(recorded.into_iter().map(|path| (path, true))); + let mut seen = HashMap::::new(); + let mut candidates = Vec::::new(); + for (path, recorded) in paths { + let canonical = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone()); + if let Some(index) = seen.get(&canonical).copied() { + candidates[index].add_equivalent_path(path); + continue; + } + let name = match manifest::resolve(&path) { + Ok(resolved) => Ok(resolved.agent.metadata.name), + Err(error) if recorded || path.exists() => Err(error.to_string()), + Err(_) => continue, + }; + seen.insert(canonical, candidates.len()); + candidates.push(ManifestCandidate::new(path, name)); + } + candidates +} + +/// Returns the root of the git repository containing `directory`, if any. +/// +/// A linked worktree keeps `.git` as a file, so only presence is checked. +fn repository_root(directory: &Path) -> Option<&Path> { + directory.ancestors().find(|ancestor| ancestor.join(".git").exists()) +} + +/// Lists Agents and their variant manifests below `directory`, or below its git repository root. +/// +/// The walk honors ignore files for directories and finds complete `agent.yaml` +/// manifests. Each Agent directory is then enumerated directly so checkout-local, +/// ignored `agent..yaml` siblings remain discoverable. +fn working_tree_manifests(directory: &Path) -> Vec { + let root = repository_root(directory).unwrap_or(directory); + let mut found: Vec = WalkBuilder::new(root) + .max_depth(Some(DISCOVERY_DEPTH)) + .require_git(false) + .follow_links(false) + .build() + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_some_and(|kind| kind.is_file()) && entry.file_name() == MANIFEST_FILE) + .flat_map(|entry| { + let base = entry.into_path(); + let Some(parent) = base.parent() else { + return vec![base]; + }; + let mut manifests = std::fs::read_dir(parent) + .into_iter() + .flatten() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| manifest::is_manifest_filename(path)) + .collect::>(); + manifests.sort_by_key(|path| (path.file_name().is_none_or(|name| name != MANIFEST_FILE), path.clone())); + manifests + }) + .collect(); + found.sort_by_key(|path| { + ( + path.components().count(), + path.parent().map(Path::to_path_buf), + path.file_name().is_none_or(|name| name != MANIFEST_FILE), + path.clone(), + ) + }); + found +} + +/// Applies the manifest under the chosen name; a rejection reopens the form with the error. +async fn create( + app: &mut App, + client: &Client, + manifest: PathBuf, + name: String, + env_file: Option, + mut form: CreateForm, +) { + match create_agent(client, manifest, name, env_file).await { + Ok(applied) => app.agent_applied(applied), + Err(error) => { + form.error = Some(error.to_string()); + app.modal = Some(Modal::CreateAgent(form)); + } + } +} + +async fn create_agent( + client: &Client, + manifest: PathBuf, + name: String, + env_file: Option, +) -> Result { + let mut request = crate::read_apply_request(manifest, env_file)?; + request.agent.metadata.name = name; + request.create_only = true; + client.apply(request).await +} + +/// Applies one completed background forward creation to the UI state. +fn forward_created(app: &mut App, forwards: &mut ActiveForwards, outcome: CreateOutcome) { + let (agent, spec, replace, result) = outcome; + app.creating = app.creating.saturating_sub(1); + match result { + Ok(forward) => { + if !forwards.push(agent.clone(), forward, &app.agents) { + app.error = Some(format!("{agent} was deleted or re-created before its forward started")); + } + } + Err(error) => { + app.modal = Some(Modal::PortForward(ForwardForm::rejected( + agent, + &spec, + replace, + error.to_string(), + ))); + } + } +} + +async fn suspended( + app: &mut App, + tui: &mut Tui, + home: &ControlPlaneHome, + client: &Client, + action: Action, +) -> CommandResult<()> { + tui.suspend()?; + let result = match action { + Action::Attach { agent, session } => attach(home, client, &agent, session, SessionRequest::default()).await, + Action::CreateSession { + agent, + session, + harness, + model_selection, + } => { + let request = SessionRequest { + harness: Some(harness), + model_selection, + initial_prompt: None, + }; + attach(home, client, &agent, session, request).await + } + Action::Exec { agent } + | Action::Open { + agent, + target: OpenTarget::Shell, + } => exec(home, client, &agent).await, + Action::Open { + agent, + target: OpenTarget::SshShell, + } => ssh_shell(client, &agent).await, + _ => Ok(()), + }; + tui.resume()?; + if let Err(error) = result { + app.error = Some(error.to_string()); + } + Ok(()) +} + +async fn attach( + home: &ControlPlaneHome, + client: &Client, + agent: &str, + session: SessionName, + request: SessionRequest, +) -> Result<(), Error> { + let wait = Wait::start(); + let target = wait + .until( + client, + agent, + client.ensure_session(agent, session, request, WaitPolicy::UntilConverged), + ) + .await?; + agent::sessions::attach(home.path(), &target).await +} + +async fn exec(home: &ControlPlaneHome, client: &Client, agent: &str) -> Result<(), Error> { + let wait = Wait::start(); + let target = wait + .until( + client, + agent, + client.ensure_execution(agent, WaitPolicy::UntilConverged), + ) + .await?; + let command = ["bash".to_owned(), "-l".to_owned()]; + let spec = agent::sandbox::platform::execution_spec(&target.operating_system, &command, true)?; + match agent::sandbox::attach_terminal( + home.path(), + &target.sandbox, + sandbox::terminal::AttachTerminalRequest::new(spec), + ) + .await? + { + TerminalAttachOutcome::Exited(_) | TerminalAttachOutcome::Detached => Ok(()), + _ => Err(Error::Session( + "terminal execution returned an unsupported outcome".into(), + )), + } +} + +/// Runs OpenSSH against the Agent's generated alias until it exits. +/// +/// Unlike `agentctl ssh`, this waits for the client rather than replacing the +/// process, which is the TUI's. +async fn ssh_shell(client: &Client, agent: &str) -> Result<(), Error> { + let wait = Wait::start(); + wait.until( + client, + agent, + client.ensure_execution(agent, WaitPolicy::UntilConverged), + ) + .await?; + let access = client.ssh_access(agent).await?; + // Awaited, not waited on: the TUI's forwards and watch share this thread. + let status = tokio::process::Command::new(crate::ssh_client_executable()) + .args(crate::ssh_client_arguments(&access)) + .status() + .await + .map_err(|error| Error::Invalid(crate::ssh_client_failure(&error)))?; + // 255 is OpenSSH's own failure; any other status is the remote shell's last command. + if status.code() == Some(255) { + // Returning to the TUI clears the screen, and with it what ssh printed about why. + eprint!( + "\nssh to {} ended with an OpenSSH error. Press Enter to return to agentctl. ", + access.alias + ); + let mut line = String::new(); + let _ = + tokio::io::AsyncBufReadExt::read_line(&mut tokio::io::BufReader::new(tokio::io::stdin()), &mut line).await; + return Err(Error::Invalid(format!( + "ssh to {} ended with an OpenSSH error", + access.alias + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::*; + + fn manifest_yaml(name: &str) -> String { + format!( + "apiVersion: agents.platform/v1alpha1\n\ + kind: Agent\n\ + metadata:\n\ + \x20 name: {name}\n\ + spec:\n\ + \x20 sandbox:\n\ + \x20 image:\n\ + \x20 type: build\n\ + \x20 context: .\n\ + \x20 dockerfile: Dockerfile\n\ + \x20 platform:\n\ + \x20 os: linux\n\ + \x20 resources:\n\ + \x20 cpu: \"1\"\n\ + \x20 memory: \"1Gi\"\n\ + \x20 rootFilesystem:\n\ + \x20 capacity: \"8Gi\"\n\ + \x20 mode: layered\n\ + \x20 home:\n\ + \x20 source: home\n\ + \x20 harnesses:\n\ + \x20 - type: claudeCode\n\ + \x20 version: \"1.0.0\"\n\ + \x20 auth: mediated\n\ + \x20 secrets: []\n\ + \x20 network:\n\ + \x20 mode: mediated\n\ + \x20 allow: all\n" + ) + } + + fn recorded_agent(name: &str, source: Option<&std::path::Path>) -> Agent { + let mut agent = manifest::decode(manifest_yaml(name).as_bytes()).expect("test manifest should decode"); + agent.status.provenance = source.map(|directory| agent::Provenance { + source_directory: directory.to_path_buf(), + manifest_path: None, + env_file: None, + }); + agent + } + + fn manifest_directory(root: &std::path::Path, name: &str, content: &str) -> PathBuf { + let directory = root.join(name); + std::fs::create_dir_all(&directory).expect("manifest directory should be created"); + std::fs::write(directory.join(MANIFEST_FILE), content).expect("manifest should be written"); + directory + } + + fn empty_directory(root: &std::path::Path, name: &str) -> PathBuf { + let directory = root.join(name); + std::fs::create_dir_all(&directory).expect("directory should be created"); + directory + } + + #[tokio::test(flavor = "local")] + async fn discovery_offers_the_working_directory_and_recorded_manifests_once() { + let root = tempfile::tempdir().expect("temporary directory"); + let cwd = manifest_directory(root.path(), "a-cwd", &manifest_yaml("local")); + let broken = manifest_directory(root.path(), "broken", "not a manifest"); + let missing = empty_directory(root.path(), "missing"); + let recorded = manifest_directory(root.path(), "recorded", &manifest_yaml("recorded")); + let agents = vec![ + recorded_agent("recorded", Some(&recorded)), + recorded_agent("duplicate", Some(&cwd)), + recorded_agent("missing", Some(&missing)), + recorded_agent("broken", Some(&broken)), + recorded_agent("unknown", None), + ]; + + let candidates = manifest_candidates(Some(cwd.clone()), &agents).await; + + assert_eq!(candidates.len(), 4); + assert_eq!(candidates[0].path, cwd.join(MANIFEST_FILE)); + assert_eq!(candidates[0].name.as_deref(), Ok("local")); + assert_eq!(candidates[1].path, broken.join(MANIFEST_FILE)); + assert!(candidates[1].name.is_err()); + assert_eq!(candidates[2].path, missing.join(MANIFEST_FILE)); + assert!(candidates[2].name.is_err()); + assert_eq!(candidates[3].path, recorded.join(MANIFEST_FILE)); + assert_eq!(candidates[3].name.as_deref(), Ok("recorded")); + } + + #[cfg(unix)] + #[tokio::test(flavor = "local")] + async fn discovery_retains_equivalent_recorded_paths_for_picker_preselection() { + let root = tempfile::tempdir().expect("temporary directory"); + let source = manifest_directory(root.path(), "source", &manifest_yaml("worker")); + let alias = root.path().join("alias"); + std::os::unix::fs::symlink(&source, &alias).expect("manifest directory symlink"); + let recorded_manifest = alias.join(MANIFEST_FILE); + let mut agent = recorded_agent("worker", Some(&alias)); + agent + .status + .provenance + .as_mut() + .expect("recorded provenance") + .manifest_path = Some(recorded_manifest.clone()); + + let candidates = manifest_candidates(Some(source.clone()), &[agent]).await; + let form = CreateForm::new(candidates, Some(&recorded_manifest)); + + assert_eq!(form.agents.len(), 1); + assert_eq!( + form.candidate().map(|candidate| candidate.path.as_path()), + Some(source.join(MANIFEST_FILE).as_path()) + ); + } + + #[tokio::test(flavor = "local")] + async fn discovery_walks_the_working_directory_tree_but_not_hidden_or_ignored_directories() { + let root = tempfile::tempdir().expect("temporary directory"); + let cwd = manifest_directory(root.path(), "cwd", &manifest_yaml("top")); + let nested = manifest_directory(&cwd, "examples/deeper", &manifest_yaml("nested")); + let sibling = manifest_directory(&cwd, "examples/other", &manifest_yaml("other")); + manifest_directory(&cwd, ".hidden", &manifest_yaml("hidden")); + manifest_directory(&cwd, "target/ignored", &manifest_yaml("ignored")); + std::fs::write(cwd.join(".gitignore"), "target/\n").expect("ignore file should be written"); + let agents = vec![recorded_agent("nested", Some(&nested))]; + + let candidates = manifest_candidates(Some(cwd.clone()), &agents).await; + + let paths: Vec<&std::path::Path> = candidates.iter().map(|candidate| candidate.path.as_path()).collect(); + assert_eq!( + paths, + [ + cwd.join(MANIFEST_FILE), + nested.join(MANIFEST_FILE), + sibling.join(MANIFEST_FILE) + ] + ); + assert_eq!(candidates[1].name.as_deref(), Ok("nested")); + } + + #[tokio::test(flavor = "local")] + async fn discovery_includes_ignored_sibling_variants_and_their_resolution_errors() { + let root = tempfile::tempdir().expect("temporary directory"); + let agent = manifest_directory(root.path(), "configured-agent", &manifest_yaml("default")); + std::fs::write(agent.join(".gitignore"), "agent.*.yaml\n").expect("Agent ignore file"); + std::fs::write( + agent.join("agent.mine.yaml"), + "apiVersion: agents.platform/v1alpha1\nkind: AgentVariant\nextends: agent.yaml\nmetadata:\n name: mine\n", + ) + .expect("local variant"); + std::fs::write( + agent.join("agent.broken.yaml"), + "apiVersion: agents.platform/v1alpha1\nkind: AgentVariant\nextends: missing.yaml\nmetadata:\n name: broken\n", + ) + .expect("broken local variant"); + + let candidates = manifest_candidates(Some(agent.clone()), &[]).await; + + assert_eq!(candidates.len(), 3); + assert_eq!(candidates[0].path, agent.join(MANIFEST_FILE)); + assert_eq!(candidates[0].name.as_deref(), Ok("default")); + assert_eq!(candidates[1].path, agent.join("agent.broken.yaml")); + assert!( + candidates[1] + .name + .as_ref() + .is_err_and(|error| error.contains("extends must name")) + ); + assert_eq!(candidates[2].path, agent.join("agent.mine.yaml")); + assert_eq!(candidates[2].name.as_deref(), Ok("mine")); + } + + #[tokio::test(flavor = "local")] + async fn discovery_walks_the_whole_git_repository_from_a_nested_working_directory() { + let root = tempfile::tempdir().expect("temporary directory"); + let repository = manifest_directory(root.path(), "repository", &manifest_yaml("root")); + std::fs::write(repository.join(".git"), "gitdir: elsewhere\n").expect("worktree marker should be written"); + let cwd = empty_directory(&repository, "src/deep/inside"); + let sibling = manifest_directory(&repository, "agents/full", &manifest_yaml("full")); + manifest_directory(root.path(), "outside", &manifest_yaml("outside")); + + let candidates = manifest_candidates(Some(cwd), &[]).await; + + let paths: Vec<&std::path::Path> = candidates.iter().map(|candidate| candidate.path.as_path()).collect(); + assert_eq!(paths, [repository.join(MANIFEST_FILE), sibling.join(MANIFEST_FILE)]); + } + + #[tokio::test(flavor = "local")] + async fn discovery_uses_the_recorded_manifest_filename() { + let root = tempfile::tempdir().expect("temporary directory"); + let source = empty_directory(root.path(), "custom"); + let manifest = source.join("worker.yml"); + std::fs::write(&manifest, manifest_yaml("custom")).expect("manifest should be written"); + let mut agent = recorded_agent("custom", Some(&source)); + agent + .status + .provenance + .as_mut() + .expect("provenance should be recorded") + .manifest_path = Some(manifest.clone()); + + let candidates = manifest_candidates(None, &[agent]).await; + + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].path, manifest); + assert_eq!(candidates[0].name.as_deref(), Ok("custom")); + } + + #[tokio::test(flavor = "local")] + async fn a_manifest_less_working_directory_never_hides_its_recorded_source() { + let root = tempfile::tempdir().expect("temporary directory"); + let cwd = empty_directory(root.path(), "cwd"); + let agents = vec![recorded_agent("worker", Some(&cwd))]; + + let candidates = manifest_candidates(Some(cwd.clone()), &agents).await; + + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].path, cwd.join(MANIFEST_FILE)); + assert!(candidates[0].name.is_err()); + } + + #[tokio::test(flavor = "local")] + async fn discovery_skips_a_working_directory_without_a_manifest() { + let root = tempfile::tempdir().expect("temporary directory"); + let cwd = empty_directory(root.path(), "cwd"); + + assert!(manifest_candidates(Some(cwd), &[]).await.is_empty()); + assert!(manifest_candidates(None, &[]).await.is_empty()); + } + + #[test] + fn row_primary_actions_require_two_clicks_on_the_same_row_in_time() { + let mut mouse = MouseInput::default(); + let start = Instant::now(); + let row = |name: &str| RowTarget::Tree(app::TreeRowId::Agent(name.into())); + + assert!(!mouse.double_click(&row("first"), start)); + assert!(!mouse.double_click(&row("second"), start + Duration::from_millis(100))); + assert!(!mouse.double_click(&row("second"), start + Duration::from_millis(700))); + assert!(mouse.double_click(&row("second"), start + Duration::from_millis(800))); + assert!(!mouse.double_click(&RowTarget::Forward(3), start + Duration::from_millis(850))); + } + + #[test] + fn mouse_uses_clickable_hints_and_ignores_unsupported_input() { + use ratatui::{Terminal, backend::TestBackend}; + + let mut app = App::new(); + let mut state = view::ViewState::default(); + let mut hit_map = None; + let mut terminal = Terminal::new(TestBackend::new(80, 12)).expect("test terminal"); + terminal + .draw(|frame| hit_map = Some(view::render(frame, &app, &mut state))) + .expect("draw"); + let hit_map = hit_map.expect("hit map"); + let mut mouse = MouseInput::default(); + let now = Instant::now(); + let event = |kind, modifiers| MouseEvent { + kind, + column: 0, + row: 10, + modifiers, + }; + + assert_eq!( + mouse.action( + event(MouseEventKind::Down(MouseButton::Left), KeyModifiers::NONE), + &hit_map, + &mut app, + now, + ), + Action::OpenCreate + ); + assert_eq!(mouse.position(), Some((0, 10))); + for input in [ + event(MouseEventKind::Down(MouseButton::Right), KeyModifiers::NONE), + event(MouseEventKind::Moved, KeyModifiers::NONE), + event(MouseEventKind::Drag(MouseButton::Left), KeyModifiers::NONE), + event(MouseEventKind::ScrollLeft, KeyModifiers::NONE), + event(MouseEventKind::Down(MouseButton::Left), KeyModifiers::SHIFT), + ] { + assert_eq!(mouse.action(input, &hit_map, &mut app, now), Action::None); + } + } + + #[test] + fn wheel_scrolls_details_only_inside_the_rendered_content() { + use ratatui::{Terminal, backend::TestBackend}; + + let mut app = App::new(); + // More lines than the view shows, so there is something to scroll. + app.detail = Some(app::Detail::text( + "detail".into(), + (1..=12).map(|line| format!("line {line}")).collect(), + )); + let mut state = view::ViewState::default(); + let mut hit_map = None; + let mut terminal = Terminal::new(TestBackend::new(40, 8)).expect("test terminal"); + terminal + .draw(|frame| hit_map = Some(view::render(frame, &app, &mut state))) + .expect("draw"); + let hit_map = hit_map.expect("hit map"); + let mut mouse = MouseInput::default(); + let now = Instant::now(); + let wheel = |column, row| MouseEvent { + kind: MouseEventKind::ScrollDown, + column, + row, + modifiers: KeyModifiers::NONE, + }; + + assert_eq!(mouse.action(wheel(1, 2), &hit_map, &mut app, now), Action::None); + assert_eq!(app.detail.as_ref().map(|detail| detail.scroll), Some(1)); + assert_eq!(mouse.action(wheel(0, 1), &hit_map, &mut app, now), Action::None); + assert_eq!(app.detail.as_ref().map(|detail| detail.scroll), Some(1)); + } + + fn materialized(id: &str) -> agent::sandbox::Assignment { + serde_json::from_value(serde_json::json!({ + "state": "materialized", + "provider": "memory", + "id": id, + })) + .expect("test assignment") + } + + async fn forward(assignment: &agent::sandbox::Assignment, guest_port: u16) -> PortForward { + let spec = ForwardSpec::parse(&format!("127.0.0.1:0:{guest_port}")).expect("spec"); + let home = tempfile::tempdir().expect("home"); + PortForward::start(home.path().to_path_buf(), assignment.clone(), spec) + .await + .expect("forward binds") + } + + #[tokio::test(flavor = "local")] + async fn a_failed_launch_keeps_the_forward_and_copies_its_address() { + let sandbox = materialized("00000000-0000-0000-0000-00000000000a"); + let url = "http://127.0.0.1:50001/".to_owned(); + let failed = || Err("no opener".to_owned()); + + let kept = launched( + failed(), + &url, + Some(url.clone()), + Some(("desk".into(), forward(&sandbox, 6080).await)), + ) + .expect("the forward still works"); + assert_eq!(kept.notice, format!("could not open {url}, so it is copied: no opener")); + assert_eq!(kept.copy.as_deref(), Some(url.as_str())); + assert!(kept.forward.is_some()); + + let reopened = launched(failed(), &url, Some(url.clone()), None).expect("the open forward still works"); + assert_eq!( + reopened.copy.as_deref(), + Some(url.as_str()), + "an open forward is copied too" + ); + + assert_eq!( + launched(failed(), "Zed on desk", None, None).err().as_deref(), + Some("opening Zed on desk failed: no opener") + ); + let opened = launched(Ok(()), "Zed on desk", None, None).expect("opened"); + assert_eq!(opened.notice, "opening Zed on desk"); + assert!(opened.copy.is_none()); + } + + #[tokio::test(flavor = "local")] + async fn a_blocked_launch_copies_the_address_and_keeps_the_forward() { + let sandbox = materialized("00000000-0000-0000-0000-00000000000a"); + let url = "http://127.0.0.1:50001/".to_owned(); + let copied = hand_over( + crate::launch::Launch::Url(url.clone()), + &url, + Some(("desk".into(), forward(&sandbox, 6080).await)), + true, + ) + .await + .expect("copied"); + assert_eq!( + copied.notice, + format!("copied {url}, reachable from the machine running agentctl") + ); + assert_eq!(copied.copy.as_deref(), Some(url.as_str())); + assert!(copied.forward.is_some()); + } + + #[tokio::test(flavor = "local")] + async fn forwards_end_with_the_sandbox_they_dial() { + let first = materialized("00000000-0000-0000-0000-00000000000a"); + let second = materialized("00000000-0000-0000-0000-00000000000b"); + let mut forwards = ActiveForwards::default(); + let mut desk = recorded_agent("desk", None); + desk.status.sandbox = Some(first.clone()); + assert!(forwards.push( + "desk".into(), + forward(&first, agent::vnc::WEB_GUEST_PORT).await, + std::slice::from_ref(&desk) + )); + + forwards.prune(std::slice::from_ref(&desk)); + assert!( + forwards.desktop("desk", DesktopViewer::Browser).is_some(), + "same Sandbox" + ); + assert!( + forwards.desktop("desk", DesktopViewer::VncClient).is_none(), + "another viewer" + ); + + desk.status.sandbox = None; + forwards.prune(std::slice::from_ref(&desk)); + assert_eq!( + forwards.entries().len(), + 1, + "no Sandbox reported yet, so nothing says it is gone" + ); + + desk.status.sandbox = Some(second.clone()); + forwards.prune(std::slice::from_ref(&desk)); + assert!( + forwards.entries().is_empty(), + "re-created under the same name, so the old forward is dead" + ); + + assert!(forwards.push("desk".into(), forward(&second, 3000).await, std::slice::from_ref(&desk))); + forwards.prune(&[]); + assert!(forwards.entries().is_empty(), "the Agent is gone"); + } + + #[tokio::test(flavor = "local")] + async fn a_forward_that_starts_after_its_agent_was_re_created_is_not_kept() { + let first = materialized("00000000-0000-0000-0000-00000000000a"); + let mut desk = recorded_agent("desk", None); + desk.status.sandbox = Some(materialized("00000000-0000-0000-0000-00000000000b")); + let mut app = App::new(); + app.agents = vec![desk]; + let mut forwards = ActiveForwards::default(); + let target = OpenTarget::Desktop(DesktopViewer::Browser); + app.start_opening("desk", target, Instant::now()); + + let opened = OpenOutcome { + notice: "opening the desktop".into(), + forward: Some(("desk".into(), forward(&first, agent::vnc::WEB_GUEST_PORT).await)), + copy: None, + }; + open_finished(&mut app, &mut forwards, Some(("desk".into(), target)), Ok(opened)); + + assert!(forwards.entries().is_empty()); + assert_eq!( + app.error.as_deref(), + Some("desk was deleted or re-created while it opened, so its address no longer works") + ); + assert!(app.opening.is_empty(), "the wait ends"); + + let spec = ForwardSpec::parse("127.0.0.1:0:3000").expect("spec"); + app.error = None; + app.creating = 1; + let started = forward(&first, 3000).await; + forward_created(&mut app, &mut forwards, ("desk".into(), spec, None, Ok(started))); + assert!(forwards.entries().is_empty()); + assert_eq!( + app.error.as_deref(), + Some("desk was deleted or re-created before its forward started") + ); + } + + #[tokio::test(flavor = "local")] + async fn an_edited_desktop_forward_is_still_the_desktop() { + let assignment = materialized("00000000-0000-0000-0000-00000000000a"); + let mut forwards = ActiveForwards::default(); + let desk = recorded_agent("desk", None); + forwards.push( + "desk".into(), + forward(&assignment, agent::vnc::WEB_GUEST_PORT).await, + std::slice::from_ref(&desk), + ); + let id = forwards.entries()[0].id; + forwards.remove(id); + let mut app = App::new(); + app.agents = vec![desk]; + app.creating = 1; + let spec = ForwardSpec::parse("127.0.0.1:0:6080").expect("spec"); + let edited = forward(&assignment, agent::vnc::WEB_GUEST_PORT).await; + + forward_created(&mut app, &mut forwards, ("desk".into(), spec, Some(id), Ok(edited))); + + assert_eq!(forwards.entries()[0].label(), Some("desktop")); + assert!( + forwards.desktop("desk", DesktopViewer::Browser).is_some(), + "o w finds it again" + ); + } +} diff --git a/agentctl/src/bin/agentctl/tui/open.rs b/agentctl/src/bin/agentctl/tui/open.rs new file mode 100644 index 0000000..5e79969 --- /dev/null +++ b/agentctl/src/bin/agentctl/tui/open.rs @@ -0,0 +1,698 @@ +//! What the open menu offers for one Agent, and why an item is unavailable. +//! +//! The side panel's Connect section follows the same rules, so the panel never +//! promises something the menu then refuses. + +use std::{ + path::{Path, PathBuf}, + process::Stdio, + time::Duration, +}; + +use agent::{Agent, Condition, ConditionStatus}; + +use crate::launch::Editor; + +/// Where the Agent's desktop is shown. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DesktopViewer { + /// The browser-based viewer the image serves. + Browser, + /// A VNC client of the person's own. + VncClient, +} + +impl DesktopViewer { + /// The guest port the viewer is served on. + pub(crate) const fn guest_port(self) -> u16 { + match self { + Self::Browser => agent::vnc::WEB_GUEST_PORT, + Self::VncClient => agent::vnc::GUEST_PORT, + } + } +} + +/// One way into an Agent. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum OpenTarget { + /// A login shell in the Sandbox, in this terminal. + Shell, + /// An editor on this machine, connected over SSH. + Editor(Editor), + /// The Agent's desktop, forwarded to this machine. + Desktop(DesktopViewer), + /// An OpenSSH login, in this terminal. + SshShell, + /// The OpenSSH alias, for tools outside the TUI. + CopyAlias, +} + +impl OpenTarget { + pub(crate) fn label(self) -> String { + match self { + Self::Shell => "Shell in the Sandbox".into(), + Self::Editor(Editor::VsCode) => "VS Code, Remote-SSH".into(), + Self::Editor(editor) => editor.label().into(), + Self::Desktop(DesktopViewer::Browser) => "Desktop in the browser".into(), + Self::Desktop(DesktopViewer::VncClient) => "Desktop in a VNC client".into(), + Self::SshShell => "SSH shell".into(), + Self::CopyAlias => "Copy SSH alias".into(), + } + } + + /// The target as notices and the header name it, without the menu's detail. + pub(crate) fn name(self) -> String { + match self { + Self::Editor(editor) => editor.label().into(), + target => target.label(), + } + } + + /// The key that chooses the target directly in the menu. + pub(crate) const fn key(self) -> char { + match self { + Self::Shell => 'e', + Self::Editor(Editor::VsCode) => 'c', + Self::Editor(Editor::Zed) => 'z', + Self::Desktop(DesktopViewer::Browser) => 'w', + Self::Desktop(DesktopViewer::VncClient) => 'v', + Self::SshShell => 's', + Self::CopyAlias => 'y', + } + } + + /// Whether the target reaches the Agent through the user's own OpenSSH + /// configuration, which then needs the generated one included. + pub(crate) const fn needs_include(self) -> bool { + matches!(self, Self::Editor(_) | Self::CopyAlias) + } +} + +/// One row of the open menu: a way into the Agent, or the SSH setup editors need. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum MenuEntry { + Open(OpenTarget), + /// Adds the generated configuration's `Include` to the user's own. + SetUpSsh, +} + +impl MenuEntry { + pub(crate) fn label(self) -> String { + match self { + Self::Open(target) => target.label(), + Self::SetUpSsh => "Set up SSH".into(), + } + } + + pub(crate) const fn key(self) -> Option { + match self { + Self::Open(target) => Some(target.key()), + Self::SetUpSsh => None, + } + } +} + +/// How long OpenSSH may take to resolve an alias; `Match exec` in the user's +/// configuration runs commands of theirs, which must not hold the check forever. +const SSH_CHECK_TIMEOUT: Duration = Duration::from_secs(5); + +/// Whether the user's OpenSSH configuration reaches Agents through the generated one. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) enum SshSetup { + /// Not checked yet, or OpenSSH could not tell: it is missing, timed out + /// or rejects the configuration. Nothing is asked for then. + #[default] + Unknown, + Installed, + Missing, +} + +impl SshSetup { + /// Asks OpenSSH how the user's configuration resolves `agent`'s alias, as + /// editors resolve it. `ssh -G` only prints the result and connects nowhere. + pub(crate) async fn check(agent: &str) -> Self { + Self::resolve(agent, None).await + } + + /// [`Self::check`] against `config` in place of the user's configuration. + async fn resolve(agent: &str, config: Option<&Path>) -> Self { + let mut ssh = tokio::process::Command::new(crate::ssh_client_executable()); + if let Some(config) = config { + ssh.arg("-F").arg(config); + } + ssh.arg("-G") + .arg(agent::ssh::alias(agent)) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true); + match tokio::time::timeout(SSH_CHECK_TIMEOUT, ssh.output()).await { + Ok(Ok(output)) if output.status.success() => { + if agent::ssh::resolves_through_agentctl(&String::from_utf8_lossy(&output.stdout), agent) { + Self::Installed + } else { + Self::Missing + } + } + _ => Self::Unknown, + } + } +} + +/// Facts about this machine the menu depends on, gathered when the TUI starts. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct Environment { + /// Why an application started here would not appear in front of the + /// person, when it would not. + pub(crate) launch_blocked: Option, + /// Each editor's launcher found on `PATH`. + pub(crate) launchers: Vec<(Editor, PathBuf)>, +} + +impl Environment { + pub(crate) fn detect() -> Self { + let path = std::env::var_os("PATH"); + Self { + launch_blocked: crate::launch::launch_blocked(|name| std::env::var_os(name)), + launchers: Editor::ALL + .into_iter() + .filter_map(|editor| Some((editor, editor.locate(path.as_deref())?))) + .collect(), + } + } + + pub(crate) fn launcher(&self, editor: Editor) -> Option<&Path> { + self.launchers + .iter() + .find(|(found, _)| *found == editor) + .map(|(_, path)| path.as_path()) + } +} + +/// One row of the open menu. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct OpenItem { + pub(crate) entry: MenuEntry, + /// Why the item cannot be chosen. + pub(crate) unavailable: Option, +} + +/// The open menu for one Agent, with its selected row. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct OpenMenu { + pub(crate) agent: String, + pub(crate) items: Vec, + pub(crate) selected: usize, +} + +impl OpenMenu { + pub(crate) fn new(agent: &Agent, environment: &Environment, setup: SshSetup) -> Self { + let items = items(agent, environment, setup); + let selected = items + .iter() + .position(|item| item.unavailable.is_none()) + .unwrap_or_default(); + Self { + agent: agent.metadata.name.clone(), + items, + selected, + } + } + + /// Moves the selection by `delta` among the items that can be chosen. + pub(crate) fn move_selection(&mut self, delta: isize) { + let available = (0..self.items.len()) + .filter(|index| self.items[*index].unavailable.is_none()) + .collect::>(); + let Some(current) = available.iter().position(|index| *index == self.selected) else { + return; + }; + let length = isize::try_from(available.len()).unwrap_or(1); + let next = (isize::try_from(current).unwrap_or_default() + delta).rem_euclid(length); + self.selected = available[usize::try_from(next).unwrap_or_default()]; + } + + /// The entry the selected row chooses, when it can be chosen. + pub(crate) fn chosen(&self) -> Option { + self.items + .get(self.selected) + .filter(|item| item.unavailable.is_none()) + .map(|item| item.entry) + } + + /// The entry `key` chooses, when it can be chosen. + pub(crate) fn by_key(&self, key: char) -> Option { + self.items + .iter() + .find(|item| item.entry.key() == Some(key) && item.unavailable.is_none()) + .map(|item| item.entry) + } + + /// Each reason an item is unavailable, once, with the labels of the items + /// it applies to, in menu order. + pub(crate) fn unavailable_reasons(&self) -> Vec<(Vec, String)> { + let mut reasons = Vec::<(Vec, String)>::new(); + for item in &self.items { + let Some(reason) = &item.unavailable else { + continue; + }; + match reasons.iter_mut().find(|(_, listed)| listed == reason) { + Some((labels, _)) => labels.push(item.entry.label()), + None => reasons.push((vec![item.entry.label()], reason.clone())), + } + } + reasons + } +} + +/// Every item of the open menu for `agent`, in menu order. +pub(crate) fn items(agent: &Agent, environment: &Environment, setup: SshSetup) -> Vec { + let ssh = ssh_unavailable(agent); + let open = |target, unavailable| OpenItem { + entry: MenuEntry::Open(target), + unavailable, + }; + let mut items = vec![open(OpenTarget::Shell, None)]; + items.extend(Editor::ALL.into_iter().map(|editor| { + open( + OpenTarget::Editor(editor), + ssh.clone().or_else(|| editor_unavailable(editor, environment)), + ) + })); + let desktop = vnc_unavailable(agent); + items.extend( + [DesktopViewer::Browser, DesktopViewer::VncClient] + .map(|viewer| open(OpenTarget::Desktop(viewer), desktop.clone())), + ); + items.extend([OpenTarget::SshShell, OpenTarget::CopyAlias].map(|target| open(target, ssh.clone()))); + if ssh.is_none() && setup == SshSetup::Missing { + items.push(OpenItem { + entry: MenuEntry::SetUpSsh, + unavailable: None, + }); + } + items +} + +/// The Agent's Connect section for the side panel: what the menu offers and +/// what stands in the way, without the rows' keys. `desktop` is the address +/// that opens a desktop forward this TUI holds open, if any. +pub(crate) fn connect_lines( + agent: &Agent, + environment: &Environment, + setup: SshSetup, + desktop: Option<&str>, +) -> Vec { + let mut lines = vec![" shell in this terminal".to_owned()]; + lines.push(match (vnc_unavailable(agent), desktop) { + (Some(reason), _) => format!(" desktop {reason}"), + (None, Some(url)) => format!(" desktop open at {url}"), + (None, None) => " desktop browser · VNC client".to_owned(), + }); + if let Some(reason) = ssh_unavailable(agent) { + lines.push(format!(" ssh {reason}")); + return lines; + } + // VS Code needs no launcher of its own, so only a blocked launch leaves no editor. + lines.push(environment.launch_blocked.as_ref().map_or_else( + || { + let editors = Editor::ALL + .into_iter() + .filter(|editor| editor_unavailable(*editor, environment).is_none()) + .map(Editor::label) + .collect::>(); + format!(" editors {}", editors.join(" · ")) + }, + |reason| format!(" editors none: {reason}"), + )); + if setup == SshSetup::Missing { + lines.push(" ! SSH not set up; o offers it".to_owned()); + } + lines.push(format!(" ssh alias {}", agent::ssh::alias(&agent.metadata.name))); + lines +} + +/// Why nothing reached over SSH can be offered, if anything is in the way. +fn ssh_unavailable(agent: &Agent) -> Option { + access_unavailable(agent, agent.spec.ssh_access(), Condition::SSH_READY, "SSH access") +} + +/// Why the desktop cannot be offered, if anything is in the way. +fn vnc_unavailable(agent: &Agent) -> Option { + access_unavailable(agent, agent.spec.vnc_access(), Condition::VNC_READY, "VNC access") +} + +/// An access capability is unavailable while undeclared or after its last pass failed. +fn access_unavailable(agent: &Agent, declared: bool, condition: &str, what: &str) -> Option { + if !declared { + return Some(format!("{what} is not declared in spec.access")); + } + agent + .status + .conditions + .iter() + .find(|found| found.kind == condition && found.status == ConditionStatus::False) + .map(|found| format!("{what} is not ready: {}", found.detail().trim_end())) +} + +fn editor_unavailable(editor: Editor, environment: &Environment) -> Option { + if let Some(reason) = &environment.launch_blocked { + return Some(reason.clone()); + } + if environment.launcher(editor).is_some() { + return None; + } + editor.missing_launcher() +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::*; + + fn agent(access: &str) -> Agent { + let yaml = format!( + "apiVersion: agents.platform/v1alpha1\n\ + kind: Agent\n\ + metadata:\n\ + \x20 name: worker\n\ + spec:\n\ + \x20 sandbox:\n\ + \x20 image:\n\ + \x20 type: build\n\ + \x20 context: .\n\ + \x20 dockerfile: Dockerfile\n\ + \x20 platform:\n\ + \x20 os: linux\n\ + \x20 resources:\n\ + \x20 cpu: \"1\"\n\ + \x20 memory: \"1Gi\"\n\ + \x20 rootFilesystem:\n\ + \x20 capacity: \"8Gi\"\n\ + \x20 mode: layered\n\ + \x20 home:\n\ + \x20 source: home\n\ + \x20 harnesses:\n\ + \x20 - type: claudeCode\n\ + \x20 version: \"1.0.0\"\n\ + \x20 auth: mediated\n\ + \x20 secrets: []\n\ + {access}\ + \x20 network:\n\ + \x20 mode: mediated\n\ + \x20 allow: all\n" + ); + agent::manifest::decode(yaml.as_bytes()).expect("test manifest") + } + + fn with_ssh() -> Agent { + agent("\x20 access:\n\x20 - type: ssh\n") + } + + fn local(editors: &[Editor]) -> Environment { + Environment { + launch_blocked: None, + launchers: editors + .iter() + .map(|editor| (*editor, PathBuf::from(format!("/opt/bin/{}", editor.label())))) + .collect(), + } + } + + fn unavailable(items: &[OpenItem], target: OpenTarget) -> Option { + items + .iter() + .find(|item| item.entry == MenuEntry::Open(target)) + .expect("item is listed") + .unavailable + .clone() + } + + #[test] + fn an_agent_without_ssh_offers_only_its_shell() { + let items = items(&agent(""), &local(&Editor::ALL), SshSetup::Missing); + + assert_eq!(unavailable(&items, OpenTarget::Shell), None); + for target in [ + OpenTarget::Editor(Editor::VsCode), + OpenTarget::SshShell, + OpenTarget::CopyAlias, + ] { + assert_eq!( + unavailable(&items, target).as_deref(), + Some("SSH access is not declared in spec.access") + ); + } + assert!(items.iter().all(|item| item.entry != MenuEntry::SetUpSsh)); + } + + #[test] + fn failed_ssh_access_names_its_cause() { + let mut agent = with_ssh(); + agent.status.conditions.push(Condition { + kind: Condition::SSH_READY.into(), + status: ConditionStatus::False, + reason: "ReconcileFailed".into(), + message: "the image lacks sshd".into(), + last_transition_time: None, + }); + + let items = items(&agent, &local(&Editor::ALL), SshSetup::Installed); + + assert_eq!( + unavailable(&items, OpenTarget::SshShell).as_deref(), + Some("SSH access is not ready: the image lacks sshd") + ); + } + + #[test] + fn editors_need_a_local_terminal_and_zed_its_launcher() { + let without_launchers = items(&with_ssh(), &local(&[]), SshSetup::Installed); + assert_eq!( + unavailable(&without_launchers, OpenTarget::Editor(Editor::VsCode)), + None + ); + assert_eq!( + unavailable(&without_launchers, OpenTarget::Editor(Editor::Zed)), + Editor::Zed.missing_launcher() + ); + + let remote = Environment { + launch_blocked: Some("this terminal has no display to open windows on".into()), + ..local(&Editor::ALL) + }; + let over_ssh = items(&with_ssh(), &remote, SshSetup::Installed); + assert!(unavailable(&over_ssh, OpenTarget::Editor(Editor::VsCode)).is_some()); + assert_eq!(unavailable(&over_ssh, OpenTarget::CopyAlias), None); + assert_eq!(unavailable(&over_ssh, OpenTarget::SshShell), None); + } + + #[test] + fn setting_up_ssh_is_offered_only_while_the_include_is_missing() { + let environment = local(&Editor::ALL); + for (setup, offered) in [ + (SshSetup::Missing, true), + (SshSetup::Installed, false), + (SshSetup::Unknown, false), + ] { + let listed = items(&with_ssh(), &environment, setup); + assert_eq!( + listed.iter().any(|item| item.entry == MenuEntry::SetUpSsh), + offered, + "{setup:?}" + ); + } + } + + #[test] + fn the_menu_selects_and_chooses_only_available_items() { + let mut menu = OpenMenu::new(&with_ssh(), &local(&[]), SshSetup::Installed); + assert_eq!(menu.chosen(), Some(MenuEntry::Open(OpenTarget::Shell))); + menu.move_selection(1); + assert_eq!(menu.chosen(), Some(MenuEntry::Open(OpenTarget::Editor(Editor::VsCode)))); + // Zed has no launcher, so the selection passes over it. + menu.move_selection(1); + assert_eq!(menu.chosen(), Some(MenuEntry::Open(OpenTarget::SshShell))); + menu.move_selection(-2); + assert_eq!(menu.chosen(), Some(MenuEntry::Open(OpenTarget::Shell))); + menu.move_selection(-1); + assert_eq!(menu.chosen(), Some(MenuEntry::Open(OpenTarget::CopyAlias))); + assert_eq!(menu.by_key('z'), None); + assert_eq!( + menu.by_key('c'), + Some(MenuEntry::Open(OpenTarget::Editor(Editor::VsCode))) + ); + } + + #[test] + fn each_unavailable_reason_is_given_once_with_what_it_blocks() { + let menu = OpenMenu::new(&agent(""), &local(&[]), SshSetup::Installed); + assert_eq!( + menu.unavailable_reasons(), + [ + ( + vec![ + "VS Code, Remote-SSH".to_owned(), + "Zed".to_owned(), + "SSH shell".to_owned(), + "Copy SSH alias".to_owned() + ], + "SSH access is not declared in spec.access".to_owned() + ), + ( + vec![ + "Desktop in the browser".to_owned(), + "Desktop in a VNC client".to_owned() + ], + "VNC access is not declared in spec.access".to_owned() + ) + ] + ); + let menu = OpenMenu::new(&with_desktop(), &local(&[]), SshSetup::Installed); + assert_eq!( + menu.unavailable_reasons(), + [( + vec!["Zed".to_owned()], + Editor::Zed.missing_launcher().expect("Zed needs a launcher") + )] + ); + } + + fn with_desktop() -> Agent { + agent("\x20 access:\n\x20 - type: ssh\n\x20 - type: vnc\n") + } + + #[test] + fn the_desktop_is_offered_when_declared_and_ready() { + let environment = local(&[]); + let browser = OpenTarget::Desktop(DesktopViewer::Browser); + assert_eq!( + unavailable(&items(&with_ssh(), &environment, SshSetup::Installed), browser).as_deref(), + Some("VNC access is not declared in spec.access") + ); + assert_eq!( + unavailable(&items(&with_desktop(), &environment, SshSetup::Installed), browser), + None + ); + + let mut failed = with_desktop(); + failed.status.conditions.push(Condition { + kind: Condition::VNC_READY.into(), + status: ConditionStatus::False, + reason: "ReconcileFailed".into(), + message: "the image runs no desktop".into(), + last_transition_time: None, + }); + let listed = items(&failed, &environment, SshSetup::Installed); + assert_eq!( + unavailable(&listed, OpenTarget::Desktop(DesktopViewer::VncClient)).as_deref(), + Some("VNC access is not ready: the image runs no desktop") + ); + assert_eq!(unavailable(&listed, OpenTarget::SshShell), None, "SSH is unaffected"); + + let menu = OpenMenu::new(&with_desktop(), &environment, SshSetup::Installed); + assert_eq!(menu.by_key('w'), Some(MenuEntry::Open(browser))); + assert_eq!( + menu.by_key('v'), + Some(MenuEntry::Open(OpenTarget::Desktop(DesktopViewer::VncClient))) + ); + } + + #[test] + fn the_panel_shows_an_open_desktop_where_it_listens() { + let lines = connect_lines(&with_desktop(), &local(&[]), SshSetup::Installed, None); + assert_eq!(lines[1], " desktop browser · VNC client"); + let lines = connect_lines( + &with_desktop(), + &local(&[]), + SshSetup::Installed, + Some("http://127.0.0.1:53817/"), + ); + assert_eq!(lines[1], " desktop open at http://127.0.0.1:53817/"); + } + + #[test] + fn connect_lines_agree_with_the_menu() { + assert_eq!( + connect_lines(&agent(""), &local(&Editor::ALL), SshSetup::Missing, None), + [ + " shell in this terminal", + " desktop VNC access is not declared in spec.access", + " ssh SSH access is not declared in spec.access" + ] + ); + assert_eq!( + connect_lines(&with_ssh(), &local(&[Editor::Zed]), SshSetup::Missing, None), + [ + " shell in this terminal", + " desktop VNC access is not declared in spec.access", + " editors VS Code · Zed", + " ! SSH not set up; o offers it", + " ssh alias agentctl-worker", + ] + ); + let remote = Environment { + launch_blocked: Some("this terminal has no display to open windows on".into()), + launchers: Vec::new(), + }; + assert_eq!( + connect_lines(&with_ssh(), &remote, SshSetup::Installed, None)[2], + " editors none: this terminal has no display to open windows on" + ); + } + + /// User configurations OpenSSH reads the generated one from, however they + /// spell the `Include`; ones where it does not apply, since a block or a + /// match of the user's own comes first; and one OpenSSH rejects. + #[cfg(unix)] + #[tokio::test(flavor = "local")] + async fn ssh_setup_is_what_openssh_resolves_however_the_include_is_written() { + if std::process::Command::new(crate::ssh_client_executable()) + .arg("-V") + .output() + .is_err() + { + eprintln!("skipped: no OpenSSH client"); + return; + } + let directory = tempfile::tempdir().expect("temporary directory"); + let generated = directory.path().join("generated").join("config"); + std::fs::create_dir_all(generated.parent().expect("parent")).expect("directory"); + let proxy = agent::ssh::render_proxy_command( + Path::new("/usr/local/bin/agentctl"), + "worker", + agent::ssh::CommandShell::Posix, + ) + .expect("proxy command"); + std::fs::write(&generated, format!("Host agentctl-worker\n ProxyCommand {proxy}\n")).expect("generated"); + let path = generated.display().to_string(); + let glob = generated.with_file_name("*").display().to_string(); + let cases = [ + (format!("Include {path}\n"), SshSetup::Installed), + (format!("include {path}\n"), SshSetup::Installed), + (format!("Include={path}\n"), SshSetup::Installed), + (format!("Include \"{path}\" # agentctl\n"), SshSetup::Installed), + (format!("Include {glob}\n"), SshSetup::Installed), + (format!("Include /nonexistent {path}\n"), SshSetup::Installed), + ( + format!("Host *\n ServerAliveInterval 30\n\nInclude {path}\n"), + SshSetup::Installed, + ), + ( + format!("Host github.com\n User git\n\nInclude {path}\n"), + SshSetup::Missing, + ), + ( + format!("Host agentctl-*\n ProxyCommand none\n\nInclude {path}\n"), + SshSetup::Missing, + ), + (String::new(), SshSetup::Missing), + ("Bogus yes\n".to_owned(), SshSetup::Unknown), + ]; + for (text, expected) in cases { + let user = directory.path().join("user_config"); + std::fs::write(&user, &text).expect("user config"); + assert_eq!(SshSetup::resolve("worker", Some(&user)).await, expected, "{text}"); + } + } +} diff --git a/agentctl/src/bin/agentctl/tui/provisioning.rs b/agentctl/src/bin/agentctl/tui/provisioning.rs new file mode 100644 index 0000000..8409318 --- /dev/null +++ b/agentctl/src/bin/agentctl/tui/provisioning.rs @@ -0,0 +1,169 @@ +//! The provisioning detail: one Agent's latest pass, followed while it is open. + +use std::collections::VecDeque; + +use agent::{ + progress::{AgentProgress, OutputPosition}, + resources::Revision, +}; +use sandbox::progress::{ProgressCursor, Update}; + +use crate::format; + +/// Output lines of the pass the detail keeps. +const OUTPUT_LINES: usize = 20; + +/// What the detail knows of one Agent's provisioning. A reply carries each +/// output line once, so the most recent lines of the pass are kept here. +#[derive(Default)] +pub(crate) struct Followed { + latest: Option, + pass: Option, + cursor: ProgressCursor, + output: VecDeque, +} + +impl Followed { + /// Revision to follow from and the output already kept. + pub(crate) fn position(&self) -> (Option, Option) { + let output = self.pass.map(|pass| OutputPosition { + pass, + sequence: self.cursor.output_sequence(), + }); + (self.latest.as_ref().map(|latest| latest.revision), output) + } + + pub(crate) fn apply(&mut self, progress: AgentProgress) { + if let Some(provisioning) = &progress.provisioning { + if self.pass != Some(provisioning.pass) { + self.pass = Some(provisioning.pass); + self.cursor = ProgressCursor::new(); + self.output.clear(); + } + for update in self.cursor.updates(&provisioning.progress) { + let line = match update { + Update::Output(line) => line.text.clone(), + Update::OutputSkipped(count) => format!("… {count} lines skipped"), + Update::StepFinished(_) | Update::PhaseFinished(_) => continue, + }; + if self.output.len() == OUTPUT_LINES { + self.output.pop_front(); + } + self.output.push_back(line); + } + } + self.latest = Some(progress); + } + + /// The Agent's readiness and failure class, then its latest pass. + pub(crate) fn lines(&self) -> Vec { + let Some(latest) = &self.latest else { + return vec!["Waiting for agentd…".to_owned()]; + }; + let mut lines = format::readiness_lines(&latest.status); + match &latest.provisioning { + Some(provisioning) => lines.extend(format::provisioning_lines( + &provisioning.progress, + self.output.iter().map(String::as_str), + )), + None => lines.push("Provisioning: no pass since agentd started".to_owned()), + } + lines + } +} + +#[cfg(test)] +mod tests { + use sandbox::{OutputStream, ProgressEvent, SandboxPhase, StepId}; + + use super::*; + + fn pass(number: u64) -> Revision { + format!("00000000-0000-0000-0000-000000000001:{number}") + .parse() + .expect("test revision") + } + + fn reply(number: u64, events: &[ProgressEvent]) -> AgentProgress { + let mut progress = sandbox::progress::Progress::new(); + for event in events { + progress.apply(event); + } + serde_json::from_value(serde_json::json!({ + "revision": pass(number), + "status": {}, + "provisioning": {"pass": pass(number), "progress": progress}, + })) + .expect("test reply") + } + + fn output(step: &StepId, text: &str) -> ProgressEvent { + ProgressEvent::StepOutput { + id: step.clone(), + stream: OutputStream::Stdout, + bytes: text.as_bytes().to_vec().into(), + } + } + + #[test] + fn output_is_kept_across_replies_and_starts_over_with_a_new_pass() { + let step = StepId::generate(); + let started = [ + ProgressEvent::PhaseStarted { + phase: SandboxPhase::ImageResolve.phase(), + }, + ProgressEvent::StepStarted { + id: step.clone(), + name: "Build Docker image".into(), + unit: None, + total: None, + }, + ]; + let mut followed = Followed::default(); + assert_eq!(followed.position(), (None, None)); + + followed.apply(reply( + 1, + &[started[0].clone(), started[1].clone(), output(&step, "one\n")], + )); + let (_, position) = followed.position(); + assert_eq!( + position, + Some(OutputPosition { + pass: pass(1), + sequence: 1 + }) + ); + // The daemon trims what the follower has already seen. + let mut trimmed = reply( + 1, + &[ + started[0].clone(), + started[1].clone(), + output(&step, "one\n"), + output(&step, "two\n"), + ], + ); + if let Some(provisioning) = &mut trimmed.provisioning { + provisioning.progress = provisioning.progress.output_from(1); + } + followed.apply(trimmed); + let lines = followed.lines(); + assert!( + lines.contains(&" → Resolve Sandbox Image (0s)".to_owned()), + "{lines:#?}" + ); + assert!(lines.contains(&" Build Docker image".to_owned())); + assert!(lines.ends_with(&[" Output:".to_owned(), " one".to_owned(), " two".to_owned()])); + + followed.apply(reply(2, &[started[0].clone()])); + assert!(!followed.lines().iter().any(|line| line.contains("one"))); + assert_eq!( + followed.position().1, + Some(OutputPosition { + pass: pass(2), + sequence: 0 + }) + ); + } +} diff --git a/agentctl/src/bin/agentctl/tui/terminal.rs b/agentctl/src/bin/agentctl/tui/terminal.rs new file mode 100644 index 0000000..92e6ff7 --- /dev/null +++ b/agentctl/src/bin/agentctl/tui/terminal.rs @@ -0,0 +1,265 @@ +use std::{ + fmt, + io::{Stdout, Write}, +}; + +use agent::Error; +use crossterm::{ + Command, + cursor::Show, + event::{DisableMouseCapture, EnableMouseCapture}, + terminal::{Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{Terminal, backend::CrosstermBackend}; + +use super::app::App; +use super::view; + +pub(crate) struct Tui { + terminal: Terminal>, + view_state: view::ViewState, + pointer_shape: PointerShape, + active: bool, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum PointerShape { + #[default] + Default, + Pointer, +} + +struct SetPointerShape(PointerShape); + +impl Command for SetPointerShape { + fn write_ansi(&self, output: &mut impl fmt::Write) -> fmt::Result { + let shape = match self.0 { + PointerShape::Default => "default", + PointerShape::Pointer => "pointer", + }; + write!(output, "\x1b]22;{shape}\x1b\\") + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + Ok(()) + } +} + +struct ResetPointerShape; + +impl Command for ResetPointerShape { + fn write_ansi(&self, output: &mut impl fmt::Write) -> fmt::Result { + output.write_str("\x1b]22;\x1b\\") + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + Ok(()) + } +} + +/// Asks the terminal to put text on the system clipboard (OSC 52). +/// +/// Terminals may ignore it, and nothing reports whether one did, so callers +/// also show the text. +struct CopyToClipboard<'a>(&'a str); + +impl Command for CopyToClipboard<'_> { + fn write_ansi(&self, output: &mut impl fmt::Write) -> fmt::Result { + use base64::Engine as _; + let encoded = base64::engine::general_purpose::STANDARD.encode(self.0); + write!(output, "\x1b]52;c;{encoded}\x1b\\") + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + Ok(()) + } +} + +impl Tui { + pub(crate) fn enter() -> Result { + install_panic_hook(); + activate()?; + let terminal = match Terminal::new(CrosstermBackend::new(std::io::stdout())) { + Ok(terminal) => terminal, + Err(error) => { + let _ = deactivate(); + return Err(Error::from(error)); + } + }; + Ok(Self { + terminal, + view_state: view::ViewState::for_environment(), + pointer_shape: PointerShape::Default, + active: true, + }) + } + + /// Width of the terminal in cells. + pub(crate) fn width(&self) -> u16 { + self.terminal.size().map_or(0, |size| size.width) + } + + pub(crate) fn draw(&mut self, app: &App) -> Result { + let mut hit_map = None; + let view_state = &mut self.view_state; + self.terminal + .draw(|frame| hit_map = Some(view::render(frame, app, view_state))) + .map_err(Error::from)?; + Ok(hit_map.unwrap_or_default()) + } + + pub(crate) fn set_pointer_for( + &mut self, + hit_map: &view::HitMap, + position: Option<(u16, u16)>, + ) -> Result<(), Error> { + let shape = if position.is_some_and(|(column, row)| hit_map.clickable_at(column, row)) { + PointerShape::Pointer + } else { + PointerShape::Default + }; + if shape != self.pointer_shape { + crossterm::execute!(std::io::stdout(), SetPointerShape(shape))?; + self.pointer_shape = shape; + } + Ok(()) + } + + pub(crate) fn suspend(&mut self) -> Result<(), Error> { + deactivate()?; + self.pointer_shape = PointerShape::Default; + self.active = false; + Ok(()) + } + + pub(crate) fn resume(&mut self) -> Result<(), Error> { + activate()?; + self.pointer_shape = PointerShape::Default; + self.active = true; + crossterm::execute!(std::io::stdout(), Clear(ClearType::All))?; + self.terminal = Terminal::new(CrosstermBackend::new(std::io::stdout())).map_err(Error::from)?; + Ok(()) + } + + pub(crate) fn restore(&mut self) -> Result<(), Error> { + self.suspend() + } +} + +impl Drop for Tui { + fn drop(&mut self) { + if self.active { + let _ = deactivate(); + } + } +} + +/// Offers `text` to the system clipboard through the terminal. +pub(crate) fn copy_to_clipboard(text: &str) -> Result<(), Error> { + crossterm::execute!(std::io::stdout(), CopyToClipboard(text))?; + Ok(()) +} + +fn activate() -> Result<(), Error> { + enable_raw_mode()?; + if let Err(error) = enter_screen(&mut std::io::stdout()) { + let _ = deactivate(); + return Err(Error::from(error)); + } + Ok(()) +} + +fn deactivate() -> Result<(), Error> { + let screen = leave_screen(&mut std::io::stdout()); + let raw = disable_raw_mode(); + screen?; + raw.map_err(Error::from) +} + +fn enter_screen(output: &mut impl Write) -> std::io::Result<()> { + crossterm::execute!( + output, + EnterAlternateScreen, + EnableMouseCapture, + SetPointerShape(PointerShape::Default) + ) +} + +fn leave_screen(output: &mut impl Write) -> std::io::Result<()> { + crossterm::execute!( + output, + ResetPointerShape, + DisableMouseCapture, + LeaveAlternateScreen, + Show + ) +} + +fn install_panic_hook() { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = deactivate(); + previous(info); + })); +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::*; + + // Crossterm deliberately routes these commands through WinAPI on Windows, + // so only ANSI terminals write their bytes into this in-memory output. + #[cfg(not(windows))] + #[test] + fn screen_activation_enables_mouse_capture_after_entering_the_alternate_screen() { + let mut output = Vec::new(); + + enter_screen(&mut output).expect("screen activation"); + + let output = String::from_utf8(output).expect("terminal commands are UTF-8"); + let alternate = output.find("?1049h").expect("enter alternate screen"); + let mouse = output.find("?1000h").expect("enable mouse capture"); + let pointer = output.find("]22;default").expect("set default pointer shape"); + assert!(alternate < mouse); + assert!(mouse < pointer); + } + + #[cfg(not(windows))] + #[test] + fn screen_cleanup_disables_mouse_capture_before_leaving_the_alternate_screen() { + let mut output = Vec::new(); + + leave_screen(&mut output).expect("screen cleanup"); + + let output = String::from_utf8(output).expect("terminal commands are UTF-8"); + let pointer = output.find("]22;").expect("reset pointer shape"); + let mouse = output.find("?1006l").expect("disable mouse capture"); + let alternate = output.find("?1049l").expect("leave alternate screen"); + assert!(pointer < mouse); + assert!(mouse < alternate); + } + + #[test] + fn pointer_shape_commands_use_osc_22_and_can_restore_the_terminal_default() { + let mut output = Vec::new(); + + crossterm::execute!(output, SetPointerShape(PointerShape::Pointer), ResetPointerShape) + .expect("pointer commands"); + + assert_eq!(output, b"\x1b]22;pointer\x1b\\\x1b]22;\x1b\\"); + } + + #[test] + fn clipboard_copies_use_osc_52_with_base64_text() { + let mut output = Vec::new(); + + crossterm::execute!(output, CopyToClipboard("agentctl-worker")).expect("clipboard command"); + + assert_eq!(output, b"\x1b]52;c;YWdlbnRjdGwtd29ya2Vy\x1b\\"); + } +} diff --git a/agentctl/src/bin/agentctl/tui/view.rs b/agentctl/src/bin/agentctl/tui/view.rs new file mode 100644 index 0000000..634821e --- /dev/null +++ b/agentctl/src/bin/agentctl/tui/view.rs @@ -0,0 +1,2539 @@ +use ratatui::{ + Frame, + layout::{Constraint, Layout, Margin, Position, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Cell, Clear, List, ListItem, ListState, Padding, Paragraph, Row, Table, TableState, Wrap}, +}; + +use super::MANIFEST_FILE; +use super::app::{ + App, CONFIRM_HINTS, CONFIRM_SSH_SETUP_HINTS, CONFIRM_SSH_SETUP_THEN_HINTS, CREATE_AGENT_HINTS, CreateField, + ForwardField, HELP, HELP_HINTS, HelpSection, Hint, Modal, MouseAction, NEW_SESSION_HINTS, OPEN_HINTS, + PORT_FORWARD_HINTS, Row as TreeRow, RowTarget, RowView, SELECTION_HINTS, SessionField, Tone, TreeRowId, View, + harness_label, +}; +use super::open::{MenuEntry, OpenMenu, OpenTarget}; + +/// Background of the selected row; without color it is drawn reversed instead. +const SELECTION: Color = Color::Rgb(52, 58, 70); +/// Narrowest tree that still shows the detail and age columns. +const WIDE_TREE: u16 = 70; +/// Narrowest terminal that shows the side panel beside the tree. +const SIDE_PANEL: u16 = 110; +/// Narrowest and widest name column of a wide tree. +const NAME_WIDTH: (usize, usize) = (12, 32); +/// Width of every form but create-Agent, whose pickers also show manifest paths. +const FORM_WIDTH: u16 = 64; +const CREATE_AGENT_FORM_WIDTH: u16 = 96; +/// Width of the help overlay: two columns inside its border and padding. +const HELP_WIDTH: u16 = 76; +/// Width of a help column, and of the key labels in it. +const HELP_COLUMN_WIDTH: usize = 36; +const HELP_KEY_WIDTH: usize = 8; +/// Label column shared by every form row. +const FORM_LABEL_WIDTH: usize = 12; +/// A picker's value between its arrows. +const PICKER_VALUE_WIDTH: usize = 18; +/// A picker's arrows, value and position, before its detail. +const PICKER_WIDTH: usize = PICKER_VALUE_WIDTH + 12; +const ERROR_HINTS: [Hint; 2] = [ + Hint::key("esc", "dismiss", crossterm::event::KeyCode::Esc), + Hint::key("q", "quit", crossterm::event::KeyCode::Char('q')), +]; +/// Lines the selection's hints may wrap onto before the footer cuts them short. +const SELECTION_HINT_LINES: usize = 2; +/// Separates hints on a line. +const HINT_SEPARATOR: &str = " · "; +/// Ends a hint line that could not fit every hint. +const HINT_OVERFLOW: &str = "…"; + +#[derive(Default)] +pub(crate) struct ViewState { + /// First tree row below the column header, pinned Agent aside. + tree_offset: usize, + forwards: ListState, + /// Draw without color; glyphs and modifiers still tell states apart. + no_color: bool, +} + +impl ViewState { + /// Honors `NO_COLOR` when it is set to anything but an empty string. + pub(crate) fn for_environment() -> Self { + Self { + no_color: std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty()), + ..Self::default() + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum WheelTarget { + Tree, + Forwards, + Detail, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum HitTarget { + Row(RowTarget), + Action(MouseAction), +} + +#[derive(Default)] +pub(crate) struct HitMap { + frame: Rect, + clicks: Vec<(Rect, HitTarget)>, + wheels: Vec<(Rect, WheelTarget)>, +} + +impl HitMap { + fn new(frame: Rect) -> Self { + Self { + frame, + ..Self::default() + } + } + + fn clear(&mut self) { + self.clicks.clear(); + self.wheels.clear(); + } + + fn click(&mut self, area: Rect, target: HitTarget) { + let area = area.intersection(self.frame); + if !area.is_empty() { + self.clicks.push((area, target)); + } + } + + fn wheel(&mut self, area: Rect, target: WheelTarget) { + let area = area.intersection(self.frame); + if !area.is_empty() { + self.wheels.push((area, target)); + } + } + + pub(crate) fn click_at(&self, column: u16, row: u16) -> Option { + let position = Position::new(column, row); + self.frame.contains(position).then_some(())?; + self.clicks + .iter() + .rev() + .find_map(|(area, target)| area.contains(position).then(|| target.clone())) + } + + pub(crate) fn clickable_at(&self, column: u16, row: u16) -> bool { + self.click_at(column, row).is_some() + } + + pub(crate) fn wheel_at(&self, column: u16, row: u16) -> Option { + let position = Position::new(column, row); + self.frame.contains(position).then_some(())?; + self.wheels + .iter() + .rev() + .find_map(|(area, target)| area.contains(position).then_some(*target)) + } +} + +/// Whether a terminal this wide shows the panel beside the tree. +pub(crate) const fn shows_side_panel(width: u16) -> bool { + width >= SIDE_PANEL +} + +pub(crate) fn render(frame: &mut Frame, app: &App, state: &mut ViewState) -> HitMap { + let mut hit_map = HitMap::new(frame.area()); + let [header, body, footer] = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(footer_height(app, frame.area().width)), + ]) + .areas(frame.area()); + render_header(frame, header, app, &mut hit_map); + if let Some(detail) = &app.detail { + render_detail(frame, body, detail, &mut hit_map); + } else if let Some(error) = &app.error { + render_error(frame, body, error, &mut hit_map); + } else if app.view == View::Forwards { + render_forwards(frame, body, app, state, &mut hit_map); + } else if shows_side_panel(body.width) { + // The panel keeps its width whatever is selected, so the tree's + // columns stay put while the selection moves. + let [tree, panel] = Layout::horizontal([Constraint::Percentage(60), Constraint::Percentage(40)]) + .spacing(1) + .areas(body); + render_tree(frame, tree, app, state, &mut hit_map); + match (&app.selection, &app.transcript) { + (Some(TreeRowId::Session { .. }), Some(transcript)) => render_transcript(frame, panel, transcript), + (Some(TreeRowId::Agent(agent)), _) => { + render_agent_panel(frame, panel, agent, &app.agent_panel_lines(agent)); + } + _ => frame.render_widget(Block::bordered().border_style(Style::new().fg(Color::DarkGray)), panel), + } + } else { + render_tree(frame, body, app, state, &mut hit_map); + } + match &app.modal { + Some(Modal::Filter | Modal::Prompt(_)) => { + hit_map.clear(); + render_footer(frame, footer, app, &mut hit_map); + } + // A form carries its own hints, so it may use the footer's rows too. + Some(modal) => { + hit_map.clear(); + render_modal(frame, body.union(footer), app, modal, &mut hit_map); + } + None => render_footer(frame, footer, app, &mut hit_map), + } + if state.no_color { + let area = frame.area(); + let buffer = frame.buffer_mut(); + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + buffer[(x, y)].set_fg(Color::Reset).set_bg(Color::Reset); + } + } + } + hit_map +} + +fn render_header(frame: &mut Frame, area: Rect, app: &App, hit_map: &mut HitMap) { + let counts = app.triage_counts(); + let mut needs_you = Style::new().fg(Color::Yellow); + if counts.needs_you > 0 { + needs_you = needs_you.add_modifier(Modifier::BOLD); + } + let mut spans = vec![ + Span::styled( + " agentctl ", + Style::new().fg(Color::Cyan).add_modifier(Modifier::REVERSED), + ), + Span::raw(" "), + ]; + if let Some(error) = &app.connection_error { + spans.push(Span::styled( + format!("reconnecting: {error} · "), + Style::new().fg(Color::Red), + )); + } + let needs_you = Span::styled(format!("{} need you", counts.needs_you), needs_you); + let x = area + .x + .saturating_add(u16::try_from(Line::from(spans.clone()).width()).unwrap_or(u16::MAX)); + let width = u16::try_from(needs_you.width()).unwrap_or(u16::MAX); + hit_map.click( + Rect::new(x, area.y, width, 1), + HitTarget::Action(MouseAction::Key( + crossterm::event::KeyCode::Tab, + crossterm::event::KeyModifiers::NONE, + )), + ); + spans.push(needs_you); + // Before the counts, so a narrow header cuts those rather than the outcome + // of a change, which is gone after a few seconds. + if let Some((notice, _)) = &app.notice { + spans.push(Span::styled(format!(" · {notice}"), Style::new().fg(Color::Cyan))); + } + for (count, label, color) in [ + (counts.working, "working", Color::Green), + (counts.starting, "starting", Color::Cyan), + (counts.idle, "idle", Color::DarkGray), + (counts.failed, "failed", Color::Red), + (counts.provisioning, "provisioning", Color::Cyan), + (counts.archived, "archived", Color::DarkGray), + ] { + if count > 0 { + spans.push(Span::styled(format!(" · {count} {label}"), Style::new().fg(color))); + } + } + if !app.filter.is_empty() { + spans.push(Span::styled( + format!(" · filter: {}", app.filter), + Style::new().fg(Color::Cyan), + )); + } + if app.creating > 0 { + spans.push(Span::styled(" · creating forward…", Style::new().fg(Color::Cyan))); + } + if app.prompting > 0 { + spans.push(Span::styled(" · sending prompt…", Style::new().fg(Color::Cyan))); + } + if app.discovering { + spans.push(Span::styled(" · scanning manifests…", Style::new().fg(Color::Cyan))); + } + if let Some((agent, target)) = app.opening.first() { + spans.push(Span::styled( + format!(" · opening {} on {agent}…", target.name()), + Style::new().fg(Color::Cyan), + )); + } + frame.render_widget(Line::from(spans), area); +} + +fn render_tree(frame: &mut Frame, area: Rect, app: &App, state: &mut ViewState, hit_map: &mut HitMap) { + let rows = app.render_rows(); + if rows.is_empty() { + let placeholder = match (app.loaded, app.filter.is_empty()) { + (false, _) => "loading…", + (true, true) => "(no agents)", + (true, false) => "(nothing matches the filter)", + }; + frame.render_widget( + Paragraph::new(placeholder).style(Style::new().fg(Color::DarkGray)), + area, + ); + return; + } + let wide = area.width >= WIDE_TREE; + let mut header = vec![ + Cell::default(), + Cell::from("NAME"), + Cell::from("STATE"), + Cell::from(Line::from("FOR").right_aligned()), + ]; + // A wide tree sizes names to fit, within bounds, and gives the rest to the detail. + let name_width = rows + .iter() + .map(|row| name_cell(row).width()) + .max() + .unwrap_or_default() + .clamp(NAME_WIDTH.0, NAME_WIDTH.1); + let mut widths = vec![ + Constraint::Length(1), + if wide { + Constraint::Length(u16::try_from(name_width).unwrap_or(u16::MAX)) + } else { + Constraint::Fill(1) + }, + Constraint::Length(12), + Constraint::Length(4), + ]; + if wide { + header.extend([Cell::from("DETAIL"), Cell::from(Line::from("AGE").right_aligned())]); + widths.extend([Constraint::Fill(1), Constraint::Length(4)]); + } + // Every column but the detail, and a space between each pair. + let detail_width = usize::from(area.width).saturating_sub(1 + name_width + 12 + 4 + 4 + 5); + // Rows fit below the column header. + let height = usize::from(area.height.saturating_sub(1)); + let (offset, pinned) = tree_viewport(app, state.tree_offset, height); + state.tree_offset = offset; + let capacity = height.saturating_sub(usize::from(pinned.is_some())); + let visible = pinned + .into_iter() + .chain(offset..rows.len().min(offset.saturating_add(capacity))) + .collect::>(); + let table_rows = visible.iter().map(|&index| { + let row = tree_row(&rows[index], wide.then_some(detail_width)); + if pinned == Some(index) { + row.style(Style::new().add_modifier(Modifier::DIM)) + } else { + row + } + }); + let table = Table::new(table_rows, widths) + .header(Row::new(header).style(Style::new().fg(Color::DarkGray))) + .row_highlight_style(selection(state)); + let selected = app.selected_index(); + let mut table_state = + TableState::default().with_selected(visible.iter().position(|index| Some(*index) == selected)); + frame.render_stateful_widget(table, area, &mut table_state); + let body = Rect::new( + area.x, + area.y.saturating_add(1), + area.width, + area.height.saturating_sub(1), + ); + hit_map.wheel(body, WheelTarget::Tree); + for (line, index) in visible.into_iter().enumerate() { + let Some(target) = app.tree_id_at(index) else { + continue; + }; + let y = body.y.saturating_add(u16::try_from(line).unwrap_or(u16::MAX)); + hit_map.click( + Rect::new(body.x, y, body.width, 1), + HitTarget::Row(RowTarget::Tree(target.clone())), + ); + if let TreeRowId::Agent(agent) = target { + hit_map.click( + Rect::new(body.x, y, body.width.min(4), 1), + HitTarget::Action(MouseAction::FoldTree(agent)), + ); + } + } +} + +/// Chooses the first row shown so the selection stays in view, and pins the +/// Agent above it when the view starts among that Agent's Sessions. +fn tree_viewport(app: &App, offset: usize, height: usize) -> (usize, Option) { + let selected = app.selected_index().unwrap_or_default(); + let mut offset = offset.min(selected).min(app.rows.len().saturating_sub(height)); + loop { + let pinned = match app.rows.get(offset) { + Some(TreeRow::Session { group, .. }) => app.rows[..offset] + .iter() + .rposition(|row| *row == TreeRow::Agent(*group)), + _ => None, + }; + let capacity = height.saturating_sub(usize::from(pinned.is_some())).max(1); + if selected < offset.saturating_add(capacity) { + return (offset, pinned); + } + offset = selected + 1 - capacity; + } +} + +/// The selected Session's recent turns, wrapped to the panel, the newest at the bottom. +fn render_transcript(frame: &mut Frame, area: Rect, transcript: &super::app::Transcript) { + let block = Block::bordered() + .title(format!(" {} · recent turns ", transcript.session.as_str())) + .border_style(Style::new().fg(Color::DarkGray)); + let inner = block.inner(area); + let mut lines = crate::format::turn_lines(&transcript.turns); + if transcript.stopped { + lines = vec![format!( + "agent/{} is stopped; its turns are shown after a start.", + transcript.agent + )]; + } else if let Some(error) = &transcript.error { + lines.push(format!("Turns unavailable: {error}")); + } else if lines.is_empty() { + lines.push( + if transcript.loading { + "Loading…" + } else { + "No turns yet." + } + .to_owned(), + ); + } + let rows = lines + .iter() + .flat_map(|line| wrap(line, usize::from(inner.width))) + .collect::>(); + let visible = rows[rows.len().saturating_sub(usize::from(inner.height))..] + .iter() + .map(|row| { + let style = if row.starts_with("===") { + Style::new().fg(Color::DarkGray) + } else if row.starts_with("[user]") { + Style::new().fg(Color::Cyan) + } else { + Style::new() + }; + Line::from(Span::styled(row.clone(), style)) + }) + .collect::>(); + frame.render_widget(Paragraph::new(visible).block(block), area); +} + +/// Splits a line into rows of at most `width` cells. +fn wrap(line: &str, width: usize) -> Vec { + let mut rows = vec![String::new()]; + let mut used = 0; + for character in line.chars() { + let cells = Line::from(character.to_string()).width(); + if used + cells > width.max(1) { + rows.push(String::new()); + used = 0; + } + if let Some(row) = rows.last_mut() { + row.push(character); + } + used += cells; + } + rows +} + +/// A tree row's name cell: Sessions are indented under their Agent. +fn name_cell(row: &RowView) -> Line<'static> { + let tone = Style::new().fg(tone_color(row.tone)); + let (indent, name) = if row.agent { + ("", tone.add_modifier(Modifier::BOLD)) + } else { + (" ", Style::new()) + }; + Line::from(vec![ + Span::raw(indent), + Span::styled(row.marker, tone), + Span::raw(" "), + Span::styled(row.name.clone(), name), + ]) +} + +/// One tree row, with the detail and age columns when the detail has a +/// width. Every state has a glyph as well as a color. +fn tree_row(row: &RowView, detail_width: Option) -> Row<'static> { + let tone = Style::new().fg(tone_color(row.tone)); + let mut state = tone; + if row.attention && !row.agent { + state = state.add_modifier(Modifier::BOLD); + } + let mut cells = vec![ + Cell::from(Span::styled( + if row.attention { "▐" } else { "" }, + Style::new().fg(Color::Yellow), + )), + Cell::from(name_cell(row)), + Cell::from(Span::styled(row.state, state)), + Cell::from(Line::from(Span::styled(row.since.clone(), tone)).right_aligned()), + ]; + if let Some(width) = detail_width { + let style = if row.agent { + tone + } else { + Style::new().fg(Color::DarkGray) + }; + let detail = if row.detail_keeps_end { + tail_ellipsized(&row.detail, width) + } else { + row.detail.clone() + }; + cells.extend([ + Cell::from(Span::styled(detail, style)), + Cell::from(Line::from(Span::styled(row.age.clone(), Style::new().fg(Color::DarkGray))).right_aligned()), + ]); + } + Row::new(cells) +} + +fn render_forwards(frame: &mut Frame, area: Rect, app: &App, state: &mut ViewState, hit_map: &mut HitMap) { + let block = Block::bordered().title(" port-forwards "); + let inner = block.inner(area); + if app.forwards.is_empty() { + frame.render_widget( + Paragraph::new("(no port forwards — press f on an agent to create one)") + .style(Style::new().fg(Color::DarkGray)) + .block(block), + area, + ); + return; + } + let items = app + .forwards + .iter() + .map(|entry| { + let mut spans = vec![ + Span::styled("⇄ ", Style::new().fg(Color::Cyan)), + Span::styled( + format!("{:<8}", entry.label().unwrap_or_default()), + Style::new().fg(Color::Cyan), + ), + Span::raw(format!("{} → {}", entry.local, entry.guest_port)), + Span::styled(format!(" {}", entry.agent), Style::new().fg(Color::DarkGray)), + ]; + match &entry.status { + Some(status) => spans.push(Span::styled(format!(" {status}"), Style::new().fg(Color::Red))), + None => spans.push(Span::styled(" active", Style::new().fg(Color::Green))), + } + ListItem::new(Line::from(spans)) + }) + .collect::>(); + let list = List::new(items).block(block).highlight_style(selection(state)); + state.forwards.select(Some(app.forward_selected)); + frame.render_stateful_widget(list, area, &mut state.forwards); + hit_map.wheel(inner, WheelTarget::Forwards); + for visible in 0..usize::from(inner.height) { + let index = state.forwards.offset().saturating_add(visible); + let Some(entry) = app.forwards.get(index) else { + break; + }; + let y = inner.y.saturating_add(u16::try_from(visible).unwrap_or(u16::MAX)); + hit_map.click( + Rect::new(inner.x, y, inner.width, 1), + HitTarget::Row(RowTarget::Forward(entry.id)), + ); + } +} + +/// A detail view, with long lines wrapped to its width so a failure message +/// is read in full. +fn render_detail(frame: &mut Frame, area: Rect, detail: &super::app::Detail, hit_map: &mut HitMap) { + let block = Block::bordered().title(format!(" {} — q back · ↑/↓ scroll ", detail.title)); + let inner = block.inner(area); + let rows = wrapped(&detail.lines, inner.width); + let limit = rows.len().saturating_sub(usize::from(inner.height)); + detail.scroll_limit.set(Some(limit)); + let scroll = detail.scroll.min(limit); + let visible = rows.into_iter().skip(scroll).map(Line::from).collect::>(); + frame.render_widget(Paragraph::new(visible).block(block), area); + hit_map.wheel(inner, WheelTarget::Detail); +} + +/// The selected Agent's status beside the tree, wrapped to the panel. +fn render_agent_panel(frame: &mut Frame, area: Rect, agent: &str, lines: &[String]) { + let block = Block::bordered() + .title(format!(" {agent} · status ")) + .border_style(Style::new().fg(Color::DarkGray)); + let inner = block.inner(area); + let rows = wrapped(lines, inner.width) + .into_iter() + .map(Line::from) + .collect::>(); + frame.render_widget(Paragraph::new(rows).block(block), area); +} + +/// Lines split at their line breaks and wrapped to `width` cells. +fn wrapped(lines: &[String], width: u16) -> Vec { + lines + .iter() + .flat_map(|line| line.split('\n')) + .flat_map(|line| wrap(line, usize::from(width))) + .collect() +} + +fn render_error(frame: &mut Frame, area: Rect, error: &str, hit_map: &mut HitMap) { + let paragraph = Paragraph::new(error) + .style(Style::new().fg(Color::Red)) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, area); + let last_error_row = (area.y..area.bottom()) + .rev() + .find(|&y| (area.x..area.right()).any(|x| !frame.buffer_mut()[(x, y)].symbol().trim().is_empty())) + .unwrap_or(area.y); + let hint_y = last_error_row.saturating_add(2); + if hint_y < area.bottom() { + let hints = Rect::new(area.x, hint_y, area.width, 1); + render_hints(frame, hints, &ERROR_HINTS, Color::Red, Color::Red, hit_map, |_| true); + } +} + +/// Footer rows: the contextual hints on as many lines as they need at this +/// width, then the global hints. Below the tree that is the widest selection's +/// hints, so moving the selection or opening a prompt never moves the tree. +fn footer_height(app: &App, width: u16) -> u16 { + let own = [app.hints()]; + let contextual: &[&[Hint]] = if app.detail.is_some() || app.view == View::Forwards { + &own + } else { + &SELECTION_HINTS + }; + let lines = contextual + .iter() + .map(|hints| hint_lines(hints, width).len()) + .max() + .unwrap_or(1) + .clamp(1, SELECTION_HINT_LINES); + u16::try_from(lines + 1).unwrap_or(u16::MAX) +} + +/// Keys that apply wherever the tree is shown, the help first so a line cut +/// short still shows where the rest are. +const fn global_hints(app: &App) -> [Hint; 6] { + use crossterm::event::KeyCode; + let archived = if app.show_archived { + "hide archived" + } else { + "show archived" + }; + [ + Hint::key("?", "help", KeyCode::Char('?')), + Hint::key("tab", "needs you", KeyCode::Tab), + Hint::key("/", "filter", KeyCode::Char('/')), + Hint::key("A", archived, KeyCode::Char('A')), + Hint::key("F", "forwards", KeyCode::Char('F')), + Hint::key("q", "quit", KeyCode::Char('q')), + ] +} + +fn render_footer(frame: &mut Frame, area: Rect, app: &App, hit_map: &mut HitMap) { + let [contextual, global] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(area); + let input = match &app.modal { + Some(Modal::Filter) => Some(("/".to_owned(), app.filter.as_str(), None)), + Some(Modal::Prompt(form)) => Some(( + format!("{} › ", form.session.as_str()), + form.input.as_str(), + form.error.as_deref(), + )), + _ => None, + }; + if let Some((prompt, text, error)) = input { + let width = usize::from(global.width).saturating_sub(Line::from(prompt.as_str()).width() + 1); + frame.render_widget( + Line::from(vec![ + Span::styled(prompt, Style::new().fg(Color::Cyan)), + Span::raw(tail_ellipsized(text, width)), + Span::styled("▏", Style::new().fg(Color::Cyan)), + ]), + global, + ); + match error { + Some(error) => { + let line = Rect::new(contextual.x, contextual.bottom().saturating_sub(1), contextual.width, 1); + frame.render_widget(Span::styled(error.to_owned(), Style::new().fg(Color::Red)), line); + } + None => render_hints( + frame, + contextual, + app.hints(), + Color::Cyan, + Color::DarkGray, + hit_map, + |_| true, + ), + } + return; + } + render_hints( + frame, + contextual, + app.hints(), + Color::Cyan, + Color::DarkGray, + hit_map, + |_| app.error.is_none(), + ); + // A detail view's own hints replace the tree's, whose keys do not apply there. + if app.detail.is_some() { + return; + } + render_hints( + frame, + global, + &global_hints(app), + Color::DarkGray, + Color::DarkGray, + hit_map, + |hint| { + if app.modal.is_some() || app.detail.is_some() || app.view == View::Forwards { + false + } else if app.error.is_some() { + hint.label == "q" + } else { + true + } + }, + ); +} + +/// Draws hints on the bottom lines of `area`, wrapping between hints. Hints +/// that do not fit give way to an ellipsis, so a cut line reads as cut. +fn render_hints( + frame: &mut Frame, + area: Rect, + hints: &[Hint], + key_color: Color, + description_color: Color, + hit_map: &mut HitMap, + clickable: impl Fn(&Hint) -> bool, +) { + let mut lines = hint_lines(hints, area.width); + let overflow = lines.len() > usize::from(area.height); + lines.truncate(usize::from(area.height)); + if overflow && let Some(last) = lines.last_mut() { + let reserved = separator_width().saturating_add(text_width(HINT_OVERFLOW)); + while let [kept @ .., _] = *last + && hints_width(last).saturating_add(reserved) > area.width + { + *last = kept; + } + } + let top = area + .bottom() + .saturating_sub(u16::try_from(lines.len()).unwrap_or(u16::MAX)); + let last_line = lines.len().saturating_sub(1); + for (row, (line, y)) in lines.iter().zip(top..).enumerate() { + let mut spans = Vec::new(); + let mut x = area.x; + for (index, hint) in line.iter().enumerate() { + if index > 0 { + spans.push(Span::styled(HINT_SEPARATOR, Style::new().fg(description_color))); + x = x.saturating_add(separator_width()); + } + spans.push(Span::styled(hint.label, Style::new().fg(key_color))); + spans.push(Span::raw(" ")); + spans.push(Span::styled(hint.description, Style::new().fg(description_color))); + let width = hint_width(hint); + if clickable(hint) + && let Some((code, modifiers)) = hint.key + { + hit_map.click( + Rect::new(x, y, width.min(area.right().saturating_sub(x)), 1), + HitTarget::Action(MouseAction::Key(code, modifiers)), + ); + } + x = x.saturating_add(width); + } + if overflow && row == last_line { + if !line.is_empty() { + spans.push(Span::styled(HINT_SEPARATOR, Style::new().fg(description_color))); + } + spans.push(Span::styled(HINT_OVERFLOW, Style::new().fg(description_color))); + } + frame.render_widget(Line::from(spans), Rect::new(area.x, y, area.width, 1)); + } +} + +/// Splits hints into lines no wider than `width`, keeping each hint whole. +fn hint_lines(hints: &[Hint], width: u16) -> Vec<&[Hint]> { + let mut lines = Vec::new(); + let mut start = 0; + for end in 1..=hints.len() { + if end - start > 1 && hints_width(&hints[start..end]) > width { + lines.push(&hints[start..end - 1]); + start = end - 1; + } + } + if start < hints.len() { + lines.push(&hints[start..]); + } + lines +} + +fn hints_width(hints: &[Hint]) -> u16 { + let separators = u16::try_from(hints.len().saturating_sub(1)).unwrap_or(u16::MAX); + hints + .iter() + .map(hint_width) + .fold(separators.saturating_mul(separator_width()), u16::saturating_add) +} + +fn separator_width() -> u16 { + text_width(HINT_SEPARATOR) +} + +fn text_width(text: &str) -> u16 { + u16::try_from(Line::from(text).width()).unwrap_or(u16::MAX) +} + +fn hint_width(hint: &Hint) -> u16 { + u16::try_from(Line::from(format!("{} {}", hint.label, hint.description)).width()).unwrap_or(u16::MAX) +} + +fn render_modal(frame: &mut Frame, area: Rect, app: &App, modal: &Modal, hit_map: &mut HitMap) { + match modal { + Modal::ConfirmDelete { agent, sessions } => { + Form::new(" delete ", Color::Red, &CONFIRM_HINTS) + .row(Line::from(format!("Delete agent {agent}?"))) + .row(note_line(&format!("{sessions} session(s) will be deleted with it."))) + .render(frame, area, FORM_WIDTH, hit_map); + } + Modal::ConfirmStop { agent } => { + Form::new(" stop ", Color::Yellow, &CONFIRM_HINTS) + .row(Line::from(format!("Stop agent {agent}?"))) + .row(note_line("Running harnesses stop with its VM.")) + .row(note_line( + "Its disk is kept; attaching after a start resumes a Session.", + )) + .render(frame, area, FORM_WIDTH, hit_map); + } + Modal::ConfirmDeleteSession { agent, session } => { + Form::new(" delete ", Color::Red, &CONFIRM_HINTS) + .row(Line::from(format!("Delete session {agent}/{session}?"))) + .row(note_line("Its harness is stopped and the Session is removed.")) + .render(frame, area, FORM_WIDTH, hit_map); + } + Modal::NewSession(form) => render_new_session(frame, area, form, hit_map), + Modal::CreateAgent(form) => render_create_agent(frame, area, form, hit_map), + Modal::PortForward(form) => render_port_forward(frame, area, form, hit_map), + Modal::Help => render_help(frame, area, hit_map), + Modal::Open(menu) => render_open(frame, area, menu, hit_map), + Modal::ConfirmQuit => render_confirm_quit(frame, area, app, hit_map), + Modal::ConfirmSshSetup { include, then, .. } => { + render_confirm_ssh_setup(frame, area, include, *then, hit_map); + } + // Typed in the footer, so the tree and the Session's turns stay in view. + Modal::Filter | Modal::Prompt(_) => {} + } +} + +/// The ways into one Agent, one per row; unavailable ones are dimmed, and why is listed below them. +fn render_open(frame: &mut Frame, area: Rect, menu: &OpenMenu, hit_map: &mut HitMap) { + const REASON_ROWS: usize = 3; + let title = format!(" open {} ", menu.agent); + let width = usize::from(FORM_WIDTH.saturating_sub(4)); + let mut form = Form::new(&title, Color::Cyan, &OPEN_HINTS); + for (index, item) in menu.items.iter().enumerate() { + let label = item.entry.label(); + if item.unavailable.is_some() { + form = form.row(note_line(&format!(" {label}"))); + continue; + } + let selected = index == menu.selected; + let marker = if selected { "▸ " } else { " " }; + let key = item.entry.key().map_or_else(String::new, |key| key.to_string()); + let fill = width.saturating_sub(2 + Line::from(label.as_str()).width() + key.len() + 1); + let style = if selected { + Style::new().fg(Color::Cyan) + } else { + Style::new() + }; + form = form.row(Line::from(vec![ + Span::styled(marker, Style::new().fg(Color::Cyan)), + Span::styled(label, style), + Span::raw(" ".repeat(fill)), + Span::styled(key, Style::new().fg(Color::Cyan)), + ])); + } + // Below the rows, where there is room to read them: each reason once, + // wrapped, and cut short so the menu still fits an 80x24 terminal. + for (labels, reason) in menu.unavailable_reasons() { + form = form.row(Line::default()); + let mut rows = wrap(&format!("{}: {reason}", labels.join(", ")), width); + if rows.len() > REASON_ROWS { + rows.truncate(REASON_ROWS); + if let Some(last) = rows.last_mut() { + let kept = last.chars().take(width.saturating_sub(1)).collect::(); + *last = format!("{}…", kept.trim_end()); + } + } + for row in rows { + form = form.row(note_line(&row)); + } + } + if menu.items.iter().any(|item| item.entry == MenuEntry::SetUpSsh) { + form = form + .row(Line::default()) + .row(Line::from(Span::styled( + "SSH needs a line in ~/.ssh/config;", + Style::new().fg(Color::Yellow), + ))) + .row(Line::from(Span::styled( + "you are asked before it is added.", + Style::new().fg(Color::Yellow), + ))); + } + let target = form.render(frame, area, FORM_WIDTH, hit_map); + for (index, item) in menu.items.iter().enumerate() { + if item.unavailable.is_none() { + hit_map.click( + line_area(target, index), + HitTarget::Action(MouseAction::ChooseOpen(index)), + ); + } + } +} + +/// Lists the forwards that close with the TUI before it quits. +fn render_confirm_quit(frame: &mut Frame, area: Rect, app: &App, hit_map: &mut HitMap) { + const LISTED: usize = 6; + let width = usize::from(FORM_WIDTH.saturating_sub(4)); + let mut form = Form::new(" quit ", Color::Cyan, &CONFIRM_HINTS) + .row(Line::from("Quit agentctl tui?")) + .row(note_line("These forwards close with it:")); + for entry in app.forwards.iter().take(LISTED) { + let mapping = format!(" {} ", entry.mapping()); + let agent = fixed_width(&entry.agent, width.saturating_sub(Line::from(mapping.as_str()).width())); + form = form.row(Line::from(vec![ + Span::styled(mapping, Style::new().fg(Color::Cyan)), + Span::styled(agent.trim_end().to_owned(), Style::new().fg(Color::DarkGray)), + ])); + } + if let Some(more) = app.forwards.len().checked_sub(LISTED).filter(|more| *more > 0) { + form = form.row(note_line(&format!(" …and {more} more"))); + } + form.render(frame, area, FORM_WIDTH, hit_map); +} + +/// Shows the exact line SSH setup adds, and where, before anything is written. +fn render_confirm_ssh_setup( + frame: &mut Frame, + area: Rect, + include: &agent::ssh::UserInclude, + then: Option, + hit_map: &mut HitMap, +) { + let file = abbreviate_home(&include.user_config.display().to_string()); + let hints: &[Hint] = if then.is_some() { + &CONFIRM_SSH_SETUP_THEN_HINTS + } else { + &CONFIRM_SSH_SETUP_HINTS + }; + let mut form = Form::new(" set up SSH ", Color::Cyan, hints) + .row(Line::from("Editors reach Agents through your OpenSSH config.")) + .row(Line::from(format!("Add this line at the top of {file}?"))) + .row(Line::default()) + .row(Line::from(Span::styled( + format!(" {}", include.line), + Style::new().fg(Color::Cyan), + ))) + .row(Line::default()) + .row(note_line("Existing lines are kept. It is added once for every")) + .row(note_line("Agent, and new Agents need nothing more.")); + if let Some(target) = then { + form = form + .row(Line::default()) + .row(Line::from(format!("Then: {}.", target.label()))); + } + form.render(frame, area, FORM_WIDTH, hit_map); +} + +/// Every key, grouped by where it applies, in two columns over the current view. +fn render_help(frame: &mut Frame, area: Rect, hit_map: &mut HitMap) { + let heading = Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD); + let label = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); + let column = |sections: &[HelpSection]| { + let mut lines = Vec::new(); + for (index, (title, keys)) in sections.iter().enumerate() { + if index > 0 { + lines.push(vec![Span::raw("")]); + } + lines.push(vec![Span::styled(*title, heading)]); + for (key, description) in *keys { + lines.push(vec![ + Span::styled(format!("{key:>HELP_KEY_WIDTH$}"), label), + Span::raw(format!(" {description}")), + ]); + } + } + lines + }; + let [left, right] = HELP.map(column); + let mut form = Form::new(" keys ", Color::Cyan, &HELP_HINTS); + for index in 0..left.len().max(right.len()) { + let mut spans = left.get(index).cloned().unwrap_or_default(); + let used = Line::from(spans.clone()).width(); + spans.push(Span::raw(" ".repeat(HELP_COLUMN_WIDTH.saturating_sub(used)))); + spans.extend(right.get(index).cloned().unwrap_or_default()); + form = form.row(Line::from(spans)); + } + form.render(frame, area, HELP_WIDTH, hit_map); +} + +fn render_new_session(frame: &mut Frame, area: Rect, form: &super::app::SessionForm, hit_map: &mut HitMap) { + let harness = form + .installation() + .map_or("", |installation| harness_label(installation.kind)); + let target = Form::new(" new session ", Color::Cyan, &NEW_SESSION_HINTS) + .row(labeled("Agent", false, text_input(&form.agent, false, ""))) + .row(labeled( + "Name", + form.field == SessionField::Name, + text_input(&form.name, form.field == SessionField::Name, ""), + )) + .row(labeled( + "Model", + form.field == SessionField::Model, + text_input( + &form.model, + form.field == SessionField::Model, + &selection_hint(form.model_default()), + ), + )) + .row(labeled( + "Effort", + form.field == SessionField::Effort, + text_input( + &form.effort, + form.field == SessionField::Effort, + &selection_hint(form.effort_default()), + ), + )) + .row(labeled( + "Harness", + false, + picker(harness, false, form.harness, form.harnesses.len(), "", 0), + )) + .error(form.error.as_deref()) + .render(frame, area, FORM_WIDTH, hit_map); + for (row, field) in [ + (1, SessionField::Name), + (2, SessionField::Model), + (3, SessionField::Effort), + ] { + hit_map.click( + line_area(target, row), + HitTarget::Action(MouseAction::FocusSessionField(field)), + ); + } + if let Some(last) = form.harnesses.len().checked_sub(1) { + let previous = form.harness.checked_sub(1).unwrap_or(last); + let next = if form.harness >= last { 0 } else { form.harness + 1 }; + map_picker_targets( + line_area(target, 4), + MouseAction::SelectHarness(previous), + MouseAction::SelectHarness(next), + hit_map, + ); + } +} + +/// What an empty selection field resolves to: the manifest default or the harness's own. +fn selection_hint(manifest_default: Option<&str>) -> String { + manifest_default.map_or_else( + || "harness default".to_owned(), + |default| format!("{default} (manifest default)"), + ) +} + +fn render_create_agent(frame: &mut Frame, area: Rect, form: &super::app::CreateForm, hit_map: &mut HitMap) { + let candidate_error = form.candidate().and_then(|candidate| candidate.name.as_ref().err()); + let mut widget = Form::new(" create agent ", Color::Cyan, &CREATE_AGENT_HINTS) + .error(form.error.as_ref().or(candidate_error).map(String::as_str)); + let Some((agent, candidate)) = form.agent().zip(form.candidate()) else { + widget + .row(Line::from("No agent manifests found.")) + .row(note_line(&format!( + "Start the TUI inside a repository or directory tree containing {MANIFEST_FILE}," + ))) + .row(note_line("or apply one first: agentctl apply -f")) + .row(Line::default()) + .render(frame, area, CREATE_AGENT_FORM_WIDTH, hit_map); + return; + }; + let detail_width = + usize::from(CREATE_AGENT_FORM_WIDTH.saturating_sub(4)).saturating_sub(FORM_LABEL_WIDTH + PICKER_WIDTH); + let manifest_file = candidate.path.file_name().map_or_else( + || candidate.path.display().to_string(), + |name| name.to_string_lossy().into_owned(), + ); + widget = widget + .row(labeled( + "Agent", + form.field == CreateField::Agent, + picker( + &agent.label(), + form.field == CreateField::Agent, + form.agent, + form.agents.len(), + &abbreviate_home(&agent.directory.display().to_string()), + detail_width, + ), + )) + .row(labeled( + "Variant", + form.field == CreateField::Variant, + picker( + &form.variant_label().unwrap_or_default(), + form.field == CreateField::Variant, + form.variant, + agent.variants.len(), + &manifest_file, + detail_width, + ), + )) + .row(labeled( + "Name", + form.field == CreateField::Name, + text_input( + &form.name, + form.field == CreateField::Name, + form.placeholder().unwrap_or_default(), + ), + )) + .row(labeled( + "Env file", + form.field == CreateField::EnvironmentFile, + text_input( + &form.env_file, + form.field == CreateField::EnvironmentFile, + "default: .env beside manifest", + ), + )); + let target = widget.render(frame, area, CREATE_AGENT_FORM_WIDTH, hit_map); + for (row, field) in [ + (0, CreateField::Agent), + (1, CreateField::Variant), + (2, CreateField::Name), + (3, CreateField::EnvironmentFile), + ] { + hit_map.click( + line_area(target, row), + HitTarget::Action(MouseAction::FocusCreateField(field)), + ); + } + for (row, field) in [(0, CreateField::Agent), (1, CreateField::Variant)] { + map_picker_targets( + line_area(target, row), + MouseAction::SelectCreate { field, delta: -1 }, + MouseAction::SelectCreate { field, delta: 1 }, + hit_map, + ); + } +} + +fn render_port_forward(frame: &mut Frame, area: Rect, form: &super::app::ForwardForm, hit_map: &mut HitMap) { + let title = if form.replace.is_some() { + " edit forward " + } else { + " port forward " + }; + let text = |label, value: &str, field, placeholder| { + labeled( + label, + form.field == field, + text_input(value, form.field == field, placeholder), + ) + }; + let target = Form::new(title, Color::Cyan, &PORT_FORWARD_HINTS) + .row(labeled("Agent", false, text_input(&form.agent, false, ""))) + .row(text("Address", &form.address, ForwardField::Address, "127.0.0.1")) + .row(text( + "Local port", + &form.local, + ForwardField::LocalPort, + "same as guest port", + )) + .row(text("Guest port", &form.guest, ForwardField::GuestPort, "")) + .error(form.error.as_deref()) + .render(frame, area, FORM_WIDTH, hit_map); + for (row, field) in [ + (1, ForwardField::Address), + (2, ForwardField::LocalPort), + (3, ForwardField::GuestPort), + ] { + hit_map.click( + line_area(target, row), + HitTarget::Action(MouseAction::FocusForwardField(field)), + ); + } +} + +/// A modal drawn at a fixed size whatever is typed: one line per row, a line +/// for an error, then the form's key hints. Row `n` is drawn on line +/// `n`, which is where callers put its mouse targets. +struct Form<'a> { + title: &'a str, + border: Color, + hints: &'a [Hint], + rows: Vec>, + error: Line<'static>, +} + +impl<'a> Form<'a> { + fn new(title: &'a str, border: Color, hints: &'a [Hint]) -> Self { + Self { + title, + border, + hints, + rows: Vec::new(), + error: Line::default(), + } + } + + fn row(mut self, row: Line<'static>) -> Self { + self.rows.push(row); + self + } + + /// Shows a validation or submission error above the hints. + fn error(mut self, error: Option<&str>) -> Self { + self.error = error.map_or_else(Line::default, |error| { + Line::from(Span::styled(error.to_owned(), Style::new().fg(Color::Red))) + }); + self + } + + fn render(self, frame: &mut Frame, area: Rect, width: u16, hit_map: &mut HitMap) -> Rect { + let hint_row = self.rows.len() + 1; + let mut lines = self.rows; + // The hints are drawn into their line after the block, as the footer's are. + lines.extend([self.error, Line::default()]); + let height = u16::try_from(lines.len()).unwrap_or(u16::MAX).saturating_add(2); + let target = centered_rect(area, width.min(area.width), height.min(area.height)); + frame.render_widget(Clear, target); + let block = Block::bordered() + .title(self.title.to_owned()) + .border_style(Style::new().fg(self.border)) + .padding(Padding::horizontal(1)); + frame.render_widget(Paragraph::new(lines).block(block), target); + render_hints( + frame, + line_area(target, hint_row), + self.hints, + Color::Cyan, + Color::DarkGray, + hit_map, + |_| true, + ); + target + } +} + +/// A form row: the label, highlighted while the row has focus, then its value. +fn labeled(label: &str, focused: bool, value: Vec>) -> Line<'static> { + let style = if focused { + Style::new().fg(Color::Cyan) + } else { + Style::new().fg(Color::DarkGray) + }; + let mut spans = vec![Span::styled(format!("{label: Vec> { + let mut spans = vec![Span::raw(" ")]; + let cursor = Span::styled("▏", Style::new().fg(Color::Cyan)); + if !value.is_empty() { + spans.push(Span::raw(value.to_owned())); + spans.extend(focused.then_some(cursor)); + return spans; + } + let gray = Style::new().fg(Color::DarkGray); + let mut placeholder = placeholder.chars(); + match placeholder.next() { + Some(first) => spans.extend([ + Span::styled( + first.to_string(), + if focused { + gray.add_modifier(Modifier::REVERSED) + } else { + gray + }, + ), + Span::styled(placeholder.collect::(), gray), + ]), + None => spans.extend(focused.then_some(cursor)), + } + spans +} + +/// A value chosen with ←/→: arrows around it, its position, then a detail +/// shortened from the front to `detail_width`. +fn picker( + value: &str, + focused: bool, + selected: usize, + total: usize, + detail: &str, + detail_width: usize, +) -> Vec> { + let (arrow, value_style) = if focused { + (Style::new().fg(Color::Cyan), Style::new().fg(Color::Cyan)) + } else { + (Style::new().fg(Color::DarkGray), Style::new()) + }; + let position = format!("{}/{}", selected.saturating_add(1), total); + vec![ + Span::styled("◂ ", arrow), + Span::styled(fixed_width(value, PICKER_VALUE_WIDTH), value_style), + Span::styled(" ▸", arrow), + Span::styled(format!(" {position:>5} "), Style::new().fg(Color::DarkGray)), + Span::styled(tail_ellipsized(detail, detail_width), Style::new().fg(Color::DarkGray)), + ] +} + +fn map_picker_targets(line: Rect, previous: MouseAction, next: MouseAction, hit_map: &mut HitMap) { + let arrows = line + .x + .saturating_add(u16::try_from(FORM_LABEL_WIDTH).unwrap_or(u16::MAX)); + let value = u16::try_from(PICKER_VALUE_WIDTH).unwrap_or(u16::MAX); + hit_map.click(Rect::new(arrows, line.y, 2, 1), HitTarget::Action(previous)); + hit_map.click( + Rect::new(arrows.saturating_add(2).saturating_add(value), line.y, 2, 1), + HitTarget::Action(next), + ); +} + +fn note_line(note: &str) -> Line<'static> { + Line::from(Span::styled(note.to_owned(), Style::new().fg(Color::DarkGray))) +} + +fn fixed_width(value: &str, width: usize) -> String { + let mut characters = value.chars(); + let prefix = characters.by_ref().take(width).collect::(); + if characters.next().is_none() { + format!("{prefix:(); + truncated.push('…'); + truncated + } +} + +fn tail_ellipsized(value: &str, width: usize) -> String { + if Line::from(value).width() <= width { + return value.to_owned(); + } + if width == 0 { + return String::new(); + } + + let available = width.saturating_sub(1); + let mut start = value.len(); + for (index, _) in value.char_indices().rev() { + if Line::from(&value[index..]).width() > available { + break; + } + start = index; + } + format!("…{}", &value[start..]) +} + +fn abbreviate_home(path: &str) -> String { + abbreviate(path, std::env::var("HOME").ok().as_deref()) +} + +fn abbreviate(path: &str, home: Option<&str>) -> String { + home.filter(|home| !home.is_empty()) + .and_then(|home| { + let rest = path.strip_prefix(home)?; + (rest.is_empty() || rest.starts_with('/')).then(|| format!("~{rest}")) + }) + .unwrap_or_else(|| path.to_owned()) +} + +/// Line `line` of a form's content, inside its border and padding. +fn line_area(popup: Rect, line: usize) -> Rect { + let inner = popup.inner(Margin::new(2, 1)); + let y = inner.y.saturating_add(u16::try_from(line).unwrap_or(u16::MAX)); + if y >= inner.bottom() { + Rect::default() + } else { + Rect::new(inner.x, y, inner.width, 1) + } +} + +fn centered_rect(area: Rect, width: u16, height: u16) -> Rect { + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + (area.height.saturating_sub(height)) / 2; + Rect { + x, + y, + width, + height: height.min(area.height), + } +} + +const fn selection(state: &ViewState) -> Style { + if state.no_color { + Style::new().add_modifier(Modifier::REVERSED) + } else { + Style::new().bg(SELECTION) + } +} + +const fn tone_color(tone: Tone) -> Color { + match tone { + Tone::Green => Color::Green, + Tone::Yellow => Color::Yellow, + Tone::Cyan => Color::Cyan, + Tone::Gray => Color::DarkGray, + Tone::Red => Color::Red, + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use ratatui::{Terminal, backend::TestBackend}; + + use super::*; + + fn buffer_text(terminal: &Terminal) -> String { + let buffer = terminal.backend().buffer(); + let area = buffer.area(); + let mut text = String::new(); + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + text.push_str(buffer[(x, y)].symbol()); + } + text.push('\n'); + } + text + } + + fn draw(terminal: &mut Terminal, app: &App) -> HitMap { + let mut state = ViewState::default(); + draw_with_state(terminal, app, &mut state) + } + + fn draw_with_state(terminal: &mut Terminal, app: &App, state: &mut ViewState) -> HitMap { + let mut hit_map = None; + terminal + .draw(|frame| hit_map = Some(render(frame, app, state))) + .expect("test draw"); + hit_map.expect("renderer returns a hit map") + } + + fn tree_app(count: usize) -> App { + let agents = (0..count) + .map(|index| { + let yaml = format!( + "apiVersion: agents.platform/v1alpha1\n\ + kind: Agent\n\ + metadata:\n\ + \x20 name: agent-{index:02}\n\ + spec:\n\ + \x20 sandbox:\n\ + \x20 image:\n\ + \x20 type: build\n\ + \x20 context: .\n\ + \x20 dockerfile: Dockerfile\n\ + \x20 platform:\n\ + \x20 os: linux\n\ + \x20 resources:\n\ + \x20 cpu: \"1\"\n\ + \x20 memory: \"1Gi\"\n\ + \x20 rootFilesystem:\n\ + \x20 capacity: \"8Gi\"\n\ + \x20 mode: layered\n\ + \x20 home:\n\ + \x20 source: home\n\ + \x20 harnesses:\n\ + \x20 - type: claudeCode\n\ + \x20 version: \"1.0.0\"\n\ + \x20 auth: mediated\n\ + \x20 secrets: []\n\ + \x20 network:\n\ + \x20 mode: mediated\n\ + \x20 allow: all\n" + ); + agent::manifest::decode(yaml.as_bytes()).expect("test manifest") + }) + .collect(); + let mut app = App::new(); + app.apply_snapshot(agents, Vec::new()); + app + } + + fn create_modal_geometry(text: &str) -> (String, usize, usize) { + let top = text + .lines() + .position(|line| line.contains("create agent")) + .expect("create Agent modal top"); + let border = text.lines().nth(top).expect("create Agent modal border").to_owned(); + let bottom = text + .lines() + .enumerate() + .skip(top + 1) + .find_map(|(row, line)| line.contains('└').then_some(row)) + .expect("create Agent modal bottom"); + (border, top, bottom) + } + + fn text_column(line: &str, text: &str) -> usize { + line.split_once(text).expect("text in rendered row").0.chars().count() + } + + #[test] + fn frame_shows_header_counts_tree_and_hints() { + let app = App::new(); + let mut terminal = Terminal::new(TestBackend::new(80, 12)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!(text.contains("agentctl")); + assert!(text.contains("agentctl 0 need you")); + assert!(text.contains("loading…")); + assert!(text.contains("A show archived · F forwards · q quit")); + } + + #[test] + fn the_footer_wraps_every_selection_hint_at_eighty_columns() { + let mut app = triage_app(); + let mut terminal = Terminal::new(TestBackend::new(80, 12)).expect("test terminal"); + let footer = |terminal: &Terminal| { + let text = buffer_text(terminal); + let mut lines = text.lines().rev().take(3).map(str::trim_end).collect::>(); + lines.reverse(); + lines.into_iter().map(str::to_owned).collect::>() + }; + + app.selection = Some(TreeRowId::Session { + agent: "agent-00".into(), + session: agent::sessions::SessionName::new("main").expect("name"), + }); + let hit_map = draw(&mut terminal, &app); + assert_eq!( + footer(&terminal), + [ + "enter attach · p prompt · o open… · a archive · d delete · s describe · y yaml", + "n new session · c new agent", + "? help · tab needs you · / filter · A show archived · F forwards · q quit", + ] + ); + assert_eq!( + hit_map.click_at(2, 10), + Some(HitTarget::Action(MouseAction::Key( + crossterm::event::KeyCode::Char('n'), + crossterm::event::KeyModifiers::NONE, + ))), + "a wrapped hint is clicked where it is drawn" + ); + + app.selection = Some(TreeRowId::Agent("agent-00".into())); + app.show_archived = true; + draw(&mut terminal, &app); + assert_eq!( + footer(&terminal), + [ + "enter fold · n new session · o open… · e exec · f forward · d delete", + "p provisioning · s describe · y yaml · x stop · z all · c new agent", + "? help · tab needs you · / filter · A hide archived · F forwards · q quit", + ] + ); + + let mut wide = Terminal::new(TestBackend::new(140, 12)).expect("test terminal"); + draw(&mut wide, &app); + let text = buffer_text(&wide); + let lines = text.lines().rev().take(3).collect::>(); + assert!(lines[1].starts_with("enter fold"), "two footer lines fit:\n{text}"); + assert!(!lines[2].contains("enter"), "{text}"); + } + + #[test] + fn a_footer_too_narrow_for_its_hints_ends_in_an_ellipsis() { + let mut app = triage_app(); + app.selection = Some(TreeRowId::Agent("agent-00".into())); + let mut terminal = Terminal::new(TestBackend::new(40, 12)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + let lines = text.lines().rev().take(3).map(str::trim_end).collect::>(); + assert_eq!(lines[2], "enter fold · n new session · o open…"); + assert_eq!(lines[1], "e exec · f forward · d delete · …"); + assert!(lines.iter().all(|line| line.chars().count() <= 40)); + } + + #[test] + fn the_header_tells_what_an_archive_did_before_its_counts() { + let mut app = triage_app(); + app.notice = Some(("main archived · A to show".into(), std::time::Instant::now())); + let mut terminal = Terminal::new(TestBackend::new(50, 10)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + let header = text.lines().next().unwrap_or_default(); + assert_eq!( + header.trim_end(), + " agentctl 1 need you · main archived · A to show", + "a narrow header cuts the counts, not the notice" + ); + } + + #[test] + fn the_footer_keeps_its_height_below_the_tree_and_fits_other_views() { + let mut app = triage_app(); + let mut heights = Vec::new(); + for selection in [ + TreeRowId::Agent("agent-00".into()), + TreeRowId::Session { + agent: "agent-00".into(), + session: agent::sessions::SessionName::new("main").expect("name"), + }, + ] { + app.selection = Some(selection); + heights.push(footer_height(&app, 80)); + } + app.modal = Some(Modal::Filter); + heights.push(footer_height(&app, 80)); + assert_eq!(heights, [3, 3, 3], "the tree keeps its rows"); + assert_eq!(footer_height(&app, 140), 2, "a wide terminal needs one line per group"); + + app.modal = None; + app.view = View::Forwards; + assert_eq!(footer_height(&app, 80), 2, "the forwards view sizes for its own keys"); + app.view = View::Tree; + app.detail = Some(super::super::app::Detail::text("describe".into(), Vec::new())); + assert_eq!(footer_height(&app, 80), 2, "and so does a detail"); + } + + fn session(agent: &str, name: &str, state: &str) -> agent::sessions::Session { + let lifecycle = match state { + "working" | "waitingForInput" => "running", + other => other, + }; + serde_json::from_value(serde_json::json!({ + "id": "00000000-0000-0000-0000-000000000001", + "agentId": "00000000-0000-0000-0000-000000000002", + "agent": agent, + "name": name, + "harness": "claudeCode", + "modelSelection": {"model": "fable"}, + "createdAt": "2026-08-25T00:00:00Z", + "status": {"state": state, "lifecycle": {"state": lifecycle}} + })) + .expect("test session") + } + + fn triage_app() -> App { + let mut app = tree_app(2); + let agents = std::mem::take(&mut app.agents); + app.apply_snapshot( + agents, + vec![ + session("agent-00", "review", "waitingForInput"), + session("agent-00", "main", "working"), + ], + ); + app + } + + #[test] + fn the_tree_aligns_state_columns_and_marks_sessions_that_need_input() { + let app = triage_app(); + let mut terminal = Terminal::new(TestBackend::new(100, 10)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + let lines = text.lines().collect::>(); + assert!(lines[0].contains("1 need you · 1 working")); + assert!(lines[1].contains("NAME") && lines[1].contains("DETAIL") && lines[1].contains("AGE")); + assert!(lines[2].starts_with("▐ ▾ agent-00"), "{}", lines[2]); + assert!(lines[3].starts_with(" * main"), "{}", lines[3]); + assert!(lines[4].starts_with("▐ ! review"), "{}", lines[4]); + let state = text_column(lines[1], "STATE"); + assert_eq!(text_column(lines[3], "Working"), state); + assert_eq!(text_column(lines[4], "Needs you"), state); + assert_eq!( + text_column(lines[4], "Claude Code · fable"), + text_column(lines[1], "DETAIL") + ); + } + + fn failing_app() -> App { + let mut app = triage_app(); + let mut agents = app.agents.clone(); + agents[1].status.conditions.push(agent::Condition { + kind: agent::Condition::READY.into(), + status: agent::ConditionStatus::False, + reason: "SandboxReconcileFailed".into(), + message: "Sandbox operation failed: resolve Sandbox Image: cache error at /home/user/.agent/cache/tmp/load-4.blob: No space left on device (os error 28)".into(), + last_transition_time: None, + }); + agents[1].status.failure = Some(agent::FailureKind::Transient); + let sessions = app.sessions.clone(); + app.apply_snapshot(agents, sessions); + app + } + + #[test] + fn a_failure_keeps_its_cause_in_view_and_the_side_panel_keeps_the_tree_still() { + let mut app = failing_app(); + app.side_panel = true; + app.select_index(3); + let mut terminal = Terminal::new(TestBackend::new(140, 12)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + let lines = text.lines().collect::>(); + let failed = lines + .iter() + .find(|line| line.contains("agent-01") && line.contains("Retrying")) + .expect("failed Agent row"); + assert!(failed.contains("Retrying"), "{failed}"); + assert!( + failed.contains("…") && failed.contains("(os error 28)"), + "the cause at the end stays: {failed}" + ); + assert!( + text.contains("agent-01 · status"), + "the panel shows the selected Agent:\n{text}" + ); + assert!(text.contains("Failure: Transient"), "{text}"); + let panel = lines + .iter() + .map(|line| line.chars().skip(text_column(lines[1], "AGE") + 5).collect::()) + .collect::(); + assert!( + panel.contains("(os error 28)"), + "the panel wraps the whole message:\n{text}" + ); + + let state = text_column(lines[1], "STATE"); + app.select_index(2); + app.transcript_request().expect("the selected Session's turns"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert_eq!( + text_column(text.lines().nth(1).expect("column header"), "STATE"), + state, + "selecting a Session leaves the columns where they were" + ); + assert!(text.contains("review · recent turns"), "{text}"); + } + + #[test] + fn every_view_draws_at_common_widths() { + fn press(app: &mut App, row: usize, key: char) { + app.select_index(row); + app.on_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char(key), + crossterm::event::KeyModifiers::NONE, + )); + } + type Open = fn(&mut App); + let views: [(&str, Open); 8] = [ + ("tree", |_| {}), + ("filter", |app| app.modal = Some(Modal::Filter)), + ("new session", |app| press(app, 0, 'n')), + ("forward", |app| press(app, 0, 'f')), + ("delete", |app| press(app, 0, 'd')), + ("describe", |app| press(app, 3, 's')), + ("prompt", |app| press(app, 2, 'p')), + ("help", |app| press(app, 0, '?')), + ]; + for width in [60, 80, 110, 160] { + for (name, open) in views { + let mut app = failing_app(); + app.side_panel = shows_side_panel(width); + open(&mut app); + let mut terminal = Terminal::new(TestBackend::new(width, 16)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + let lines = text.lines().collect::>(); + assert!( + lines[0].contains("agentctl"), + "{name} at {width} keeps the header:\n{text}" + ); + if name == "describe" { + assert!(!text.contains("q quit"), "a detail offers only its own keys:\n{text}"); + } + assert!( + lines.iter().rev().take(2).any(|line| !line.trim().is_empty()) || text.contains('┌'), + "{name} at {width} shows its hints:\n{text}" + ); + } + } + } + + #[test] + fn help_lists_every_key_by_where_it_applies_and_the_footer_offers_it() { + let mut app = triage_app(); + let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!( + text.lines().last().is_some_and(|line| line.starts_with("? help")), + "the footer offers help first:\n{text}" + ); + + app.modal = Some(Modal::Help); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + for expected in [ + "keys", + "Fleet", + "Selected Agent", + "Selected Session", + "follow provisioning", + "esc close", + ] { + assert!(text.contains(expected), "{expected:?} in:\n{text}"); + } + } + + #[test] + fn a_detail_scrolls_to_the_end_of_its_wrapped_lines() { + let mut app = triage_app(); + let lines = (1..=5) + .map(|line| format!("{}END{line}", "x".repeat(117))) + .collect::>(); + app.detail = Some(super::super::app::Detail::text("long".into(), lines)); + let mut terminal = Terminal::new(TestBackend::new(40, 12)).expect("test terminal"); + draw(&mut terminal, &app); + let press = |app: &mut App, code| { + app.on_key(crossterm::event::KeyEvent::new( + code, + crossterm::event::KeyModifiers::NONE, + )); + }; + for _ in 0..30 { + press(&mut app, crossterm::event::KeyCode::Char('j')); + } + draw(&mut terminal, &app); + assert!( + buffer_text(&terminal).contains("END5"), + "the last wrapped row comes into view:\n{}", + buffer_text(&terminal) + ); + let bottom = app.detail.as_ref().map(|detail| detail.scroll); + press(&mut app, crossterm::event::KeyCode::Char('k')); + assert_eq!( + app.detail.as_ref().map(|detail| detail.scroll), + bottom.map(|scroll| scroll - 1), + "one step up moves at once" + ); + } + + #[test] + fn a_narrow_tree_keeps_name_state_and_time_in_state() { + let app = triage_app(); + let mut terminal = Terminal::new(TestBackend::new(50, 10)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!(text.contains("Needs you")); + assert!(!text.contains("DETAIL")); + assert!(!text.contains("Claude Code")); + } + + #[test] + fn no_color_draws_without_color_but_keeps_glyphs_and_the_selection() { + let mut app = triage_app(); + app.select_index(1); + let mut state = ViewState { + no_color: true, + ..ViewState::default() + }; + let mut terminal = Terminal::new(TestBackend::new(100, 10)).expect("test terminal"); + draw_with_state(&mut terminal, &app, &mut state); + let buffer = terminal.backend().buffer(); + assert!( + buffer + .content() + .iter() + .all(|cell| cell.fg == Color::Reset && cell.bg == Color::Reset) + ); + assert!( + buffer[(6, 3)].modifier.contains(Modifier::REVERSED), + "the selected row stays marked" + ); + assert!(buffer_text(&terminal).contains("! review")); + } + + #[test] + fn a_selected_session_shows_its_latest_turns_beside_the_tree() { + let mut app = triage_app(); + app.side_panel = true; + app.select_index(2); + let (agent, session) = app.transcript_request().expect("turns are requested"); + let turns = serde_json::from_value(serde_json::json!([ + {"messages": [{"role": "user", "parts": [{"kind": "text", "text": "first question"}]}]}, + {"messages": [ + {"role": "user", "parts": [{"kind": "text", "text": "update the snapshot?"}]}, + {"role": "assistant", "parts": [ + {"kind": "toolCall", "name": "Bash"}, + {"kind": "text", "text": "The snapshot changed as expected. Shall I accept it and rerun the suite?"} + ]} + ]} + ])) + .expect("test turns"); + app.transcript_loaded(&agent, &session, Ok(turns)); + let mut terminal = Terminal::new(TestBackend::new(120, 9)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!(text.contains("review · recent turns")); + assert!(text.contains("[assistant] -> Bash")); + assert!( + text.contains("rerun the suite?"), + "the newest line wraps into view:\n{text}" + ); + assert!(!text.contains("first question"), "older lines give way to newer ones"); + + terminal.backend_mut().resize(100, 9); + draw(&mut terminal, &app); + assert!( + !buffer_text(&terminal).contains("recent turns"), + "a narrow terminal keeps the tree only" + ); + } + + #[test] + fn a_prompt_is_typed_in_the_footer_below_the_turns() { + let mut app = triage_app(); + app.select_index(2); + for code in [ + crossterm::event::KeyCode::Char('p'), + crossterm::event::KeyCode::Char('y'), + crossterm::event::KeyCode::Char('e'), + crossterm::event::KeyCode::Char('s'), + ] { + app.on_key(crossterm::event::KeyEvent::new( + code, + crossterm::event::KeyModifiers::NONE, + )); + } + let mut terminal = Terminal::new(TestBackend::new(120, 9)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + let footer = text.lines().rev().take(2).collect::>(); + assert!(footer[0].starts_with("review › yes▏"), "{footer:?}"); + assert!(footer[1].starts_with("enter send · esc cancel"), "{footer:?}"); + } + + #[test] + fn a_lost_connection_keeps_the_last_reported_tree_visible() { + let mut app = tree_app(2); + app.connection_error = Some("connection refused".into()); + let mut terminal = Terminal::new(TestBackend::new(80, 8)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!(text.contains("reconnecting: connection refused")); + assert!(text.contains("agent-01")); + } + + #[test] + fn tree_hit_map_uses_the_rendered_offset_and_updates_after_resize() { + let mut app = tree_app(10); + app.select_index(9); + let mut state = ViewState::default(); + // Three footer lines, as the selection's hints wrap at this width. + let mut terminal = Terminal::new(TestBackend::new(40, 9)).expect("test terminal"); + let agent = |name: &str| Some(HitTarget::Row(RowTarget::Tree(TreeRowId::Agent(name.into())))); + + let compact = draw_with_state(&mut terminal, &app, &mut state); + assert_eq!(state.tree_offset, 6); + assert_eq!(compact.click_at(10, 1), None, "the column header is not a row"); + assert_eq!(compact.click_at(10, 2), agent("agent-06")); + assert_eq!(compact.click_at(10, 5), agent("agent-09")); + assert_eq!(compact.click_at(10, 6), None, "footer is not a list row"); + assert_eq!(compact.click_at(40, 2), None, "right edge is out of bounds"); + + terminal.backend_mut().resize(40, 13); + let resized = draw_with_state(&mut terminal, &app, &mut state); + assert_eq!(state.tree_offset, 2, "a taller terminal shows the rows above"); + assert_eq!(resized.click_at(10, 2), agent("agent-02")); + assert_eq!(resized.click_at(10, 9), agent("agent-09")); + assert_eq!( + resized.click_at(10, 10), + None, + "the resized map has no stale row target" + ); + } + + #[test] + fn scrolling_into_an_agents_sessions_pins_the_agent_above_them() { + let mut app = tree_app(1); + let agents = std::mem::take(&mut app.agents); + app.apply_snapshot( + agents, + (0..8) + .map(|index| session("agent-00", &format!("s{index}"), "idle")) + .collect(), + ); + app.select_index(8); + let mut state = ViewState::default(); + let mut terminal = Terminal::new(TestBackend::new(80, 9)).expect("test terminal"); + + let hit_map = draw_with_state(&mut terminal, &app, &mut state); + let text = buffer_text(&terminal); + let lines = text.lines().collect::>(); + assert!(lines[2].contains("▾ agent-00"), "{}", lines[2]); + assert!(lines[3].contains("s5") && lines[5].contains("s7"), "{text}"); + assert_eq!( + hit_map.click_at(10, 2), + Some(HitTarget::Row(RowTarget::Tree(TreeRowId::Agent("agent-00".into())))) + ); + assert_eq!( + terminal.backend().buffer()[(10, 5)].bg, + SELECTION, + "the selected Session stays in view below the pinned Agent" + ); + } + + #[test] + fn the_filter_is_typed_in_the_footer_and_named_in_the_header() { + let mut app = triage_app(); + app.on_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('/'), + crossterm::event::KeyModifiers::NONE, + )); + for character in "rev".chars() { + app.on_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char(character), + crossterm::event::KeyModifiers::NONE, + )); + } + let mut terminal = Terminal::new(TestBackend::new(100, 10)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!(text.contains("filter: rev")); + assert!(text.contains("review") && !text.contains("main")); + assert!(text.lines().last().is_some_and(|line| line.starts_with("/rev▏"))); + + app.filter = "nothing".into(); + app.rebuild(); + draw(&mut terminal, &app); + assert!(buffer_text(&terminal).contains("(nothing matches the filter)")); + } + + #[test] + fn forward_hit_map_excludes_its_border_and_tracks_scrolling() { + let mut app = App::new(); + app.view = View::Forwards; + app.forwards = (0..10) + .map(|id| super::super::app::ForwardEntry { + id, + agent: format!("agent-{id}"), + local: format!("127.0.0.1:{}", 8000 + id), + guest_port: 80, + status: None, + finished: false, + }) + .collect(); + app.forward_selected = 7; + let mut state = ViewState::default(); + let mut terminal = Terminal::new(TestBackend::new(60, 8)).expect("test terminal"); + + let hit_map = draw_with_state(&mut terminal, &app, &mut state); + + assert_eq!(state.forwards.offset(), 5); + assert_eq!(hit_map.click_at(1, 2), Some(HitTarget::Row(RowTarget::Forward(5)))); + assert_eq!(hit_map.wheel_at(1, 2), Some(WheelTarget::Forwards)); + assert_eq!(hit_map.click_at(0, 2), None, "left border is inert"); + assert_eq!(hit_map.click_at(1, 1), None, "top border is inert"); + } + + #[test] + fn error_body_hints_are_clickable_where_they_are_rendered() { + let mut app = App::new(); + app.error = Some("request failed because".into()); + let mut terminal = Terminal::new(TestBackend::new(20, 8)).expect("test terminal"); + + let hit_map = draw(&mut terminal, &app); + let dismiss = HitTarget::Action(MouseAction::Key( + crossterm::event::KeyCode::Esc, + crossterm::event::KeyModifiers::NONE, + )); + let quit = HitTarget::Action(MouseAction::Key( + crossterm::event::KeyCode::Char('q'), + crossterm::event::KeyModifiers::NONE, + )); + + assert_eq!(hit_map.click_at(0, 4), Some(dismiss)); + assert_eq!(hit_map.click_at(14, 4), Some(quit)); + assert_eq!(hit_map.click_at(12, 4), None, "separator is inert"); + } + + #[test] + fn modal_hit_map_blocks_the_underlying_list_and_exposes_confirmation() { + let mut app = tree_app(3); + app.modal = Some(Modal::ConfirmDelete { + agent: "agent-00".into(), + sessions: 0, + }); + let mut terminal = Terminal::new(TestBackend::new(80, 12)).expect("test terminal"); + + let hit_map = draw(&mut terminal, &app); + + assert_eq!(hit_map.click_at(0, 1), None, "modal prevents click-through"); + let confirmation = HitTarget::Action(MouseAction::Key( + crossterm::event::KeyCode::Char('y'), + crossterm::event::KeyModifiers::NONE, + )); + let area = hit_map + .clicks + .iter() + .find_map(|(area, target)| (target == &confirmation).then_some(*area)) + .expect("confirmation control"); + assert_eq!(hit_map.click_at(area.x, area.y), Some(confirmation)); + } + + #[test] + fn the_stop_confirmation_says_what_a_stop_keeps_in_full() { + let mut app = tree_app(1); + app.modal = Some(Modal::ConfirmStop { + agent: "agent-00".into(), + }); + let mut terminal = Terminal::new(TestBackend::new(80, 16)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!(text.contains("Stop agent agent-00?"), "{text}"); + assert!(text.contains("Running harnesses stop with its VM."), "{text}"); + assert!( + text.contains("Its disk is kept; attaching after a start resumes a Session."), + "{text}" + ); + } + + #[test] + fn modal_hit_maps_expose_form_fields_and_choices() { + let mut app = tree_app(1); + app.on_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('n'), + crossterm::event::KeyModifiers::NONE, + )); + let mut terminal = Terminal::new(TestBackend::new(100, 16)).expect("test terminal"); + + let session = draw(&mut terminal, &app); + assert!( + session.clicks.iter().any(|(_, target)| { + target == &HitTarget::Action(MouseAction::FocusSessionField(SessionField::Model)) + }) + ); + assert!( + session + .clicks + .iter() + .any(|(_, target)| { target == &HitTarget::Action(MouseAction::SelectHarness(0)) }) + ); + + app.modal = Some(Modal::PortForward(super::super::app::ForwardForm { + agent: "agent-00".into(), + address: "127.0.0.1".into(), + local: String::new(), + guest: "8080".into(), + field: ForwardField::GuestPort, + error: None, + replace: None, + })); + let forward = draw(&mut terminal, &app); + assert!(forward.clicks.iter().any(|(_, target)| { + target == &HitTarget::Action(MouseAction::FocusForwardField(ForwardField::Address)) + })); + assert!(forward.clicks.iter().any(|(_, target)| { + target + == &HitTarget::Action(MouseAction::Key( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::NONE, + )) + })); + assert!(forward.clicks.iter().any(|(_, target)| { + target + == &HitTarget::Action(MouseAction::Key( + crossterm::event::KeyCode::Tab, + crossterm::event::KeyModifiers::NONE, + )) + })); + let text = buffer_text(&terminal); + let (row, line) = text + .lines() + .enumerate() + .find(|(_, line)| line.contains("esc cancel")) + .expect("form hints"); + let column = u16::try_from(text_column(line, "esc cancel")).expect("column"); + assert_eq!( + forward.click_at(column, u16::try_from(row).expect("row")), + Some(HitTarget::Action(MouseAction::Key( + crossterm::event::KeyCode::Esc, + crossterm::event::KeyModifiers::NONE, + ))), + "a form hint is clicked where it is drawn:\n{text}" + ); + } + + fn modal_border(terminal: &Terminal) -> Vec<(usize, usize)> { + buffer_text(terminal) + .lines() + .enumerate() + .filter_map(|(row, line)| line.find('┌').or_else(|| line.find('└')).map(|column| (row, column))) + .collect() + } + + #[test] + fn forms_keep_their_size_when_an_error_appears_and_leave_the_footer_empty() { + let mut app = tree_app(1); + app.on_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('n'), + crossterm::event::KeyModifiers::NONE, + )); + let mut terminal = Terminal::new(TestBackend::new(100, 16)).expect("test terminal"); + draw(&mut terminal, &app); + let valid = modal_border(&terminal); + let footer = buffer_text(&terminal).lines().rev().take(2).collect::(); + assert!(footer.trim().is_empty(), "form hints are not repeated in the footer"); + + if let Some(Modal::NewSession(form)) = &mut app.modal { + form.name = "a-much-longer-session-name-than-before".into(); + form.error = Some("session name is invalid".into()); + } + draw(&mut terminal, &app); + assert!(buffer_text(&terminal).contains("session name is invalid")); + assert_eq!(modal_border(&terminal), valid); + } + + #[test] + fn create_agent_modal_shows_the_picker_and_placeholder_name() { + use super::super::app::{CreateField, CreateForm, ManifestCandidate}; + + let mut app = App::new(); + app.modal = Some(Modal::CreateAgent(CreateForm::new( + vec![ + ManifestCandidate::new(std::path::PathBuf::from("/sources/full/agent.yaml"), Ok("full".into())), + ManifestCandidate::new( + std::path::PathBuf::from("/sources/full/agent.nested.yaml"), + Ok("full-nested".into()), + ), + ManifestCandidate::new( + std::path::PathBuf::from("/sources/broken/agent.yaml"), + Err("manifest cannot be decoded".into()), + ), + ], + None, + ))); + let mut terminal = Terminal::new(TestBackend::new(100, 16)).expect("test terminal"); + let hit_map = draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!(text.contains("create agent")); + assert!(text.contains("Agent ◂ full")); + assert!(text.contains("Variant ◂ default")); + assert!(text.contains("Name full")); + assert!(text.contains("Env file default: .env beside manifest")); + assert!(text.contains("enter create · tab/↑/↓ field · ←/→ select · esc cancel")); + let initial_geometry = create_modal_geometry(&text); + let agent_line = text.lines().find(|line| line.contains("│ Agent")).expect("Agent row"); + let variant_line = text + .lines() + .find(|line| line.contains("│ Variant")) + .expect("Variant row"); + let name_line = text.lines().find(|line| line.contains("│ Name")).expect("Name row"); + let env_line = text + .lines() + .find(|line| line.contains("│ Env file")) + .expect("environment row"); + assert_eq!(text_column(agent_line, "◂"), text_column(variant_line, "◂")); + assert_eq!( + text_column(agent_line, "/sources/full"), + text_column(variant_line, "agent.yaml") + ); + let value_column = text_column(agent_line, "full"); + assert_eq!(value_column, text_column(variant_line, "default")); + assert_eq!(value_column, text_column(name_line, "full")); + assert_eq!(value_column, text_column(env_line, "default")); + assert!( + hit_map + .clicks + .iter() + .any(|(_, target)| { target == &HitTarget::Action(MouseAction::FocusCreateField(CreateField::Name)) }) + ); + assert!(hit_map.clicks.iter().any(|(_, target)| { + target + == &HitTarget::Action(MouseAction::SelectCreate { + field: CreateField::Agent, + delta: 1, + }) + })); + assert!(hit_map.clicks.iter().any(|(_, target)| { + target + == &HitTarget::Action(MouseAction::Key( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::NONE, + )) + })); + + let Some(Modal::CreateAgent(form)) = &mut app.modal else { + panic!("expected the CreateAgent modal"); + }; + form.agent = 1; + form.variant = 0; + form.field = CreateField::Name; + form.name = "copy".into(); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!(text.contains("Agent ◂ broken")); + assert!(text.contains("Variant ◂ default")); + assert!(text.contains("manifest cannot be decoded")); + assert!(text.contains("Name copy▏")); + assert!(text.contains("Env file default: .env beside manifest")); + assert_eq!(create_modal_geometry(&text), initial_geometry); + } + + #[test] + fn create_agent_modal_preserves_the_end_of_long_source_paths() { + use super::super::app::{CreateForm, ManifestCandidate}; + + let prefix = "/a/source/directory/whose/leading/components/do/not/fit/inside/the/create/agent/modal"; + let mut app = App::new(); + app.modal = Some(Modal::CreateAgent(CreateForm::new( + vec![ManifestCandidate::new( + std::path::PathBuf::from(prefix).join("agents/full/agent.yaml"), + Ok("full".into()), + )], + None, + ))); + let mut terminal = Terminal::new(TestBackend::new(100, 16)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + let agent_line = text.lines().find(|line| line.contains("│ Agent")).expect("Agent row"); + let agent_line = agent_line.replace('\\', "/"); + assert!(agent_line.contains('…')); + assert!(agent_line.contains("/fit/inside/the/create/agent/modal/agents/full")); + assert!(!agent_line.contains("/a/source/directory")); + assert!( + agent_line.trim_end().ends_with('│'), + "path remains inside the modal: {agent_line}" + ); + } + + #[test] + fn a_text_input_puts_the_cursor_over_its_placeholder_or_after_its_value() { + let spans = text_input("", true, "full"); + assert_eq!(spans[1].content, "f"); + assert!(spans[1].style.add_modifier.contains(Modifier::REVERSED)); + assert_eq!(spans[2].content, "ull"); + + let spans = text_input("my", true, "full"); + assert_eq!(spans[1].content, "my"); + assert_eq!(spans[2].content, "▏"); + assert_eq!(text_input("", false, "").len(), 1, "only the indent"); + } + + #[test] + fn header_reports_a_running_manifest_scan() { + let mut app = App::new(); + app.discovering = true; + let mut terminal = Terminal::new(TestBackend::new(80, 12)).expect("test terminal"); + draw(&mut terminal, &app); + assert!(buffer_text(&terminal).contains("scanning manifests…")); + } + + #[test] + fn create_agent_modal_explains_an_empty_picker() { + use super::super::app::CreateForm; + + let mut app = App::new(); + app.modal = Some(Modal::CreateAgent(CreateForm::new(Vec::new(), None))); + let mut terminal = Terminal::new(TestBackend::new(80, 14)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!(text.contains("No agent manifests found.")); + assert!(text.contains("agentctl apply")); + } + + #[test] + fn home_abbreviation_replaces_only_the_whole_home_component() { + assert_eq!(abbreviate("/Users/dev/code", Some("/Users/dev")), "~/code"); + assert_eq!(abbreviate("/Users/dev", Some("/Users/dev")), "~"); + assert_eq!( + abbreviate("/Users/devops/code", Some("/Users/dev")), + "/Users/devops/code" + ); + assert_eq!(abbreviate("/srv/code", None), "/srv/code"); + assert_eq!(abbreviate("/srv/code", Some("")), "/srv/code"); + } + + fn ssh_menu_app(setup: super::super::open::SshSetup) -> App { + let mut app = tree_app(1); + let mut agents = std::mem::take(&mut app.agents); + agents[0].spec.access = vec![agent::AccessSpec::Ssh {}]; + app.apply_snapshot(agents, Vec::new()); + app.ssh_setup = setup; + app.ssh_include = Some(agent::ssh::UserInclude { + user_config: "/tmp/user/.ssh/config".into(), + line: "Include ~/.agent/ssh/config".into(), + }); + app.selection = Some(TreeRowId::Agent("agent-00".into())); + app.on_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('o'), + crossterm::event::KeyModifiers::NONE, + )); + app + } + + #[test] + fn the_open_menu_lists_keys_reasons_and_the_setup_note() { + let mut app = ssh_menu_app(super::super::open::SshSetup::Missing); + let mut terminal = Terminal::new(TestBackend::new(80, 30)).expect("test terminal"); + let hit_map = draw(&mut terminal, &app); + let text = buffer_text(&terminal); + + assert!(text.contains(" open agent-00 "), "{text}"); + let shell = text + .lines() + .find(|line| line.contains("Shell in the Sandbox")) + .expect("shell row"); + assert!( + shell.contains("▸") && shell.trim_end().trim_end_matches('│').trim_end().ends_with('e'), + "{shell}" + ); + let zed = crate::launch::Editor::Zed + .missing_launcher() + .expect("Zed needs a launcher"); + assert!(text.contains(&format!("Zed: {zed}")), "{text}"); + assert!(text.contains("Set up SSH"), "{text}"); + assert!(text.contains("SSH needs a line in ~/.ssh/config;"), "{text}"); + assert!( + text.lines().skip(1).all(|line| !line.contains("agentctl ")), + "below the title, the menu offers keys, not commands:\n{text}" + ); + + let (row, _) = text + .lines() + .enumerate() + .find(|(_, line)| line.contains("SSH shell")) + .expect("SSH shell row"); + let column = u16::try_from(text.lines().nth(row).expect("row").find("SSH").expect("label")).expect("column"); + let target = hit_map.click_at(column, u16::try_from(row).expect("row")); + assert!( + matches!(target, Some(HitTarget::Action(MouseAction::ChooseOpen(_)))), + "{target:?}" + ); + let Some(HitTarget::Action(action)) = target else { + unreachable!() + }; + assert_eq!( + app.on_mouse(action), + super::super::app::Action::Open { + agent: "agent-00".into(), + target: OpenTarget::SshShell, + }, + "the click opens the row it hit" + ); + } + + #[test] + fn the_open_menu_and_quit_question_fit_an_80x24_terminal() { + let mut app = ssh_menu_app(super::super::open::SshSetup::Missing); + let mut agents = std::mem::take(&mut app.agents); + agents[0].spec.access = vec![agent::AccessSpec::Ssh {}, agent::AccessSpec::Vnc {}]; + agents[0].status.conditions.push(agent::Condition { + kind: agent::Condition::VNC_READY.into(), + status: agent::ConditionStatus::False, + reason: "ReconcileFailed".into(), + message: agent::vnc::image_contract_missing("/etc/agent-access.d/vnc.conf is missing"), + last_transition_time: None, + }); + app.apply_snapshot(agents, Vec::new()); + app.modal = None; + app.on_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('o'), + crossterm::event::KeyModifiers::NONE, + )); + let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!(text.contains("Desktop in the browser"), "{text}"); + assert!( + text.contains("re-apply the Agent…"), + "a long reason is cut short:\n{text}" + ); + assert!(text.contains("you are asked before it is added."), "{text}"); + assert!(text.contains("esc cancel"), "{text}"); + + app.modal = Some(Modal::ConfirmQuit); + app.forwards = (0..20) + .map(|id| super::super::app::ForwardEntry { + id, + agent: format!("an-agent-with-a-name-longer-than-the-dialog-{id:02}"), + local: format!("127.0.0.1:{}", 50000 + id), + guest_port: 6080, + status: None, + finished: false, + }) + .collect(); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + assert!(text.contains("desktop 50000:6080 an-agent-with"), "{text}"); + assert!(text.contains("…and 14 more"), "{text}"); + assert!(text.contains("y confirm"), "{text}"); + let listed = text + .lines() + .filter(|line| line.contains("an-agent-with")) + .collect::>(); + assert!( + listed.iter().all(|line| line.contains('…')), + "long names are shortened, not cut:\n{text}" + ); + } + + #[test] + fn ssh_setup_shows_the_exact_line_and_file_before_writing() { + let mut app = ssh_menu_app(super::super::open::SshSetup::Missing); + app.on_key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('c'), + crossterm::event::KeyModifiers::NONE, + )); + let mut terminal = Terminal::new(TestBackend::new(80, 20)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + + assert!(text.contains(" set up SSH "), "{text}"); + assert!( + text.contains("Add this line at the top of /tmp/user/.ssh/config?"), + "{text}" + ); + assert!(text.contains(" Include ~/.agent/ssh/config"), "{text}"); + assert!(text.contains("Then: VS Code, Remote-SSH."), "{text}"); + assert!(text.contains("enter add · o open anyway · esc back"), "{text}"); + } + + #[test] + fn no_help_row_runs_into_the_overlay_border() { + let mut app = triage_app(); + app.modal = Some(Modal::Help); + let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("test terminal"); + draw(&mut terminal, &app); + let text = buffer_text(&terminal); + for line in text.lines().filter(|line| line.contains("│ ")) { + let inside: Vec = line.chars().collect(); + let border = inside + .iter() + .rposition(|character| *character == '│') + .expect("right border"); + // The form pads one cell inside its border; the cell before that is the last one text uses. + assert_eq!(inside[border - 2], ' ', "cut off at the border:\n{line}"); + } + } +} diff --git a/agentctl/src/bin/agentd.rs b/agentctl/src/bin/agentd.rs new file mode 100644 index 0000000..7bfce03 --- /dev/null +++ b/agentctl/src/bin/agentd.rs @@ -0,0 +1,233 @@ +use std::{io::IsTerminal as _, path::PathBuf, process::ExitCode, rc::Rc, time::Duration}; + +use agent::{ + Error, + control_api::Server, + control_plane::{ControlPlane, Controller, Reconciler}, + local::home::ControlPlaneHome, + persistence, + sandbox::ExecutionService, + sessions::Service as SessionService, +}; +use clap::Parser; +use tokio::runtime::LocalRuntime; + +/// Image materialization runs in this process and allocates heavily for a +/// short time. mimalloc returns unused pages to the operating system after a +/// short delay, so agentd's memory use falls again once an image is ready. +#[global_allocator] +static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; + +#[derive(Parser)] +#[command(name = "agentd", about = "Run the per-user Agent control plane", version = agent::build_version())] +struct Arguments { + /// Agent control-plane home. + #[arg(long)] + home: Option, +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("agentd: {error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), Error> { + let arguments = Arguments::parse(); + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(agent::local::process::daemon_log_filter())), + ) + .with_writer(std::io::stderr) + .with_ansi(std::io::stderr().is_terminal()) + .init(); + let home = ControlPlaneHome::resolve(arguments.home.as_deref())?; + let _lock = acquire_home_lock(&home)?; + let database = persistence::Database::open(&home.path().join("agent.db"))?; + let runtime = LocalRuntime::new()?; + runtime.block_on(run_control_plane(home, database)) +} + +fn acquire_home_lock(home: &ControlPlaneHome) -> Result { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + loop { + match home.acquire_lock() { + Ok(lock) => return Ok(lock), + Err(Error::Io(error)) + if error.kind() == std::io::ErrorKind::WouldBlock && std::time::Instant::now() < deadline => + { + std::thread::sleep(Duration::from_millis(50)); + } + Err(error) => return Err(error), + } + } +} + +type ErrorHandler = Rc, &Error)>; + +/// Wires SSH and VNC access around the `agentctl` installed beside this +/// daemon, which generated SSH client configuration and reported forwarding +/// commands dial Agents through. SSH keeps its host keys in the database; VNC +/// holds no key material and generates no client configuration. +fn access( + home: &ControlPlaneHome, + database: &persistence::Database, + store: Rc, +) -> Result<(Rc, Rc), Error> { + let agentd = std::env::current_exe()?; + let sibling = agentd.with_file_name(format!("agentctl{}", std::env::consts::EXE_SUFFIX)); + let agentctl = agent::ssh::stable_agentctl_path(&sibling, std::env::var_os("PATH").as_deref()); + let host_keys: Rc = Rc::new(database.clone()); + let ssh = agent::ssh::Access::new(home, agentctl.clone(), host_keys, store.clone()); + Ok((Rc::new(ssh), Rc::new(agent::vnc::Access::new(agentctl, store)))) +} + +/// Logs recoverable reconciliation errors for one durable resource kind. +fn reconciliation_errors(resource: &'static str) -> ErrorHandler { + Rc::new(move |id, error| { + if let Some(id) = id { + tracing::warn!(resource, %id, %error, "reconciliation failed"); + } else { + tracing::error!(resource, %error, "reconciliation scan failed"); + } + }) +} + +async fn open_sandboxes( + home: &ControlPlaneHome, + database: &persistence::Database, + credentials: Rc, + policy: Rc, + platform_api_port: u16, +) -> Result<(Rc, String), Error> { + let microsandbox = Rc::new( + agent::sandbox::microsandbox::Adapter::open( + home.path(), + database.clone(), + credentials, + policy, + platform_api_port, + ) + .await?, + ); + let session_hook_url = microsandbox.platform_url("/v1/session/hooks")?; + let provider: Rc = microsandbox; + let platform: Rc = Rc::new(agent::sandbox::platform::Linux); + Ok(( + Rc::new(agent::sandbox::Service::new([provider], [platform])?), + session_hook_url, + )) +} + +#[allow( + clippy::too_many_lines, + reason = "wires every daemon subsystem explicitly, as the Control API server's dependencies are" +)] +async fn run_control_plane(home: ControlPlaneHome, database: persistence::Database) -> Result<(), Error> { + let store = Rc::new(database.clone()); + let credentials = Rc::new(agent::harness::AuthenticationManager::new(database.clone())); + let policy = Rc::new(agent::authorization::AgentPolicyEngine::new()); + let platform_api_listener = agent::platform_api::bind_persistent(&home.path().join("platform-api-port")).await?; + let platform_api_port = platform_api_listener.local_addr()?.port(); + let (sandboxes, session_hook_url) = + open_sandboxes(&home, &database, credentials.clone(), policy, platform_api_port).await?; + let session_reports: Rc = store.clone(); + let session_store: Rc = store.clone(); + let session_runtime: Rc = Rc::new(agent::sessions::Tmux); + let agent_sandboxes = Rc::new(agent::sessions::AgentSandboxes::new(store.clone(), sandboxes.clone())); + let changes = database.changes(); + let provisioning = agent::progress::ProvisioningState::new(changes.clone()); + + let platform_api_server = Rc::new(agent::platform_api::Server::new( + session_reports, + Rc::new(|error| tracing::error!(%error, "Platform API connection failed")), + )); + let session_reconciler: Rc> = + Rc::new(agent::sessions::Reconciler::new( + session_store.clone(), + agent_sandboxes.clone(), + session_runtime.clone(), + session_hook_url, + )); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + session_reconciler, + Duration::from_secs(30), + reconciliation_errors("Session"), + ); + let session_notifier = Rc::new(agent::sessions::AgentNotifier::new( + session_store.clone(), + session_wakeup.clone(), + Rc::new(|error| tracing::error!(%error, "Session notification scan failed")), + )); + let (ssh, vnc) = access(&home, &database, store.clone())?; + let reconciler = Rc::new( + Reconciler::new(store.clone(), sandboxes.clone(), provisioning.clone()) + .with_session_notifier(session_notifier) + .with_ssh_access(ssh.clone()) + .with_vnc_access(vnc.clone()), + ); + let (controller, wakeup) = Controller::new( + store.clone(), + reconciler, + Duration::from_secs(30), + reconciliation_errors("Agent"), + ); + let control_plane = + Rc::new(ControlPlane::new(store.clone(), Rc::new(wakeup.clone())).with_provisioning(provisioning)); + let convergence = agent::control_plane::Convergence::new(wakeup, store.clone(), changes.clone()); + let executions = Rc::new(ExecutionService::new(store.clone(), convergence.clone())); + let sessions = Rc::new(SessionService::new( + session_store, + agent_sandboxes, + session_runtime, + convergence.clone(), + session_wakeup, + )); + agent::upgrade::consume_pending_session_relaunch(&home, &sessions).await?; + let server = Rc::new(Server::new( + control_plane, + credentials.clone(), + Rc::new(convergence.clone()), + executions, + sessions, + ssh, + vnc, + changes, + Rc::new(|error| tracing::error!(%error, "Control API connection failed")), + )); + let mut controller_task = tokio::task::spawn_local(controller.run()); + let mut session_controller_task = tokio::task::spawn_local(session_controller.run()); + let mut platform_api_task = tokio::task::spawn_local(platform_api_server.serve(platform_api_listener)); + let socket_path = home.socket_path(); + + let result = tokio::select! { + result = server.serve_path(&socket_path) => result, + result = tokio::signal::ctrl_c() => result.map_err(Error::from), + result = &mut controller_task => match result { + Ok(()) => Err(Error::Daemon("reconciliation controller stopped".into())), + Err(error) => Err(Error::Daemon(format!("reconciliation controller task failed: {error}"))), + }, + result = &mut session_controller_task => match result { + Ok(()) => Err(Error::Daemon("Session reconciliation controller stopped".into())), + Err(error) => Err(Error::Daemon(format!("Session reconciliation controller task failed: {error}"))), + }, + result = &mut platform_api_task => match result { + Ok(Ok(())) => Err(Error::Daemon("Platform API stopped".into())), + Ok(Err(error)) => Err(Error::Daemon(format!("Platform API failed: {error}"))), + Err(error) => Err(Error::Daemon(format!("Platform API task failed: {error}"))), + }, + }; + controller_task.abort(); + session_controller_task.abort(); + platform_api_task.abort(); + let _ = controller_task.await; + let _ = session_controller_task.await; + let _ = platform_api_task.await; + result +} diff --git a/agentctl/src/control_api/client.rs b/agentctl/src/control_api/client.rs new file mode 100644 index 0000000..c39b94a --- /dev/null +++ b/agentctl/src/control_api/client.rs @@ -0,0 +1,486 @@ +use std::{cell::Cell, rc::Rc}; + +use sandbox::LocalFuture; +use serde::{Serialize, de::DeserializeOwned}; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; + +use crate::{Agent, Error, control_plane, control_plane::WaitPolicy, harness, sessions}; + +use super::protocol::{ + DaemonInfo, DirectoryParams, ExecutionEnsureParams, JSON_RPC_VERSION, LoginParams, METHOD_APPLY, METHOD_AUTH_LOGIN, + METHOD_CONVERGE, METHOD_DELETE, METHOD_EXECUTION_ENSURE, METHOD_GET, METHOD_HEALTH, METHOD_LIST, METHOD_PROGRESS, + METHOD_RESOLVE_DIRECTORY, METHOD_RESOURCES_WATCH, METHOD_SESSION_ARCHIVE, METHOD_SESSION_DELETE, + METHOD_SESSION_ENSURE, METHOD_SESSION_GET, METHOD_SESSION_LIST, METHOD_SESSION_PROMPT, METHOD_SESSION_TURNS, + METHOD_SESSION_UNARCHIVE, METHOD_SHUTDOWN, METHOD_SSH_ACCESS, METHOD_START, METHOD_STOP, METHOD_VNC_ACCESS, + NameParams, ProgressParams, ReadMessage, Request, ResourcesWatchParams, Response, SessionEnsureParams, + SessionListParams, SessionParams, SessionPromptParams, SessionTurnsParams, ShutdownParams, ShutdownResult, + read_message, +}; + +/// A byte stream usable by the Agent Control API client. +pub trait Connection: AsyncRead + AsyncWrite + Unpin {} + +impl Connection for T {} + +/// Opens one connection for one local API call. +pub trait Connector { + /// Connects to the local control plane. + fn connect(&self) -> LocalFuture<'_, Result, Error>>; +} + +/// Calls an Agent control plane over a local stream transport. +pub struct Client { + connector: Rc, + next_id: Cell, +} + +impl Client { + /// Creates a client with a replaceable local connector. + #[must_use] + pub fn new(connector: Rc) -> Self { + Self { + connector, + next_id: Cell::new(0), + } + } + + /// Creates a client for the platform local socket at `path`. + #[must_use] + pub fn for_path(path: std::path::PathBuf) -> Self { + Self::new(Rc::new(super::socket::PathConnector::new(path))) + } + + /// Checks whether the local daemon speaks the expected Control API. + /// + /// # Errors + /// + /// Returns an error when the daemon is unavailable or protocol-incompatible. + pub async fn health(&self) -> Result { + self.call(METHOD_HEALTH, serde_json::json!({})).await + } + + /// Requires a daemon built with this client's application protocol and version. + /// + /// # Errors + /// + /// Returns an error with both identities when a daemon is reachable but incompatible. + pub async fn require_compatible_daemon(&self) -> Result { + let daemon = self.health().await?; + daemon.require_compatible()?; + Ok(daemon) + } + + /// Requests a graceful daemon shutdown for an upgrade. + /// + /// # Errors + /// + /// Returns an error when active Sessions block the transition or the daemon + /// cannot drain its listeners and in-flight calls. + pub async fn shutdown_for_upgrade(&self) -> Result, Error> { + let result: ShutdownResult = self + .call( + METHOD_SHUTDOWN, + ShutdownParams { + reason: "upgrade".into(), + }, + ) + .await?; + Ok(result.warnings) + } + + /// Creates or updates an Agent resource. + /// + /// # Errors + /// + /// Returns an error when transport, protocol validation, or the control-plane operation fails. + pub async fn apply(&self, request: control_plane::ApplyRequest) -> Result { + self.call(METHOD_APPLY, request).await + } + + /// Gets an Agent resource by name. + /// + /// # Errors + /// + /// Returns an error when transport, protocol validation, or the control-plane operation fails. + pub async fn get(&self, name: &str) -> Result { + self.call(METHOD_GET, NameParams { name: name.into() }).await + } + + /// Lists every active Agent. + /// + /// # Errors + /// + /// Returns an error when transport, protocol validation, or storage fails. + pub async fn list_agents(&self) -> Result, Error> { + self.call(METHOD_LIST, serde_json::json!({})).await + } + + /// Resolves the closest persisted Agent source directory containing `directory`. + /// + /// # Errors + /// + /// Returns an error when no unique Agent matches or the API call fails. + pub async fn resolve_agent(&self, directory: std::path::PathBuf) -> Result { + self.resolve_agent_variant(directory, None).await + } + + /// Resolves the closest persisted Agent by directory and optional leaf variant. + /// + /// # Errors + /// + /// Returns an error when no unique Agent matches or the API call fails. + pub async fn resolve_agent_variant( + &self, + directory: std::path::PathBuf, + variant: Option, + ) -> Result { + self.call(METHOD_RESOLVE_DIRECTORY, DirectoryParams { directory, variant }) + .await + } + + /// Converges an Agent and resolves its exact transient Execution target. + /// + /// `wait` decides whether the call returns after one reconciliation pass or + /// waits through background retries until Ready. Follow progress alongside + /// with [`Self::agent_progress`]. + /// + /// # Errors + /// + /// Returns an error when the Agent is missing, deleting, invalid, or fails to + /// reach a ready materialized Sandbox. + pub async fn ensure_execution( + &self, + name: &str, + wait: WaitPolicy, + ) -> Result { + self.call( + METHOD_EXECUTION_ENSURE, + ExecutionEnsureParams { + name: name.into(), + follow: wait == WaitPolicy::UntilConverged, + }, + ) + .await + } + + /// Waits for a change after `after`, then returns the Agent's stored status + /// and the progress of its latest pass. Without a revision, or with one + /// from an earlier daemon process, it returns at once; with a current one + /// it may return the unchanged state after a keepalive interval. When + /// `output` names the latest pass, only output after it is included. + /// + /// # Errors + /// + /// Returns an error when the Agent is missing or the call fails. + pub async fn agent_progress( + &self, + name: &str, + after: Option, + output: Option, + ) -> Result { + self.call( + METHOD_PROGRESS, + ProgressParams { + name: name.into(), + after, + output, + }, + ) + .await + } + + /// Waits for an Agent or Session to change after `after`, then returns + /// every Agent and Session. Without a revision, or with one from an earlier + /// daemon process, it returns the current state at once; with a current + /// revision it may return the unchanged state after a keepalive interval. + /// + /// # Errors + /// + /// Returns an error when transport, protocol validation, or daemon reads fail. + pub async fn watch_resources( + &self, + after: Option, + ) -> Result { + self.call(METHOD_RESOURCES_WATCH, ResourcesWatchParams { after }).await + } + + /// Describes how to reach an Agent over SSH. + /// + /// # Errors + /// + /// Returns an error when the Agent is unknown, deleting, or declares no SSH access. + pub async fn ssh_access(&self, name: &str) -> Result { + self.call(METHOD_SSH_ACCESS, NameParams { name: name.into() }).await + } + + /// Describes how to reach an Agent's desktop over VNC. + /// + /// # Errors + /// + /// Returns an error when the Agent is unknown, deleting, or declares no VNC access. + pub async fn vnc_access(&self, name: &str) -> Result { + self.call(METHOD_VNC_ACCESS, NameParams { name: name.into() }).await + } + + /// Requests deletion of an Agent and its owned sandbox. + /// + /// # Errors + /// + /// Returns an error when transport, protocol validation, or the control-plane operation fails. + pub async fn delete(&self, name: &str) -> Result<(), Error> { + let _result: serde_json::Value = self.call(METHOD_DELETE, NameParams { name: name.into() }).await?; + Ok(()) + } + + /// Waits until an Agent has its desired run state, Ready or stopped, and + /// returns it as stored then; transient failures are waited through. + /// + /// # Errors + /// + /// Returns an error when the Agent is missing, deleted, invalid or + /// unresponsive, or the call fails. + pub async fn converge(&self, name: &str) -> Result { + self.call(METHOD_CONVERGE, NameParams { name: name.into() }).await + } + + /// Records whether an Agent's Sandbox runs and returns the Agent as stored; + /// the reconciler stops or starts it. + /// + /// # Errors + /// + /// Returns an error when transport, protocol validation, or the control-plane operation fails. + pub async fn set_run_state(&self, name: &str, state: crate::RunState) -> Result { + let method = match state { + crate::RunState::Running => METHOD_START, + crate::RunState::Stopped => METHOD_STOP, + }; + self.call(method, NameParams { name: name.into() }).await + } + + /// Stores a host-acquired harness credential in the daemon. + /// + /// # Errors + /// + /// Returns an error when the credential is invalid, rejected, or cannot be persisted. + pub async fn auth_login( + &self, + harness: harness::Harness, + credential: String, + imported: bool, + ) -> Result { + self.call( + METHOD_AUTH_LOGIN, + LoginParams { + harness, + credential, + imported, + }, + ) + .await + } + + /// Creates or resolves one named session attach target. + /// + /// `request` selects the harness, model, effort and first prompt of a + /// Session this call creates; see [`sessions::Service::ensure`] for the + /// precedence against manifest defaults. `wait` decides whether the call + /// returns after one Agent reconciliation pass or waits through background + /// retries until Ready. Follow progress alongside with [`Self::agent_progress`]. + /// + /// # Errors + /// + /// Returns an error when the Agent is not ready, a selection conflicts with + /// an existing Session, or the registry cannot persist the session. + pub async fn ensure_session( + &self, + agent: &str, + name: sessions::SessionName, + request: sessions::SessionRequest, + wait: WaitPolicy, + ) -> Result { + self.call( + METHOD_SESSION_ENSURE, + SessionEnsureParams { + agent: agent.into(), + name, + harness: request.harness, + model_selection: request.model_selection, + initial_prompt: request.initial_prompt, + follow: wait == WaitPolicy::UntilConverged, + }, + ) + .await + } + + /// Delivers a prompt to a running Session's harness. With `wait`, waits for + /// its completed-turn counter to advance with identical waiting activity in + /// two consecutive polls, 250 ms apart. + /// Work observed during settling requires another completion. + /// The timeout bounds completion waiting after submission, excluding setup and delivery. + /// Conversation output is read separately with [`Self::session_turns`]. + /// + /// # Errors + /// + /// Returns an error when the Session is not running or the input cannot be delivered. + pub async fn prompt_session( + &self, + agent: &str, + name: sessions::SessionName, + prompt: String, + wait: bool, + timeout: Option, + ) -> Result<(), Error> { + let _result: serde_json::Value = self + .call( + METHOD_SESSION_PROMPT, + SessionPromptParams { + agent: agent.into(), + name, + prompt, + wait, + timeout, + }, + ) + .await?; + Ok(()) + } + + /// Reads the harness transcript of a Session as ordered turns. + /// + /// # Errors + /// + /// Returns an error when the Session or its transcript cannot be read. + pub async fn session_turns( + &self, + agent: &str, + name: sessions::SessionName, + last: Option, + ) -> Result, Error> { + self.call( + METHOD_SESSION_TURNS, + SessionTurnsParams { + agent: agent.into(), + name, + last, + }, + ) + .await + } + + /// Gets one named Session scoped to an Agent. + /// + /// # Errors + /// + /// Returns an error when either resource is missing or the registry cannot be read. + pub async fn get_session(&self, agent: &str, name: sessions::SessionName) -> Result { + self.call( + METHOD_SESSION_GET, + SessionParams { + agent: agent.into(), + name, + harness: None, + }, + ) + .await + } + + /// Requests release of one Session: its harness is stopped and the Session + /// is removed, freeing its name. + /// + /// # Errors + /// + /// Returns an error when either resource is missing, or the release pass fails. + pub async fn delete_session(&self, agent: &str, name: sessions::SessionName) -> Result<(), Error> { + let _result: serde_json::Value = self + .call( + METHOD_SESSION_DELETE, + SessionParams { + agent: agent.into(), + name, + harness: None, + }, + ) + .await?; + Ok(()) + } + + /// Archives or unarchives one Session and returns it as recorded. Archiving + /// stops its harness until it is unarchived; the Session keeps its name and + /// conversation. + /// + /// # Errors + /// + /// Returns an error when either resource is missing or the pass fails. + pub async fn set_session_archived( + &self, + agent: &str, + name: sessions::SessionName, + archived: bool, + ) -> Result { + self.call( + if archived { + METHOD_SESSION_ARCHIVE + } else { + METHOD_SESSION_UNARCHIVE + }, + SessionParams { + agent: agent.into(), + name, + harness: None, + }, + ) + .await + } + + /// Lists tracked Sessions, optionally scoped to one Agent. + /// + /// # Errors + /// + /// Returns an error when the scoped Agent is missing or the registry cannot be read. + pub async fn list_sessions(&self, agent: Option<&str>) -> Result, Error> { + self.call( + METHOD_SESSION_LIST, + SessionListParams { + agent: agent.map(str::to_owned), + }, + ) + .await + } + + async fn call(&self, method: &str, params: P) -> Result { + let id = self.next_id.get().wrapping_add(1); + self.next_id.set(id); + let request = Request { + jsonrpc: JSON_RPC_VERSION.into(), + method: method.into(), + params: serde_json::to_value(params)?, + id, + }; + let mut stream = self.connector.connect().await?; + let mut bytes = serde_json::to_vec(&request)?; + bytes.push(b'\n'); + stream.write_all(&bytes).await?; + stream.flush().await?; + + let mut stream = BufReader::new(stream); + let line = match read_message(&mut stream).await? { + ReadMessage::Complete(line) => line, + ReadMessage::EndOfStream | ReadMessage::TooLarge => { + return Err(Error::Invalid("invalid Agent Control API response".into())); + } + }; + let response: Response = serde_json::from_slice(&line)?; + if response.jsonrpc != JSON_RPC_VERSION || response.id != id { + return Err(Error::Invalid("invalid Agent Control API response".into())); + } + if let Some(error) = response.error { + return Err(Error::Rpc(error)); + } + serde_json::from_value( + response + .result + .ok_or_else(|| Error::Invalid("Agent Control API response has no result".into()))?, + ) + .map_err(Error::from) + } +} diff --git a/agentctl/src/control_api/mod.rs b/agentctl/src/control_api/mod.rs new file mode 100644 index 0000000..834acee --- /dev/null +++ b/agentctl/src/control_api/mod.rs @@ -0,0 +1,13 @@ +//! Versioned Agent Control API with JSON-RPC 2.0/JSONL and replaceable stream transports. + +mod client; +mod protocol; +mod server; +mod socket; + +pub use client::{Client, Connection, Connector}; +pub use protocol::{DaemonInfo, PROTOCOL_VERSION, ResponseError}; +pub use server::{ + AgentApi, AuthenticationApi, ConvergenceApi, ErrorHandler, ExecutionApi, Server, SessionApi, SshAccessApi, + VncAccessApi, +}; diff --git a/agentctl/src/control_api/protocol.rs b/agentctl/src/control_api/protocol.rs new file mode 100644 index 0000000..545aa3e --- /dev/null +++ b/agentctl/src/control_api/protocol.rs @@ -0,0 +1,290 @@ +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncBufRead, AsyncBufReadExt as _}; + +/// Agent Control API version, independent of the JSON-RPC envelope. +pub const PROTOCOL_VERSION: &str = "v4"; +pub(crate) const JSON_RPC_VERSION: &str = "2.0"; + +pub(crate) const METHOD_APPLY: &str = "agents.v1.apply"; +pub(crate) const METHOD_HEALTH: &str = "control.v1.health"; +pub(crate) const METHOD_SHUTDOWN: &str = "control.v1.shutdown"; +pub(crate) const METHOD_GET: &str = "agents.v1.get"; +pub(crate) const METHOD_LIST: &str = "agents.v1.list"; +pub(crate) const METHOD_PROGRESS: &str = "agents.v1.progress"; +pub(crate) const METHOD_RESOURCES_WATCH: &str = "resources.v1.watch"; +pub(crate) const METHOD_RESOLVE_DIRECTORY: &str = "agents.v1.resolveDirectory"; +pub(crate) const METHOD_EXECUTION_ENSURE: &str = "agents.v1.ensureExecution"; +pub(crate) const METHOD_DELETE: &str = "agents.v1.delete"; +pub(crate) const METHOD_STOP: &str = "agents.v1.stop"; +pub(crate) const METHOD_START: &str = "agents.v1.start"; +pub(crate) const METHOD_CONVERGE: &str = "agents.v1.converge"; +pub(crate) const METHOD_SSH_ACCESS: &str = "agents.v1.sshAccess"; +pub(crate) const METHOD_VNC_ACCESS: &str = "agents.v1.vncAccess"; +pub(crate) const METHOD_AUTH_LOGIN: &str = "authentication.v1.login"; +pub(crate) const METHOD_SESSION_ENSURE: &str = "sessions.v1.ensure"; +pub(crate) const METHOD_SESSION_GET: &str = "sessions.v1.get"; +pub(crate) const METHOD_SESSION_LIST: &str = "sessions.v1.list"; +pub(crate) const METHOD_SESSION_PROMPT: &str = "sessions.v1.prompt"; +pub(crate) const METHOD_SESSION_TURNS: &str = "sessions.v1.turns"; +pub(crate) const METHOD_SESSION_DELETE: &str = "sessions.v1.delete"; +pub(crate) const METHOD_SESSION_ARCHIVE: &str = "sessions.v1.archive"; +pub(crate) const METHOD_SESSION_UNARCHIVE: &str = "sessions.v1.unarchive"; + +pub(crate) const CODE_PARSE_ERROR: i32 = -32700; +pub(crate) const CODE_INVALID_REQUEST: i32 = -32600; +pub(crate) const CODE_METHOD_NOT_FOUND: i32 = -32601; +pub(crate) const CODE_INVALID_PARAMS: i32 = -32602; +pub(crate) const CODE_INTERNAL: i32 = -32603; +pub(crate) const CODE_NOT_FOUND: i32 = -32004; +pub(crate) const CODE_IMMUTABLE: i32 = -32009; +pub(crate) const CODE_UPDATING: i32 = -32010; +pub(crate) const MAX_MESSAGE_BYTES: usize = 4 * 1024 * 1024; + +pub(crate) enum ReadMessage { + EndOfStream, + Complete(Vec), + TooLarge, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Request { + pub jsonrpc: String, + pub method: String, + #[serde(default)] + pub params: serde_json::Value, + pub id: u64, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Response { + pub jsonrpc: String, + pub id: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// JSON-RPC error returned by the local control plane. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, thiserror::Error)] +#[error("{message}")] +pub struct ResponseError { + /// Stable JSON-RPC or application error code. + pub code: i32, + /// Human-readable error message. + pub message: String, +} + +impl ResponseError { + /// Returns whether the daemon rejected the request's desired state or parameters. + #[must_use] + pub const fn is_invalid_params(&self) -> bool { + self.code == CODE_INVALID_PARAMS + } + + /// Returns whether the addressed resource does not exist. + #[must_use] + pub const fn is_not_found(&self) -> bool { + self.code == CODE_NOT_FOUND + } +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct NameParams { + pub name: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ExecutionEnsureParams { + pub name: String, + #[serde(default, skip_serializing_if = "is_false")] + pub follow: bool, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ResourcesWatchParams { + /// Revision of the previous reply; absent requests the current state now. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub after: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ProgressParams { + pub name: String, + /// Revision of the previous reply; absent requests the current state now. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub after: Option, + /// Output the caller already has, so the reply carries only later lines. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SessionParams { + pub agent: String, + pub name: crate::sessions::SessionName, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harness: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SessionEnsureParams { + pub agent: String, + pub name: crate::sessions::SessionName, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harness: Option, + /// Provider-owned model and effort level; validated, otherwise opaque to the daemon. + #[serde(default, skip_serializing_if = "crate::ModelSelection::is_empty")] + pub model_selection: crate::ModelSelection, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub initial_prompt: Option, + #[serde(default, skip_serializing_if = "is_false")] + pub follow: bool, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SessionPromptParams { + pub agent: String, + pub name: crate::sessions::SessionName, + pub prompt: String, + #[serde(default, skip_serializing_if = "is_false")] + pub wait: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SessionTurnsParams { + pub agent: String, + pub name: crate::sessions::SessionName, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct DirectoryParams { + pub directory: std::path::PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub variant: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SessionListParams { + #[serde(default)] + pub agent: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LoginParams { + pub harness: crate::harness::Harness, + pub credential: String, + /// The credential was supplied by the caller rather than minted by the host login flow. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub imported: bool, +} + +/// Identity returned by the frozen lifecycle health method. +/// +/// Both fields are optional so an updater can identify the preview 1 daemon, +/// which did not report a build version. Normal commands require exact values. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DaemonInfo { + /// Application protocol spoken by the daemon. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub protocol_version: Option, + /// Build version of the daemon executable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub build_version: Option, +} + +impl DaemonInfo { + /// Requires the daemon to be the exact counterpart of this client build. + /// + /// # Errors + /// + /// Returns an error containing both identities when either value differs. + pub fn require_compatible(&self) -> Result<(), crate::Error> { + if self.protocol_version.as_deref() == Some(PROTOCOL_VERSION) + && self.build_version.as_deref() == Some(crate::build_version()) + { + return Ok(()); + } + Err(crate::Error::Daemon(format!( + "running agentd is incompatible: protocol {:?}, build {:?}; agentctl expects protocol {PROTOCOL_VERSION:?}, build {:?}", + self.protocol_version, + self.build_version, + crate::build_version() + ))) + } +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ShutdownParams { + pub reason: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ShutdownResult { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, +} + +pub(crate) fn error_response(id: u64, code: i32, message: impl Into) -> Response { + Response { + jsonrpc: JSON_RPC_VERSION.into(), + id, + result: None, + error: Some(ResponseError { + code, + message: message.into(), + }), + } +} + +pub(crate) async fn read_message(reader: &mut R) -> std::io::Result { + let mut message = Vec::new(); + loop { + let available = reader.fill_buf().await?; + if available.is_empty() { + return Ok(if message.is_empty() { + ReadMessage::EndOfStream + } else { + ReadMessage::Complete(message) + }); + } + let end = available + .iter() + .position(|byte| *byte == b'\n') + .map_or(available.len(), |position| position + 1); + if message.len().saturating_add(end) > MAX_MESSAGE_BYTES { + return Ok(ReadMessage::TooLarge); + } + let complete = available.get(end - 1) == Some(&b'\n'); + message.extend_from_slice(&available[..end]); + reader.consume(end); + if complete { + return Ok(ReadMessage::Complete(message)); + } + } +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +const fn is_false(value: &bool) -> bool { + !*value +} diff --git a/agentctl/src/control_api/server.rs b/agentctl/src/control_api/server.rs new file mode 100644 index 0000000..0205d3f --- /dev/null +++ b/agentctl/src/control_api/server.rs @@ -0,0 +1,932 @@ +use std::{cell::Cell, rc::Rc, time::Duration}; + +use sandbox::LocalFuture; +use serde::Serialize; +use serde_json::Value; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; +use tokio::sync::Notify; + +use crate::{ + Agent, Error, control_plane, control_plane::WaitPolicy, harness, progress::AgentProgress, resources::Changes, + sessions, +}; + +use super::protocol::{ + CODE_IMMUTABLE, CODE_INTERNAL, CODE_INVALID_PARAMS, CODE_INVALID_REQUEST, CODE_METHOD_NOT_FOUND, CODE_NOT_FOUND, + CODE_PARSE_ERROR, CODE_UPDATING, DirectoryParams, ExecutionEnsureParams, JSON_RPC_VERSION, LoginParams, + METHOD_APPLY, METHOD_AUTH_LOGIN, METHOD_CONVERGE, METHOD_DELETE, METHOD_EXECUTION_ENSURE, METHOD_GET, + METHOD_HEALTH, METHOD_LIST, METHOD_PROGRESS, METHOD_RESOLVE_DIRECTORY, METHOD_RESOURCES_WATCH, + METHOD_SESSION_ARCHIVE, METHOD_SESSION_DELETE, METHOD_SESSION_ENSURE, METHOD_SESSION_GET, METHOD_SESSION_LIST, + METHOD_SESSION_PROMPT, METHOD_SESSION_TURNS, METHOD_SESSION_UNARCHIVE, METHOD_SHUTDOWN, METHOD_SSH_ACCESS, + METHOD_START, METHOD_STOP, METHOD_VNC_ACCESS, NameParams, PROTOCOL_VERSION, ProgressParams, ReadMessage, Request, + ResourcesWatchParams, Response, SessionEnsureParams, SessionListParams, SessionParams, SessionPromptParams, + SessionTurnsParams, ShutdownParams, error_response, read_message, +}; + +/// Quiet period after a change before a progress reply, so a burst of byte +/// progress costs one reply. +const PROGRESS_SETTLE: Duration = Duration::from_millis(50); +/// Quiet period after a resource change before a watch replies, so a burst of +/// changes, such as byte progress during an image pull, costs one reply. +const WATCH_SETTLE: Duration = Duration::from_millis(150); +/// Longest a watch waits without a change; the unchanged reply tells the +/// watcher the daemon is still there. +const WATCH_KEEPALIVE: Duration = Duration::from_secs(30); + +/// Agent operations exposed through the Agent Control API. +pub trait AgentApi { + /// Creates or updates desired Agent state. + fn apply(&self, request: control_plane::ApplyRequest) -> LocalFuture<'_, Result>; + + /// Gets an Agent by name. + fn get<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result>; + + /// Lists every active Agent. + fn list(&self) -> LocalFuture<'_, Result, Error>>; + + /// Resolves an Agent from its persisted source directory. + fn resolve_directory<'a>( + &'a self, + directory: &'a std::path::Path, + variant: Option<&'a crate::AgentVariantName>, + ) -> LocalFuture<'a, Result>; + + /// Requests asynchronous deletion. + fn delete<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result<(), Error>>; + + /// Records whether an Agent's Sandbox runs; see [`control_plane::ControlPlane::set_run_state`]. + fn set_run_state<'a>(&'a self, name: &'a str, state: crate::RunState) -> LocalFuture<'a, Result>; + + /// Reads an Agent's stored status and its latest pass's progress, with + /// only the output after `output` when it names the same pass. + fn progress<'a>( + &'a self, + name: &'a str, + output: Option, + ) -> LocalFuture<'a, Result<(crate::Status, Option), Error>>; +} + +impl AgentApi for control_plane::ControlPlane { + fn apply(&self, request: control_plane::ApplyRequest) -> LocalFuture<'_, Result> { + Box::pin(async move { Self::apply(self, request).await }) + } + + fn get<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result> { + Box::pin(async move { Self::get(self, name).await }) + } + + fn list(&self) -> LocalFuture<'_, Result, Error>> { + Box::pin(async move { Self::list(self).await }) + } + + fn resolve_directory<'a>( + &'a self, + directory: &'a std::path::Path, + variant: Option<&'a crate::AgentVariantName>, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { self.resolve_directory_variant(directory, variant).await }) + } + + fn delete<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { Self::delete(self, name).await }) + } + + fn set_run_state<'a>(&'a self, name: &'a str, state: crate::RunState) -> LocalFuture<'a, Result> { + Box::pin(async move { Self::set_run_state(self, name, state).await }) + } + + fn progress<'a>( + &'a self, + name: &'a str, + output: Option, + ) -> LocalFuture<'a, Result<(crate::Status, Option), Error>> { + Box::pin(async move { Self::progress(self, name, output).await }) + } +} + +/// Host-side authentication operations exposed through the local control API. +pub trait AuthenticationApi { + /// Stores a credential for one harness; `imported` marks one supplied by the caller + /// instead of minted by the host login flow. + fn login<'a>( + &'a self, + harness: harness::Harness, + credential: &'a str, + imported: bool, + ) -> LocalFuture<'a, Result>; +} + +impl AuthenticationApi for harness::AuthenticationManager { + fn login<'a>( + &'a self, + harness: harness::Harness, + credential: &'a str, + imported: bool, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + self.login(harness, zeroize::Zeroizing::new(credential.to_owned()), imported) + .await + }) + } +} + +/// Host-tracked session operations exposed through the local control API. +pub trait SessionApi { + /// Creates or resolves one named session attach target; see [`sessions::Service::ensure`]. + fn ensure<'a>( + &'a self, + agent: &'a str, + name: &'a sessions::SessionName, + request: sessions::SessionRequest, + wait: WaitPolicy, + ) -> LocalFuture<'a, Result>; + + /// Gets one named Session scoped to an Agent. + fn get<'a>( + &'a self, + agent: &'a str, + name: &'a sessions::SessionName, + ) -> LocalFuture<'a, Result>; + + /// Lists tracked Sessions, optionally scoped to one Agent. + fn list<'a>(&'a self, agent: Option<&'a str>) -> LocalFuture<'a, Result, Error>>; + + /// Delivers a prompt to a running Session's harness, optionally waiting for + /// a completed turn and settled activity; see [`sessions::Service::prompt`]. + fn prompt<'a>( + &'a self, + agent: &'a str, + name: &'a sessions::SessionName, + prompt: &'a str, + wait: bool, + timeout: Option, + ) -> LocalFuture<'a, Result<(), Error>>; + + /// Reads the harness transcript of a Session as ordered turns. + fn turns<'a>( + &'a self, + agent: &'a str, + name: &'a sessions::SessionName, + last: Option, + ) -> LocalFuture<'a, Result, Error>>; + + /// Requests release of one Session; see [`sessions::Service::delete`]. + fn delete<'a>(&'a self, agent: &'a str, name: &'a sessions::SessionName) -> LocalFuture<'a, Result<(), Error>>; + + /// Archives or unarchives one Session; see [`sessions::Service::set_archived`]. + fn set_archived<'a>( + &'a self, + agent: &'a str, + name: &'a sessions::SessionName, + archived: bool, + ) -> LocalFuture<'a, Result>; + + /// Lists Sessions whose work or terminal attachment prevents an upgrade. + fn upgrade_readiness(&self) -> LocalFuture<'_, Result>; +} + +impl SessionApi for sessions::Service { + fn ensure<'a>( + &'a self, + agent: &'a str, + name: &'a sessions::SessionName, + request: sessions::SessionRequest, + wait: WaitPolicy, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { Self::ensure(self, agent, name, request, wait).await }) + } + + fn prompt<'a>( + &'a self, + agent: &'a str, + name: &'a sessions::SessionName, + prompt: &'a str, + wait: bool, + timeout: Option, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { Self::prompt(self, agent, name, prompt, wait, timeout).await }) + } + + fn turns<'a>( + &'a self, + agent: &'a str, + name: &'a sessions::SessionName, + last: Option, + ) -> LocalFuture<'a, Result, Error>> { + Box::pin(async move { Self::turns(self, agent, name, last).await }) + } + + fn get<'a>( + &'a self, + agent: &'a str, + name: &'a sessions::SessionName, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { Self::get(self, agent, name).await }) + } + + fn list<'a>(&'a self, agent: Option<&'a str>) -> LocalFuture<'a, Result, Error>> { + Box::pin(async move { Self::list(self, agent).await }) + } + + fn set_archived<'a>( + &'a self, + agent: &'a str, + name: &'a sessions::SessionName, + archived: bool, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { Self::set_archived(self, agent, name, archived).await }) + } + + fn delete<'a>(&'a self, agent: &'a str, name: &'a sessions::SessionName) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { Self::delete(self, agent, name).await }) + } + + fn upgrade_readiness(&self) -> LocalFuture<'_, Result> { + Box::pin(Self::upgrade_readiness(self)) + } +} + +/// Waiting for an Agent to converge, exposed through the local control API. +pub trait ConvergenceApi { + /// Waits until an Agent has its desired run state; see + /// [`control_plane::Convergence::converge`]. + fn converge<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result<(), Error>>; +} + +impl ConvergenceApi for control_plane::Convergence { + fn converge<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { self.converge(name, WaitPolicy::UntilConverged).await.map(drop) }) + } +} + +/// Transient Agent Execution target resolution exposed through the local control API. +pub trait ExecutionApi { + /// Converges an Agent and returns its exact ready Sandbox assignment. + fn ensure<'a>( + &'a self, + name: &'a str, + wait: WaitPolicy, + ) -> LocalFuture<'a, Result>; +} + +impl ExecutionApi for crate::sandbox::ExecutionService { + fn ensure<'a>( + &'a self, + name: &'a str, + wait: WaitPolicy, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { Self::ensure(self, name, wait).await }) + } +} + +/// SSH access descriptors exposed through the local control API. +pub trait SshAccessApi { + /// Describes the SSH access of a named Agent. + fn describe<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result>; +} + +impl SshAccessApi for crate::ssh::Access { + fn describe<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result> { + Box::pin(async move { Self::describe(self, name).await }) + } +} + +/// VNC access descriptors exposed through the local control API. +pub trait VncAccessApi { + /// Describes the VNC access of a named Agent. + fn describe<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result>; +} + +impl VncAccessApi for crate::vnc::Access { + fn describe<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result> { + Box::pin(async move { Self::describe(self, name).await }) + } +} + +/// Observes an isolated connection error without terminating the daemon. +pub type ErrorHandler = Rc; + +#[derive(Clone, Copy, Default, Eq, PartialEq)] +enum LifecycleState { + #[default] + Running, + Checking, + Draining, +} + +#[derive(Default)] +struct Lifecycle { + state: Cell, + active_mutations: Cell, + mutations_idle: Notify, + shutdown: Notify, +} + +impl Lifecycle { + fn admit_mutation(&self) -> Option> { + if self.state.get() != LifecycleState::Running { + return None; + } + self.active_mutations.set(self.active_mutations.get() + 1); + Some(MutationGuard { lifecycle: self }) + } + + async fn wait_for_mutations(&self) { + loop { + let notified = self.mutations_idle.notified(); + if self.active_mutations.get() == 0 { + return; + } + notified.await; + } + } +} + +struct MutationGuard<'a> { + lifecycle: &'a Lifecycle, +} + +impl Drop for MutationGuard<'_> { + fn drop(&mut self) { + let remaining = self.lifecycle.active_mutations.get() - 1; + self.lifecycle.active_mutations.set(remaining); + if remaining == 0 { + self.lifecycle.mutations_idle.notify_waiters(); + } + } +} + +struct ShutdownCheck<'a> { + lifecycle: &'a Lifecycle, + committed: bool, +} + +impl ShutdownCheck<'_> { + fn commit(mut self) { + self.lifecycle.state.set(LifecycleState::Draining); + self.lifecycle.shutdown.notify_waiters(); + self.committed = true; + } +} + +impl Drop for ShutdownCheck<'_> { + fn drop(&mut self) { + if !self.committed { + self.lifecycle.state.set(LifecycleState::Running); + } + } +} + +/// Serves the Agent Control API. +pub struct Server { + agents: Rc, + authentication: Rc, + convergence: Rc, + executions: Rc, + sessions: Rc, + ssh: Rc, + vnc: Rc, + changes: Changes, + on_error: ErrorHandler, + lifecycle: Lifecycle, +} + +impl Server { + /// Creates an Agent Control API server. + #[must_use] + #[allow( + clippy::too_many_arguments, + reason = "each API the server dispatches to is wired explicitly at the daemon boundary" + )] + pub fn new( + agents: Rc, + authentication: Rc, + convergence: Rc, + executions: Rc, + sessions: Rc, + ssh: Rc, + vnc: Rc, + changes: Changes, + on_error: ErrorHandler, + ) -> Self { + Self { + agents, + authentication, + convergence, + executions, + sessions, + ssh, + vnc, + changes, + on_error, + lifecycle: Lifecycle::default(), + } + } + + /// Listens on the platform's local socket implementation. + /// + /// # Errors + /// + /// Returns an error when the endpoint cannot be secured, bound, or served. + pub async fn serve_path(self: Rc, path: &std::path::Path) -> Result<(), Error> { + super::socket::serve(self, path).await + } + + /// Serves one JSON object per line until the client closes its stream. + /// + /// # Errors + /// + /// Returns an error when a message is malformed, exceeds the limit, or cannot be read or written. + pub async fn serve_connection(&self, stream: S) -> Result<(), Error> + where + S: AsyncRead + AsyncWrite + Unpin, + { + let mut stream = BufReader::new(stream); + loop { + if self.is_draining() { + return Ok(()); + } + let message = tokio::select! { + message = read_message(&mut stream) => message?, + () = self.shutdown_requested() => return Ok(()), + }; + let line = match message { + ReadMessage::EndOfStream => return Ok(()), + ReadMessage::Complete(line) => line, + ReadMessage::TooLarge => { + write_response( + stream.get_mut(), + &error_response(0, CODE_PARSE_ERROR, "JSON-RPC request exceeds 4 MiB"), + ) + .await?; + return Err(Error::Invalid("Agent Control API request exceeds 4 MiB".into())); + } + }; + + let request = match serde_json::from_slice::(&line) { + Ok(request) => request, + Err(error) => { + write_response( + stream.get_mut(), + &error_response(0, CODE_PARSE_ERROR, "invalid JSON-RPC request"), + ) + .await?; + return Err(Error::Json(error)); + } + }; + let response = if ends_with_its_client(&request.method) { + // A reply that is ready wins over a client that has half-closed. + tokio::select! { + biased; + response = self.handle(request) => response, + () = disconnected(&mut stream) => return Ok(()), + } + } else { + self.handle(request).await + }; + write_response(stream.get_mut(), &response).await?; + } + } + + pub(crate) fn report(&self, error: &Error) { + (self.on_error)(error); + } + + pub(crate) fn is_draining(&self) -> bool { + self.lifecycle.state.get() == LifecycleState::Draining + } + + pub(crate) async fn shutdown_requested(&self) { + if !self.is_draining() { + self.lifecycle.shutdown.notified().await; + } + } + + async fn handle(&self, request: Request) -> Response { + if request.jsonrpc != JSON_RPC_VERSION || request.method.is_empty() { + return error_response(request.id, CODE_INVALID_REQUEST, "invalid JSON-RPC 2.0 request"); + } + let _mutation = if is_mutating(&request.method) { + let Some(mutation) = self.lifecycle.admit_mutation() else { + return error_response(request.id, CODE_UPDATING, "Agent daemon is preparing for an upgrade"); + }; + Some(mutation) + } else { + None + }; + match request.method.as_str() { + METHOD_APPLY => self.handle_apply(request.id, request.params).await, + METHOD_HEALTH => result_response( + request.id, + Ok(serde_json::json!({ + "protocolVersion": PROTOCOL_VERSION, + "buildVersion": crate::build_version() + })), + ), + METHOD_SHUTDOWN => self.handle_shutdown(request.id, request.params).await, + METHOD_GET => self.handle_get(request.id, request.params).await, + METHOD_LIST => result_response(request.id, self.agents.list().await), + METHOD_PROGRESS => self.handle_progress(request.id, request.params).await, + METHOD_RESOURCES_WATCH => self.handle_resources_watch(request.id, request.params).await, + METHOD_RESOLVE_DIRECTORY => self.handle_resolve_directory(request.id, request.params).await, + METHOD_EXECUTION_ENSURE => self.handle_execution_ensure(request.id, request.params).await, + METHOD_DELETE => self.handle_delete(request.id, request.params).await, + METHOD_CONVERGE => self.handle_converge(request.id, request.params).await, + METHOD_STOP => { + self.handle_run_state(request.id, request.params, crate::RunState::Stopped) + .await + } + METHOD_START => { + self.handle_run_state(request.id, request.params, crate::RunState::Running) + .await + } + METHOD_SSH_ACCESS => self.handle_ssh_access(request.id, request.params).await, + METHOD_VNC_ACCESS => self.handle_vnc_access(request.id, request.params).await, + METHOD_AUTH_LOGIN => self.handle_auth_login(request.id, request.params).await, + METHOD_SESSION_ENSURE => self.handle_session_ensure(request.id, request.params).await, + METHOD_SESSION_GET => self.handle_session_get(request.id, request.params).await, + METHOD_SESSION_LIST => self.handle_session_list(request.id, request.params).await, + METHOD_SESSION_PROMPT => self.handle_session_prompt(request.id, request.params).await, + METHOD_SESSION_TURNS => self.handle_session_turns(request.id, request.params).await, + METHOD_SESSION_DELETE => self.handle_session_delete(request.id, request.params).await, + METHOD_SESSION_ARCHIVE => self.handle_session_archive(request.id, request.params, true).await, + METHOD_SESSION_UNARCHIVE => self.handle_session_archive(request.id, request.params, false).await, + _ => error_response(request.id, CODE_METHOD_NOT_FOUND, "method not found"), + } + } + + async fn handle_shutdown(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "shutdown reason is required"); + }; + if params.reason != "upgrade" { + return error_response(id, CODE_INVALID_PARAMS, "unsupported shutdown reason"); + } + if self.lifecycle.state.get() != LifecycleState::Running { + return error_response(id, CODE_UPDATING, "Agent daemon is already preparing for an upgrade"); + } + self.lifecycle.state.set(LifecycleState::Checking); + let check = ShutdownCheck { + lifecycle: &self.lifecycle, + committed: false, + }; + let readiness = tokio::time::timeout(Duration::from_mins(1), async { + let readiness = self.sessions.upgrade_readiness().await?; + if !readiness.blockers.is_empty() { + return Ok(readiness); + } + self.lifecycle.wait_for_mutations().await; + self.sessions.upgrade_readiness().await + }) + .await; + match readiness { + Ok(Ok(readiness)) if readiness.blockers.is_empty() => { + check.commit(); + result_response(id, Ok(serde_json::json!({"warnings": readiness.warnings}))) + } + Ok(Ok(readiness)) => error_response( + id, + CODE_INVALID_PARAMS, + format!("active Sessions block the upgrade: {}", readiness.blockers.join(", ")), + ), + Ok(Err(error)) => result_response::(id, Err(error)), + Err(_) => error_response(id, CODE_UPDATING, "Agent did not finish preparing for an upgrade"), + } + } + + async fn handle_apply(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "invalid apply parameters"); + }; + result_response(id, self.agents.apply(params).await) + } + + async fn handle_get(&self, id: u64, value: Value) -> Response { + let params = match name_params(value) { + Ok(params) => params, + Err(response) => return response_with_id(id, response), + }; + result_response(id, self.agents.get(¶ms.name).await) + } + + async fn handle_resolve_directory(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "directory is required"); + }; + result_response( + id, + self.agents + .resolve_directory(¶ms.directory, params.variant.as_ref()) + .await, + ) + } + + async fn handle_delete(&self, id: u64, value: Value) -> Response { + let params = match name_params(value) { + Ok(params) => params, + Err(response) => return response_with_id(id, response), + }; + result_response( + id, + self.agents.delete(¶ms.name).await.map(|()| serde_json::json!({})), + ) + } + + /// Waits until the Agent has its desired run state, then returns it as + /// `agents.v1.get` does. Draining ends the wait, so an upgrade is never held + /// by a waiter; the desired state is stored, so nothing is lost. + async fn handle_converge(&self, id: u64, value: Value) -> Response { + let params = match name_params(value) { + Ok(params) => params, + Err(response) => return response_with_id(id, response), + }; + let converged = tokio::select! { + converged = self.convergence.converge(¶ms.name) => converged, + () = self.shutdown_requested() => { + return error_response( + id, + CODE_UPDATING, + "Agent daemon is preparing for an upgrade; run the command again once it is back", + ); + } + }; + let agent = match converged { + Ok(()) => self.agents.get(¶ms.name).await, + Err(error) => Err(error), + }; + result_response(id, agent) + } + + async fn handle_run_state(&self, id: u64, value: Value, state: crate::RunState) -> Response { + let params = match name_params(value) { + Ok(params) => params, + Err(response) => return response_with_id(id, response), + }; + result_response(id, self.agents.set_run_state(¶ms.name, state).await) + } + + async fn handle_ssh_access(&self, id: u64, value: Value) -> Response { + let params = match name_params(value) { + Ok(params) => params, + Err(response) => return response_with_id(id, response), + }; + result_response(id, self.ssh.describe(¶ms.name).await) + } + + /// Long-polls for a change after the caller's revision, then returns the + /// Agent's status and progress. Draining returns at once so an upgrade is + /// never held by a follower. + async fn handle_progress(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response( + id, + CODE_INVALID_PARAMS, + "name is required, and after must be a revision", + ); + }; + tokio::select! { + _changed = self.changes.changed_since(params.after, PROGRESS_SETTLE, WATCH_KEEPALIVE) => {} + () = self.shutdown_requested() => {} + } + let revision = self.changes.revision(); + let progress = self + .agents + .progress(¶ms.name, params.output) + .await + .map(|(status, provisioning)| AgentProgress { + revision, + status, + provisioning, + }); + result_response(id, progress) + } + + /// Long-polls for a resource change after the caller's revision, then + /// returns every Agent and Session. Draining returns at once so an upgrade + /// is never held by a watcher. + async fn handle_resources_watch(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "after must be a resource revision"); + }; + tokio::select! { + _changed = self.changes.changed_since(params.after, WATCH_SETTLE, WATCH_KEEPALIVE) => {} + () = self.shutdown_requested() => {} + } + let revision = self.changes.revision(); + let resources = async { + Ok(crate::resources::Resources { + revision, + agents: self.agents.list().await?, + sessions: self.sessions.list(None).await?, + }) + }; + result_response(id, resources.await) + } + + async fn handle_vnc_access(&self, id: u64, value: Value) -> Response { + let params = match name_params(value) { + Ok(params) => params, + Err(response) => return response_with_id(id, response), + }; + result_response(id, self.vnc.describe(¶ms.name).await) + } + + async fn handle_execution_ensure(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "name is required"); + }; + if params.name.is_empty() { + return error_response(id, CODE_INVALID_PARAMS, "name is required"); + } + result_response( + id, + self.executions.ensure(¶ms.name, wait_policy(params.follow)).await, + ) + } + + async fn handle_auth_login(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "harness and credential are required"); + }; + result_response( + id, + self.authentication + .login(params.harness, ¶ms.credential, params.imported) + .await, + ) + } + + async fn handle_session_ensure(&self, id: u64, value: Value) -> Response { + let params = match serde_json::from_value::(value) { + Ok(params) => params, + // The selections carry their own validation, so name the decoding failure + // instead of blaming the two required fields. + Err(error) => { + return error_response( + id, + CODE_INVALID_PARAMS, + format!("agent and session name are required, and selections must be valid: {error}"), + ); + } + }; + let wait = wait_policy(params.follow); + let request = sessions::SessionRequest { + harness: params.harness, + model_selection: params.model_selection, + initial_prompt: params.initial_prompt, + }; + result_response( + id, + self.sessions.ensure(¶ms.agent, ¶ms.name, request, wait).await, + ) + } + + async fn handle_session_prompt(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "agent, session name and prompt are required"); + }; + result_response( + id, + self.sessions + .prompt(¶ms.agent, ¶ms.name, ¶ms.prompt, params.wait, params.timeout) + .await + .map(|()| serde_json::json!({})), + ) + } + + async fn handle_session_turns(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "agent and session name are required"); + }; + result_response(id, self.sessions.turns(¶ms.agent, ¶ms.name, params.last).await) + } + + async fn handle_session_get(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "agent and session name are required"); + }; + result_response(id, self.sessions.get(¶ms.agent, ¶ms.name).await) + } + + async fn handle_session_delete(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "agent and session name are required"); + }; + result_response( + id, + self.sessions + .delete(¶ms.agent, ¶ms.name) + .await + .map(|()| serde_json::json!({})), + ) + } + + async fn handle_session_archive(&self, id: u64, value: Value, archived: bool) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "agent and session name are required"); + }; + result_response( + id, + self.sessions.set_archived(¶ms.agent, ¶ms.name, archived).await, + ) + } + + async fn handle_session_list(&self, id: u64, value: Value) -> Response { + let Ok(params) = serde_json::from_value::(value) else { + return error_response(id, CODE_INVALID_PARAMS, "invalid Session list parameters"); + }; + result_response(id, self.sessions.list(params.agent.as_deref()).await) + } +} + +const fn wait_policy(follow: bool) -> WaitPolicy { + if follow { + WaitPolicy::UntilConverged + } else { + WaitPolicy::FirstPass + } +} + +/// Whether a request waits and may stop when its client disconnects. Each +/// only reads or waits, or, like `sessions.v1.ensure`, its writes are each +/// complete on their own. Every other request runs to completion, so an +/// interrupted command never leaves a change half made. +fn ends_with_its_client(method: &str) -> bool { + matches!( + method, + METHOD_PROGRESS + | METHOD_RESOURCES_WATCH + | METHOD_CONVERGE + | METHOD_EXECUTION_ENSURE + | METHOD_SESSION_ENSURE + | METHOD_SESSION_TURNS + ) +} + +/// Completes once the client has closed its end. A client sends nothing while +/// it waits for a reply, so data here is a pipelined request, left for the +/// next read. +async fn disconnected(reader: &mut R) { + use tokio::io::AsyncBufReadExt as _; + match reader.fill_buf().await { + Ok(buffer) if !buffer.is_empty() => std::future::pending().await, + Ok(_) | Err(_) => {} + } +} + +fn is_mutating(method: &str) -> bool { + matches!( + method, + METHOD_APPLY + | METHOD_DELETE + | METHOD_STOP + | METHOD_START + | METHOD_EXECUTION_ENSURE + | METHOD_AUTH_LOGIN + | METHOD_SESSION_ENSURE + | METHOD_SESSION_PROMPT + | METHOD_SESSION_DELETE + | METHOD_SESSION_ARCHIVE + | METHOD_SESSION_UNARCHIVE + ) +} + +fn name_params(value: Value) -> Result { + serde_json::from_value::(value) + .ok() + .filter(|params| !params.name.is_empty()) + .ok_or_else(|| error_response(0, CODE_INVALID_PARAMS, "name is required")) +} + +const fn response_with_id(id: u64, mut response: Response) -> Response { + response.id = id; + response +} + +fn result_response(id: u64, result: Result) -> Response { + match result { + Ok(value) => serde_json::to_value(value).map_or_else( + |_| error_response(id, CODE_INTERNAL, "encode response result"), + |result| Response { + jsonrpc: JSON_RPC_VERSION.into(), + id, + result: Some(result), + error: None, + }, + ), + Err(Error::NotFound) => error_response(id, CODE_NOT_FOUND, Error::NotFound.to_string()), + Err(Error::Immutable(field)) => error_response(id, CODE_IMMUTABLE, Error::Immutable(field).to_string()), + Err(Error::Conflict) => error_response(id, CODE_IMMUTABLE, Error::Conflict.to_string()), + Err(Error::Invalid(message)) => error_response(id, CODE_INVALID_PARAMS, message), + Err(error @ Error::Stopped(_)) => error_response(id, CODE_INVALID_PARAMS, error.to_string()), + Err(error) => error_response(id, CODE_INTERNAL, error.to_string()), + } +} + +async fn write_response(writer: &mut W, response: &Response) -> Result<(), Error> { + let mut bytes = serde_json::to_vec(response)?; + bytes.push(b'\n'); + writer.write_all(&bytes).await?; + writer.flush().await?; + Ok(()) +} diff --git a/agentctl/src/control_api/socket.rs b/agentctl/src/control_api/socket.rs new file mode 100644 index 0000000..fa26e49 --- /dev/null +++ b/agentctl/src/control_api/socket.rs @@ -0,0 +1,232 @@ +use std::{path::PathBuf, rc::Rc, time::Duration}; + +use futures_util::{FutureExt as _, StreamExt as _, stream::FuturesUnordered}; +use sandbox::LocalFuture; + +use crate::Error; + +use super::{Connector, Server, client::Connection}; + +const MAX_CONCURRENT_CONNECTIONS: usize = 128; +const CONNECTION_DRAIN_TIMEOUT: Duration = Duration::from_mins(1); +type ConnectionFuture = futures_util::future::LocalBoxFuture<'static, ()>; + +async fn drain_connections(connections: &mut FuturesUnordered, timeout: Duration) { + if tokio::time::timeout(timeout, async { while connections.next().await.is_some() {} }) + .await + .is_err() + { + tracing::warn!("cancelled Control API calls that did not finish during the shutdown drain"); + } +} + +/// Connector for the fixed per-user Agent Control API socket path. +pub(super) struct PathConnector { + path: PathBuf, +} + +impl PathConnector { + #[must_use] + pub(super) const fn new(path: PathBuf) -> Self { + Self { path } + } +} + +#[cfg(unix)] +impl Connector for PathConnector { + fn connect(&self) -> LocalFuture<'_, Result, Error>> { + Box::pin(async move { + let stream = tokio::net::UnixStream::connect(&self.path).await?; + Ok(Box::new(stream) as Box) + }) + } +} + +#[cfg(target_os = "windows")] +impl Connector for PathConnector { + fn connect(&self) -> LocalFuture<'_, Result, Error>> { + Box::pin(async move { + use tokio_util::compat::FuturesAsyncReadCompatExt as _; + + let stream = win_uds::net::AsyncStream::connect(&self.path).await?.compat(); + Ok(Box::new(stream) as Box) + }) + } +} + +#[cfg(unix)] +pub(crate) async fn serve(server: Rc, path: &std::path::Path) -> Result<(), Error> { + use std::os::unix::fs::FileTypeExt; + + let parent = path + .parent() + .ok_or_else(|| Error::Invalid("local API socket has no parent directory".into()))?; + std::fs::create_dir_all(parent)?; + crate::local::home::secure_directory(parent)?; + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_socket() => std::fs::remove_file(path)?, + Ok(_) => return Err(Error::Invalid("local API path exists and is not a socket".into())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(Error::Io(error)), + } + let listener = tokio::net::UnixListener::bind(path)?; + crate::local::home::secure_file(path)?; + let mut connections = FuturesUnordered::::new(); + + loop { + if server.is_draining() { + break; + } + tokio::select! { + accepted = listener.accept(), if connections.len() < MAX_CONCURRENT_CONNECTIONS => { + let (stream, _) = accepted?; + let connection_server = server.clone(); + connections.push(async move { + if let Err(error) = connection_server.serve_connection(stream).await { + connection_server.report(&error); + } + }.boxed_local()); + } + Some(()) = connections.next(), if !connections.is_empty() => {} + () = server.shutdown_requested() => break, + } + } + drain_connections(&mut connections, CONNECTION_DRAIN_TIMEOUT).await; + Ok(()) +} + +#[cfg(target_os = "windows")] +pub(crate) async fn serve(server: Rc, path: &std::path::Path) -> Result<(), Error> { + use tokio_util::compat::FuturesAsyncReadCompatExt as _; + + let parent = path + .parent() + .ok_or_else(|| Error::Invalid("local API socket has no parent directory".into()))?; + std::fs::create_dir_all(parent)?; + crate::local::home::secure_directory(parent)?; + sweep_quarantined_socket_directories(parent); + match std::fs::symlink_metadata(path) { + Ok(_) => { + // Windows leaves the AF_UNIX path behind after an abnormal exit. + // Refuse a path with a live listener, but remove an unreachable + // entry before binding. agentd holds the exclusive home lock while + // calling this function, so another daemon cannot race recovery. + if win_uds::net::AsyncStream::connect(path).await.is_ok() { + return Err(Error::Invalid("local API path is already occupied".into())); + } + // afd.sys can keep a stale socket file undeletable and unbindable + // until reboot. Renaming its directory aside still works then, so + // quarantine it and recreate the directory before binding. + if std::fs::remove_file(path).is_err() { + quarantine_socket_directory(parent)?; + std::fs::create_dir_all(parent)?; + crate::local::home::secure_directory(parent)?; + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(Error::Io(error)), + } + // The socket inherits the user-only ACL from the home directory secured + // above; icacls cannot open an AF_UNIX socket reparse point (error 1920). + let listener = win_uds::net::AsyncListener::bind(path)?; + let _cleanup = SocketCleanup(path.to_path_buf()); + let mut connections = FuturesUnordered::::new(); + + loop { + if server.is_draining() { + break; + } + tokio::select! { + accepted = listener.accept(), if connections.len() < MAX_CONCURRENT_CONNECTIONS => { + let (stream, _) = accepted?; + let connection_server = server.clone(); + connections.push(async move { + if let Err(error) = connection_server.serve_connection(stream.compat()).await { + connection_server.report(&error); + } + }.boxed_local()); + } + Some(()) = connections.next(), if !connections.is_empty() => {} + () = server.shutdown_requested() => break, + } + } + drain_connections(&mut connections, CONNECTION_DRAIN_TIMEOUT).await; + Ok(()) +} + +#[cfg(target_os = "windows")] +const QUARANTINE_INFIX: &str = ".stale-"; +#[cfg(target_os = "windows")] +const QUARANTINE_ATTEMPTS: u32 = 1000; + +/// Renames the socket directory to an unused `.stale-` sibling. +#[cfg(target_os = "windows")] +fn quarantine_socket_directory(directory: &std::path::Path) -> Result<(), Error> { + let name = directory + .file_name() + .ok_or_else(|| Error::Invalid("local API socket directory has no name".into()))?; + for attempt in 0..QUARANTINE_ATTEMPTS { + let mut candidate = name.to_os_string(); + candidate.push(format!("{QUARANTINE_INFIX}{attempt}")); + let candidate = directory.with_file_name(candidate); + if candidate.exists() { + continue; + } + match std::fs::rename(directory, &candidate) { + Ok(()) => return Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(Error::Io(error)), + } + } + Err(Error::Invalid( + "no free quarantine name for the local API socket directory".into(), + )) +} + +/// Best-effort removal of quarantined socket directories; stale `AF_UNIX` +/// files become deletable again after a reboot. +#[cfg(target_os = "windows")] +fn sweep_quarantined_socket_directories(directory: &std::path::Path) { + let (Some(parent), Some(name)) = (directory.parent(), directory.file_name().and_then(|name| name.to_str())) else { + return; + }; + let prefix = format!("{name}{QUARANTINE_INFIX}"); + let Ok(entries) = std::fs::read_dir(parent) else { + return; + }; + for entry in entries.flatten() { + if entry.file_name().to_str().is_some_and(|name| name.starts_with(&prefix)) { + let _ignored = std::fs::remove_dir_all(entry.path()); + } + } +} + +#[cfg(target_os = "windows")] +struct SocketCleanup(PathBuf); + +#[cfg(target_os = "windows")] +impl Drop for SocketCleanup { + fn drop(&mut self) { + let _ignored = std::fs::remove_file(&self.0); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test(flavor = "current_thread")] + async fn shutdown_drain_is_bounded_by_its_deadline() { + let mut connections = FuturesUnordered::new(); + connections.push(std::future::pending::<()>().boxed_local()); + + tokio::time::timeout( + Duration::from_millis(100), + drain_connections(&mut connections, Duration::ZERO), + ) + .await + .expect("bounded drain"); + assert_eq!(connections.len(), 1); + drop(connections); + } +} diff --git a/agentctl/src/control_plane/controller.rs b/agentctl/src/control_plane/controller.rs new file mode 100644 index 0000000..7ce01fe --- /dev/null +++ b/agentctl/src/control_plane/controller.rs @@ -0,0 +1,49 @@ +//! Agent specialization of the generic keyed reconciliation controller. + +use std::{rc::Rc, time::Duration}; + +use crate::{Error, control_plane::AgentId, controller}; + +use super::SharedAgentStore; + +/// Observes recoverable Agent reconciliation errors without stopping the controller. +pub type ErrorHandler = controller::ErrorHandler; + +/// A handle for requesting immediate Agent convergence. +pub type Wakeup = controller::Wakeup; + +struct Source(SharedAgentStore); + +impl controller::Source for Source { + fn list_keys(&self) -> ::sandbox::LocalFuture<'_, Result, Error>> { + Box::pin(async move { + self.0 + .list() + .await + .map(|records| records.into_iter().map(|record| record.id).collect()) + }) + } +} + +/// Generic keyed reconciliation specialized for durable Agents. +pub struct Controller(controller::Controller); + +impl Controller { + /// Creates an Agent controller and its independently shareable wake-up handle. + #[must_use] + pub fn new( + store: SharedAgentStore, + reconciler: Rc>, + interval: Duration, + on_error: ErrorHandler, + ) -> (Self, Wakeup) { + let (controller, wakeup) = + controller::Controller::new(Rc::new(Source(store)), reconciler, interval, "Agent", on_error); + (Self(controller), wakeup) + } + + /// Reconciles existing Agents immediately and then continuously. + pub async fn run(self) { + self.0.run().await; + } +} diff --git a/agentctl/src/control_plane/convergence.rs b/agentctl/src/control_plane/convergence.rs new file mode 100644 index 0000000..0c7680c --- /dev/null +++ b/agentctl/src/control_plane/convergence.rs @@ -0,0 +1,128 @@ +//! Requests converging an Agent and waiting for the outcome. +//! +//! A waiter reads readiness and failure from the stored Agent and rereads it +//! whenever the daemon-wide revision advances, so it can skip intermediate +//! states but never miss the terminal one. Progress is observed separately, +//! through the Agent's provisioning state. + +use std::time::Duration; + +use crate::{AgentId, Error, FailureKind, ReconcileFailure, resources::Changes}; + +use super::{SharedAgentStore, Wakeup}; + +/// Longest a waiter goes without rereading the stored Agent. +const RECHECK_INTERVAL: Duration = Duration::from_secs(30); +/// How long a waiter lets changes gather before rereading the stored Agent. +/// Every progress event of any Agent advances the revision, so a waiter +/// rereads once per burst instead of once per event. +const SETTLE: Duration = Duration::from_millis(50); + +/// How long a request waits for the Agent it woke. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WaitPolicy { + /// Returns after one reconciliation pass with that pass's outcome. + FirstPass, + /// Keeps waiting through transient failures, which the background controller + /// retries, until the Agent has its desired run state or that state is + /// invalid. A running Agent's guest recorded as unresponsive ends the wait + /// instead. + UntilConverged, +} + +/// Wakes Agent convergence and lets a request wait for it. +#[derive(Clone)] +pub struct Convergence { + wakeup: Wakeup, + store: SharedAgentStore, + changes: Changes, +} + +impl Convergence { + /// Pairs the controller's wake-up handle with the stored Agents and their revision. + #[must_use] + pub fn new(wakeup: Wakeup, store: SharedAgentStore, changes: Changes) -> Self { + Self { wakeup, store, changes } + } + + /// Wakes convergence of the named Agent, waits according to `wait`, and + /// returns the Agent as stored when the wait ended. Converged means its + /// desired run state was recorded for its generation: Ready when it runs, + /// stopped when it is stopped. A caller that needs the Sandbox running + /// refuses a stopped Agent itself, before and after converging. + /// + /// # Errors + /// + /// Returns `Error::Invalid` when desired state must change, the first pass's + /// failure under [`WaitPolicy::FirstPass`], `Error::SandboxUnresponsive` + /// when a running Agent's guest is unresponsive, `Error::Conflict` when the + /// Agent is being or was deleted, `Error::NotFound` when it does not exist, + /// or a storage error. + pub async fn converge(&self, name: &str, wait: WaitPolicy) -> Result { + let record = self.store.get_by_name(name).await?; + if record.agent.metadata.deletion_timestamp.is_some() { + return Err(Error::Conflict); + } + let id = record.id; + let woken = self.wakeup.reconcile(id).await; + let record = self.get(id).await?; + match (wait, woken) { + (WaitPolicy::FirstPass, Ok(())) => return Ok(record), + (WaitPolicy::FirstPass, Err(failure)) => { + // A stalled guest is reported as the stall, not as a daemon failure. + return Err(record.agent.status.unresponsive().map_or_else( + || failure.into(), + |stalled| Error::SandboxUnresponsive(stalled.detail()), + )); + } + (WaitPolicy::UntilConverged, Err(failure)) if failure.kind == FailureKind::Invalid => { + return Err(failure.into()); + } + (WaitPolicy::UntilConverged, _) => {} + } + loop { + let revision = self.changes.revision(); + let record = self.get(id).await?; + if let Some(outcome) = outcome(&record) { + return outcome.map(|()| record); + } + self.changes + .changed_since(Some(revision), SETTLE, RECHECK_INTERVAL) + .await; + } + } + + /// Reads the waited-on Agent, which must still exist. + async fn get(&self, id: AgentId) -> Result { + match self.store.get(id).await { + Ok(record) if record.agent.metadata.deletion_timestamp.is_none() => Ok(record), + Ok(_) | Err(Error::NotFound) => Err(Error::Conflict), + Err(error) => Err(error), + } + } +} + +/// Ends a wait on `record`: `Ok` once a pass for its generation recorded its +/// desired run state, an error when it cannot get there without a change, and +/// `None` while it still may. +fn outcome(record: &super::AgentRecord) -> Option> { + let status = &record.agent.status; + let stopped = record.agent.spec.is_stopped(); + if status.observed_generation == record.agent.metadata.generation { + if (stopped && status.is_stopped()) || (!stopped && status.is_ready()) { + return Some(Ok(())); + } + if let Some(message) = status.invalid() { + return Some(Err(ReconcileFailure { + kind: FailureKind::Invalid, + message, + } + .into())); + } + } + // A stopped Agent reaches nothing in its guest, so a stall recorded before it stopped is stale. + if !stopped && let Some(stalled) = status.unresponsive() { + return Some(Err(Error::SandboxUnresponsive(stalled.detail()))); + } + None +} diff --git a/agentctl/src/control_plane/memory.rs b/agentctl/src/control_plane/memory.rs new file mode 100644 index 0000000..f7f3816 --- /dev/null +++ b/agentctl/src/control_plane/memory.rs @@ -0,0 +1,181 @@ +//! Single-threaded in-memory Agent Control Plane components. + +use std::{cell::RefCell, collections::BTreeMap}; + +use sandbox::LocalFuture; +use time::OffsetDateTime; + +use crate::{AgentId, Error, Status, resources::Changes}; + +use super::{AgentRecord, AgentStore}; + +/// In-memory Agent store with generation-based compare-and-swap writes. +/// +/// Like the database, every successful write advances its change history. +#[derive(Default)] +pub struct InMemoryAgentStore { + state: RefCell, + changes: Changes, +} + +#[derive(Default)] +struct State { + records: BTreeMap, + active_names: BTreeMap, +} + +impl InMemoryAgentStore { + /// Creates an empty store. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Creates an empty store whose writes advance `changes`. + #[must_use] + pub fn with_changes(changes: Changes) -> Self { + Self { + changes, + ..Self::default() + } + } + + fn changed(&self, result: Result) -> Result { + if result.is_ok() { + self.changes.bump(); + } + result + } +} + +impl AgentStore for InMemoryAgentStore { + fn get(&self, id: AgentId) -> LocalFuture<'_, Result> { + Box::pin(async move { + let state = self.state.borrow(); + let record = state.records.get(&id).ok_or(Error::NotFound)?; + (state.active_names.get(&record.agent.metadata.name) == Some(&id)) + .then(|| record.clone()) + .ok_or(Error::NotFound) + }) + } + + fn get_by_name<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result> { + Box::pin(async move { + let state = self.state.borrow(); + let id = state.active_names.get(name).ok_or(Error::NotFound)?; + state.records.get(id).cloned().ok_or(Error::NotFound) + }) + } + + fn list(&self) -> LocalFuture<'_, Result, Error>> { + Box::pin(async move { + let state = self.state.borrow(); + Ok(state + .active_names + .values() + .filter_map(|id| state.records.get(id)) + .cloned() + .collect()) + }) + } + + fn put(&self, mut record: AgentRecord, expected_generation: u64) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + let result = (|| { + record.agent.status.progress = None; + record.agent.status.provenance = None; + let id = record.id; + let name = record.agent.metadata.name.clone(); + let mut state = self.state.borrow_mut(); + if expected_generation == 0 { + if state.active_names.contains_key(&name) || state.records.contains_key(&id) { + return Err(Error::Conflict); + } + state.active_names.insert(name, id); + state.records.insert(id, record); + return Ok(()); + } + + let active_id = state.active_names.get(&name).copied().ok_or(Error::Conflict)?; + if active_id != id { + return Err(Error::Conflict); + } + let current = state.records.get_mut(&id).ok_or(Error::Conflict)?; + if current.agent.metadata.generation != expected_generation + || current.agent.metadata.deletion_timestamp.is_some() + { + return Err(Error::Conflict); + } + *current = record; + Ok(()) + })(); + self.changed(result) + }) + } + + fn update_status( + &self, + id: AgentId, + generation: u64, + mut status: Status, + ) -> LocalFuture<'_, Result> { + Box::pin(async move { + let result = (|| { + status.progress = None; + status.provenance = None; + let mut state = self.state.borrow_mut(); + let name = state + .records + .get(&id) + .map(|record| record.agent.metadata.name.clone()) + .ok_or(Error::NotFound)?; + if state.active_names.get(&name) != Some(&id) { + return Err(Error::NotFound); + } + let record = state.records.get_mut(&id).ok_or(Error::NotFound)?; + if record.agent.metadata.generation != generation { + return Err(Error::Conflict); + } + status.stamp_transitions(&record.agent.status, OffsetDateTime::now_utc()); + record.agent.status = status.clone(); + Ok(status) + })(); + self.changed(result) + }) + } + + fn mark_deleting<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result> { + Box::pin(async move { + let result = (|| { + let mut state = self.state.borrow_mut(); + let id = state.active_names.get(name).copied().ok_or(Error::NotFound)?; + let record = state.records.get_mut(&id).ok_or(Error::NotFound)?; + if record.agent.metadata.deletion_timestamp.is_none() { + record.agent.metadata.deletion_timestamp = Some(OffsetDateTime::now_utc()); + } + Ok(record.clone()) + })(); + self.changed(result) + }) + } + + fn finalize_deletion(&self, id: AgentId, generation: u64) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + let result = (|| { + let mut state = self.state.borrow_mut(); + let record = state.records.get_mut(&id).ok_or(Error::NotFound)?; + if record.agent.metadata.generation != generation || record.agent.metadata.deletion_timestamp.is_none() + { + return Err(Error::Conflict); + } + let name = record.agent.metadata.name.clone(); + if state.active_names.get(&name) != Some(&id) { + return Err(Error::NotFound); + } + state.active_names.remove(&name); + Ok(()) + })(); + self.changed(result) + }) + } +} diff --git a/agentctl/src/control_plane/mod.rs b/agentctl/src/control_plane/mod.rs new file mode 100644 index 0000000..d76d735 --- /dev/null +++ b/agentctl/src/control_plane/mod.rs @@ -0,0 +1,48 @@ +//! Declarative Agent storage, reconciliation, and continuous repair. + +mod controller; +mod convergence; +pub mod memory; +mod reconciler; +mod resource; +mod service; + +use std::rc::Rc; + +use ::sandbox::LocalFuture; + +use crate::{Error, Status}; + +pub use controller::{Controller, ErrorHandler, Wakeup}; +pub use convergence::{Convergence, WaitPolicy}; +pub use reconciler::{Reconciler, SessionNotifier}; +pub use resource::{AgentId, AgentRecord, ENV_FILE}; +pub use service::{ApplyRequest, ControlPlane, Notifier}; + +/// Separates desired-state writes from reconciler status writes using generation checks. +pub trait AgentStore { + /// Gets an active Agent record by immutable identity. + fn get(&self, id: AgentId) -> LocalFuture<'_, Result>; + + /// Gets an active Agent record by its user-facing name. + fn get_by_name<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result>; + + /// Lists an independent snapshot of every record. + fn list(&self) -> LocalFuture<'_, Result, Error>>; + + /// Creates or replaces desired state if the stored generation still matches. + fn put(&self, record: AgentRecord, expected_generation: u64) -> LocalFuture<'_, Result<(), Error>>; + + /// Replaces observed state if the reconciled generation is still current, + /// stamping condition transition times against the stored status, and + /// returns the status as stored. + fn update_status(&self, id: AgentId, generation: u64, status: Status) -> LocalFuture<'_, Result>; + + /// Atomically records the first deletion request. + fn mark_deleting<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result>; + + /// Finalizes a soft-deleted record if its desired generation has not changed. + fn finalize_deletion(&self, id: AgentId, generation: u64) -> LocalFuture<'_, Result<(), Error>>; +} + +pub(crate) type SharedAgentStore = Rc; diff --git a/agentctl/src/control_plane/reconciler.rs b/agentctl/src/control_plane/reconciler.rs new file mode 100644 index 0000000..405b5e8 --- /dev/null +++ b/agentctl/src/control_plane/reconciler.rs @@ -0,0 +1,527 @@ +use std::rc::Rc; + +use crate::{Condition, ConditionStatus, Error, FailureKind, ReconcileFailure, Status}; + +use super::{AgentRecord, SharedAgentStore}; +use crate::progress::{ProvisioningState, SandboxObserver}; +use crate::sandbox::responsiveness::stall_detail; + +/// Receives low-latency hints when an Agent transition affects its Sessions. +pub trait SessionNotifier { + /// Wakes every durable Session owned by the Agent incarnation. + fn notify(&self, id: crate::AgentId); + + /// Reconciles every durable Session owned by the Agent incarnation and + /// completes once each has finished a pass that began after this call. + fn settle(&self, id: crate::AgentId) -> ::sandbox::LocalFuture<'_, ()>; +} + +/// Converges one stored Agent generation without owning an API request. +pub struct Reconciler { + store: SharedAgentStore, + sandboxes: Rc, + sessions: Option>, + ssh: Option>, + vnc: Option>, + provisioning: ProvisioningState, +} + +impl Reconciler { + /// Creates an Agent reconciler over persistent resources and runtime-resolved Sandboxes. + #[must_use] + pub fn new( + store: SharedAgentStore, + sandboxes: Rc, + provisioning: ProvisioningState, + ) -> Self { + Self { + store, + sandboxes, + sessions: None, + ssh: None, + vnc: None, + provisioning, + } + } + + /// Reconciles declared SSH access after the Sandbox is set up. + #[must_use] + pub fn with_ssh_access(mut self, ssh: Rc) -> Self { + self.ssh = Some(ssh); + self + } + + /// Reconciles declared VNC access after the Sandbox is set up. + #[must_use] + pub fn with_vnc_access(mut self, vnc: Rc) -> Self { + self.vnc = Some(vnc); + self + } + + /// Wakes dependent Sessions when readiness or Sandbox identity changes. + #[must_use] + pub fn with_session_notifier(mut self, sessions: Rc) -> Self { + self.sessions = Some(sessions); + self + } + + /// Converges the latest generation of one Agent and records what it observed. + /// + /// # Errors + /// + /// Returns an error when storage or sandbox lifecycle convergence fails. + pub async fn reconcile(&self, id: crate::AgentId) -> Result<(), Error> { + let mut record = match self.store.get(id).await { + Ok(record) => record, + Err(Error::NotFound) => return Ok(()), + Err(error) => return Err(error), + }; + if record.agent.metadata.deletion_timestamp.is_some() { + return self.release(&record).await; + } + if record.agent.spec.is_stopped() { + return self.stop(&record).await; + } + + if record.agent.status.sandbox.is_none() { + let provider = match self.sandboxes.resolve(&record).await { + Ok(provider) => provider, + Err(error) => { + self.record_failure(&record, "ProviderResolutionFailed", &ReconcileFailure::classify(&error)) + .await?; + return Err(error); + } + }; + let status = Status::observed( + record.agent.metadata.generation, + Some(crate::sandbox::Assignment::Selected { provider }), + vec![condition( + Condition::READY, + ConditionStatus::False, + "ProviderSelected", + "Sandbox provisioning has not completed", + )], + ); + record.agent.status = self.update_status(&record, status, None).await?; + } + + if recorded_by_stop(&record.agent.status) { + let starting = not_ready(&record, Condition::REASON_STARTING, ""); + record.agent.status = self.update_status(&record, starting, None).await?; + } + + let status = &record.agent.status; + let observer = if status.is_ready() && status.observed_generation == record.agent.metadata.generation { + SandboxObserver::resync(record.id, self.provisioning.clone()) + } else { + SandboxObserver::new(record.id, self.provisioning.clone()) + }; + let ensured = match self.sandboxes.ensure(&record, observer.reporter()).await { + Ok(ensured) => ensured, + Err(error @ Error::SandboxUnresponsive(_)) => { + let assignment = record.agent.status.sandbox.clone(); + return self.record_unresponsive(&record, assignment, &observer, error).await; + } + Err(error) => return self.record_ensure_failure(&record, &observer, error).await, + }; + + let provider = record + .agent + .status + .sandbox + .as_ref() + .ok_or_else(|| Error::Database("persisted Sandbox Provider assignment disappeared".into()))? + .provider() + .clone(); + + let assignment = crate::sandbox::Assignment::Materialized { + provider, + id: ensured.id, + harnesses: ensured.harnesses.clone(), + }; + let mut conditions = vec![condition( + Condition::SANDBOX_READY, + ConditionStatus::True, + "SandboxRunning", + "", + )]; + self.reconcile_declared_access(&record, &ensured.sandbox, &assignment, &mut conditions, &observer) + .await?; + conditions.insert( + 1, + responsive_condition(self.sandboxes.reports_heartbeat(&ensured.sandbox.snapshot().id)), + ); + conditions.push(condition(Condition::READY, ConditionStatus::True, "SandboxReady", "")); + let status = Status::observed(record.agent.metadata.generation, Some(assignment), conditions); + // As on failure, readiness is stored before followers see the pass end. + self.update_status(&record, status, None).await?; + observer.succeeded(); + if ensured.runtime_restarted { + self.notify_sessions(record.id); + } + Ok(()) + } + + /// Reconciles every access capability the platform offers, in order. + async fn reconcile_declared_access( + &self, + record: &AgentRecord, + sandbox: &::sandbox::SandboxHandle, + assignment: &crate::sandbox::Assignment, + conditions: &mut Vec, + observer: &SandboxObserver, + ) -> Result<(), Error> { + let id = &sandbox.snapshot().id; + if let Some(ssh) = &self.ssh { + let pass = self.sandboxes.guard_guest(record, id, ssh.reconcile(record, sandbox)); + self.reconcile_access(SSH, pass, record, assignment, conditions, observer) + .await?; + } + if let Some(vnc) = &self.vnc { + let pass = self.sandboxes.guard_guest(record, id, vnc.reconcile(record, sandbox)); + self.reconcile_access(VNC, pass, record, assignment, conditions, observer) + .await?; + } + Ok(()) + } + + /// Runs one access capability's pass and appends its condition. A failure + /// is recorded as the Agent's `Ready=False` before it is returned. + async fn reconcile_access( + &self, + kind: AccessKind, + pass: impl Future>, + record: &AgentRecord, + assignment: &crate::sandbox::Assignment, + conditions: &mut Vec, + observer: &SandboxObserver, + ) -> Result<(), Error> { + let phase = if (kind.declared)(&record.agent.spec) { + Some(observer.reporter().start_phase(kind.phase).await) + } else { + None + }; + match pass.await { + Ok(true) => { + if let Some(phase) = phase { + phase.complete().await; + } + conditions.push(condition(kind.condition, ConditionStatus::True, kind.ready_reason, "")); + Ok(()) + } + Ok(false) => Ok(()), + Err(error @ Error::SandboxUnresponsive(_)) => { + conditions.clear(); + self.record_unresponsive(record, Some(assignment.clone()), observer, error) + .await + } + Err(error) => { + let failure = ReconcileFailure::classify(&error); + conditions.push(condition( + kind.condition, + ConditionStatus::False, + "ReconcileFailed", + &failure.message, + )); + conditions.push(condition( + Condition::READY, + ConditionStatus::False, + kind.failed_reason, + &failure.message, + )); + let status = Status::observed( + record.agent.metadata.generation, + Some(assignment.clone()), + std::mem::take(conditions), + ); + let stored = self.update_status(record, status, Some(failure.kind)).await; + observer.failed(&failure); + stored?; + Err(error) + } + } + } + + /// Records a failed Sandbox ensure or setup as the Agent's `Ready=False` and returns `error`. + async fn record_ensure_failure( + &self, + record: &AgentRecord, + observer: &SandboxObserver, + error: Error, + ) -> Result<(), Error> { + let failure = ReconcileFailure::classify(&error); + let message = error.to_string(); + let status = Status::observed( + record.agent.metadata.generation, + record.agent.status.sandbox.clone(), + vec![ + condition( + Condition::READY, + ConditionStatus::False, + "SandboxReconcileFailed", + &message, + ), + condition( + Condition::SANDBOX_READY, + ConditionStatus::False, + "ReconcileFailed", + &message, + ), + ], + ); + // The failure class is stored before followers see the pass fail. + let stored = self.update_status(record, status, Some(failure.kind)).await; + observer.failed(&failure); + stored?; + Err(error) + } + + /// Records that the Sandbox's guest stopped responding and returns `error`. + /// + /// A stall is only found in work after the Sandbox started, so the Sandbox + /// itself is running; only the guest inside it has stopped. + async fn record_unresponsive( + &self, + record: &AgentRecord, + assignment: Option, + observer: &SandboxObserver, + error: Error, + ) -> Result<(), Error> { + let failure = ReconcileFailure::classify(&error); + let mut conditions = vec![condition( + Condition::SANDBOX_READY, + ConditionStatus::True, + "SandboxRunning", + "", + )]; + conditions.push(condition( + Condition::SANDBOX_RESPONSIVE, + ConditionStatus::False, + "HeartbeatStale", + &stall_detail(), + )); + conditions.push(condition( + Condition::READY, + ConditionStatus::False, + "SandboxUnresponsive", + &failure.message, + )); + let status = Status::observed(record.agent.metadata.generation, assignment, conditions); + let stored = self.update_status(record, status, Some(failure.kind)).await; + observer.failed(&failure); + stored?; + Err(error) + } + + /// Stops the Sandbox of an Agent whose run state is Stopped and records it + /// as stopped. The Sandbox keeps its identity, storage and assignment, so a + /// start boots the same disk. Nothing reaches into the guest, so a guest + /// that stopped responding cannot hold the stop up. + async fn stop(&self, record: &AgentRecord) -> Result<(), Error> { + let current = record.agent.status.observed_generation == record.agent.metadata.generation; + let already_stopped = current && record.agent.status.is_stopped(); + if !(current && recorded_by_stop(&record.agent.status)) { + // Not Ready before the VM goes away, so Sessions are told and go Idle first. + let stopping = not_ready(record, Condition::REASON_STOPPING, ""); + self.update_status(record, stopping, None).await?; + } + let observer = if already_stopped { + SandboxObserver::resync(record.id, self.provisioning.clone()) + } else { + SandboxObserver::new(record.id, self.provisioning.clone()) + }; + let phase = observer.reporter().start_phase(crate::progress::SANDBOX_STOP).await; + if let Err(error) = self.sandboxes.stop(record).await { + let failure = ReconcileFailure::classify(&error); + let stored = self.record_failure(record, Condition::REASON_STOPPING, &failure).await; + observer.failed(&failure); + stored?; + return Err(error); + } + phase.complete().await; + let hint = format!("run `agentctl start agent/{}` to start it", record.agent.metadata.name); + let stopped = Status::observed( + record.agent.metadata.generation, + record.agent.status.sandbox.clone(), + vec![ + condition( + Condition::SANDBOX_READY, + ConditionStatus::False, + Condition::REASON_STOPPED, + "", + ), + condition( + Condition::READY, + ConditionStatus::False, + Condition::REASON_STOPPED, + &hint, + ), + ], + ); + self.update_status(record, stopped, None).await?; + if !already_stopped && let Some(sessions) = &self.sessions { + // The next pass, such as a start, waits until every Session has seen + // the stop, so a Session pass that began before it cannot relaunch + // its harness in the started VM. + sessions.settle(record.id).await; + } + observer.succeeded(); + Ok(()) + } + + async fn release(&self, record: &AgentRecord) -> Result<(), Error> { + self.sandboxes.release(record).await?; + if let Some(ssh) = &self.ssh { + ssh.remove(record).await?; + } + if let Some(vnc) = &self.vnc { + vnc.forget(record.id); + } + self.notify_sessions(record.id); + self.store + .finalize_deletion(record.id, record.agent.metadata.generation) + .await?; + self.provisioning.forget(record.id); + Ok(()) + } + + async fn record_failure( + &self, + record: &AgentRecord, + reason: &str, + failure: &ReconcileFailure, + ) -> Result<(), Error> { + self.update_status(record, not_ready(record, reason, &failure.message), Some(failure.kind)) + .await + .map(drop) + } + + /// Records the pass's observed status and failure class and returns it as stored. + async fn update_status( + &self, + record: &AgentRecord, + mut status: Status, + failure: Option, + ) -> Result { + status.failure = failure; + // A resync that observes what is already stored writes nothing, so it + // advances no revision and wakes no watcher. + let stored = Status { + progress: None, + provenance: None, + ..record.agent.status.clone() + }; + let mut unchanged = status.clone(); + unchanged.stamp_transitions(&stored, time::OffsetDateTime::UNIX_EPOCH); + if unchanged == stored { + return Ok(stored); + } + let notify = session_relevant_transition(&record.agent.status, &status); + let stored = self + .store + .update_status(record.id, record.agent.metadata.generation, status) + .await?; + if notify { + self.notify_sessions(record.id); + } + Ok(stored) + } + + fn notify_sessions(&self, id: crate::AgentId) { + if let Some(sessions) = &self.sessions { + sessions.notify(id); + } + } +} + +impl crate::controller::Reconcile for Reconciler { + fn reconcile(&self, id: crate::AgentId) -> ::sandbox::LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { Self::reconcile(self, id).await }) + } +} + +/// How one access capability reports its pass in progress and conditions. +struct AccessKind { + declared: fn(&crate::Spec) -> bool, + phase: ::sandbox::Phase, + condition: &'static str, + ready_reason: &'static str, + failed_reason: &'static str, +} + +const SSH: AccessKind = AccessKind { + declared: crate::Spec::ssh_access, + phase: crate::progress::SSH_ACCESS, + condition: Condition::SSH_READY, + ready_reason: "ServerRunning", + failed_reason: "SshAccessFailed", +}; + +const VNC: AccessKind = AccessKind { + declared: crate::Spec::vnc_access, + phase: crate::progress::VNC_ACCESS, + condition: Condition::VNC_READY, + ready_reason: "BridgeListening", + failed_reason: "VncAccessFailed", +}; + +fn condition(kind: &str, status: ConditionStatus, reason: &str, message: &str) -> Condition { + Condition { + kind: kind.into(), + status, + reason: reason.into(), + message: message.into(), + last_transition_time: None, + } +} + +/// Reports the guest's heartbeat after a pass whose guest work finished. A +/// Sandbox that reports no heartbeat gives no evidence either way. +fn responsive_condition(reports_heartbeat: bool) -> Condition { + if reports_heartbeat { + condition( + Condition::SANDBOX_RESPONSIVE, + ConditionStatus::True, + "HeartbeatAdvancing", + "", + ) + } else { + condition( + Condition::SANDBOX_RESPONSIVE, + ConditionStatus::Unknown, + "HeartbeatNotObserved", + "", + ) + } +} + +/// A status with only `Ready=False` for `reason`, keeping the record's Sandbox assignment. +fn not_ready(record: &AgentRecord, reason: &str, message: &str) -> Status { + Status::observed( + record.agent.metadata.generation, + record.agent.status.sandbox.clone(), + vec![condition(Condition::READY, ConditionStatus::False, reason, message)], + ) +} + +/// Whether `status` was recorded by a pass that stopped, or tried to stop, the Sandbox. +fn recorded_by_stop(status: &Status) -> bool { + status + .ready_condition() + .is_some_and(|ready| ready.reason == Condition::REASON_STOPPED || ready.reason == Condition::REASON_STOPPING) +} + +fn session_relevant_transition(previous: &Status, current: &Status) -> bool { + previous.is_ready() != current.is_ready() + || recorded_by_stop(previous) != recorded_by_stop(current) + || previous.sandbox.as_ref().and_then(crate::sandbox::Assignment::id) + != current.sandbox.as_ref().and_then(crate::sandbox::Assignment::id) + || previous + .sandbox + .as_ref() + .and_then(crate::sandbox::Assignment::installed_harnesses) + != current + .sandbox + .as_ref() + .and_then(crate::sandbox::Assignment::installed_harnesses) +} diff --git a/agentctl/src/control_plane/resource.rs b/agentctl/src/control_plane/resource.rs new file mode 100644 index 0000000..4d389da --- /dev/null +++ b/agentctl/src/control_plane/resource.rs @@ -0,0 +1,99 @@ +//! Internal identity and persisted representation of an Agent resource. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{Agent, Error}; + +/// Immutable identity of one Agent incarnation. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct AgentId(Uuid); + +impl AgentId { + pub(crate) fn generate() -> Self { + Self(Uuid::new_v4()) + } + + /// Returns the underlying UUID. + #[must_use] + pub const fn as_uuid(&self) -> &Uuid { + &self.0 + } +} + +impl std::fmt::Display for AgentId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(formatter) + } +} + +impl std::str::FromStr for AgentId { + type Err = uuid::Error; + + fn from_str(value: &str) -> Result { + value.parse().map(Self) + } +} + +/// Stored desired and observed Agent state plus local source provenance. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +pub struct AgentRecord { + /// Immutable identity of this Agent incarnation. + pub id: AgentId, + /// Absolute directory against which manifest-relative sources are resolved. + pub source_directory: std::path::PathBuf, + /// Absolute path of the manifest last applied, when the client reported it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manifest_path: Option, + /// Absolute path of the environment file, when it is not [`ENV_FILE`] beside the manifest. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub env_file: Option, + /// Desired state and most recently observed status. + pub agent: Agent, +} + +/// Default environment file name, resolved in the source directory. +pub const ENV_FILE: &str = ".env"; + +impl AgentRecord { + /// Returns the host file that supplies declared manifest values. + #[must_use] + pub fn env_file_path(&self) -> std::path::PathBuf { + self.env_file + .clone() + .unwrap_or_else(|| self.source_directory.join(ENV_FILE)) + } + + /// Derives the Provider-independent Sandbox name for this Agent incarnation. + /// + /// # Errors + /// + /// Returns an error only if the stable Agent identity cannot form a valid Sandbox name. + pub fn sandbox_name(&self) -> Result<::sandbox::SandboxName, Error> { + ::sandbox::SandboxName::new(format!("agent-{}", self.id)) + .map_err(|error| Error::Database(format!("Agent ID cannot identify its Sandbox: {error}"))) + } + + /// Refuses work that needs the Sandbox running while the Agent is stopped. + /// + /// # Errors + /// + /// Returns [`Error::Stopped`] when the Agent's run state is Stopped. + pub fn reject_stopped(&self) -> Result<(), Error> { + if self.agent.spec.is_stopped() { + return Err(Error::Stopped(self.agent.metadata.name.clone())); + } + Ok(()) + } + + /// Derives the hostname the Sandbox reports: the Agent name. + /// + /// # Errors + /// + /// Returns an error only if the validated Agent name cannot form a hostname. + pub fn sandbox_hostname(&self) -> Result<::sandbox::Hostname, Error> { + ::sandbox::Hostname::new(self.agent.metadata.name.clone()) + .map_err(|error| Error::Database(format!("Agent name cannot be its Sandbox hostname: {error}"))) + } +} diff --git a/agentctl/src/control_plane/service.rs b/agentctl/src/control_plane/service.rs new file mode 100644 index 0000000..02eda51 --- /dev/null +++ b/agentctl/src/control_plane/service.rs @@ -0,0 +1,644 @@ +use std::{path::PathBuf, rc::Rc}; + +use ignore::WalkBuilder; + +use crate::{Agent, AgentId, Error, MountSpec, progress::ProvisioningState}; + +use super::{AgentRecord, SharedAgentStore, Wakeup}; + +/// Desired state supplied by a local API client. +#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ApplyRequest { + /// Absolute directory against which local manifest sources are resolved. + pub source_directory: PathBuf, + /// Absolute path of the manifest being applied, recorded for discovery. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manifest_path: Option, + /// Absolute path of the file supplying declared manifest values. Defaults to `.env` beside the + /// manifest; omitted on an update keeps the recorded path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub env_file: Option, + /// Fail instead of updating when the name already identifies an Agent. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub create_only: bool, + /// Agent manifest to store. + pub agent: Agent, +} + +/// Wakes reconciliation after desired state changes. +pub trait Notifier { + /// Schedules reconciliation without blocking the API request. + fn notify(&self, id: crate::AgentId); +} + +impl Notifier for Wakeup { + fn notify(&self, id: crate::AgentId) { + self.notify(id); + } +} + +/// Agent Control Plane facade for desired-state operations. +pub struct ControlPlane { + store: SharedAgentStore, + notifier: Rc, + provisioning: ProvisioningState, +} + +impl ControlPlane { + /// Creates an Agent Control Plane facade. + #[must_use] + pub fn new(store: SharedAgentStore, notifier: Rc) -> Self { + Self { + store, + notifier, + provisioning: ProvisioningState::default(), + } + } + + /// Projects the reconciler's in-memory provisioning state onto returned Agents. + #[must_use] + pub fn with_provisioning(mut self, provisioning: ProvisioningState) -> Self { + self.provisioning = provisioning; + self + } + + /// Stores desired state and returns without waiting for reconciliation. + /// + /// # Errors + /// + /// Returns an error when the request is invalid, changes an immutable field, conflicts with deletion, + /// or cannot be stored. + pub async fn apply(&self, request: ApplyRequest) -> Result { + validate_request_paths(&request)?; + + let mut desired = request.agent; + desired.clear_managed_fields(); + resolve_mount_sources(&mut desired, &request.source_directory).await?; + desired.validate()?; + reject_dot_env_in_bind_mounts(&desired).await?; + let run_state = desired.spec.run_state; + + loop { + let result = match self.store.get_by_name(&desired.metadata.name).await { + Ok(current) => { + if request.create_only { + return Err(Error::Invalid(format!( + "an Agent named {:?} already exists", + desired.metadata.name + ))); + } + if current.agent.metadata.deletion_timestamp.is_some() { + return Err(Error::Conflict); + } + if current.source_directory != request.source_directory { + return Err(Error::Immutable("sourceDirectory")); + } + validate_immutable_fields(¤t, &desired)?; + desired.spec.set_run_state(applied_run_state(run_state, Some(¤t))); + let manifest_path = request.manifest_path.clone().or_else(|| current.manifest_path.clone()); + let env_file = request.env_file.clone().or_else(|| current.env_file.clone()); + self.reject_exposed_secret_files( + current.id, + &request.source_directory, + env_file.as_deref(), + &desired, + ) + .await?; + if current.agent.spec == desired.spec + && current.manifest_path == manifest_path + && current.env_file == env_file + { + self.notifier.notify(current.id); + return Ok(self.resource(current)); + } + + let expected_generation = current.agent.metadata.generation; + desired.metadata.generation = expected_generation + 1; + desired.status = current.agent.status; + self.store + .put( + AgentRecord { + id: current.id, + source_directory: request.source_directory.clone(), + manifest_path: manifest_path.clone(), + env_file: env_file.clone(), + agent: desired.clone(), + }, + expected_generation, + ) + .await + .map(|()| (current.id, manifest_path, env_file)) + } + Err(Error::NotFound) => { + let id = AgentId::generate(); + desired.metadata.generation = 1; + desired.spec.set_run_state(applied_run_state(run_state, None)); + self.reject_exposed_secret_files( + id, + &request.source_directory, + request.env_file.as_deref(), + &desired, + ) + .await?; + self.store + .put( + AgentRecord { + id, + source_directory: request.source_directory.clone(), + manifest_path: request.manifest_path.clone(), + env_file: request.env_file.clone(), + agent: desired.clone(), + }, + 0, + ) + .await + .map(|()| (id, request.manifest_path.clone(), request.env_file.clone())) + } + Err(error) => return Err(error), + }; + + match result { + Err(Error::Conflict) => {} + Err(error) => return Err(error), + Ok((id, manifest_path, env_file)) => { + self.notifier.notify(id); + desired.status.provenance = Some(crate::Provenance { + source_directory: request.source_directory, + manifest_path, + env_file, + }); + return Ok(desired); + } + } + } + } + + /// Rejects desired state that would expose a selected secret file inside a Sandbox. + /// + /// Secret files hold the real values that mediation exists to keep out of Sandboxes. A bind + /// mount whose source contains this Agent's selected non-default secret file or another active + /// Agent's selected secret file would hand those values to the guest, so the combination is + /// refused at apply time. Default `.env` files are covered by the bind-source scan above. Bind + /// mount sources are canonical by this point. + async fn reject_exposed_secret_files( + &self, + id: AgentId, + source_directory: &std::path::Path, + env_file: Option<&std::path::Path>, + desired: &Agent, + ) -> Result<(), Error> { + let mut secret_files = Vec::new(); + if !desired.spec.secrets.is_empty() { + let path = env_file.map_or_else(|| source_directory.join(super::resource::ENV_FILE), PathBuf::from); + if env_file.is_some() || tokio::fs::try_exists(&path).await? { + secret_files.push((desired.metadata.name.clone(), canonical_secret_file(&path).await)); + } + } + let mut mounts = bind_mount_sources(desired); + for other in self.store.list().await? { + if other.id == id || other.agent.metadata.deletion_timestamp.is_some() { + continue; + } + if !other.agent.spec.secrets.is_empty() { + let path = other.env_file_path(); + if other.env_file.is_some() || tokio::fs::try_exists(&path).await? { + secret_files.push((other.agent.metadata.name.clone(), canonical_secret_file(&path).await)); + } + } + if !desired.spec.secrets.is_empty() { + mounts.extend( + bind_mount_sources(&other.agent) + .into_iter() + .map(|(_, source)| (format!("Agent {:?}", other.agent.metadata.name), source)), + ); + } + } + for (owner, secret_file) in &secret_files { + for (mount, source) in &mounts { + if secret_file.starts_with(source) { + return Err(Error::Invalid(format!( + "{mount} bind-mounts {} which contains the secret file {} of Agent {owner:?}; \ + the Sandbox would see its real values. Keep secret files outside mounted directories, \ + for example with `agentctl apply --env-file`", + source.display(), + secret_file.display(), + ))); + } + } + } + Ok(()) + } + + /// Gets desired and most recently observed state. + /// + /// # Errors + /// + /// Returns an error when the Agent does not exist or storage fails. + pub async fn get(&self, name: &str) -> Result { + self.store.get_by_name(name).await.map(|record| self.resource(record)) + } + + /// Lists every active Agent ordered by name. + /// + /// # Errors + /// + /// Returns an error when storage cannot be read. + pub async fn list(&self) -> Result, Error> { + self.store + .list() + .await + .map(|records| records.into_iter().map(|record| self.resource(record)).collect()) + } + + /// Reads an Agent's stored status and the complete progress of its latest + /// pass. When `output` names that pass, only later output is included. + /// + /// # Errors + /// + /// Returns an error when the Agent does not exist or storage fails. + pub async fn progress( + &self, + name: &str, + output: Option, + ) -> Result<(crate::Status, Option), Error> { + let record = self.store.get_by_name(name).await?; + let provisioning = self.provisioning.get(record.id).map(|mut provisioning| { + if let Some(output) = output.filter(|output| output.pass == provisioning.pass) { + provisioning.progress = provisioning.progress.output_from(output.sequence); + } + provisioning + }); + Ok((record.agent.status, provisioning)) + } + + /// Resolves the closest Agent source directory containing `directory`. + /// + /// # Errors + /// + /// Returns an error when no Agent matches, multiple Agents share the closest + /// source directory, or storage cannot be read. + pub async fn resolve_directory(&self, directory: &std::path::Path) -> Result { + self.resolve_directory_variant(directory, None).await + } + + /// Resolves the closest Agent associated with `directory`, optionally by + /// the variant encoded in its recorded leaf manifest filename. + /// + /// When several closest Agents tie without an explicit variant, exactly one + /// Agent originating from the default `agent.yaml` manifest is preferred. + /// + /// # Errors + /// + /// Returns an error when no Agent matches, selection remains ambiguous, or + /// storage cannot be read. + pub async fn resolve_directory_variant( + &self, + directory: &std::path::Path, + variant: Option<&crate::AgentVariantName>, + ) -> Result { + if !directory.is_absolute() { + return Err(Error::Invalid("directory must be absolute".into())); + } + let directory = canonical_or_original(directory).await; + let mut matches = Vec::new(); + for record in self.store.list().await? { + let mut closest_depth: Option = None; + for source in association_directories(&record) { + let source = canonical_or_original(source).await; + if directory.starts_with(&source) { + closest_depth = Some(closest_depth.unwrap_or_default().max(source.components().count())); + } + } + if let Some(depth) = closest_depth { + matches.push((record, depth)); + } + } + let Some(depth) = matches.iter().map(|(_, depth)| *depth).max() else { + return Err(Error::NotFound); + }; + matches.retain(|(_, candidate_depth)| *candidate_depth == depth); + if let Some(variant) = variant { + let filename = variant.filename(); + matches.retain(|(record, _)| { + record + .manifest_path + .as_deref() + .and_then(std::path::Path::file_name) + .is_some_and(|name| name == filename.as_str()) + }); + if matches.is_empty() { + return Err(Error::Invalid(format!( + "no Agent associated with this directory was applied from {filename}" + ))); + } + } else if matches.len() > 1 { + let defaults = matches + .iter() + .enumerate() + .filter_map(|(index, (record, _))| { + let filename = record + .manifest_path + .as_deref() + .and_then(std::path::Path::file_name) + .or_else(|| Some(std::ffi::OsStr::new(crate::manifest::MANIFEST_FILE))); + (filename == Some(std::ffi::OsStr::new(crate::manifest::MANIFEST_FILE))).then_some(index) + }) + .collect::>(); + if let [index] = defaults.as_slice() { + return Ok(self.resource(matches.swap_remove(*index).0)); + } + } + if matches.len() != 1 { + let mut names = matches + .iter() + .map(|(record, _)| record.agent.metadata.name.clone()) + .collect::>(); + names.sort(); + return Err(Error::Invalid(format!( + "multiple Agents were applied from this directory ({}); specify --agent or --variant", + names.join(", ") + ))); + } + matches + .pop() + .map(|(record, _)| self.resource(record)) + .ok_or(Error::NotFound) + } + + /// Records whether an Agent's Sandbox runs and returns the Agent as stored, + /// without waiting for the reconciler to stop or start it. A change is a new + /// generation; setting the run state the Agent already has changes nothing. + /// + /// # Errors + /// + /// Returns an error when the Agent does not exist, is being deleted, or + /// cannot be stored. + pub async fn set_run_state(&self, name: &str, state: crate::RunState) -> Result { + loop { + let mut record = self.store.get_by_name(name).await?; + if record.agent.metadata.deletion_timestamp.is_some() { + return Err(Error::Conflict); + } + if record.agent.spec.run_state() != state { + let expected_generation = record.agent.metadata.generation; + record.agent.spec.set_run_state(state); + record.agent.metadata.generation = expected_generation + 1; + match self.store.put(record.clone(), expected_generation).await { + Ok(()) => {} + Err(Error::Conflict) => continue, + Err(error) => return Err(error), + } + } + self.notifier.notify(record.id); + return Ok(self.resource(record)); + } + } + + /// Marks an Agent for asynchronous release. Repeated deletion is safe. + /// + /// # Errors + /// + /// Returns an error when the deletion marker cannot be stored. + pub async fn delete(&self, name: &str) -> Result<(), Error> { + match self.store.mark_deleting(name).await { + Ok(record) => { + self.notifier.notify(record.id); + Ok(()) + } + Err(Error::NotFound) => Ok(()), + Err(error) => Err(error), + } + } +} + +/// Rejects any bind source containing a `.env` file, case-insensitively and regardless of ignores. +/// +/// Filesystem traversal is blocking and may cover a whole checkout, so it stays off the local +/// async runtime. Directories named `.env` are allowed. Symbolic links are not followed, but a +/// link itself named `.env` is rejected. +async fn reject_dot_env_in_bind_mounts(agent: &Agent) -> Result<(), Error> { + let mounts = bind_mount_sources(agent); + tokio::task::spawn_blocking(move || { + for (field, source) in mounts { + for result in WalkBuilder::new(&source) + .hidden(false) + .ignore(false) + .git_ignore(false) + .git_global(false) + .git_exclude(false) + .parents(false) + .follow_links(false) + .build() + { + let entry = result.map_err(|error| { + Error::Invalid(format!( + "cannot inspect {field}.source {} for .env files: {error}", + source.display() + )) + })?; + let is_directory = entry.file_type().is_some_and(|kind| kind.is_dir()); + let is_env_file = entry + .file_name() + .as_encoded_bytes() + .eq_ignore_ascii_case(super::resource::ENV_FILE.as_bytes()); + if !is_directory && is_env_file { + return Err(Error::Invalid(format!( + "{field} bind-mounts {} which contains .env at {}; the Sandbox would see its real values. \ + Remove the file or keep it outside mounted directories", + source.display(), + entry.path().display(), + ))); + } + } + } + Ok(()) + }) + .await + .map_err(|error| Error::Daemon(format!("bind-mount .env inspection failed: {error}")))? +} + +impl ControlPlane { + /// Converts a stored record to its API representation, projecting + /// provisioning progress and provenance into status. + fn resource(&self, record: AgentRecord) -> Agent { + let mut agent = record.agent; + agent.status.progress = self.provisioning.summary(record.id); + agent.status.provenance = Some(crate::Provenance { + source_directory: record.source_directory, + manifest_path: record.manifest_path, + env_file: record.env_file, + }); + agent + } +} + +/// The run state an apply stores: the manifest's, else the Agent's own, so +/// applying a manifest that omits it never stops or starts the Agent. +fn applied_run_state(requested: Option, current: Option<&AgentRecord>) -> crate::RunState { + requested + .or_else(|| current.map(|current| current.agent.spec.run_state())) + .unwrap_or_default() +} + +fn validate_immutable_fields(current: &AgentRecord, desired: &Agent) -> Result<(), Error> { + if current.agent.spec.sandbox.image != desired.spec.sandbox.image { + return Err(Error::Immutable("spec.sandbox.image")); + } + if current.agent.spec.sandbox.platform != desired.spec.sandbox.platform { + return Err(Error::Immutable("spec.sandbox.platform")); + } + if current.agent.spec.sandbox.init_system != desired.spec.sandbox.init_system { + return Err(Error::Immutable("spec.sandbox.initSystem")); + } + if current.agent.spec.sandbox.resources.root_filesystem().mode() + != desired.spec.sandbox.resources.root_filesystem().mode() + { + return Err(Error::Immutable("spec.sandbox.resources.rootFilesystem.mode")); + } + if current.agent.spec.sandbox.mounts != desired.spec.sandbox.mounts { + return Err(Error::Immutable("spec.sandbox.mounts")); + } + if current.agent.spec.home != desired.spec.home { + return Err(Error::Immutable("spec.home")); + } + if current.agent.spec.instructions != desired.spec.instructions { + return Err(Error::Immutable("spec.instructions")); + } + if current.agent.spec.skills != desired.spec.skills { + return Err(Error::Immutable("spec.skills")); + } + let current_kinds = current + .agent + .spec + .harnesses + .iter() + .map(|harness| harness.kind) + .collect::>(); + let desired_kinds = desired + .spec + .harnesses + .iter() + .map(|harness| harness.kind) + .collect::>(); + if current_kinds != desired_kinds { + return Err(Error::Immutable("spec.harnesses.type")); + } + let current_auth = current + .agent + .spec + .harnesses + .iter() + .map(|harness| (harness.kind, harness.auth)) + .collect::>(); + let desired_auth = desired + .spec + .harnesses + .iter() + .map(|harness| (harness.kind, harness.auth)) + .collect::>(); + if current_auth != desired_auth { + return Err(Error::Immutable("spec.harnesses.auth")); + } + Ok(()) +} + +async fn resolve_mount_sources(agent: &mut Agent, source_directory: &std::path::Path) -> Result<(), Error> { + for (index, mount) in agent.spec.sandbox.mounts.iter_mut().enumerate() { + let MountSpec::Bind { source, .. } = mount else { + continue; + }; + let unresolved = if source.is_absolute() { + source.clone() + } else { + source_directory.join(&*source) + }; + let resolved = tokio::fs::canonicalize(&unresolved).await.map_err(|error| { + Error::Invalid(format!( + "spec.sandbox.mounts[{index}].source {} cannot be resolved: {error}", + unresolved.display() + )) + })?; + if !tokio::fs::metadata(&resolved).await?.is_dir() { + return Err(Error::Invalid(format!( + "spec.sandbox.mounts[{index}].source {} must identify a directory", + resolved.display() + ))); + } + *source = resolved; + } + Ok(()) +} + +fn validate_request_paths(request: &ApplyRequest) -> Result<(), Error> { + if !request.source_directory.is_absolute() { + return Err(Error::Invalid("sourceDirectory must be absolute".into())); + } + if let Some(manifest) = &request.manifest_path + && manifest.parent() != Some(request.source_directory.as_path()) + { + return Err(Error::Invalid( + "manifestPath must name a file in sourceDirectory".into(), + )); + } + if let Some(env_file) = &request.env_file + && !env_file.is_absolute() + { + return Err(Error::Invalid("envFile must be absolute".into())); + } + Ok(()) +} + +fn bind_mount_sources(agent: &Agent) -> Vec<(String, PathBuf)> { + agent + .spec + .sandbox + .mounts + .iter() + .enumerate() + .filter_map(|(index, mount)| match mount { + MountSpec::Bind { source, .. } => Some((format!("spec.sandbox.mounts[{index}]"), source.clone())), + MountSpec::Tmpfs { .. } => None, + }) + .collect() +} + +async fn canonical_or_original(path: &std::path::Path) -> PathBuf { + tokio::fs::canonicalize(path) + .await + .unwrap_or_else(|_| path.to_path_buf()) +} + +/// Canonical form of a secret file for comparison against canonical bind mount sources. +/// +/// The file, and any number of its parent directories, may not exist yet. The nearest existing +/// ancestor is canonicalized and the missing components appended, so a symlink anywhere above +/// the file still compares equal to the resolved mount source. +async fn canonical_secret_file(path: &std::path::Path) -> PathBuf { + let mut missing = Vec::new(); + let mut ancestor = path; + loop { + if let Ok(canonical) = tokio::fs::canonicalize(ancestor).await { + return missing + .iter() + .rev() + .fold(canonical, |joined, component| joined.join(component)); + } + match (ancestor.parent(), ancestor.file_name()) { + (Some(parent), Some(name)) => { + missing.push(name); + ancestor = parent; + } + _ => return path.to_path_buf(), + } + } +} + +fn association_directories(record: &AgentRecord) -> impl Iterator { + std::iter::once(record.source_directory.as_path()).chain(record.agent.spec.sandbox.mounts.iter().filter_map( + |mount| match mount { + MountSpec::Bind { source, .. } => Some(source.as_path()), + MountSpec::Tmpfs { .. } => None, + }, + )) +} diff --git a/agentctl/src/controller.rs b/agentctl/src/controller.rs new file mode 100644 index 0000000..e35d514 --- /dev/null +++ b/agentctl/src/controller.rs @@ -0,0 +1,263 @@ +//! Generic keyed at-least-once reconciliation scheduling. + +use std::{ + collections::{BTreeMap, BTreeSet}, + rc::Rc, + time::Duration, +}; + +use futures_util::{FutureExt as _, StreamExt as _, stream::FuturesUnordered}; +use tokio::{ + sync::{mpsc, oneshot}, + time::{Instant, MissedTickBehavior}, +}; + +use crate::Error; + +const MAX_CONCURRENT_RECONCILES: usize = 16; +const WAKEUP_QUEUE_CAPACITY: usize = 1_024; + +/// One at-least-once convergence pass over a durable resource key. +pub trait Reconcile { + /// Converges one resource and records its observed state. + fn reconcile(&self, key: Key) -> ::sandbox::LocalFuture<'_, Result<(), Error>>; +} + +/// Lists durable resource keys for startup and repair scans. +pub(crate) trait Source { + fn list_keys(&self) -> ::sandbox::LocalFuture<'_, Result, Error>>; +} + +/// Observes recoverable reconciliation errors without stopping the controller. +pub(crate) type ErrorHandler = Rc, &Error)>; + +struct Request { + key: Key, + response: Option>>, +} + +/// Whether a failed reconciliation pass can succeed later without operator action. +#[derive(Clone, Copy, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub enum FailureKind { + /// Desired state must change before another pass can succeed. + Invalid, + /// A later pass may succeed without any change to desired state. + Transient, +} + +/// One classified reconciliation failure. +#[derive(Clone, Debug)] +pub struct ReconcileFailure { + /// Classification decided once at the reconcile boundary. + pub kind: FailureKind, + /// Human-readable failure detail. + pub message: String, +} + +impl ReconcileFailure { + /// Classifies a reconciliation error by its variant, never by its message. + /// + /// Invalid desired state, an immutable field that changed, and a Sandbox + /// request the Provider rejects or cannot support are permanent until the + /// operator changes something; everything else is retried. + #[must_use] + pub fn classify(error: &Error) -> Self { + let permanent = match error { + Error::Invalid(_) | Error::Immutable(_) => true, + Error::Sandbox(sandbox) => matches!( + sandbox.kind(), + ::sandbox::ErrorKind::InvalidRequest + | ::sandbox::ErrorKind::Immutable + | ::sandbox::ErrorKind::Unsupported + ), + _ => false, + }; + Self { + kind: if permanent { + FailureKind::Invalid + } else { + FailureKind::Transient + }, + message: match error { + Error::Invalid(message) => message.clone(), + error => error.to_string(), + }, + } + } +} + +impl std::fmt::Display for ReconcileFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +impl From for Error { + fn from(failure: ReconcileFailure) -> Self { + match failure.kind { + FailureKind::Invalid => Self::Invalid(failure.message), + FailureKind::Transient => Self::Daemon(failure.message), + } + } +} + +/// A handle for requesting immediate keyed convergence. +pub struct Wakeup { + sender: mpsc::Sender>, + resource: &'static str, +} + +impl Clone for Wakeup { + fn clone(&self) -> Self { + Self { + sender: self.sender.clone(), + resource: self.resource, + } + } +} + +impl Wakeup { + /// Queues convergence and waits for the resulting reconciliation pass. + /// + /// # Errors + /// + /// Returns the pass's classified failure, or a transient failure when the controller stops. + pub async fn reconcile(&self, key: Key) -> Result<(), ReconcileFailure> { + let (response, receiver) = oneshot::channel(); + self.sender + .send(Request { + key, + response: Some(response), + }) + .await + .map_err(|_| transient(format!("{} controller stopped", self.resource)))?; + receiver + .await + .map_err(|_| transient(format!("{} controller dropped a response", self.resource)))? + } + + /// Provides a best-effort low-latency hint for already-durable state. + pub fn notify(&self, key: Key) { + let _ignored = self.sender.try_send(Request { key, response: None }); + } +} + +type Response = oneshot::Sender>; +type ReconcileResult = (Key, Vec, Result<(), Error>); +type ReconcileFuture = futures_util::future::LocalBoxFuture<'static, ReconcileResult>; + +/// Continuously schedules independent reconciliations keyed by durable identity. +/// +/// At most one reconciliation runs for a key. A wakeup received during a pass +/// schedules a subsequent pass, and waiters complete only after the pass that +/// observed their request. +pub(crate) struct Controller { + source: Rc>, + reconciler: Rc>, + receiver: mpsc::Receiver>, + interval: Duration, + on_error: ErrorHandler, +} + +impl Controller +where + Key: Copy + Ord + 'static, +{ + pub(crate) fn new( + source: Rc>, + reconciler: Rc>, + interval: Duration, + resource: &'static str, + on_error: ErrorHandler, + ) -> (Self, Wakeup) { + assert!(!interval.is_zero(), "reconciliation interval must be non-zero"); + let (sender, receiver) = mpsc::channel(WAKEUP_QUEUE_CAPACITY); + ( + Self { + source, + reconciler, + receiver, + interval, + on_error, + }, + Wakeup { sender, resource }, + ) + } + + pub(crate) async fn run(mut self) { + let mut ticker = tokio::time::interval_at(Instant::now() + self.interval, self.interval); + ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + let mut pending = BTreeMap::>::new(); + let mut running = BTreeSet::new(); + let mut reconciliations = FuturesUnordered::>::new(); + self.enqueue_all(&mut pending).await; + + loop { + self.start_pending(&mut pending, &mut running, &reconciliations); + tokio::select! { + biased; + request = self.receiver.recv() => { + let Some(request) = request else { return; }; + enqueue(request, &mut pending); + while let Ok(request) = self.receiver.try_recv() { + enqueue(request, &mut pending); + } + } + _ = ticker.tick() => self.enqueue_all(&mut pending).await, + Some((key, responses, result)) = reconciliations.next(), if !reconciliations.is_empty() => { + running.remove(&key); + if let Err(error) = &result { + (self.on_error)(Some(key), error); + } + let response = result.as_ref().copied().map_err(ReconcileFailure::classify); + for sender in responses { + let _ignored = sender.send(response.clone()); + } + } + } + } + } + + async fn enqueue_all(&self, pending: &mut BTreeMap>) { + match self.source.list_keys().await { + Ok(keys) => { + for key in keys { + pending.entry(key).or_default(); + } + } + Err(error) => (self.on_error)(None, &error), + } + } + + fn start_pending( + &self, + pending: &mut BTreeMap>, + running: &mut BTreeSet, + reconciliations: &FuturesUnordered>, + ) { + while running.len() < MAX_CONCURRENT_RECONCILES { + let Some(key) = pending.keys().find(|key| !running.contains(key)).copied() else { + break; + }; + let responses = pending.remove(&key).unwrap_or_default(); + running.insert(key); + let reconciler = self.reconciler.clone(); + reconciliations.push(async move { (key, responses, reconciler.reconcile(key).await) }.boxed_local()); + } + } +} + +const fn transient(message: String) -> ReconcileFailure { + ReconcileFailure { + kind: FailureKind::Transient, + message, + } +} + +fn enqueue(request: Request, pending: &mut BTreeMap>) { + let responses = pending.entry(request.key).or_default(); + if let Some(response) = request.response { + responses.push(response); + } +} diff --git a/agentctl/src/environment.rs b/agentctl/src/environment.rs new file mode 100644 index 0000000..64fcacc --- /dev/null +++ b/agentctl/src/environment.rs @@ -0,0 +1,141 @@ +//! Explicit non-secret environment imported from an Agent's environment file. + +use std::collections::BTreeMap; + +use zeroize::Zeroizing; + +use crate::{Error, control_plane::AgentRecord}; + +pub(crate) async fn resolve(record: &AgentRecord) -> Result, Error> { + if record.agent.spec.environment.is_empty() { + return Ok(BTreeMap::new()); + } + let values = read(&record.env_file_path()).await?; + record + .agent + .spec + .environment + .iter() + .map(|variable| { + let value = required(&values, variable.source())?; + Ok((variable.name.clone(), value.to_owned())) + }) + .collect() +} + +pub(crate) async fn read(path: &std::path::Path) -> Result>, Error> { + let Some(bytes) = read_if_exists(path).await? else { + return Err(Error::Invalid(format!( + "manifest values require the environment file {} (default: .env beside the manifest; override with `agentctl apply --env-file`)", + path.display() + ))); + }; + parse(&bytes) +} + +pub(crate) async fn read_or_empty(path: &std::path::Path) -> Result>, Error> { + let Some(bytes) = read_if_exists(path).await? else { + return Ok(BTreeMap::new()); + }; + parse(&bytes) +} + +async fn read_if_exists(path: &std::path::Path) -> Result>>, Error> { + match tokio::fs::read(path).await { + Ok(bytes) => Ok(Some(Zeroizing::new(bytes))), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(Error::Io(error)), + } +} + +fn parse(bytes: &[u8]) -> Result>, Error> { + let text = std::str::from_utf8(bytes).map_err(|_| Error::Invalid(".env must be UTF-8".into()))?; + let mut values = BTreeMap::new(); + for (line_index, original) in text.lines().enumerate() { + let line = original.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((name, value)) = line.split_once('=') else { + return Err(Error::Invalid(format!( + "invalid .env assignment on line {}", + line_index + 1 + ))); + }; + let name = name.trim(); + if !portable_name(name) { + return Err(Error::Invalid(format!( + "invalid .env variable name on line {}", + line_index + 1 + ))); + } + let value = unquote(value.trim()) + .ok_or_else(|| Error::Invalid(format!("unbalanced .env quotes on line {}", line_index + 1)))?; + if values.insert(name.into(), Zeroizing::new(value.into())).is_some() { + return Err(Error::Invalid(format!("duplicate .env variable {name:?}"))); + } + } + Ok(values) +} + +pub(crate) fn optional<'a>(values: &'a BTreeMap>, name: &str) -> Option<&'a str> { + values + .get(name) + .map(|value| value.as_str()) + .filter(|value| !value.is_empty()) +} + +pub(crate) fn required<'a>(values: &'a BTreeMap>, name: &str) -> Result<&'a str, Error> { + let value = values + .get(name) + .ok_or_else(|| Error::Invalid(format!(".env does not define required variable {name:?}")))?; + if value.is_empty() { + return Err(Error::Invalid(format!(".env variable {name:?} must not be empty"))); + } + Ok(value) +} + +fn portable_name(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .enumerate() + .all(|(index, byte)| byte == b'_' || byte.is_ascii_alphabetic() || (index > 0 && byte.is_ascii_digit())) +} + +fn unquote(value: &str) -> Option<&str> { + match value.as_bytes().first() { + Some(b'"') => value.strip_prefix('"')?.strip_suffix('"'), + Some(b'\'') => value.strip_prefix('\'')?.strip_suffix('\''), + _ if value.ends_with(['"', '\'']) => None, + _ => Some(value), + } +} + +#[cfg(test)] +mod tests { + use super::{optional, read_or_empty}; + use std::collections::BTreeMap; + use zeroize::Zeroizing; + + #[tokio::test] + async fn missing_optional_environment_file_is_empty() -> Result<(), crate::Error> { + let directory = tempfile::tempdir()?; + let values = read_or_empty(&directory.path().join("missing.env")).await?; + + assert!(values.is_empty()); + Ok(()) + } + + #[test] + fn optional_values_omit_missing_and_empty_entries() { + let values = BTreeMap::from([ + ("EMPTY".into(), Zeroizing::new(String::new())), + ("PRESENT".into(), Zeroizing::new("value".into())), + ]); + + assert_eq!(optional(&values, "MISSING"), None); + assert_eq!(optional(&values, "EMPTY"), None); + assert_eq!(optional(&values, "PRESENT"), Some("value")); + } +} diff --git a/agentctl/src/harness/claude_code/authentication.rs b/agentctl/src/harness/claude_code/authentication.rs new file mode 100644 index 0000000..fd01fd4 --- /dev/null +++ b/agentctl/src/harness/claude_code/authentication.rs @@ -0,0 +1,238 @@ +//! Claude Code host-side authentication. +//! +//! The agent stack holds its **own** long-lived Claude token, minted on the +//! host by `claude setup-token` and delivered through `agentctl claude login`. +//! It is a separate OAuth grant from the user's interactive Claude Code login, +//! so importing it neither reads nor rotates the host login: both keep working. +//! A setup token is long-lived and self-contained, so there is no refresh +//! token and no host-side refresh loop. + +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::{Error, harness::ImportedAuthentication, persistence}; + +use super::{ACCESS_SECRET, API_HOST, PROVIDER, SETUP_TOKEN_PREFIX}; + +/// Endpoint used to validate a freshly supplied token before it is stored. +const CLAUDE_PROFILE_URL: &str = "https://api.anthropic.com/api/oauth/profile"; +const CLAUDE_OAUTH_BETA: &str = "oauth-2025-04-20"; +const VALIDATE_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +/// Owns the agent stack's Claude token on the trusted host. +pub(in crate::harness) struct Authentication { + database: persistence::Database, + client: reqwest::Client, + profile_url: String, +} + +impl Authentication { + /// Creates a harness authentication manager over the shared database owner. + #[must_use] + pub(in crate::harness) fn new(database: persistence::Database) -> Self { + Self { + database, + client: reqwest::Client::new(), + profile_url: CLAUDE_PROFILE_URL.into(), + } + } + + /// Validates a host-minted setup token and stores it as the agent credential. + /// + /// Token material is never returned or logged. + /// + /// # Errors + /// + /// Returns an error when the token is malformed, rejected by Claude, or cannot be persisted. + pub(in crate::harness) async fn login(&self, token: Zeroizing) -> Result { + let token = Zeroizing::new(token.trim().to_owned()); + if !token.starts_with(SETUP_TOKEN_PREFIX) { + return Err(Error::Invalid( + "Claude token is not a setup token; run `claude setup-token` to mint one".into(), + )); + } + self.validate(&token).await?; + let metadata = ClaudeMetadata { + kind: CredentialKind::SetupToken, + }; + self.database + .put_provider_account(persistence::ProviderAccountWrite { + provider: PROVIDER.into(), + credentials: vec![persistence::StoredSecret { + name: ACCESS_SECRET.into(), + value: Zeroizing::new(token.as_bytes().to_vec()), + }], + metadata_json: serde_json::to_string(&metadata)?, + }) + .await?; + Ok(ImportedAuthentication { + provider: PROVIDER.into(), + ready: true, + }) + } + + /// Confirms the token is recognized by Claude before it is trusted. + /// + /// This catches a mistyped or truncated token, not scope: a setup token is + /// scoped for inference, not profile access, so the profile endpoint + /// answers `403` (authenticated, forbidden) for a good token and `401` + /// (unauthenticated) for a bad one. Only `401` rejects. A network failure + /// or server error never blocks login — the token already passed its format + /// check, and mediation surfaces any real problem on first use. + async fn validate(&self, token: &str) -> Result<(), Error> { + let sent = self + .client + .get(&self.profile_url) + .timeout(VALIDATE_REQUEST_TIMEOUT) + .bearer_auth(token) + .header("anthropic-beta", CLAUDE_OAUTH_BETA) + .send() + .await; + let Ok(response) = sent else { + tracing::warn!("could not reach Claude to validate the token; storing it anyway"); + return Ok(()); + }; + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + Err(Error::Invalid( + "Claude rejected the token; mint a fresh one with `claude setup-token`".into(), + )) + } else { + if !status.is_success() && status != reqwest::StatusCode::FORBIDDEN { + tracing::warn!(%status, "unexpected HTTP status while validating the token; storing it anyway"); + } + Ok(()) + } + } + + #[cfg(test)] + fn with_profile_url(mut self, profile_url: String) -> Self { + self.profile_url = profile_url; + self + } +} + +/// How a stored Claude credential was obtained. A future `agentctl`-driven +/// PKCE grant can extend this without a schema change. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +enum CredentialKind { + /// A long-lived token minted by `claude setup-token`. + SetupToken, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct ClaudeMetadata { + kind: CredentialKind, +} + +/// Confirms a stored Claude credential exists for mediation. +pub(super) async fn is_ready(database: &persistence::Database) -> Result { + database.provider_account_exists(PROVIDER).await +} + +/// The host that the stored Claude token is mediated to. +pub(super) const fn mediated_host() -> &'static str { + API_HOST +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use sandbox::secret_store::{SecretReference, SecretStore as _}; + use tempfile::TempDir; + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + use super::*; + + async fn serve_once(status: &'static str) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind profile endpoint"); + let endpoint = format!("http://{}/profile", listener.local_addr().expect("local address")); + tokio::task::spawn_local(async move { + let (mut stream, _) = listener.accept().await.expect("accept"); + let mut chunk = [0_u8; 1_024]; + let _ = stream.read(&mut chunk).await; + let body = br#"{"account":{}}"#; + stream + .write_all( + format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .as_bytes(), + ) + .await + .expect("write headers"); + stream.write_all(body).await.expect("write body"); + }); + endpoint + } + + #[tokio::test(flavor = "local")] + async fn stores_a_validated_setup_token_without_a_refresh_secret() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let endpoint = serve_once("200 OK").await; + let manager = Authentication::new(database.clone()).with_profile_url(endpoint); + + let imported = manager + .login(Zeroizing::new("sk-ant-oat01-token-canary\n".into())) + .await + .expect("login"); + assert_eq!(imported.provider, "claude"); + assert!(imported.ready); + + let access = database + .resolve(&SecretReference::from_opaque("claude-access-token")) + .await + .expect("access token"); + assert_eq!(access.expose(), b"sk-ant-oat01-token-canary"); + assert!(is_ready(&database).await.expect("readiness")); + } + + #[tokio::test(flavor = "local")] + async fn rejects_a_non_setup_token_without_a_network_call() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + // Unroutable profile URL proves format validation happens before the network call. + let manager = Authentication::new(database.clone()).with_profile_url("http://127.0.0.1:1/profile".into()); + let error = manager + .login(Zeroizing::new("not-a-real-token".into())) + .await + .expect_err("reject"); + assert!(!error.to_string().contains("not-a-real-token")); + assert!(!is_ready(&database).await.expect("readiness")); + } + + #[tokio::test(flavor = "local")] + async fn rejects_only_an_unauthenticated_token() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let endpoint = serve_once("401 Unauthorized").await; + let manager = Authentication::new(database.clone()).with_profile_url(endpoint); + manager + .login(Zeroizing::new("sk-ant-oat01-bad".into())) + .await + .expect_err("reject"); + assert!(!is_ready(&database).await.expect("readiness")); + } + + #[tokio::test(flavor = "local")] + async fn accepts_a_forbidden_response_because_setup_tokens_lack_profile_scope() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + // A valid setup token is scoped for inference, not profile, so the + // profile endpoint answers 403 — which must still be accepted. + let endpoint = serve_once("403 Forbidden").await; + let manager = Authentication::new(database.clone()).with_profile_url(endpoint); + manager + .login(Zeroizing::new("sk-ant-oat01-valid-but-scoped".into())) + .await + .expect("accept scoped token"); + assert!(is_ready(&database).await.expect("readiness")); + } +} diff --git a/agentctl/src/harness/claude_code/bootstrap/linux.rs b/agentctl/src/harness/claude_code/bootstrap/linux.rs new file mode 100644 index 0000000..c4590b9 --- /dev/null +++ b/agentctl/src/harness/claude_code/bootstrap/linux.rs @@ -0,0 +1,93 @@ +//! Linux Sandbox configuration for mediated Claude Code authentication. + +use sandbox::SandboxHandle; + +use crate::{ + Error, + sandbox::platform::{files::write_if_changed, run_checked}, +}; + +use super::super::ACCESS_PLACEHOLDER; + +/// Days before Claude Code's retention sweep deletes an untouched transcript: effectively never. +const TRANSCRIPT_RETENTION_DAYS: u32 = 36_500; + +pub(super) async fn configure( + sandbox: &SandboxHandle, + home: &str, + instructions: Option<&[u8]>, + skills: &[crate::harness::Skill], +) -> Result<(), Error> { + let skills_path = format!("{home}/.claude/skills"); + let config = format!("{home}/.claude"); + let hooks_path = format!("{config}/hooks"); + let credentials_path = format!("{config}/.credentials.json"); + let hook_path = format!("{config}/hooks/activity-hook.mjs"); + let settings_path = format!("{config}/agent-settings.json"); + let status_line_path = format!("{config}/status-line.mjs"); + let instructions_path = format!("{config}/CLAUDE.md"); + run_checked(sandbox, "/usr/bin/mkdir", ["-p", hooks_path.as_str()]).await?; + let credentials = serde_json::to_vec(&serde_json::json!({ + "claudeAiOauth": { + "accessToken": ACCESS_PLACEHOLDER, + "refreshToken": "agent-mediated-refresh-placeholder-not-a-real-credential", + "expiresAt": 4_102_444_800_000_i64, + "refreshTokenExpiresAt": 4_102_444_800_000_i64, + "scopes": ["user:inference"] + } + }))?; + write_if_changed(sandbox, &credentials_path, &credentials).await?; + if let Some(instructions) = instructions { + write_if_changed(sandbox, &instructions_path, instructions).await?; + } + write_if_changed(sandbox, &hook_path, super::super::hooks::script()?.as_bytes()).await?; + write_if_changed(sandbox, &status_line_path, super::super::status_line::script()).await?; + // HACK: the mediated setup token is inference-only, so Claude Code cannot read the account's + // plan entitlement and gates Fable behind a usage-credits prompt. Declaring the subscription + // type and rate-limit tier in the settings env satisfies the client-side plan-inclusion check + // (the literal "max" tier is what the check looks for, regardless of the real plan); the server + // still authorizes inference independently. Both are required — the type alone unblocks Max + // models but not Fable. Remove when github.com/anthropics/claude-code#79360 ships. + let settings = serde_json::to_vec(&serde_json::json!({ + "env": { + "CLAUDE_CODE_SUBSCRIPTION_TYPE": "max", + "CLAUDE_CODE_RATE_LIMIT_TIER": "default_claude_max_5x" + }, + "hooks": super::super::hooks::configuration(&hook_path), + "statusLine": super::super::status_line::configuration(&status_line_path), + // Claude Code deletes transcripts untouched for 30 days by default, so an Idle Session + // would resume into a fresh conversation. Discarding a conversation is the platform's call. + "cleanupPeriodDays": TRANSCRIPT_RETENTION_DAYS + }))?; + write_if_changed(sandbox, &settings_path, &settings).await?; + // Runtime file transfer writes as the Sandbox supervisor (root), while + // executions run as the image user. Correct only the directories and files + // managed above: recursive ownership walks would traverse the growing + // harness state tree on every reconciliation pass. + run_checked( + sandbox, + "/usr/bin/sudo", + [ + "/usr/bin/chown", + "agent:agent", + config.as_str(), + hooks_path.as_str(), + credentials_path.as_str(), + hook_path.as_str(), + settings_path.as_str(), + status_line_path.as_str(), + ], + ) + .await?; + run_checked(sandbox, "/usr/bin/chmod", ["600", credentials_path.as_str()]).await?; + if instructions.is_some() { + run_checked( + sandbox, + "/usr/bin/sudo", + ["/usr/bin/chown", "agent:agent", instructions_path.as_str()], + ) + .await?; + run_checked(sandbox, "/usr/bin/chmod", ["644", instructions_path.as_str()]).await?; + } + crate::harness::skills::install_linux(sandbox, &skills_path, skills).await +} diff --git a/agentctl/src/harness/claude_code/bootstrap/mod.rs b/agentctl/src/harness/claude_code/bootstrap/mod.rs new file mode 100644 index 0000000..59abd96 --- /dev/null +++ b/agentctl/src/harness/claude_code/bootstrap/mod.rs @@ -0,0 +1,12 @@ +//! Sandbox-platform-specific Claude Code configuration. + +mod linux; + +pub(super) async fn configure_linux( + sandbox: &sandbox::SandboxHandle, + home: &str, + instructions: Option<&[u8]>, + skills: &[crate::harness::Skill], +) -> Result<(), crate::Error> { + linux::configure(sandbox, home, instructions, skills).await +} diff --git a/agentctl/src/harness/claude_code/hooks.rs b/agentctl/src/harness/claude_code/hooks.rs new file mode 100644 index 0000000..96c4cbe --- /dev/null +++ b/agentctl/src/harness/claude_code/hooks.rs @@ -0,0 +1,82 @@ +//! Claude Code's hook events and what each one tells the platform. + +use crate::{harness::hook_script::HookScript, sessions::ActivityEvent}; + +/// Every Claude Code hook event the platform folds. `SessionStart` also carries +/// the native session ID and transcript location; the rest are activity signals +/// that keep a working Session from looking idle and let an orchestrator wait. +const EVENTS: &[(&str, ActivityEvent)] = &[ + ("SessionStart", ActivityEvent::SessionStart), + ("UserPromptSubmit", ActivityEvent::TurnStarted), + ("Stop", ActivityEvent::TurnCompleted), + ("StopFailure", ActivityEvent::TurnCompleted), + ("PermissionRequest", ActivityEvent::WaitingForInput), + ("Notification", ActivityEvent::WaitingForInput), +]; + +/// `Notification` types that mean Claude Code is blocked on the operator; +/// authentication and dialog notifications carry no activity signal. +const WAITING_NOTIFICATIONS: &[&str] = &["permission_prompt", "idle_prompt"]; + +/// `SessionStart` sources that begin a conversation the platform tracks. +const SESSION_START_MATCHER: &str = "startup|resume|clear|compact"; + +const SCRIPT: HookScript<'static> = HookScript { + events: EVENTS, + waiting_notifications: WAITING_NOTIFICATIONS, +}; + +/// Renders Claude Code's activity hook script. +/// +/// # Errors +/// +/// Returns an error when the event table cannot be encoded. +pub(super) fn script() -> Result { + SCRIPT.render() +} + +/// The `hooks` value of Claude Code's settings, registering the script for +/// exactly the events in the table. +pub(super) fn configuration(hook_path: &str) -> serde_json::Value { + let command = serde_json::json!({ "type": "command", "command": format!("node {hook_path}") }); + let hooks = SCRIPT + .event_names() + .map(|event| { + let mut entry = serde_json::json!({ "hooks": [command] }); + if event == "SessionStart" { + entry["matcher"] = SESSION_START_MATCHER.into(); + } + (event.to_owned(), serde_json::Value::Array(vec![entry])) + }) + .collect::>(); + serde_json::Value::Object(hooks) +} + +#[cfg(test)] +mod tests { + use super::{EVENTS, configuration, script}; + use crate::harness::hook_script::embedded_events; + + #[test] + fn the_script_embeds_the_table_and_the_configuration_registers_it() { + let script = script().expect("script renders"); + let embedded = embedded_events(&script); + assert_eq!(embedded.len(), EVENTS.len()); + for (name, event) in EVENTS { + assert!(embedded.iter().any(|(n, e)| n == name && e == event), "{name}"); + } + assert!(script.contains(r#"const WAITING_NOTIFICATIONS = ["permission_prompt","idle_prompt"];"#)); + + let configuration = configuration("/home/agent/.claude/hooks/activity-hook.mjs"); + let registered = configuration.as_object().expect("hooks object"); + assert_eq!(registered.len(), EVENTS.len()); + for (name, _) in EVENTS { + let entry = ®istered[*name][0]; + assert_eq!( + entry["hooks"][0]["command"], + "node /home/agent/.claude/hooks/activity-hook.mjs" + ); + assert_eq!(entry.get("matcher").is_some(), *name == "SessionStart"); + } + } +} diff --git a/agentctl/src/harness/claude_code/mod.rs b/agentctl/src/harness/claude_code/mod.rs new file mode 100644 index 0000000..46bbae8 --- /dev/null +++ b/agentctl/src/harness/claude_code/mod.rs @@ -0,0 +1,338 @@ +//! Claude Code harness adapter. + +use std::fmt::Write as _; + +use crate::{ + Error, + harness::{LaunchRequest, MediatedSecret, ProcessLaunch, shell_single_quoted}, + persistence, +}; +use sandbox::secret_store::SecretReference; + +pub(super) mod authentication; +mod bootstrap; +mod hooks; +mod status_line; +pub(super) mod transcript; + +const PROVIDER: &str = "claude"; +const ACCESS_SECRET: &str = "claude-access-token"; +pub(super) const ACCESS_ENVIRONMENT: &str = "CLAUDE_CODE_OAUTH_TOKEN"; +const ACCESS_PLACEHOLDER: &str = "sk-ant-oat01-agent-mediated-placeholder-not-a-real-credential"; +/// Second binding on the same credential, under a name the harness does not +/// scrub. Claude Code removes `CLAUDE_CODE_OAUTH_TOKEN` from every process it +/// spawns, so a Session cannot read its own placeholder to hand to a nested +/// `agentd`; this name survives, as the Codex one already does. +const NESTED_ENVIRONMENT: &str = "AGENT_CLAUDE_ACCESS_TOKEN"; +/// A binding needs its own placeholder, and one placeholder may not contain +/// another, so this is not a spelling of `ACCESS_PLACEHOLDER`. A nested Agent +/// therefore sends this value outward and the outer mediation resolves it to +/// the same stored credential. +const NESTED_PLACEHOLDER: &str = "sk-ant-oat01-agent-mediated-nested-placeholder-not-a-real-credential"; +const API_HOST: &str = "api.anthropic.com"; +/// Fullscreen Claude owns an alternate-screen viewport whose redraws can corrupt under tmux; +/// normal-screen output remains stable and gives tmux durable scrollback. +const DISABLE_ALTERNATE_SCREEN_ENVIRONMENT: &str = "CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN"; +/// The model recorded for Sessions that predate recorded selections. The adapter +/// launched every Session on this alias from preview 2 until selections arrived, +/// because the mediated setup token cannot enumerate models and Fable never +/// appeared in the `/model` picker. Preview 1 Sessions ran on Claude Code's own +/// default; recording the alias for them too keeps every earlier conversation on +/// one known model instead of whatever the harness defaults to next. Manifests +/// now declare the default for new Sessions. +pub(super) const MODEL_LAUNCHED_BEFORE_SELECTION: &str = "fable"; + +pub(super) async fn authentication_ready(database: &persistence::Database) -> Result { + authentication::is_ready(database).await +} + +pub(super) async fn prepare(database: &persistence::Database) -> Result, Error> { + if !authentication::is_ready(database).await? { + return Err(Error::Invalid( + "Claude Code authentication is not ready; run `agentctl claude login`".into(), + )); + } + Ok(vec![ + MediatedSecret { + environment: ACCESS_ENVIRONMENT, + placeholder: ACCESS_PLACEHOLDER.into(), + reference: SecretReference::from_opaque(ACCESS_SECRET), + allowed_hosts: vec![authentication::mediated_host().into()], + }, + MediatedSecret { + environment: NESTED_ENVIRONMENT, + placeholder: NESTED_PLACEHOLDER.into(), + reference: SecretReference::from_opaque(ACCESS_SECRET), + allowed_hosts: vec![authentication::mediated_host().into()], + }, + ]) +} + +pub(super) fn conflicts_with_managed_secret(name: &str, placeholder: Option<&str>) -> bool { + matches!(name, ACCESS_ENVIRONMENT | NESTED_ENVIRONMENT) + || matches!(placeholder, Some(ACCESS_PLACEHOLDER | NESTED_PLACEHOLDER)) +} + +pub(super) fn manages_environment(name: &str) -> bool { + matches!( + name, + ACCESS_ENVIRONMENT + | NESTED_ENVIRONMENT + | "CLAUDE_CONFIG_DIR" + | DISABLE_ALTERNATE_SCREEN_ENVIRONMENT + | "DISABLE_AUTOUPDATER" + ) +} + +/// Long-lived Claude setup tokens carry this prefix. +const SETUP_TOKEN_PREFIX: &str = "sk-ant-oat"; + +/// Mints a long-lived Claude token on the host with `claude setup-token`. +/// +/// Runs the harness CLI to completion with the terminal attached so its own +/// browser-redirect OAuth flow (and the ephemeral localhost callback it starts) +/// can finish; it is never timed out. The token is read from stdout, with a +/// paste fallback when the harness prints it only to the terminal. +/// +/// # Errors +/// +/// Returns an error when the harness CLI is missing, fails, or yields no token. +pub(super) fn acquire_host_token() -> Result, Error> { + use std::process::{Command, Stdio}; + + eprintln!("Minting a long-lived Claude token with `claude setup-token`."); + eprintln!("A browser window will open — approve the request, then return here."); + let output = Command::new("claude") + .arg("setup-token") + .stdin(Stdio::inherit()) + .stderr(Stdio::inherit()) + .stdout(Stdio::piped()) + .spawn() + .map_err(|error| { + Error::Invalid(format!( + "could not run `claude setup-token` (is Claude Code installed on this host?): {error}" + )) + })? + .wait_with_output() + .map_err(|error| Error::Invalid(format!("`claude setup-token` did not run: {error}")))?; + if !output.status.success() { + return Err(Error::Invalid("`claude setup-token` did not complete".into())); + } + if let Some(token) = String::from_utf8_lossy(&output.stdout) + .split_whitespace() + .find(|word| word.starts_with(SETUP_TOKEN_PREFIX)) + { + return Ok(zeroize::Zeroizing::new(token.to_owned())); + } + prompt_for_token() +} + +/// Reads a token pasted by the user when it did not appear on stdout. +fn prompt_for_token() -> Result, Error> { + use std::io::Write as _; + + eprint!("Paste the Claude token shown above: "); + std::io::stderr().flush().ok(); + let mut line = zeroize::Zeroizing::new(String::new()); + std::io::stdin() + .read_line(&mut line) + .map_err(|error| Error::Invalid(format!("could not read the pasted token: {error}")))?; + let token = zeroize::Zeroizing::new(line.trim().to_owned()); + if token.is_empty() { + return Err(Error::Invalid("no Claude token was provided".into())); + } + Ok(token) +} + +pub(super) async fn bootstrap_linux( + sandbox: &sandbox::SandboxHandle, + home: &str, + instructions: Option<&[u8]>, + skills: &[crate::harness::Skill], +) -> Result<(), Error> { + bootstrap::configure_linux(sandbox, home, instructions, skills).await +} + +pub(super) async fn verify_linux( + sandbox: &sandbox::SandboxHandle, + expected_version: Option<&str>, +) -> Result<(), Error> { + let output = super::version_output(sandbox, "claude").await?; + if !output.status.success() { + let message = format!("`claude --version` exited with code {}", output.status.code); + // 126/127 mean the image does not provide the harness; retrying cannot change that. + // Any other failure this early in the guest's life may be transient. + return Err(if matches!(output.status.code, 126 | 127) { + Error::Invalid(format!("Claude Code is missing: {message}")) + } else { + Error::SandboxSetup(message) + }); + } + let stdout = std::str::from_utf8(&output.stdout) + .map_err(|_| Error::SandboxSetup("`claude --version` returned non-UTF-8 output".into()))?; + let installed = stdout + .split_whitespace() + .next() + .ok_or_else(|| Error::SandboxSetup("`claude --version` returned no version".into()))?; + if let Some(expected) = expected_version.filter(|expected| *expected != installed) { + return Err(Error::Invalid(format!( + "declared Claude Code version {expected:?} does not match installed version {installed:?}" + ))); + } + Ok(()) +} + +pub(super) fn launch_linux(request: &LaunchRequest<'_>) -> ProcessLaunch { + let config = format!("{}/.claude", request.home); + let mut base = format!("claude --dangerously-skip-permissions --settings {config}/agent-settings.json"); + // Claude Code takes a model alias (`fable`, `opus`) or full model name, and one of its own + // effort levels. Both are opaque here and apply to fresh and resumed conversations alike. + if let Some(model) = &request.model_selection.model { + let _infallible = write!(base, " --model {}", shell_single_quoted(model.as_str())); + } + if let Some(effort) = &request.model_selection.effort { + let _infallible = write!(base, " --effort {}", shell_single_quoted(effort.as_str())); + } + // A fresh conversation may start on a positional prompt; `--` keeps a prompt + // that begins with `-` from being read as an option. + let fresh = request.initial_prompt.map_or_else( + || base.clone(), + |message| format!("{base} -- {}", shell_single_quoted(message)), + ); + // Claude Code currently reports UUID conversation IDs. Keep that + // harness-specific constraint out of the generic Session reconciler. + let resume = request.resume.and_then(|native| native.parse::().ok()); + let command = match resume { + // SessionStart can report an ID before Claude creates its JSONL. Treat + // the harness-owned transcript as the authority for resumability so + // an untouched Session can still wake from Idle as a fresh Session. + Some(native) => format!( + "if /usr/bin/find {config}/projects -type f -name '{native}.jsonl' -print -quit 2>/dev/null \ + | /usr/bin/grep -q .; then exec {base} --resume {native}; else exec {fresh}; fi" + ), + None => fresh, + }; + ProcessLaunch { + command, + // Launch-only overrides keep the tmux session non-interactive and its + // conversation in tmux history without depending on image ENV. + environment: vec![ + ("CLAUDE_CONFIG_DIR".into(), config), + (DISABLE_ALTERNATE_SCREEN_ENVIRONMENT.into(), "1".into()), + ("DISABLE_AUTOUPDATER".into(), "1".into()), + ], + } +} + +#[cfg(test)] +mod tests { + use crate::harness::{Effort, LaunchRequest, Model, ModelSelection}; + + const UNSELECTED: ModelSelection = ModelSelection { + model: None, + effort: None, + }; + + fn request<'a>(resume: Option<&'a str>, initial_prompt: Option<&'a str>) -> LaunchRequest<'a> { + LaunchRequest { + home: "/home/agent", + resume, + initial_prompt, + model_selection: &UNSELECTED, + } + } + + #[test] + fn the_nested_binding_is_a_distinct_unambiguous_setup_token() { + // The Network Backend rejects bindings whose placeholders repeat or contain one another. + assert_ne!(super::ACCESS_PLACEHOLDER, super::NESTED_PLACEHOLDER); + assert!(!super::ACCESS_PLACEHOLDER.contains(super::NESTED_PLACEHOLDER)); + assert!(!super::NESTED_PLACEHOLDER.contains(super::ACCESS_PLACEHOLDER)); + // `agentctl claude login` only accepts a setup token, so a nested Agent can chain on this. + assert!(super::NESTED_PLACEHOLDER.starts_with(super::SETUP_TOKEN_PREFIX)); + } + + #[test] + fn a_manifest_cannot_redeclare_either_claude_binding() { + for name in [super::ACCESS_ENVIRONMENT, super::NESTED_ENVIRONMENT] { + assert!(super::manages_environment(name)); + assert!(super::conflicts_with_managed_secret(name, None)); + } + for placeholder in [super::ACCESS_PLACEHOLDER, super::NESTED_PLACEHOLDER] { + assert!(super::conflicts_with_managed_secret("UNRELATED", Some(placeholder))); + } + } + + #[test] + fn resume_launch_requires_a_native_transcript() { + let native = "160cdb4b-5997-464c-9d22-602786eb45d4"; + let launch = super::launch_linux(&request(Some(native), None)); + + assert!(launch.command.contains("/home/agent/.claude/projects")); + assert!(launch.command.contains("160cdb4b-5997-464c-9d22-602786eb45d4.jsonl")); + assert!(launch.command.contains("--resume 160cdb4b-5997-464c-9d22-602786eb45d4")); + assert!(launch.command.contains("else exec claude")); + assert!(launch.environment.contains(&("DISABLE_AUTOUPDATER".into(), "1".into()))); + } + + #[test] + fn launches_in_tmux_scrollback_instead_of_the_alternate_screen() { + let launch = super::launch_linux(&request(None, None)); + + assert!( + launch + .environment + .contains(&(super::DISABLE_ALTERNATE_SCREEN_ENVIRONMENT.into(), "1".into())) + ); + assert!(super::manages_environment(super::DISABLE_ALTERNATE_SCREEN_ENVIRONMENT)); + } + + #[test] + fn non_uuid_native_id_is_not_a_claude_resume_target() { + let launch = super::launch_linux(&request(Some("opaque-harness-id"), None)); + + assert!(!launch.command.contains("--resume")); + } + + #[test] + fn a_fresh_launch_passes_the_first_prompt_as_one_quoted_argument() { + let launch = super::launch_linux(&request(None, Some("fix it's\nbroken"))); + + assert!( + // `--` keeps a prompt that starts with `-` or names a subcommand positional. + launch.command.ends_with(" -- 'fix it'\\''s\nbroken'"), + "{}", + launch.command + ); + assert!(!launch.command.contains("--resume")); + } + + #[test] + fn launches_select_no_model_or_effort_unless_the_session_carries_them() { + let launch = super::launch_linux(&request(None, None)); + + assert!(!launch.command.contains("--model")); + assert!(!launch.command.contains("--effort")); + } + + #[test] + fn model_and_effort_apply_to_fresh_and_resumed_conversations() { + let selection = ModelSelection { + model: Some(Model::new("fable").expect("model")), + effort: Some(Effort::new("xhigh").expect("effort")), + }; + let launch = super::launch_linux(&LaunchRequest { + model_selection: &selection, + ..request(Some("160cdb4b-5997-464c-9d22-602786eb45d4"), Some("go")) + }); + + assert_eq!( + launch.command.matches("--model 'fable' --effort 'xhigh'").count(), + 2, + "{}", + launch.command + ); + assert!(launch.command.contains("--effort 'xhigh' --resume 160cdb4b")); + assert!(launch.command.contains("--effort 'xhigh' -- 'go'")); + } +} diff --git a/agentctl/src/harness/claude_code/status_line.mjs b/agentctl/src/harness/claude_code/status_line.mjs new file mode 100644 index 0000000..3af6bf3 --- /dev/null +++ b/agentctl/src/harness/claude_code/status_line.mjs @@ -0,0 +1,64 @@ +import { execFileSync } from "node:child_process"; +import { homedir } from "node:os"; + +let raw = ""; +process.stdin.setEncoding("utf8"); +for await (const chunk of process.stdin) raw += chunk; + +let state; +try { + state = JSON.parse(raw); +} catch { + process.exit(0); +} + +const clean = (value) => String(value).replace(/[\u0000-\u001f\u007f-\u009f]/g, ""); +const percentage = (value) => { + if (value === null || value === undefined) return null; + const number = Number(value); + return Number.isFinite(number) ? Math.round(Math.min(100, Math.max(0, number))) : null; +}; +const remaining = (window) => { + const used = percentage(window?.used_percentage); + return used === null ? null : 100 - used; +}; +const compactPath = (path) => { + const home = homedir(); + if (path === home) return "~"; + return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path; +}; +const branch = (cwd) => { + try { + return execFileSync("git", ["-C", cwd, "symbolic-ref", "--quiet", "--short", "HEAD"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 250, + }).trim(); + } catch { + return ""; + } +}; + +const segments = []; +const model = [state.model?.display_name, state.effort?.level].filter(Boolean).map(clean).join(" "); +if (model) segments.push(model); + +const cwd = state.workspace?.current_dir ?? state.cwd; +if (cwd) { + segments.push(clean(compactPath(cwd))); + const currentBranch = branch(cwd); + if (currentBranch) segments.push(clean(currentBranch)); +} + +const contextUsed = percentage(state.context_window?.used_percentage); +if (contextUsed !== null) segments.push(`Context ${contextUsed}% used`); + +const fiveHourLeft = remaining(state.rate_limits?.five_hour); +if (fiveHourLeft !== null) segments.push(`5h ${fiveHourLeft}% left`); +const weeklyLeft = remaining(state.rate_limits?.seven_day); +if (weeklyLeft !== null) segments.push(`weekly ${weeklyLeft}% left`); + +if (state.version) segments.push(clean(state.version)); +if (typeof state.fast_mode === "boolean") segments.push(`Fast ${state.fast_mode ? "on" : "off"}`); + +process.stdout.write(segments.join(" · ")); diff --git a/agentctl/src/harness/claude_code/status_line.rs b/agentctl/src/harness/claude_code/status_line.rs new file mode 100644 index 0000000..e57b295 --- /dev/null +++ b/agentctl/src/harness/claude_code/status_line.rs @@ -0,0 +1,12 @@ +//! Claude Code status-line configuration for managed Agent Sessions. + +pub(super) const fn script() -> &'static [u8] { + include_bytes!("status_line.mjs") +} + +pub(super) fn configuration(script_path: &str) -> serde_json::Value { + serde_json::json!({ + "type": "command", + "command": format!("node {script_path}") + }) +} diff --git a/agentctl/src/harness/claude_code/transcript.rs b/agentctl/src/harness/claude_code/transcript.rs new file mode 100644 index 0000000..42cd184 --- /dev/null +++ b/agentctl/src/harness/claude_code/transcript.rs @@ -0,0 +1,389 @@ +//! Parsing the Claude Code JSONL transcript into runtime-neutral turns. +//! +//! Claude Code appends one JSON object per line. An operator prompt is a +//! `user` entry whose `message.content` is a string; tool results are `user` +//! entries with a `tool_result` block array; each assistant content block is +//! its own `assistant` entry sharing one `message.id`. The file also records +//! entries that are not conversation: subagent side chains, meta entries, +//! compaction summaries and text the client injects on the user's behalf +//! (slash-command echoes, background task notifications, system reminders). +//! Input the operator sends while a turn is running is not a `user` entry at +//! all: it is an `attachment` of type `queued_command` absorbed into the turn. + +use std::collections::HashMap; + +use serde_json::Value; + +use crate::{ + Error, + sessions::{Message, Part, Role, Turn}, +}; + +/// Parses Claude Code JSONL into ordered turns. +/// +/// # Errors +/// +/// Returns an error when the transcript is not UTF-8. Lines that are not JSON +/// objects are skipped, so a partially written trailing line never fails a read. +pub(crate) fn parse(bytes: &[u8]) -> Result, Error> { + let text = + std::str::from_utf8(bytes).map_err(|error| Error::Session(format!("transcript is not UTF-8: {error}")))?; + let mut builder = Builder::default(); + for line in text.lines().map(str::trim).filter(|line| !line.is_empty()) { + if let Ok(entry) = serde_json::from_str::(line) { + builder.push(&entry); + } + } + Ok(builder.turns) +} + +/// Drops records before the first operator prompt in a bounded file suffix. +pub(crate) fn trim_partial(bytes: &[u8]) -> &[u8] { + let mut offset = 0; + for line in bytes.split_inclusive(|byte| *byte == b'\n') { + if serde_json::from_slice::(line).is_ok_and(|entry| starts_turn(&entry)) { + return &bytes[offset..]; + } + offset += line.len(); + } + &bytes[bytes.len()..] +} + +fn starts_turn(entry: &Value) -> bool { + if flag(entry, "isSidechain") || flag(entry, "isMeta") || flag(entry, "isCompactSummary") { + return false; + } + if entry.get("type").and_then(Value::as_str) != Some("user") { + return false; + } + let Some(message) = entry.get("message") else { + return false; + }; + match message.get("content") { + Some(Value::String(text)) => !is_injected(text), + Some(Value::Array(blocks)) => { + let text = blocks + .iter() + .filter(|block| block.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|block| block.get("text").and_then(Value::as_str)) + .collect::(); + !text.is_empty() && !is_injected(&text) + } + _ => false, + } +} + +#[derive(Default)] +struct Builder { + turns: Vec, + /// Location of each recorded tool call by Claude's `tool_use` ID, so a + /// later `tool_result` can mark it failed. + tool_calls: HashMap, + /// `message.id` of the assistant message the last assistant entry belongs + /// to; consecutive entries with the same ID are one message. + assistant_message: Option, +} + +impl Builder { + fn push(&mut self, entry: &Value) { + if flag(entry, "isSidechain") || flag(entry, "isMeta") || flag(entry, "isCompactSummary") { + return; + } + match entry.get("type").and_then(Value::as_str) { + Some("user") => { + if let Some(message) = entry.get("message") { + self.push_user(message); + } + } + Some("assistant") => { + if let Some(message) = entry.get("message") { + self.push_assistant(message); + } + } + Some("attachment") => { + if let Some(attachment) = entry.get("attachment") { + self.push_attachment(attachment); + } + } + _ => {} + } + } + + /// Operator input absorbed into the running turn joins that turn as a user + /// message; other attachments (environment, reminders) are not conversation. + fn push_attachment(&mut self, attachment: &Value) { + if attachment.get("type").and_then(Value::as_str) != Some("queued_command") + || attachment + .get("origin") + .and_then(|origin| origin.get("kind")) + .and_then(Value::as_str) + != Some("human") + { + return; + } + let Some(prompt) = attachment.get("prompt").and_then(Value::as_str) else { + return; + }; + self.assistant_message = None; + self.current_turn().messages.push(Message { + role: Role::User, + parts: vec![Part::Text { + text: prompt.to_owned(), + }], + }); + } + + fn current_turn(&mut self) -> &mut Turn { + if self.turns.is_empty() { + self.turns.push(Turn::default()); + } + let last = self.turns.len() - 1; + &mut self.turns[last] + } + + fn push_user(&mut self, message: &Value) { + match message.get("content") { + Some(Value::String(text)) => self.push_prompt(text), + Some(Value::Array(blocks)) => { + let mut prompt = String::new(); + for block in blocks { + match block.get("type").and_then(Value::as_str) { + Some("tool_result") => self.record_tool_result(block), + Some("text") => { + if let Some(text) = block.get("text").and_then(Value::as_str) { + prompt.push_str(text); + } + } + _ => {} + } + } + if !prompt.is_empty() { + self.push_prompt(&prompt); + } + } + _ => {} + } + } + + /// Starts a turn on an operator prompt; injected client text is not one. + fn push_prompt(&mut self, text: &str) { + if is_injected(text) { + return; + } + self.assistant_message = None; + self.turns.push(Turn { + messages: vec![Message { + role: Role::User, + parts: vec![Part::Text { text: text.to_owned() }], + }], + }); + } + + fn record_tool_result(&mut self, block: &Value) { + if block.get("is_error").and_then(Value::as_bool) != Some(true) { + return; + } + let Some(id) = block.get("tool_use_id").and_then(Value::as_str) else { + return; + }; + if let Some(&(turn, message, part)) = self.tool_calls.get(id) + && let Some(Part::ToolCall { failed, .. }) = self + .turns + .get_mut(turn) + .and_then(|turn| turn.messages.get_mut(message)) + .and_then(|message| message.parts.get_mut(part)) + { + *failed = true; + } + } + + fn push_assistant(&mut self, message: &Value) { + let Some(blocks) = message.get("content").and_then(Value::as_array) else { + return; + }; + let id = message.get("id").and_then(Value::as_str); + for block in blocks { + match block.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(text) = block.get("text").and_then(Value::as_str) { + self.append_assistant_part(id, Part::Text { text: text.to_owned() }); + } + } + Some("tool_use") => { + if let Some(name) = block.get("name").and_then(Value::as_str) { + let location = self.append_assistant_part( + id, + Part::ToolCall { + name: name.to_owned(), + failed: false, + }, + ); + if let Some(tool_use) = block.get("id").and_then(Value::as_str) { + self.tool_calls.insert(tool_use.to_owned(), location); + } + } + } + _ => {} + } + } + } + + /// Appends a part to the current assistant message, opening a new message + /// when the entry belongs to a different API response, and a new turn when + /// the transcript starts mid-conversation. + fn append_assistant_part(&mut self, id: Option<&str>, part: Part) -> (usize, usize, usize) { + if self.turns.is_empty() { + self.turns.push(Turn::default()); + } + let turn_index = self.turns.len() - 1; + let turn = &mut self.turns[turn_index]; + let continues = id.is_some() && id == self.assistant_message.as_deref(); + if !continues || turn.messages.last().is_none_or(|last| last.role != Role::Assistant) { + turn.messages.push(Message { + role: Role::Assistant, + parts: Vec::new(), + }); + self.assistant_message = id.map(str::to_owned); + } + let message_index = turn.messages.len() - 1; + let message = &mut turn.messages[message_index]; + message.parts.push(part); + (turn_index, message_index, message.parts.len() - 1) + } +} + +fn flag(entry: &Value, name: &str) -> bool { + entry.get(name).and_then(Value::as_bool) == Some(true) +} + +/// Recognizes the harness's local-command and notification envelopes. Ordinary +/// XML is operator input; explicitly human queued attachments bypass this filter. +fn is_injected(text: &str) -> bool { + let trimmed = text.trim(); + [ + "command-name", + "local-command-stdout", + "local-command-stderr", + "task-notification", + "system-reminder", + ] + .iter() + .any(|tag| trimmed.starts_with(&format!("<{tag}>")) && trimmed.contains(&format!(""))) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIXTURE: &str = include_str!("../../../tests/fixtures/claude-code-transcript.jsonl"); + + #[test] + fn a_bounded_suffix_discards_a_partial_turn() { + let suffix = concat!( + r#"{"type":"assistant","message":{"id":"old","content":[{"type":"text","text":"partial"}]}}"#, + "\n", + r#"{"type":"user","message":{"content":"complete"}}"#, + "\n", + ); + + let turns = parse(trim_partial(suffix.as_bytes())).expect("suffix"); + + assert!(matches!(turns[0].messages[0].parts[0], Part::Text { ref text } if text == "complete")); + } + + #[test] + fn recorded_transcript_yields_operator_turns_only() { + let turns = parse(FIXTURE.as_bytes()).expect("parse"); + let prompts = turns + .iter() + .map(|turn| match &turn.messages[0].parts[0] { + Part::Text { text } => text.as_str(), + Part::ToolCall { .. } => panic!("a turn starts with the prompt"), + }) + .collect::>(); + assert_eq!(prompts, ["Rename the helper and run the tests.", "Now commit it."]); + } + + #[test] + fn input_absorbed_mid_turn_joins_the_running_turn() { + let turns = parse(FIXTURE.as_bytes()).expect("parse"); + let second = &turns[1]; + let users = second + .messages + .iter() + .filter(|message| message.role == Role::User) + .map(|message| match &message.parts[0] { + Part::Text { text } => text.as_str(), + Part::ToolCall { .. } => panic!("user text"), + }) + .collect::>(); + assert_eq!(users, ["Now commit it.", "Use a conventional commit message."]); + assert_eq!(turns.len(), 2, "absorbed input does not start a turn"); + } + + #[test] + fn tool_results_mark_failures_and_the_final_message_follows_the_last_tool_call() { + let turns = parse(FIXTURE.as_bytes()).expect("parse"); + let first = &turns[0]; + let tools = first + .messages + .iter() + .flat_map(|message| &message.parts) + .filter_map(|part| match part { + Part::ToolCall { name, failed } => Some((name.as_str(), *failed)), + Part::Text { .. } => None, + }) + .collect::>(); + assert_eq!(tools, [("Edit", false), ("Bash", true), ("Bash", false)]); + assert!( + matches!(first.messages.last().and_then(|message| message.parts.last()), Some(Part::Text { text }) if text == "Renamed and the tests pass.") + ); + } + + #[test] + fn assistant_blocks_of_one_response_form_one_message() { + let turns = parse(FIXTURE.as_bytes()).expect("parse"); + let assistant_messages = turns[0] + .messages + .iter() + .filter(|message| message.role == Role::Assistant) + .count(); + // Response 1: commentary + Edit; response 2: Bash; response 3: Bash; response 4: answer. + assert_eq!(assistant_messages, 4); + assert_eq!(turns[0].messages[1].parts.len(), 2); + } + + #[test] + fn injected_markup_is_not_a_prompt() { + for injected in [ + "/clear\nclear", + "\nx\n", + "\nnote\n", + ] { + assert!(is_injected(injected), "{injected}"); + } + assert!(!is_injected("
hi")); + assert!(!is_injected("Compare and ")); + assert!(!is_injected("plain prompt")); + } +} + +#[cfg(test)] +mod input_preservation_tests { + #[test] + fn human_xml_prompts_survive_normal_and_queued_records() { + let prompt = "Reply exactly DONE"; + let records = [ + serde_json::json!({"type":"user", "message":{"content":prompt}}), + serde_json::json!({"type":"attachment", "attachment":{"type":"queued_command", "origin":{"kind":"human"}, "prompt":prompt}}), + ]; + for record in records { + let turns = super::parse(record.to_string().as_bytes()).expect("parse"); + assert_eq!(turns.len(), 1); + assert_eq!( + turns[0].messages[0].parts[0], + crate::sessions::Part::Text { text: prompt.into() } + ); + } + } +} diff --git a/agentctl/src/harness/codex/authentication.rs b/agentctl/src/harness/codex/authentication.rs new file mode 100644 index 0000000..ec60f97 --- /dev/null +++ b/agentctl/src/harness/codex/authentication.rs @@ -0,0 +1,686 @@ +//! Codex CLI host-side `ChatGPT` subscription authentication. + +use std::{cell::RefCell, time::Instant, time::SystemTime}; + +use base64::Engine as _; +use sandbox::secret_store::{SecretMaterial, SecretReference, SecretStore as _}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::{Error, harness::ImportedAuthentication, persistence}; + +use super::{ACCESS_PLACEHOLDER, ACCESS_SECRET, ACCOUNT_SECRET, PROVIDER, REFRESH_PLACEHOLDER, REFRESH_SECRET}; + +const REFRESH_URL: &str = "https://auth.openai.com/oauth/token"; +const OAUTH_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; +const REFRESH_AHEAD_SECONDS: i64 = 5 * 60; +const TRANSIENT_FAILURE_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(30); +const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +/// Owns a `ChatGPT` OAuth grant used only by the Agent stack. +pub(in crate::harness) struct Authentication { + database: persistence::Database, + client: reqwest::Client, + refresh_url: String, + refresh_lock: tokio::sync::Mutex<()>, + refresh_failure: RefCell>, +} + +impl Authentication { + #[must_use] + pub(in crate::harness) fn new(database: persistence::Database) -> Self { + Self { + database, + client: reqwest::Client::new(), + refresh_url: REFRESH_URL.into(), + refresh_lock: tokio::sync::Mutex::new(()), + refresh_failure: RefCell::new(None), + } + } + + /// Imports the independent `ChatGPT` grant produced in agentctl's private Codex home. + /// + /// An `auth.json` carrying the platform's own placeholders is the credential an Agent + /// Sandbox already holds. Importing it makes this `agentd` a nested one: the tokens are + /// stored verbatim, never refreshed, and the enclosing Sandbox's mediator substitutes the + /// real grant. No real credential exists at this level. An `imported` credential must be + /// such a placeholder file: importing a user's real `auth.json` would let this `agentd` and + /// the user's own Codex CLI rotate the same refresh token against each other. + pub(in crate::harness) async fn login( + &self, + credential: Zeroizing, + imported: bool, + ) -> Result { + let source: LoginFile = serde_json::from_str(&credential) + .map_err(|_| Error::Invalid("Codex login did not produce valid authentication data".into()))?; + if source.auth_mode.as_deref() != Some("chatgpt") { + return Err(Error::Invalid( + "Codex login did not produce a ChatGPT subscription grant".into(), + )); + } + let tokens = source + .tokens + .ok_or_else(|| Error::Invalid("Codex login did not produce ChatGPT tokens".into()))?; + let access_token = Zeroizing::new(tokens.access_token.trim().to_owned()); + let refresh_token = Zeroizing::new(tokens.refresh_token.trim().to_owned()); + let account_id = tokens + .account_id + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| Error::Invalid("Codex login did not identify a ChatGPT account".into()))?; + if access_token.is_empty() || refresh_token.is_empty() { + return Err(Error::Invalid("Codex login produced incomplete ChatGPT tokens".into())); + } + let kind = if *refresh_token == REFRESH_PLACEHOLDER && *access_token == ACCESS_PLACEHOLDER { + CredentialKind::Mediated + } else if imported { + return Err(Error::Invalid( + "only an Agent's mediated Codex credential file can be imported; run `agentctl codex login` on the host to sign in".into(), + )); + } else { + CredentialKind::ChatgptOauth + }; + let metadata = CodexMetadata { + kind, + account_id, + expires_at: jwt_expiry(&access_token)?, + }; + self.store(&access_token, &refresh_token, &metadata).await?; + self.refresh_failure.borrow_mut().take(); + Ok(ImportedAuthentication { + provider: PROVIDER.into(), + ready: true, + }) + } + + pub(in crate::harness) async fn resolve_access(&self) -> Result { + self.refresh_if_needed() + .await + .map_err(|error| sandbox::Error::Backend(error.to_string()))?; + self.database + .resolve(&SecretReference::from_opaque(ACCESS_SECRET)) + .await + } + + #[allow(clippy::option_if_let_else)] + async fn refresh_if_needed(&self) -> Result<(), Error> { + let _refresh = self.refresh_lock.lock().await; + let metadata = self.metadata().await?; + if matches!(metadata.kind, CredentialKind::Mediated) + || metadata.expires_at > unix_time()?.saturating_add(REFRESH_AHEAD_SECONDS) + { + return Ok(()); + } + if let Some(error) = self.cached_refresh_failure() { + return Err(error); + } + + let result = self.refresh(&metadata).await; + match &result { + Ok(()) => { + self.refresh_failure.borrow_mut().take(); + } + Err(failure) => { + let cached = match failure { + RefreshFailure::Permanent(message) => CachedRefreshFailure::Permanent(message.clone()), + RefreshFailure::Transient(message) => CachedRefreshFailure::Transient { + message: message.clone(), + retry_at: Instant::now() + TRANSIENT_FAILURE_COOLDOWN, + }, + }; + *self.refresh_failure.borrow_mut() = Some(cached); + } + } + result.map_err(RefreshFailure::into_error) + } + + fn cached_refresh_failure(&self) -> Option { + let mut cached = self.refresh_failure.borrow_mut(); + match cached.as_ref() { + Some(CachedRefreshFailure::Permanent(message)) => Some(Error::Invalid(message.clone())), + Some(CachedRefreshFailure::Transient { message, retry_at }) if Instant::now() < *retry_at => { + Some(Error::Invalid(message.clone())) + } + Some(CachedRefreshFailure::Transient { .. }) | None => { + cached.take(); + None + } + } + } + + async fn refresh(&self, metadata: &CodexMetadata) -> Result<(), RefreshFailure> { + let stored = self + .database + .resolve(&SecretReference::from_opaque(REFRESH_SECRET)) + .await + .map_err(|error| { + RefreshFailure::permanent(format!( + "Codex refresh credential is unavailable: {error}; run `agentctl codex login` again" + )) + })?; + let refresh_token = std::str::from_utf8(stored.expose()).map_err(|_| { + RefreshFailure::permanent("Codex refresh credential is invalid; run `agentctl codex login` again") + })?; + let response = self + .client + .post(&self.refresh_url) + .timeout(REQUEST_TIMEOUT) + .json(&RefreshRequest { + client_id: OAUTH_CLIENT_ID, + grant_type: "refresh_token", + refresh_token, + }) + .send() + .await + .map_err(|_| RefreshFailure::transient("could not reach OpenAI to refresh Codex authentication"))?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + let code = refresh_error_code(&body); + let invalid_grant = status == reqwest::StatusCode::BAD_REQUEST + && code + .as_deref() + .is_some_and(|code| code.eq_ignore_ascii_case("invalid_grant")); + let permanent = status == reqwest::StatusCode::UNAUTHORIZED + || invalid_grant + || matches!( + code.as_deref(), + Some("refresh_token_expired" | "refresh_token_reused" | "refresh_token_invalidated") + ); + if permanent { + let reason = match code.as_deref() { + Some("refresh_token_expired") => "the refresh token has expired", + Some("refresh_token_reused") => "the refresh token was already used", + Some("refresh_token_invalidated") => "the refresh token was revoked", + _ => "OpenAI rejected the refresh grant", + }; + return Err(RefreshFailure::permanent(format!( + "Codex authentication can no longer be refreshed because {reason}; run `agentctl codex login` again" + ))); + } + return Err(RefreshFailure::transient(format!( + "OpenAI temporarily failed to refresh Codex authentication (HTTP {status})" + ))); + } + let response: RefreshResponse = response + .json() + .await + .map_err(|_| RefreshFailure::transient("OpenAI returned an invalid Codex refresh response"))?; + let access_token = Zeroizing::new(response.access_token.trim().to_owned()); + if access_token.is_empty() { + return Err(RefreshFailure::transient("OpenAI returned an empty Codex access token")); + } + let refresh_token = Zeroizing::new( + response + .refresh_token + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(refresh_token) + .to_owned(), + ); + let replacement = CodexMetadata { + kind: CredentialKind::ChatgptOauth, + account_id: metadata.account_id.clone(), + expires_at: jwt_expiry(&access_token) + .map_err(|_| RefreshFailure::transient("OpenAI returned an invalid Codex access token"))?, + }; + self.store(&access_token, &refresh_token, &replacement) + .await + .map_err(|_| RefreshFailure::transient("could not store refreshed Codex authentication")) + } + + async fn store(&self, access_token: &str, refresh_token: &str, metadata: &CodexMetadata) -> Result<(), Error> { + self.database + .put_provider_account(persistence::ProviderAccountWrite { + provider: PROVIDER.into(), + credentials: vec![ + persistence::StoredSecret { + name: ACCESS_SECRET.into(), + value: Zeroizing::new(access_token.as_bytes().to_vec()), + }, + persistence::StoredSecret { + name: REFRESH_SECRET.into(), + value: Zeroizing::new(refresh_token.as_bytes().to_vec()), + }, + persistence::StoredSecret { + name: ACCOUNT_SECRET.into(), + value: Zeroizing::new(metadata.account_id.as_bytes().to_vec()), + }, + ], + metadata_json: serde_json::to_string(metadata)?, + }) + .await + } + + async fn metadata(&self) -> Result { + let metadata = self + .database + .provider_account_metadata(PROVIDER) + .await? + .ok_or_else(|| Error::Invalid("Codex authentication is not ready; run `agentctl codex login`".into()))?; + serde_json::from_str(&metadata) + .map_err(|_| Error::Invalid("stored Codex authentication metadata is invalid; log in again".into())) + } + + #[cfg(test)] + fn with_refresh_url(mut self, refresh_url: String) -> Self { + self.refresh_url = refresh_url; + self + } +} + +pub(super) async fn selected_account_id(database: &persistence::Database) -> Result { + let metadata = database + .provider_account_metadata(PROVIDER) + .await? + .ok_or_else(|| Error::Invalid("Codex authentication is not ready; run `agentctl codex login`".into()))?; + let metadata: CodexMetadata = serde_json::from_str(&metadata) + .map_err(|_| Error::Invalid("stored Codex authentication metadata is invalid; log in again".into()))?; + Ok(metadata.account_id) +} + +#[derive(Clone)] +enum RefreshFailure { + Permanent(String), + Transient(String), +} + +impl RefreshFailure { + fn permanent(message: impl Into) -> Self { + Self::Permanent(message.into()) + } + + fn transient(message: impl Into) -> Self { + Self::Transient(message.into()) + } + + fn into_error(self) -> Error { + Error::Invalid(match self { + Self::Permanent(message) | Self::Transient(message) => message, + }) + } +} + +enum CachedRefreshFailure { + Permanent(String), + Transient { message: String, retry_at: Instant }, +} + +#[derive(Deserialize)] +struct LoginFile { + auth_mode: Option, + tokens: Option, +} + +#[derive(Deserialize)] +struct LoginTokens { + access_token: String, + refresh_token: String, + account_id: Option, +} + +#[derive(Clone, Copy, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +enum CredentialKind { + /// A real `ChatGPT` OAuth grant, refreshed by this `agentd`. + ChatgptOauth, + /// Placeholders from an enclosing Sandbox; an outer mediator holds the real grant. + Mediated, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct CodexMetadata { + kind: CredentialKind, + account_id: String, + expires_at: i64, +} + +#[derive(Serialize)] +struct RefreshRequest<'a> { + client_id: &'static str, + grant_type: &'static str, + refresh_token: &'a str, +} + +#[derive(Deserialize)] +struct RefreshResponse { + access_token: String, + refresh_token: Option, +} + +fn refresh_error_code(body: &str) -> Option { + let value = serde_json::from_str::(body).ok()?; + let code = match value.get("error") { + Some(serde_json::Value::String(code)) => Some(code.clone()), + Some(serde_json::Value::Object(error)) => error.get("code")?.as_str().map(str::to_owned), + _ => value.get("code")?.as_str().map(str::to_owned), + }?; + Some(code.to_ascii_lowercase()) +} + +fn jwt_expiry(token: &str) -> Result { + let encoded = token + .split('.') + .nth(1) + .ok_or_else(|| Error::Invalid("Codex access token is not a JWT".into()))?; + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| Error::Invalid("Codex access token has an invalid JWT payload".into()))?; + let claims: JwtClaims = serde_json::from_slice(&payload) + .map_err(|_| Error::Invalid("Codex access token has invalid JWT claims".into()))?; + Ok(claims.exp) +} + +fn unix_time() -> Result { + let seconds = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_err(|_| Error::Invalid("host clock is before the Unix epoch".into()))? + .as_secs(); + i64::try_from(seconds).map_err(|_| Error::Invalid("host clock cannot be represented".into())) +} + +#[derive(Deserialize)] +struct JwtClaims { + exp: i64, +} + +pub(super) async fn is_ready(database: &persistence::Database) -> Result { + let Some(metadata) = database.provider_account_metadata(PROVIDER).await? else { + return Ok(false); + }; + if serde_json::from_str::(&metadata).is_err() { + return Ok(false); + } + Ok(database + .resolve(&SecretReference::from_opaque(ACCESS_SECRET)) + .await + .is_ok() + && database + .resolve(&SecretReference::from_opaque(REFRESH_SECRET)) + .await + .is_ok() + && database + .resolve(&SecretReference::from_opaque(ACCOUNT_SECRET)) + .await + .is_ok()) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use std::{cell::Cell, rc::Rc}; + + use tempfile::TempDir; + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + use super::*; + + fn jwt(exp: i64) -> String { + let payload = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(serde_json::json!({ "exp": exp }).to_string()); + format!("header.{payload}.signature") + } + + fn login_file(access_token: &str, refresh_token: &str) -> Zeroizing { + Zeroizing::new( + serde_json::json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "id_token": "header.payload.signature", + "access_token": access_token, + "refresh_token": refresh_token, + "account_id": "account-canary" + }, + "last_refresh": "2026-08-24T00:00:00Z" + }) + .to_string(), + ) + } + + async fn serve_refresh(access_token: String, refresh_token: Option<&'static str>) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind refresh endpoint"); + let endpoint = format!("http://{}/oauth/token", listener.local_addr().expect("local address")); + tokio::task::spawn_local(async move { + let (mut stream, _) = listener.accept().await.expect("accept"); + let mut request = [0_u8; 4_096]; + let read = stream.read(&mut request).await.expect("read request"); + let request = String::from_utf8_lossy(&request[..read]); + assert!(request.contains("refresh-canary")); + assert!(request.contains(OAUTH_CLIENT_ID)); + let body = serde_json::json!({ + "access_token": access_token, + "refresh_token": refresh_token, + }) + .to_string(); + stream + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await + .expect("write response"); + }); + endpoint + } + + async fn serve_refresh_failure(status: &str, body: &'static str) -> (String, Rc>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind refresh endpoint"); + let endpoint = format!("http://{}/oauth/token", listener.local_addr().expect("local address")); + let requests = Rc::new(Cell::new(0)); + let observed = Rc::clone(&requests); + let status = status.to_owned(); + tokio::task::spawn_local(async move { + loop { + let (mut stream, _) = listener.accept().await.expect("accept"); + let mut request = [0_u8; 4_096]; + let _read = stream.read(&mut request).await.expect("read request"); + observed.set(observed.get() + 1); + stream + .write_all( + format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await + .expect("write response"); + } + }); + (endpoint, requests) + } + + #[tokio::test(flavor = "local")] + async fn imports_only_the_required_chatgpt_grant_state() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let manager = Authentication::new(database.clone()); + let access_token = jwt(unix_time().expect("time") + 3_600); + + let imported = manager + .login(login_file(&access_token, "refresh-canary"), false) + .await + .expect("login"); + + assert_eq!(imported.provider, "codex"); + assert!(imported.ready); + assert_eq!( + database + .resolve(&SecretReference::from_opaque(ACCESS_SECRET)) + .await + .expect("access token") + .expose(), + access_token.as_bytes() + ); + assert_eq!( + database + .resolve(&SecretReference::from_opaque(REFRESH_SECRET)) + .await + .expect("refresh token") + .expose(), + b"refresh-canary" + ); + assert_eq!( + database + .resolve(&SecretReference::from_opaque(ACCOUNT_SECRET)) + .await + .expect("account ID") + .expose(), + b"account-canary" + ); + assert!(is_ready(&database).await.expect("readiness")); + } + + #[tokio::test(flavor = "local")] + async fn rejects_api_key_authentication_data() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let manager = Authentication::new(database.clone()); + + let error = manager + .login( + Zeroizing::new( + serde_json::json!({ "auth_mode": "apikey", "OPENAI_API_KEY": "secret-canary" }).to_string(), + ), + false, + ) + .await + .expect_err("reject API key"); + + assert!(error.to_string().contains("ChatGPT subscription grant")); + assert!(!error.to_string().contains("secret-canary")); + assert!(!is_ready(&database).await.expect("readiness")); + } + + #[tokio::test(flavor = "local")] + async fn placeholder_credentials_are_stored_verbatim_and_never_refreshed() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let (endpoint, refresh_calls) = serve_refresh_failure("500 Internal Server Error", "{}").await; + let manager = Authentication::new(database.clone()).with_refresh_url(endpoint); + let credential = Zeroizing::new( + serde_json::json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "id_token": ACCESS_PLACEHOLDER, + "access_token": ACCESS_PLACEHOLDER, + "refresh_token": REFRESH_PLACEHOLDER, + "account_id": "account-test" + }, + "last_refresh": "2026-08-24T00:00:00Z" + }) + .to_string(), + ); + manager.login(credential, true).await.expect("placeholder login"); + + let resolved = manager.resolve_access().await.expect("placeholder access token"); + + assert_eq!(resolved.expose(), ACCESS_PLACEHOLDER.as_bytes()); + assert_eq!( + selected_account_id(&database).await.expect("account ID"), + "account-test" + ); + assert_eq!(refresh_calls.get(), 0); + assert!(is_ready(&database).await.expect("readiness")); + } + + #[tokio::test(flavor = "local")] + async fn a_real_credential_file_cannot_be_imported() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let manager = Authentication::new(database.clone()); + let access = jwt(unix_time().expect("time") + 3_600); + + let error = manager + .login(login_file(&access, "refresh-canary"), true) + .await + .expect_err("a real ChatGPT grant must not be imported"); + + assert!(error.to_string().contains("agentctl codex login")); + assert!(!error.to_string().contains("refresh-canary")); + assert!(!is_ready(&database).await.expect("readiness")); + } + + #[tokio::test(flavor = "local")] + async fn refreshes_and_rotates_before_resolving_the_access_token() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let old_access = jwt(unix_time().expect("time") - 1); + let new_access = jwt(unix_time().expect("time") + 3_600); + let endpoint = serve_refresh(new_access.clone(), Some("rotated-refresh")).await; + let manager = Authentication::new(database.clone()).with_refresh_url(endpoint); + manager + .login(login_file(&old_access, "refresh-canary"), false) + .await + .expect("login"); + + let (first, second) = tokio::join!(manager.resolve_access(), manager.resolve_access()); + let first = first.expect("first resolved token"); + let second = second.expect("second resolved token"); + + assert_eq!(first.expose(), new_access.as_bytes()); + assert_eq!(second.expose(), new_access.as_bytes()); + assert_eq!( + database + .resolve(&SecretReference::from_opaque(REFRESH_SECRET)) + .await + .expect("rotated refresh token") + .expose(), + b"rotated-refresh" + ); + } + + #[tokio::test(flavor = "local")] + async fn transient_refresh_failures_have_a_cooldown_without_login_guidance() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let expired_access = jwt(unix_time().expect("time") - 1); + let (endpoint, requests) = serve_refresh_failure("502 Bad Gateway", r#"{"error":"upstream_error"}"#).await; + let manager = Authentication::new(database).with_refresh_url(endpoint); + manager + .login(login_file(&expired_access, "refresh-canary"), false) + .await + .expect("login"); + + let first = manager.resolve_access().await.expect_err("first refresh fails"); + let second = manager.resolve_access().await.expect_err("cooldown retains failure"); + + assert_eq!(requests.get(), 1); + assert!(first.to_string().contains("temporarily failed")); + assert!(!first.to_string().contains("codex login")); + assert_eq!(first.to_string(), second.to_string()); + } + + #[tokio::test(flavor = "local")] + async fn terminal_refresh_failures_require_login_and_are_not_retried() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let expired_access = jwt(unix_time().expect("time") - 1); + let (endpoint, requests) = + serve_refresh_failure("400 Bad Request", r#"{"error":{"code":"refresh_token_reused"}}"#).await; + let manager = Authentication::new(database).with_refresh_url(endpoint); + manager + .login(login_file(&expired_access, "refresh-canary"), false) + .await + .expect("login"); + + let first = manager.resolve_access().await.expect_err("first refresh fails"); + let second = manager.resolve_access().await.expect_err("terminal failure retained"); + + assert_eq!(requests.get(), 1); + assert!(first.to_string().contains("already used")); + assert!(first.to_string().contains("agentctl codex login")); + assert_eq!(first.to_string(), second.to_string()); + } +} diff --git a/agentctl/src/harness/codex/bootstrap/linux.rs b/agentctl/src/harness/codex/bootstrap/linux.rs new file mode 100644 index 0000000..ae8d350 --- /dev/null +++ b/agentctl/src/harness/codex/bootstrap/linux.rs @@ -0,0 +1,185 @@ +//! Linux Sandbox configuration for mediated Codex CLI authentication. + +use sandbox::SandboxHandle; + +use crate::{ + Error, + sandbox::platform::{ + files::{read_existing, write_if_changed}, + run_checked, + }, +}; + +use super::super::{ACCESS_PLACEHOLDER, ACCOUNT_ENVIRONMENT, REFRESH_PLACEHOLDER}; + +pub(super) async fn configure( + sandbox: &SandboxHandle, + home: &str, + instructions: Option<&[u8]>, + skills: &[crate::harness::Skill], +) -> Result<(), Error> { + let skills_path = format!("{home}/.agents/skills"); + let config = format!("{home}/.codex"); + let hooks_path = format!("{config}/hooks"); + let auth_path = format!("{config}/auth.json"); + let hook_path = format!("{config}/hooks/activity-hook.mjs"); + let hooks_config_path = format!("{config}/hooks.json"); + let instructions_path = format!("{config}/AGENTS.md"); + + run_checked(sandbox, "/usr/bin/mkdir", ["-p", hooks_path.as_str()]).await?; + let account_id = sandbox + .snapshot() + .environment + .get(ACCOUNT_ENVIRONMENT) + .cloned() + .ok_or_else(|| Error::SandboxSetup("Codex account ID was not prepared for this Sandbox".into()))?; + // Codex must believe it owns a normal ChatGPT login while the real, + // rotating grant remains host-only. The fake JWT expiry and fresh refresh + // timestamp suppress proactive guest refresh; a 401 can only attempt the + // deliberately unusable placeholder refresh token. + let now = time::OffsetDateTime::now_utc(); + let last_refresh = now + .format(&time::format_description::well_known::Rfc3339) + .map_err(|error| Error::SandboxSetup(format!("could not format Codex refresh time: {error}")))?; + let auth = serde_json::json!({ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "id_token": ACCESS_PLACEHOLDER, + "access_token": ACCESS_PLACEHOLDER, + "refresh_token": REFRESH_PLACEHOLDER, + "account_id": account_id, + }, + "last_refresh": last_refresh, + }); + let existing = read_existing(sandbox, &auth_path, AUTH_MAX_BYTES).await; + if auth_needs_refresh(existing.as_deref(), &auth, now) { + write_if_changed(sandbox, &auth_path, &serde_json::to_vec(&auth)?).await?; + } + if let Some(instructions) = instructions { + write_if_changed(sandbox, &instructions_path, instructions).await?; + } + write_if_changed(sandbox, &hook_path, super::super::hooks::script()?.as_bytes()).await?; + let hooks = serde_json::to_vec(&super::super::hooks::configuration(&hook_path))?; + write_if_changed(sandbox, &hooks_config_path, &hooks).await?; + + run_checked( + sandbox, + "/usr/bin/sudo", + [ + "/usr/bin/chown", + "agent:agent", + config.as_str(), + hooks_path.as_str(), + auth_path.as_str(), + hook_path.as_str(), + hooks_config_path.as_str(), + ], + ) + .await?; + run_checked(sandbox, "/usr/bin/chmod", ["600", auth_path.as_str()]).await?; + if instructions.is_some() { + run_checked( + sandbox, + "/usr/bin/sudo", + ["/usr/bin/chown", "agent:agent", instructions_path.as_str()], + ) + .await?; + run_checked(sandbox, "/usr/bin/chmod", ["644", instructions_path.as_str()]).await?; + } + crate::harness::skills::install_linux(sandbox, &skills_path, skills).await +} + +/// How long the placeholder login's refresh timestamp may age before setup rewrites it. +/// +/// Codex only refreshes proactively once the timestamp is much older than this, and a +/// rewrite every reconciliation pass would make the file churn for no reason. +const AUTH_REFRESH_MAX_AGE: time::Duration = time::Duration::hours(24); +/// Longest login file setup parses; the Sandbox user can write the file, so a larger one is +/// replaced rather than read. +const AUTH_MAX_BYTES: usize = 1024 * 1024; + +/// Whether the placeholder login on disk must be replaced by `desired`. +/// +/// The two differ in `last_refresh` on every pass by construction; that alone does not warrant a +/// rewrite until the recorded timestamp is older than [`AUTH_REFRESH_MAX_AGE`]. Anything else +/// unreadable, unparsable or different does. +fn auth_needs_refresh(existing: Option<&[u8]>, desired: &serde_json::Value, now: time::OffsetDateTime) -> bool { + let Some(mut existing) = existing.and_then(|bytes| serde_json::from_slice::(bytes).ok()) else { + return true; + }; + let Some(recorded) = existing + .get("last_refresh") + .and_then(serde_json::Value::as_str) + .and_then(|value| time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339).ok()) + else { + return true; + }; + if now - recorded > AUTH_REFRESH_MAX_AGE || recorded > now { + return true; + } + if let Some(object) = existing.as_object_mut() { + object.remove("last_refresh"); + } + let mut desired = desired.clone(); + if let Some(object) = desired.as_object_mut() { + object.remove("last_refresh"); + } + existing != desired +} + +#[cfg(test)] +mod tests { + use super::auth_needs_refresh; + + fn desired() -> serde_json::Value { + serde_json::json!({ + "auth_mode": "chatgpt", + "tokens": {"access_token": "placeholder"}, + "last_refresh": "2026-09-17T12:00:00Z", + }) + } + + fn at(rfc3339: &str) -> time::OffsetDateTime { + time::OffsetDateTime::parse(rfc3339, &time::format_description::well_known::Rfc3339).expect("timestamp") + } + + #[test] + fn keeps_a_recent_equivalent_login() { + let existing = + br#"{"auth_mode":"chatgpt","tokens":{"access_token":"placeholder"},"last_refresh":"2026-09-17T09:00:00Z"}"#; + assert!(!auth_needs_refresh( + Some(existing), + &desired(), + at("2026-09-17T12:00:00Z") + )); + } + + #[test] + fn refreshes_a_stale_timestamp() { + let existing = + br#"{"auth_mode":"chatgpt","tokens":{"access_token":"placeholder"},"last_refresh":"2026-09-15T09:00:00Z"}"#; + assert!(auth_needs_refresh( + Some(existing), + &desired(), + at("2026-09-17T12:00:00Z") + )); + } + + #[test] + fn rewrites_when_the_login_differs_or_is_unreadable() { + let existing = + br#"{"auth_mode":"chatgpt","tokens":{"access_token":"other"},"last_refresh":"2026-09-17T09:00:00Z"}"#; + assert!(auth_needs_refresh( + Some(existing), + &desired(), + at("2026-09-17T12:00:00Z") + )); + assert!(auth_needs_refresh( + Some(b"not json"), + &desired(), + at("2026-09-17T12:00:00Z") + )); + assert!(auth_needs_refresh(None, &desired(), at("2026-09-17T12:00:00Z"))); + } +} diff --git a/agentctl/src/harness/codex/bootstrap/mod.rs b/agentctl/src/harness/codex/bootstrap/mod.rs new file mode 100644 index 0000000..2117d60 --- /dev/null +++ b/agentctl/src/harness/codex/bootstrap/mod.rs @@ -0,0 +1,12 @@ +//! Sandbox-platform-specific Codex CLI configuration. + +mod linux; + +pub(super) async fn configure_linux( + sandbox: &sandbox::SandboxHandle, + home: &str, + instructions: Option<&[u8]>, + skills: &[crate::harness::Skill], +) -> Result<(), crate::Error> { + linux::configure(sandbox, home, instructions, skills).await +} diff --git a/agentctl/src/harness/codex/hooks.rs b/agentctl/src/harness/codex/hooks.rs new file mode 100644 index 0000000..a81c341 --- /dev/null +++ b/agentctl/src/harness/codex/hooks.rs @@ -0,0 +1,80 @@ +//! Codex's hook events and what each one tells the platform. + +use crate::{harness::hook_script::HookScript, sessions::ActivityEvent}; + +/// Every Codex hook event the platform folds. Codex 0.153 has no +/// `Notification`; Stop and Interrupt report a turn ending, not task success. +const EVENTS: &[(&str, ActivityEvent)] = &[ + ("SessionStart", ActivityEvent::SessionStart), + ("UserPromptSubmit", ActivityEvent::TurnStarted), + ("Stop", ActivityEvent::TurnCompleted), + ("Interrupt", ActivityEvent::TurnCompleted), + ("PermissionRequest", ActivityEvent::WaitingForInput), +]; + +/// Seconds Codex waits for the hook before continuing; a slow report must not +/// stall a turn. +const HOOK_TIMEOUT_SECONDS: u32 = 3; + +const SCRIPT: HookScript<'static> = HookScript { + events: EVENTS, + waiting_notifications: &[], +}; + +/// Renders Codex's activity hook script. +/// +/// # Errors +/// +/// Returns an error when the event table cannot be encoded. +pub(super) fn script() -> Result { + SCRIPT.render() +} + +/// The contents of Codex's `hooks.json`, registering the script for exactly +/// the events in the table. +pub(super) fn configuration(hook_path: &str) -> serde_json::Value { + let command = serde_json::json!({ + "type": "command", + "command": format!("node {hook_path}"), + "timeout": HOOK_TIMEOUT_SECONDS, + }); + let hooks = SCRIPT + .event_names() + .map(|event| { + let entry = serde_json::json!({ "hooks": [command] }); + (event.to_owned(), serde_json::Value::Array(vec![entry])) + }) + .collect::>(); + serde_json::json!({ "hooks": hooks }) +} + +#[cfg(test)] +mod tests { + use super::{EVENTS, configuration, script}; + use crate::harness::hook_script::embedded_events; + + #[test] + fn the_script_embeds_the_table_and_the_configuration_registers_it() { + let script = script().expect("script renders"); + let embedded = embedded_events(&script); + assert_eq!(embedded.len(), EVENTS.len()); + for (name, event) in EVENTS { + assert!(embedded.iter().any(|(n, e)| n == name && e == event), "{name}"); + } + assert!(script.contains("const WAITING_NOTIFICATIONS = [];")); + + let configuration = configuration("/home/agent/.codex/hooks/activity-hook.mjs"); + let registered = configuration["hooks"].as_object().expect("hooks object"); + assert_eq!(registered.len(), EVENTS.len()); + assert!(registered.get("Notification").is_none()); + for (name, _) in EVENTS { + let entry = ®istered[*name][0]; + assert_eq!( + entry["hooks"][0]["command"], + "node /home/agent/.codex/hooks/activity-hook.mjs" + ); + assert_eq!(entry["hooks"][0]["timeout"], 3); + assert!(entry.get("matcher").is_none()); + } + } +} diff --git a/agentctl/src/harness/codex/mod.rs b/agentctl/src/harness/codex/mod.rs new file mode 100644 index 0000000..df089b8 --- /dev/null +++ b/agentctl/src/harness/codex/mod.rs @@ -0,0 +1,400 @@ +//! `OpenAI` Codex CLI harness adapter. + +use std::{fmt::Write as _, io::Read as _}; + +use sandbox::secret_store::SecretReference; + +use crate::{ + Error, + harness::{LaunchRequest, MediatedSecret, ProcessLaunch, shell_single_quoted}, + persistence, +}; + +pub(super) mod authentication; +mod bootstrap; +mod hooks; +pub(super) mod transcript; + +const PROVIDER: &str = "codex"; +const ACCESS_SECRET: &str = "codex-access-token"; +const REFRESH_SECRET: &str = "codex-refresh-token"; +const ACCOUNT_SECRET: &str = "codex-account-id"; +pub(super) const ACCESS_ENVIRONMENT: &str = "AGENT_CODEX_ACCESS_TOKEN"; +const ACCOUNT_ENVIRONMENT: &str = "AGENT_CODEX_ACCOUNT_ID"; +const ACCESS_PLACEHOLDER: &str = concat!( + "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.", + "eyJleHAiOjQxMDI0NDQ4MDAsImh0dHBzOi8vYXBpLm9wZW5haS5jb20vYXV0aCI6eyJjaGF0Z3B0X2FjY291bnRfaWQiOiJhZ2VudC1tZWRpYXRlZC1jb2RleC1hY2NvdW50LXBsYWNlaG9sZGVyIn19.", + "agent" +); +const ACCOUNT_PLACEHOLDER: &str = "agent-mediated-codex-account-placeholder"; +const REFRESH_PLACEHOLDER: &str = "agent-mediated-codex-refresh-placeholder-not-a-real-token"; +const CHATGPT_HOST: &str = "chatgpt.com"; + +pub(super) fn owns_secret(reference: &SecretReference) -> bool { + reference.as_str() == ACCESS_SECRET +} + +pub(super) async fn authentication_ready(database: &persistence::Database) -> Result { + authentication::is_ready(database).await +} + +pub(super) async fn prepare(database: &persistence::Database) -> Result, Error> { + if !authentication::is_ready(database).await? { + return Err(Error::Invalid( + "Codex authentication is not ready; run `agentctl codex login`".into(), + )); + } + Ok(vec![ + MediatedSecret { + environment: ACCESS_ENVIRONMENT, + placeholder: ACCESS_PLACEHOLDER.into(), + reference: SecretReference::from_opaque(ACCESS_SECRET), + allowed_hosts: vec![CHATGPT_HOST.into()], + }, + MediatedSecret { + environment: ACCOUNT_ENVIRONMENT, + // The account ID is visible in authenticated workspace discovery. Codex 0.156 + // needs the selected ID locally to match that response before it can start. + placeholder: authentication::selected_account_id(database).await?, + reference: SecretReference::from_opaque(ACCOUNT_SECRET), + allowed_hosts: vec![CHATGPT_HOST.into()], + }, + ]) +} + +pub(super) fn conflicts_with_managed_secret(name: &str, placeholder: Option<&str>) -> bool { + matches!(name, ACCESS_ENVIRONMENT | ACCOUNT_ENVIRONMENT) + || matches!(placeholder, Some(ACCESS_PLACEHOLDER | ACCOUNT_PLACEHOLDER)) +} + +pub(super) fn manages_environment(name: &str) -> bool { + matches!( + name, + ACCESS_ENVIRONMENT | ACCOUNT_ENVIRONMENT | "CODEX_HOME" | "CODEX_CA_CERTIFICATE" + ) +} + +/// Creates a separate `ChatGPT` login grant without reading the user's Codex home. +pub(super) fn acquire_host_credential( + control_plane_home: &std::path::Path, +) -> Result, Error> { + let temporary_root = control_plane_home.join("tmp"); + std::fs::create_dir_all(&temporary_root)?; + crate::local::home::secure_directory(&temporary_root)?; + remove_stale_login_homes(&temporary_root); + let home = tempfile::Builder::new() + .prefix("codex-login-") + .tempdir_in(temporary_root)?; + let status = std::process::Command::new("codex") + .env("CODEX_HOME", home.path()) + .env_remove("CODEX_ACCESS_TOKEN") + .env_remove("CODEX_API_KEY") + .env_remove("OPENAI_API_KEY") + .args([ + "-c", + "cli_auth_credentials_store=\"file\"", + "-c", + "forced_login_method=\"chatgpt\"", + "login", + ]) + .status() + .map_err(|error| Error::Invalid(format!("could not start `codex login`: {error}")))?; + if !status.success() { + return Err(Error::Invalid(format!("`codex login` exited with {status}"))); + } + + let path = home.path().join("auth.json"); + if !std::fs::symlink_metadata(&path)?.file_type().is_file() { + return Err(Error::Invalid( + "`codex login` did not create a regular auth.json file".into(), + )); + } + let mut credential = zeroize::Zeroizing::new(String::new()); + std::fs::File::open(path)?.read_to_string(&mut credential)?; + if credential.trim().is_empty() { + return Err(Error::Invalid("`codex login` created an empty auth.json file".into())); + } + Ok(credential) +} + +fn remove_stale_login_homes(temporary_root: &std::path::Path) { + let Ok(entries) = std::fs::read_dir(temporary_root) else { + return; + }; + for entry in entries.flatten() { + if entry + .file_name() + .to_str() + .is_some_and(|name| name.starts_with("codex-login-")) + && entry.file_type().is_ok_and(|kind| kind.is_dir()) + { + let _ignored = std::fs::remove_dir_all(entry.path()); + } + } +} + +pub(super) async fn bootstrap_linux( + sandbox: &sandbox::SandboxHandle, + home: &str, + instructions: Option<&[u8]>, + skills: &[crate::harness::Skill], +) -> Result<(), Error> { + bootstrap::configure_linux(sandbox, home, instructions, skills).await +} + +pub(super) async fn verify_linux( + sandbox: &sandbox::SandboxHandle, + expected_version: Option<&str>, +) -> Result<(), Error> { + let output = super::version_output(sandbox, "codex").await?; + if !output.status.success() { + let message = format!("`codex --version` exited with code {}", output.status.code); + // 126/127 mean the image does not provide the harness; retrying cannot change that. + // Any other failure this early in the guest's life may be transient. + return Err(if matches!(output.status.code, 126 | 127) { + Error::Invalid(format!("Codex is missing: {message}")) + } else { + Error::SandboxSetup(message) + }); + } + let stdout = std::str::from_utf8(&output.stdout) + .map_err(|_| Error::SandboxSetup("`codex --version` returned non-UTF-8 output".into()))?; + let installed = stdout + .split_whitespace() + .nth(1) + .ok_or_else(|| Error::SandboxSetup("`codex --version` returned no version".into()))?; + if let Some(expected) = expected_version.filter(|expected| *expected != installed) { + return Err(Error::Invalid(format!( + "declared Codex version {expected:?} does not match installed version {installed:?}" + ))); + } + Ok(()) +} + +/// Codex's provisional composer accepts pastes but drops submission keys. The +/// launch-owned session-ID title appears only after `SessionConfigured`, and +/// survives the header scrolling offscreen during resume. The pinned TUI +/// truncates UUIDs to 29 ASCII characters plus three dots. +pub(super) fn input_ready_without_report(cursor_line: &str, title: &str) -> bool { + cursor_line.trim_start().starts_with("› ") + && title.strip_suffix("...").is_some_and(|prefix| { + prefix.len() == 29 + && prefix.bytes().enumerate().all(|(index, byte)| { + if matches!(index, 8 | 13 | 18 | 23) { + byte == b'-' + } else { + byte.is_ascii_hexdigit() + } + }) + }) +} + +pub(super) fn launch_linux(request: &LaunchRequest<'_>) -> ProcessLaunch { + let config = format!("{}/.codex", request.home); + // Run Codex inside the Session's pane rather than on the shared background server + // Codex starts by default; launch overrides would otherwise fall back to embedded + // mode with a startup warning. + let flags = "--dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust --no-daemon"; + // Launch-only overrides keep adapter-owned authentication and the fixed + // Session root non-interactive without overwriting builder config.toml. + // Inline rendering lets tmux retain conversation output in pane history. + // https://developers.openai.com/codex/config-reference + let mut configuration = format!( + "-c 'cli_auth_credentials_store=\"file\"' -c 'tui.alternate_screen=\"never\"' -c 'check_for_update_on_startup=false' -c 'tui.terminal_title=[\"session-id\"]' \ + -c 'tui.status_line=[\"model-with-reasoning\",\"current-dir\",\"git-branch\",\"context-used\",\"weekly-limit\",\"codex-version\",\"fast-mode\"]' \ + -c 'projects.{}.trust_level=\"trusted\"'", + crate::sandbox::platform::WORKING_DIRECTORY + ); + // Codex takes the model as `-m`; effort has no flag of its own and travels as the + // `model_reasoning_effort` config override. Validated selections need no TOML escaping. + if let Some(model) = &request.model_selection.model { + let _infallible = write!(configuration, " -m {}", shell_single_quoted(model.as_str())); + } + if let Some(effort) = &request.model_selection.effort { + let _infallible = write!(configuration, " -c 'model_reasoning_effort=\"{}\"'", effort.as_str()); + } + let base = format!("codex {flags} {configuration}"); + // A fresh conversation may start on a positional prompt; `--` keeps a prompt + // that begins with `-` or names a subcommand (`resume`) positional. + let fresh = request.initial_prompt.map_or_else( + || base.clone(), + |message| format!("{base} -- {}", shell_single_quoted(message)), + ); + let resume = request.resume.and_then(|native| native.parse::().ok()); + let command = match resume { + Some(native) => format!( + "if /usr/bin/find {config}/sessions -type f \\( \ + -name 'rollout-*-{native}.jsonl' -o -name 'rollout-*-{native}.jsonl.zst' \\) \ + -print -quit 2>/dev/null | /usr/bin/grep -q .; \ + then exec codex resume {flags} {configuration} {native}; else exec {fresh}; fi" + ), + None => fresh, + }; + ProcessLaunch { + command, + environment: vec![ + ("CODEX_HOME".into(), config), + ( + "CODEX_CA_CERTIFICATE".into(), + "/etc/ssl/certs/ca-certificates.crt".into(), + ), + ], + } +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use crate::harness::{Effort, LaunchRequest, Model, ModelSelection}; + + const UNSELECTED: ModelSelection = ModelSelection { + model: None, + effort: None, + }; + + fn request<'a>(resume: Option<&'a str>, initial_prompt: Option<&'a str>) -> LaunchRequest<'a> { + LaunchRequest { + home: "/home/agent", + resume, + initial_prompt, + model_selection: &UNSELECTED, + } + } + + #[test] + fn input_readiness_waits_for_the_initialized_composer() { + let title = "01234567-1234-1234-1234-12345..."; + assert!(super::input_ready_without_report("› Ask Codex to do anything", title)); + assert!(super::input_ready_without_report(" › Find a bug", title)); + for title in ["", "agent-dev", "model: loading", "01234567-1234-1234-1234-1234g..."] { + assert!(!super::input_ready_without_report("› Ask Codex to do anything", title)); + } + for cursor in ["Starting Codex...", "Select a model", ""] { + assert!(!super::input_ready_without_report(cursor, title)); + } + } + + #[test] + fn stale_private_login_homes_are_removed_without_touching_other_files() { + let root = TempDir::new().expect("temporary directory"); + let stale = root.path().join("codex-login-stale"); + std::fs::create_dir(&stale).expect("stale login home"); + std::fs::write(stale.join("auth.json"), "refresh-canary").expect("stale credential"); + let unrelated = root.path().join("other-state"); + std::fs::create_dir(&unrelated).expect("unrelated state"); + + super::remove_stale_login_homes(root.path()); + + assert!(!stale.exists()); + assert!(unrelated.exists()); + } + + #[test] + fn every_executed_launch_uses_inline_scrollback_once() { + for resume in [None, Some("160cdb4b-5997-464c-9d22-602786eb45d4")] { + let launch = super::launch_linux(&request(resume, None)); + // Resume has two mutually exclusive commands: resume and fresh fallback. + let commands = launch.command.split("codex ").skip(1).collect::>(); + assert_eq!(commands.len(), if resume.is_some() { 2 } else { 1 }); + for command in commands { + assert_eq!(command.matches("tui.alternate_screen=\"never\"").count(), 1); + assert!(!command.contains("raw_output_mode")); + } + } + } + + #[test] + fn every_executed_launch_runs_without_the_shared_server() { + for resume in [None, Some("160cdb4b-5997-464c-9d22-602786eb45d4")] { + let launch = super::launch_linux(&request(resume, None)); + let commands = launch.command.split("codex ").skip(1).collect::>(); + assert_eq!(commands.len(), if resume.is_some() { 2 } else { 1 }); + for command in commands { + assert_eq!(command.matches("--no-daemon").count(), 1); + } + } + } + + #[test] + fn resume_launch_requires_a_native_rollout() { + let native = "160cdb4b-5997-464c-9d22-602786eb45d4"; + let launch = super::launch_linux(&request(Some(native), None)); + + assert!(launch.command.contains("/home/agent/.codex/sessions")); + assert!( + launch + .command + .contains("rollout-*-160cdb4b-5997-464c-9d22-602786eb45d4.jsonl'") + ); + assert!( + launch + .command + .contains("rollout-*-160cdb4b-5997-464c-9d22-602786eb45d4.jsonl.zst'") + ); + assert!(!launch.command.contains("_*.jsonl")); + assert!(!launch.command.contains(".jsonl*")); + assert!( + launch + .command + .contains("codex resume --dangerously-bypass-approvals-and-sandbox") + ); + assert!(launch.command.contains("cli_auth_credentials_store=\"file\"")); + assert!( + launch + .command + .contains("projects./home/agent/code.trust_level=\"trusted\"") + ); + assert!(launch.command.contains(native)); + assert!(launch.command.contains("else exec codex")); + } + + #[test] + fn non_uuid_native_id_is_not_a_codex_resume_target() { + let launch = super::launch_linux(&request(Some("opaque-harness-id"), None)); + + assert!(!launch.command.contains("codex resume")); + } + + #[test] + fn a_fresh_launch_passes_the_first_prompt_as_one_quoted_argument() { + let launch = super::launch_linux(&request(None, Some("fix it's\nbroken"))); + + assert!( + // `--` keeps a prompt that starts with `-` or names a subcommand positional. + launch.command.ends_with(" -- 'fix it'\\''s\nbroken'"), + "{}", + launch.command + ); + assert!(!launch.command.contains("codex resume")); + } + + #[test] + fn launches_select_no_model_or_effort_unless_the_session_carries_them() { + let launch = super::launch_linux(&request(None, None)); + + assert!(!launch.command.contains(" -m ")); + assert!(!launch.command.contains("model_reasoning_effort")); + } + + #[test] + fn model_and_effort_apply_to_fresh_and_resumed_conversations() { + let selection = ModelSelection { + model: Some(Model::new("gpt-5.4-codex").expect("model")), + effort: Some(Effort::new("high").expect("effort")), + }; + let launch = super::launch_linux(&LaunchRequest { + model_selection: &selection, + ..request(Some("160cdb4b-5997-464c-9d22-602786eb45d4"), Some("go")) + }); + + let selection = "-m 'gpt-5.4-codex' -c 'model_reasoning_effort=\"high\"'"; + assert_eq!(launch.command.matches(selection).count(), 2, "{}", launch.command); + assert!( + launch + .command + .contains(&format!("{selection} 160cdb4b-5997-464c-9d22-602786eb45d4;")) + ); + assert!(launch.command.contains(&format!("{selection} -- 'go'"))); + } +} diff --git a/agentctl/src/harness/codex/transcript.rs b/agentctl/src/harness/codex/transcript.rs new file mode 100644 index 0000000..8db7a72 --- /dev/null +++ b/agentctl/src/harness/codex/transcript.rs @@ -0,0 +1,533 @@ +//! Parsing the Codex rollout JSONL into runtime-neutral turns. +//! +//! A rollout records `event_msg` lines that delimit turns (`task_started`, +//! `task_complete`, `turn_aborted`) and `response_item` lines with the model +//! conversation. Codex injects context as `user`-role messages ahead of the +//! operator's prompt (AGENTS.md, environment details), so a user message is +//! never a turn boundary here: the turn markers are, and within a turn the last +//! user message before the model's first output is the operator's prompt. + +use std::collections::HashMap; + +use serde_json::Value; + +use crate::{ + Error, + sessions::{Message, Part, Role, Turn}, +}; + +/// Codex prefixes a bundled context-and-request user message with this marker. +const REQUEST_MARKER: &str = "## My request for Codex:"; + +// Codex 0.153 keeps success as internal metadata. Command results persist their +// exit code in the tool's output header instead (core/src/tools/{mod,context}.rs). +// Inspect only recognized command headers, never arbitrary command stdout. +fn command_failed(name: &str, output: Option<&Value>) -> bool { + let Some(output) = output.and_then(|output| { + output + .as_str() + .or_else(|| output.as_array()?.first()?.get("text")?.as_str()) + }) else { + return false; + }; + let prefix = match name { + "shell" | "shell_command" => "Exit code: ", + "exec_command" | "write_stdin" => "Process exited with code ", + _ => return false, + }; + output + .lines() + .take_while(|line| *line != "Output:") + .filter_map(|line| line.strip_prefix(prefix)) + .filter_map(|code| code.parse::().ok()) + .any(|code| code != 0) +} + +fn is_command_tool(name: &str) -> bool { + matches!( + name, + "exec" | "exec_command" | "shell" | "shell_command" | "write_stdin" + ) +} + +/// Parses Codex rollout JSONL into ordered turns. +/// +/// # Errors +/// +/// Returns an error when the transcript is not UTF-8. Lines that are not JSON +/// objects are skipped, so a partially written trailing line never fails a read. +pub(crate) fn parse(bytes: &[u8]) -> Result, Error> { + let text = + std::str::from_utf8(bytes).map_err(|error| Error::Session(format!("transcript is not UTF-8: {error}")))?; + let mut builder = Builder::default(); + for line in text.lines().map(str::trim).filter(|line| !line.is_empty()) { + if let Ok(entry) = serde_json::from_str::(line) { + builder.push(&entry); + } + } + Ok(builder.finish()) +} + +/// Drops records before the first turn boundary in a bounded rollout suffix. +pub(crate) fn trim_partial(bytes: &[u8]) -> &[u8] { + let mut offset = 0; + for line in bytes.split_inclusive(|byte| *byte == b'\n') { + if serde_json::from_slice::(line).is_ok_and(|entry| { + entry.get("type").and_then(Value::as_str) == Some("event_msg") + && matches!( + entry.pointer("/payload/type").and_then(Value::as_str), + Some("task_started" | "turn_started") + ) + }) { + return &bytes[offset..]; + } + offset += line.len(); + } + &bytes[bytes.len()..] +} + +#[derive(Default)] +struct Builder { + turns: Vec, + /// Candidate prompt: the latest user message seen before the model's first + /// output in the current turn. Earlier candidates were injected context. + pending_prompt: Option, + /// Whether the current turn has recorded model output yet. + answered: bool, + /// Location of each recorded tool call by `call_id`. + tool_calls: HashMap, + /// Failure observed in nested code-mode `CommandExecution` records since + /// the last outer `exec` output. Codex assigns nested calls an `exec-*` ID, + /// distinct from the outer custom tool call's `call_id`. + pending_nested_command_failed: Option, +} + +impl Builder { + fn push(&mut self, entry: &Value) { + let Some(payload) = entry.get("payload") else { + return; + }; + match entry.get("type").and_then(Value::as_str) { + Some("event_msg") => match payload.get("type").and_then(Value::as_str) { + Some("task_started" | "turn_started") => self.start_turn(), + Some("task_complete" | "turn_complete" | "turn_aborted") => self.flush_prompt(), + Some("patch_apply_end") => { + if payload.get("success").and_then(Value::as_bool) == Some(false) { + self.mark_tool_failed(payload); + } + } + Some("mcp_tool_call_end") + if payload.pointer("/result/Err").is_some() + || payload.pointer("/result/Ok/isError").and_then(Value::as_bool) == Some(true) => + { + self.mark_tool_failed(payload); + } + Some("item_completed") + if payload.pointer("/item/type").and_then(Value::as_str) == Some("CommandExecution") => + { + self.record_command_result(&payload["item"]); + } + _ => {} + }, + Some("response_item") => self.push_item(payload), + _ => {} + } + } + + fn start_turn(&mut self) { + self.flush_prompt(); + if self.turns.last().is_none_or(|turn| !turn.messages.is_empty()) { + self.turns.push(Turn::default()); + } + self.answered = false; + self.pending_nested_command_failed = None; + } + + fn push_item(&mut self, payload: &Value) { + match payload.get("type").and_then(Value::as_str) { + Some("message") => { + let text = payload + .get("content") + .and_then(Value::as_array) + .map(|parts| { + parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::() + }) + .unwrap_or_default(); + match payload.get("role").and_then(Value::as_str) { + Some("user") => self.push_user(text), + Some("assistant") if !text.is_empty() => { + self.push_output(Message { + role: Role::Assistant, + parts: vec![Part::Text { text }], + }); + } + // Developer and system messages are harness context, not conversation. + _ => {} + } + } + Some("function_call" | "custom_tool_call") => { + let Some(name) = payload.get("name").and_then(Value::as_str) else { + return; + }; + let location = self.push_output(Message { + role: Role::Assistant, + parts: vec![Part::ToolCall { + name: name.to_owned(), + failed: false, + }], + }); + if let Some(call_id) = payload.get("call_id").and_then(Value::as_str) { + self.tool_calls.insert(call_id.to_owned(), location); + } + } + Some("function_call_output" | "custom_tool_call_output") => { + let nested_failed = self.pending_nested_command_failed.unwrap_or(false); + let mut consumed_nested_result = false; + if let Some(Part::ToolCall { name, failed }) = self.tool_part(payload) { + *failed |= command_failed(name, payload.get("output")); + if name == "exec" { + *failed |= nested_failed; + consumed_nested_result = true; + } + } + if consumed_nested_result { + self.pending_nested_command_failed = None; + } + } + _ => {} + } + } + + fn tool_part(&mut self, payload: &Value) -> Option<&mut Part> { + let call_id = payload.get("call_id")?.as_str()?; + self.tool_part_by_call_id(call_id) + } + + fn tool_part_by_call_id(&mut self, call_id: &str) -> Option<&mut Part> { + let &(turn, message, part) = self.tool_calls.get(call_id)?; + self.turns.get_mut(turn)?.messages.get_mut(message)?.parts.get_mut(part) + } + + fn record_command_result(&mut self, item: &Value) { + let Some(id) = item.get("id").and_then(Value::as_str) else { + return; + }; + let failed = matches!(item.get("status").and_then(Value::as_str), Some("failed" | "declined")); + if let Some(Part::ToolCall { + name, + failed: tool_failed, + }) = self.tool_part_by_call_id(id) + { + if is_command_tool(name) { + *tool_failed |= failed; + } + } else if id.starts_with("exec-") { + self.pending_nested_command_failed = Some(self.pending_nested_command_failed.unwrap_or(false) || failed); + } + } + + fn mark_tool_failed(&mut self, payload: &Value) { + if let Some(Part::ToolCall { failed, .. }) = self.tool_part(payload) { + *failed = true; + } + } + + fn push_user(&mut self, text: String) { + let request = text + .strip_prefix("") + .and_then(|context| context.split_once("")) + .and_then(|(_, suffix)| suffix.trim_start().strip_prefix(REQUEST_MARKER)) + .map(|request| request.trim().to_owned()); + let text = request.unwrap_or(text); + if self.answered { + // Operator input injected mid-turn (steering) is conversation. + self.current_turn().messages.push(Message { + role: Role::User, + parts: vec![Part::Text { text }], + }); + } else { + self.pending_prompt = Some(text); + } + } + + fn push_output(&mut self, message: Message) -> (usize, usize, usize) { + self.flush_prompt(); + self.answered = true; + let turn_index = self.turns.len().saturating_sub(1); + let turn = self.current_turn(); + turn.messages.push(message); + let message_index = turn.messages.len() - 1; + (turn_index, message_index, turn.messages[message_index].parts.len() - 1) + } + + /// Commits the surviving prompt candidate as the turn's operator message. + fn flush_prompt(&mut self) { + if let Some(text) = self.pending_prompt.take() { + self.current_turn().messages.push(Message { + role: Role::User, + parts: vec![Part::Text { text }], + }); + } + } + + fn current_turn(&mut self) -> &mut Turn { + if self.turns.is_empty() { + self.turns.push(Turn::default()); + } + let last = self.turns.len() - 1; + &mut self.turns[last] + } + + fn finish(mut self) -> Vec { + self.flush_prompt(); + self.turns.retain(|turn| !turn.messages.is_empty()); + self.turns + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIXTURE: &str = include_str!("../../../tests/fixtures/codex-rollout.jsonl"); + + #[test] + fn a_bounded_suffix_discards_a_partial_turn() { + let suffix = concat!( + r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"text":"partial"}]}}"#, + "\n", + r#"{"type":"event_msg","payload":{"type":"task_started"}}"#, + "\n", + r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"text":"complete"}]}}"#, + "\n", + ); + + let turns = parse(trim_partial(suffix.as_bytes())).expect("suffix"); + + assert!(matches!(turns[0].messages[0].parts[0], Part::Text { ref text } if text == "complete")); + } + + fn prompts(turns: &[Turn]) -> Vec<&str> { + turns + .iter() + .map(|turn| match &turn.messages[0].parts[0] { + Part::Text { text } => text.as_str(), + Part::ToolCall { .. } => panic!("a turn starts with the prompt"), + }) + .collect() + } + + #[test] + fn recorded_rollout_yields_operator_turns_and_drops_injected_context() { + let turns = parse(FIXTURE.as_bytes()).expect("parse"); + assert_eq!( + prompts(&turns), + ["Measure the desk and draw it.", "Make the top bar slimmer."] + ); + assert!( + turns + .iter() + .flat_map(|turn| &turn.messages) + .flat_map(|message| &message.parts) + .all(|part| !matches!(part, Part::Text { text } if text.contains("AGENTS.md"))), + "AGENTS.md context is not conversation" + ); + } + + #[test] + fn tool_calls_failures_and_final_message_are_recorded() { + let turns = parse(FIXTURE.as_bytes()).expect("parse"); + let first = &turns[0]; + let tools = first + .messages + .iter() + .flat_map(|message| &message.parts) + .filter_map(|part| match part { + Part::ToolCall { name, failed } => Some((name.as_str(), *failed)), + Part::Text { .. } => None, + }) + .collect::>(); + assert_eq!(tools, [("exec_command", false), ("shell", true)]); + assert!( + matches!(first.messages.last().and_then(|message| message.parts.last()), Some(Part::Text { text }) if text == "Drawn to scale.") + ); + } + + #[test] + fn failures_use_command_headers_and_native_patch_and_mcp_results() { + for (name, output, event, expected) in [ + ( + "exec_command", + "Chunk ID: abc\nWall time: 0.1 seconds\nProcess exited with code 2\nOutput:\nmissing file", + serde_json::Value::Null, + true, + ), + ( + "exec_command", + "Chunk ID: abc\nWall time: 0.1 seconds\nProcess exited with code 0\nOutput:\nProcess exited with code 2", + serde_json::Value::Null, + false, + ), + ( + "exec_command", + "Chunk ID: abc\nWall time: 0.1 seconds\nProcess running with session ID 123\nOutput:\n", + serde_json::Value::Null, + false, + ), + ( + "apply_patch", + "", + serde_json::json!({"type":"patch_apply_end", "call_id":"c", "success":false, "status":"failed"}), + true, + ), + ( + "mcp__example__read", + "", + serde_json::json!({"type":"mcp_tool_call_end", "call_id":"c", "result":{"Err":"connection closed"}}), + true, + ), + ( + "mcp__example__read", + "", + serde_json::json!({"type":"mcp_tool_call_end", "call_id":"c", "result":{"Ok":{"content":[], "isError":true}}}), + true, + ), + ] { + let lines = [ + serde_json::json!({"type":"response_item", "payload":{"type":"function_call", "call_id":"c", "name":name}}), + serde_json::json!({"type":"response_item", "payload":{"type":"function_call_output", "call_id":"c", "output":output}}), + serde_json::json!({"type":"event_msg", "payload":event}), + ]; + let transcript = lines.iter().map(ToString::to_string).collect::>().join("\n"); + let turns = parse(transcript.as_bytes()).expect("parse"); + assert!( + matches!(&turns[0].messages[0].parts[0], Part::ToolCall { failed, .. } if *failed == expected), + "{name}: {transcript}" + ); + } + } + + #[test] + fn direct_command_results_match_call_ids_out_of_order() { + let lines = [ + serde_json::json!({"type":"response_item", "payload":{"type":"function_call", "call_id":"a", "name":"exec_command"}}), + serde_json::json!({"type":"response_item", "payload":{"type":"function_call", "call_id":"b", "name":"write_stdin"}}), + serde_json::json!({"type":"event_msg", "payload":{"type":"item_completed", "item":{"type":"CommandExecution", "id":"b", "status":"failed", "exit_code":1}}}), + serde_json::json!({"type":"event_msg", "payload":{"type":"item_completed", "item":{"type":"CommandExecution", "id":"a", "status":"completed", "exit_code":0}}}), + serde_json::json!({"type":"response_item", "payload":{"type":"function_call_output", "call_id":"a", "output":[]}}), + serde_json::json!({"type":"response_item", "payload":{"type":"function_call_output", "call_id":"b", "output":[]}}), + serde_json::json!({"type":"response_item", "payload":{"type":"function_call", "call_id":"c", "name":"exec_command"}}), + serde_json::json!({"type":"event_msg", "payload":{"type":"item_completed", "item":{"type":"CommandExecution", "id":"c", "status":"declined", "exit_code":-1}}}), + serde_json::json!({"type":"response_item", "payload":{"type":"function_call_output", "call_id":"c", "output":[]}}), + serde_json::json!({"type":"response_item", "payload":{"type":"function_call", "call_id":"d", "name":"exec_command"}}), + serde_json::json!({"type":"event_msg", "payload":{"type":"item_completed", "item":{"type":"CommandExecution", "id":"missing", "status":"failed", "exit_code":1}}}), + serde_json::json!({"type":"event_msg", "payload":{"type":"item_completed", "item":{"type":"CommandExecution", "id":"d", "status":"completed", "exit_code":0}}}), + serde_json::json!({"type":"response_item", "payload":{"type":"function_call_output", "call_id":"d", "output":[]}}), + ]; + let transcript = lines.iter().map(ToString::to_string).collect::>().join("\n"); + + let turns = parse(transcript.as_bytes()).expect("parse"); + let tools = turns[0] + .messages + .iter() + .flat_map(|message| &message.parts) + .filter_map(|part| match part { + Part::ToolCall { name, failed } => Some((name.as_str(), *failed)), + Part::Text { .. } => None, + }) + .collect::>(); + + assert_eq!( + tools, + [ + ("exec_command", false), + ("write_stdin", true), + ("exec_command", true), + ("exec_command", false), + ] + ); + } + + #[test] + fn nested_code_mode_results_are_aggregated_for_the_outer_exec() { + let lines = [ + serde_json::json!({"type":"response_item", "payload":{"type":"custom_tool_call", "call_id":"outer-a", "name":"exec"}}), + serde_json::json!({"type":"event_msg", "payload":{"type":"item_completed", "item":{"type":"CommandExecution", "id":"exec-child-a1", "status":"completed", "exit_code":0}}}), + serde_json::json!({"type":"event_msg", "payload":{"type":"item_completed", "item":{"type":"CommandExecution", "id":"exec-child-a2", "status":"failed", "exit_code":7}}}), + serde_json::json!({"type":"response_item", "payload":{"type":"function_call", "call_id":"direct", "name":"exec_command"}}), + serde_json::json!({"type":"response_item", "payload":{"type":"function_call_output", "call_id":"direct", "output":[]}}), + serde_json::json!({"type":"response_item", "payload":{"type":"custom_tool_call_output", "call_id":"outer-a", "output":[]}}), + serde_json::json!({"type":"response_item", "payload":{"type":"custom_tool_call", "call_id":"outer-b", "name":"exec"}}), + serde_json::json!({"type":"event_msg", "payload":{"type":"item_completed", "item":{"type":"CommandExecution", "id":"exec-child-b", "status":"completed", "exit_code":0}}}), + serde_json::json!({"type":"response_item", "payload":{"type":"custom_tool_call_output", "call_id":"outer-b", "output":[]}}), + ]; + let transcript = lines.iter().map(ToString::to_string).collect::>().join("\n"); + + let turns = parse(transcript.as_bytes()).expect("parse"); + let tools = turns[0] + .messages + .iter() + .flat_map(|message| &message.parts) + .filter_map(|part| match part { + Part::ToolCall { name, failed } => Some((name.as_str(), *failed)), + Part::Text { .. } => None, + }) + .collect::>(); + + assert_eq!(tools, [("exec", true), ("exec_command", false), ("exec", false)]); + } + + #[test] + fn a_bundled_request_keeps_only_the_operator_text() { + let jsonl = concat!( + r#"{"type":"event_msg","payload":{"type":"task_started"}}"#, + "\n", + r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"x\n\n## My request for Codex:\nhello"}]}}"#, + "\n", + r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hi"}]}}"#, + "\n", + r#"{"type":"event_msg","payload":{"type":"task_complete"}}"#, + "\n", + ); + let turns = parse(jsonl.as_bytes()).expect("parse"); + assert_eq!(prompts(&turns), ["hello"]); + assert!( + matches!(turns[0].messages.last().and_then(|message| message.parts.last()), Some(Part::Text { text }) if text == "hi") + ); + } + + #[test] + fn an_aborted_turn_closes_and_the_next_prompt_starts_a_new_one() { + let jsonl = concat!( + r#"{"type":"event_msg","payload":{"type":"task_started"}}"#, + "\n", + r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"first"}]}}"#, + "\n", + r#"{"type":"event_msg","payload":{"type":"turn_aborted"}}"#, + "\n", + r#"{"type":"event_msg","payload":{"type":"task_started"}}"#, + "\n", + r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"second"}]}}"#, + "\n", + ); + let turns = parse(jsonl.as_bytes()).expect("parse"); + assert_eq!(prompts(&turns), ["first", "second"]); + } +} + +#[cfg(test)] +mod input_preservation_tests { + #[test] + fn a_literal_request_heading_does_not_truncate_operator_input() { + let prompt = "Explain this heading: ## My request for Codex: keep all of this"; + let record = serde_json::json!({"type":"response_item", "payload":{"type":"message", "role":"user", "content":[{"type":"input_text", "text":prompt}]}}); + let turns = super::parse(record.to_string().as_bytes()).expect("parse"); + assert_eq!( + turns[0].messages[0].parts[0], + crate::sessions::Part::Text { text: prompt.into() } + ); + } +} diff --git a/agentctl/src/harness/hook_script.rs b/agentctl/src/harness/hook_script.rs new file mode 100644 index 0000000..ed9c02b --- /dev/null +++ b/agentctl/src/harness/hook_script.rs @@ -0,0 +1,259 @@ +//! Renderer for the harness activity report hook. +//! +//! Both supported harnesses run hook commands with a JSON description of the +//! event on stdin, so one script shape serves both. Everything harness-specific +//! (which `hook_event_name`s exist, what each one means to the platform, which +//! notification types matter) is a table the adapter owns and hands to +//! [`HookScript::render`]; this module knows only the wire contract with the +//! Platform API. + +use crate::sessions::ActivityEvent; + +/// The harness-specific table an activity hook script is rendered from. +pub(super) struct HookScript<'a> { + /// Harness hook event names and the platform signal each one carries. + pub(super) events: &'a [(&'a str, ActivityEvent)], + /// `Notification` types that mean the harness is blocked on the operator; + /// other notifications carry no signal. Empty when the harness has no + /// notification hook. + pub(super) waiting_notifications: &'a [&'a str], +} + +const TEMPLATE: &str = r#"import { randomUUID } from "node:crypto"; + +const url = process.env.AGENT_SESSION_HOOK_URL; +const token = process.env.AGENT_SESSION_TOKEN; +const sessionId = process.env.AGENT_SESSION_ID; + +// Harness hook event -> platform activity signal. Rendered from the adapter's +// table; events not listed carry no signal and are ignored. +const EVENTS = __EVENTS__; +const WAITING_NOTIFICATIONS = __WAITING_NOTIFICATIONS__; + +function read(stream) { + // Codex 0.156 keeps stdin open while waiting for the hook to exit. + // Resolve on a complete object without waiting for EOF or stream cleanup. + return new Promise((resolve) => { + let data = ""; + const finish = (value) => { + stream.off("data", onData); + stream.off("end", onEnd); + stream.off("error", onEnd); + resolve(value); + }; + const parse = () => { + try { + const value = JSON.parse(data); + return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null; + } catch { + return null; + } + }; + const onData = (chunk) => { + data += chunk; + if (data.length > 1048576) { + finish(null); + } else { + const value = parse(); + if (value !== null) finish(value); + } + }; + const onEnd = () => finish(parse()); + stream.setEncoding("utf8"); + stream.on("data", onData); + stream.on("end", onEnd); + stream.on("error", onEnd); + }); +} + +const input = await read(process.stdin); +if (!url || !token || !sessionId || input === null) process.exit(0); +const event = EVENTS[input.hook_event_name]; +if (!event) process.exit(0); +// A nested Agent's own reports carry an agent_id; never forward those. +if (input.agent_id) process.exit(0); +// Only notifications that block on the operator are activity. +if ( + input.hook_event_name === "Notification" && + typeof input.notification_type === "string" && + !WAITING_NOTIFICATIONS.includes(input.notification_type) +) { + process.exit(0); +} + +const body = { sessionId, eventId: randomUUID(), event, source: typeof input.source === "string" ? input.source : "" }; +if (event === "sessionStart") { + if (typeof input.session_id !== "string" || input.session_id === "") process.exit(0); + body.nativeSessionId = input.session_id; + if (typeof input.transcript_path === "string" && input.transcript_path !== "") { + body.transcriptPath = input.transcript_path; + } +} +const payload = JSON.stringify(body); + +// Start and terminal reports unblock callers, so they retry within a strict budget. +// The payload keeps the same event ID across retries, including a lost response. +// Other activity uses one short best-effort attempt. +const retryable = ["sessionStart", "turnCompleted", "waitingForInput"].includes(event); +const attempts = retryable ? 3 : 1; +const budget = retryable ? 1500 : 300; +const perAttempt = retryable ? 450 : 250; +const deadline = Date.now() + budget; +for (let attempt = 0; attempt < attempts; attempt += 1) { + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + try { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: payload, + signal: AbortSignal.timeout(Math.min(perAttempt, remaining)), + }); + if (response.ok) break; + } catch {} + if (attempt < attempts - 1) { + const pause = Math.min(75, deadline - Date.now()); + if (pause > 0) await new Promise((resolve) => setTimeout(resolve, pause)); + } +} +process.exit(0); +"#; + +impl HookScript<'_> { + /// Renders the hook script with the adapter's tables embedded. + /// + /// # Errors + /// + /// Returns an error when the tables cannot be encoded as JSON. + pub(super) fn render(&self) -> Result { + let events = self + .events + .iter() + .map(|(name, event)| Ok(((*name).to_owned(), serde_json::to_value(event)?))) + .collect::, serde_json::Error>>()?; + let events = serde_json::to_string(&serde_json::Value::Object(events))?; + let waiting = serde_json::to_string(self.waiting_notifications)?; + Ok(TEMPLATE + .replace("__EVENTS__", &events) + .replace("__WAITING_NOTIFICATIONS__", &waiting)) + } + + /// Hook event names the adapter registers: exactly the table's keys. + pub(super) fn event_names(&self) -> impl Iterator { + self.events.iter().map(|(name, _)| *name) + } +} + +/// Parses the event table back out of a rendered script, in the wire format +/// the Platform API reads; adapters use it to prove their table round-trips. +#[cfg(test)] +pub(super) fn embedded_events(script: &str) -> Vec<(String, ActivityEvent)> { + let start = script.find("const EVENTS = ").expect("table") + "const EVENTS = ".len(); + let end = script[start..].find(";\n").expect("terminator") + start; + let table: serde_json::Map = + serde_json::from_str(&script[start..end]).expect("embedded JSON"); + table + .into_iter() + .map(|(name, value)| (name, serde_json::from_value(value).expect("wire value parses"))) + .collect() +} + +#[cfg(test)] +mod tests { + use super::{HookScript, embedded_events}; + use crate::sessions::ActivityEvent; + + #[tokio::test] + async fn hook_exits_after_complete_json_even_when_stdin_remains_open() { + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + let script = HookScript { + events: &[("SessionStart", ActivityEvent::SessionStart)], + waiting_notifications: &[], + } + .render() + .expect("script"); + let script = script.replacen( + "const input = await read(process.stdin);", + "process.stdout.write('ready\\n');\nconst input = await read(process.stdin);", + 1, + ); + let Ok(mut child) = tokio::process::Command::new("node") + .arg("--input-type=module") + .arg("-e") + .arg(script) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .env_remove("AGENT_SESSION_HOOK_URL") + .env_remove("AGENT_SESSION_TOKEN") + .env_remove("AGENT_SESSION_ID") + .kill_on_drop(true) + .spawn() + else { + // Node is optional for Rust-only development environments. + return; + }; + let mut ready = [0; 6]; + tokio::time::timeout( + std::time::Duration::from_secs(10), + child.stdout.as_mut().expect("stdout").read_exact(&mut ready), + ) + .await + .expect("Node started") + .expect("Node reported readiness"); + assert_eq!(&ready, b"ready\n"); + child + .stdin + .as_mut() + .expect("stdin") + .write_all(br#"{"hook_event_name":"SessionStart"}"#) + .await + .expect("write hook payload"); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + if let Some(status) = child.try_wait().expect("hook status") { + assert!(status.success()); + break; + } + assert!(tokio::time::Instant::now() < deadline, "hook waited for stdin EOF"); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + } + + #[test] + fn renders_the_given_tables_verbatim() { + let script = HookScript { + events: &[ + ("Begin", ActivityEvent::TurnStarted), + ("End", ActivityEvent::TurnCompleted), + ], + waiting_notifications: &["ask"], + } + .render() + .expect("script renders"); + assert_eq!( + embedded_events(&script), + [ + ("Begin".to_owned(), ActivityEvent::TurnStarted), + ("End".to_owned(), ActivityEvent::TurnCompleted), + ] + ); + assert!(script.contains(r#"const WAITING_NOTIFICATIONS = ["ask"];"#)); + assert!(!script.contains("__EVENTS__")); + } + + #[test] + fn session_start_carries_identity_and_retries_within_one_budget() { + let script = HookScript { + events: &[("SessionStart", ActivityEvent::SessionStart)], + waiting_notifications: &[], + } + .render() + .expect("script renders"); + assert!(script.contains("body.nativeSessionId = input.session_id;")); + assert!(script.contains("body.transcriptPath = input.transcript_path;")); + assert!(script.contains("retryable ? 3 : 1")); + assert!(script.contains("retryable ? 1500 : 300")); + } +} diff --git a/agentctl/src/harness/mod.rs b/agentctl/src/harness/mod.rs new file mode 100644 index 0000000..c4f56d8 --- /dev/null +++ b/agentctl/src/harness/mod.rs @@ -0,0 +1,597 @@ +//! Harness-specific adapters behind the closed Agent manifest harness selection. + +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::{Error, persistence}; + +mod claude_code; +mod codex; +mod hook_script; +mod skills; + +const VERSION_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); +const MAX_INITIAL_PROMPT_ARGUMENT_BYTES: usize = 64 * 1024; +const MAX_SELECTION_CHARACTERS: usize = 128; + +pub(crate) use skills::{Skill, SkillFile}; + +/// Supported harnesses. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Harness { + /// Anthropic Claude Code. + ClaudeCode, + /// `OpenAI` Codex CLI. + Codex, +} + +impl Harness { + /// Returns the manifest and CLI spelling of this harness family. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ClaudeCode => "claudeCode", + Self::Codex => "codex", + } + } +} + +impl std::fmt::Display for Harness { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl std::str::FromStr for Harness { + type Err = Error; + + fn from_str(value: &str) -> Result { + match value { + "claudeCode" => Ok(Self::ClaudeCode), + "codex" => Ok(Self::Codex), + _ => Err(Error::Invalid(format!("unsupported harness {value:?}"))), + } + } +} + +/// Supported harness authentication modes. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum HarnessAuthMode { + /// Credentials remain on the host and are injected into authorized requests. + Mediated, +} + +/// Declares a validated, provider-owned launch selection carried as an opaque string. +/// +/// Model names and effort levels belong to the harness vendor: they differ between +/// harnesses and gain new values without a platform release, so the platform only +/// checks that a value can travel safely to the harness command line. +macro_rules! launch_selection { + ($(#[$doc:meta])* $name:ident, $label:literal) => { + $(#[$doc])* + #[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] + #[serde(try_from = "String", into = "String")] + pub struct $name(String); + + impl $name { + /// Creates a validated selection. + /// + /// # Errors + /// + /// Returns an error unless the value is 1–128 ASCII letters, digits or + /// `-`, `_`, `.`, `:`, `/`, `@`, `+`. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_selection($label, &value)?; + Ok(Self(value)) + } + + /// Returns the selection as the text handed to the harness. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl TryFrom for $name { + type Error = Error; + + fn try_from(value: String) -> Result { + Self::new(value) + } + } + + impl From<$name> for String { + fn from(value: $name) -> Self { + value.0 + } + } + + impl std::str::FromStr for $name { + type Err = Error; + + fn from_str(value: &str) -> Result { + Self::new(value) + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } + } + }; +} + +launch_selection! { + /// Harness-owned model selection, such as a Claude Code alias or a Codex model name. + Model, "model" +} + +launch_selection! { + /// Harness-owned effort or reasoning level, such as `high`. + Effort, "effort" +} + +/// A harness's model and effort level, each optional and provider-owned. +/// +/// Declared on a harness installation as the defaults for its new Sessions, +/// requested when a Session is created, and recorded with the Session as the +/// selection every launch of its harness applies. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ModelSelection { + /// Model name in the harness's own spelling; `None` leaves the harness default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Effort level in the harness's own spelling; `None` leaves the harness default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort: Option, +} + +impl ModelSelection { + /// Whether neither a model nor an effort level is selected. + #[must_use] + pub const fn is_empty(&self) -> bool { + self.model.is_none() && self.effort.is_none() + } + + /// Fills each unselected field from `defaults`, field by field. + #[must_use] + pub fn or(self, defaults: &Self) -> Self { + Self { + model: self.model.or_else(|| defaults.model.clone()), + effort: self.effort.or_else(|| defaults.effort.clone()), + } + } + + /// Returns the model as text, when selected. + #[must_use] + pub fn model_str(&self) -> Option<&str> { + self.model.as_ref().map(Model::as_str) + } + + /// Returns the effort level as text, when selected. + #[must_use] + pub fn effort_str(&self) -> Option<&str> { + self.effort.as_ref().map(Effort::as_str) + } + + /// Describes the first field `requested` selects differently from this + /// recorded selection, as a clause following the Session's name; `None` + /// when every requested field matches or is unselected. + #[must_use] + pub fn conflict_with(&self, requested: &Self) -> Option { + [ + ("model", self.model_str(), requested.model_str()), + ("effort", self.effort_str(), requested.effort_str()), + ] + .into_iter() + .find_map(|(field, recorded, requested)| { + let requested = requested.filter(|requested| Some(*requested) != recorded)?; + Some(recorded.map_or_else( + || format!("leaves the {field} to the harness default, not {requested:?}"), + |recorded| format!("already uses {field} {recorded:?}, not {requested:?}"), + )) + }) + } +} + +fn validate_selection(label: &str, value: &str) -> Result<(), Error> { + if value.is_empty() + || value.chars().count() > MAX_SELECTION_CHARACTERS + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'/' | b'@' | b'+')) + { + return Err(Error::Invalid(format!( + "{label} must be 1-{MAX_SELECTION_CHARACTERS} ASCII letters, digits or '-', '_', '.', ':', '/', '@', '+'" + ))); + } + Ok(()) +} + +/// One harness installation declared for an Agent. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct HarnessSpec { + /// Closed harness family identifier. + #[serde(rename = "type")] + pub kind: Harness, + /// Exact version installed by the Agent image; omitted when the image owns the version, so image bumps need no manifest change. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + /// Authentication delivery mode. + pub auth: HarnessAuthMode, + /// Whether a host login this harness cannot find omits the installation instead of blocking + /// Agent provisioning. Re-evaluated on every pass, so a later host login installs it. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub optional: bool, + /// Whether new Sessions select this installation when no harness is specified. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub default: bool, + /// Model and effort level for new Sessions of this installation that do not + /// select their own. Omitted, the harness picks its own defaults. + #[serde(default, skip_serializing_if = "ModelSelection::is_empty")] + pub defaults: ModelSelection, +} + +/// Non-secret result of importing a host harness login. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ImportedAuthentication { + /// Authentication provider identifier. + pub provider: String, + /// Whether usable credentials were imported. + pub ready: bool, +} + +/// Dispatches host-side authentication to the selected harness adapter. +pub struct AuthenticationManager { + database: persistence::Database, + claude_code: claude_code::authentication::Authentication, + codex: codex::authentication::Authentication, +} + +impl AuthenticationManager { + /// Creates the harness authentication manager over the shared database owner. + #[must_use] + pub fn new(database: persistence::Database) -> Self { + Self { + database: database.clone(), + claude_code: claude_code::authentication::Authentication::new(database.clone()), + codex: codex::authentication::Authentication::new(database), + } + } + + /// Stores a credential for the selected harness. + /// + /// `imported` marks a credential supplied by the caller rather than minted by the host login + /// flow; adapters whose host grant must stay isolated only accept mediated placeholders that way. + /// + /// # Errors + /// + /// Returns an error when the credential is invalid or cannot be persisted. + pub async fn login( + &self, + harness: Harness, + credential: Zeroizing, + imported: bool, + ) -> Result { + match harness { + Harness::ClaudeCode => self.claude_code.login(credential).await, + Harness::Codex => self.codex.login(credential, imported).await, + } + } +} + +impl sandbox::secret_store::SecretStore for AuthenticationManager { + fn set<'a>( + &'a self, + name: &'a str, + value: &'a [u8], + ) -> sandbox::LocalFuture<'a, Result> { + sandbox::secret_store::SecretStore::set(&self.database, name, value) + } + + fn resolve<'a>( + &'a self, + reference: &'a sandbox::secret_store::SecretReference, + ) -> sandbox::LocalFuture<'a, Result> { + Box::pin(async move { + if codex::owns_secret(reference) { + self.codex.resolve_access().await + } else { + sandbox::secret_store::SecretStore::resolve(&self.database, reference).await + } + }) + } +} + +/// Acquires a host credential for the selected harness, interactively. +/// +/// Runs on the client host, where a terminal and browser are available; the +/// harness-specific login mechanism lives behind the closed harness enum. +/// +/// # Errors +/// +/// Returns an error when the harness login tool is missing, fails, or yields no credential. +pub fn acquire_host_credential( + harness: Harness, + control_plane_home: &std::path::Path, +) -> Result, Error> { + match harness { + Harness::ClaudeCode => claude_code::acquire_host_token(), + Harness::Codex => codex::acquire_host_credential(control_plane_home), + } +} + +/// Reports whether the host login this harness mediates is present and usable. +/// +/// An optional installation is omitted rather than failing provisioning when this is false, so the +/// check must distinguish "no login here" from a genuine fault, which stays an error. +pub(crate) async fn authentication_ready(harness: Harness, database: &persistence::Database) -> Result { + match harness { + Harness::ClaudeCode => claude_code::authentication_ready(database).await, + Harness::Codex => codex::authentication_ready(database).await, + } +} + +pub(crate) async fn prepare(harness: Harness, database: &persistence::Database) -> Result, Error> { + match harness { + Harness::ClaudeCode => claude_code::prepare(database).await, + Harness::Codex => codex::prepare(database).await, + } +} + +pub(crate) struct MediatedSecret { + pub(crate) environment: &'static str, + pub(crate) placeholder: String, + pub(crate) reference: sandbox::secret_store::SecretReference, + pub(crate) allowed_hosts: Vec, +} + +pub(crate) fn conflicts_with_managed_secret(harness: Harness, name: &str, placeholder: Option<&str>) -> bool { + match harness { + Harness::ClaudeCode => claude_code::conflicts_with_managed_secret(name, placeholder), + Harness::Codex => codex::conflicts_with_managed_secret(name, placeholder), + } +} + +pub(crate) fn manages_environment(harness: Harness, name: &str) -> bool { + match harness { + Harness::ClaudeCode => claude_code::manages_environment(name), + Harness::Codex => codex::manages_environment(name), + } +} + +pub(crate) async fn bootstrap_linux( + harness: Harness, + sandbox: &sandbox::SandboxHandle, + home: &str, + instructions: Option<&[u8]>, + skills: &[Skill], +) -> Result<(), Error> { + match harness { + Harness::ClaudeCode => claude_code::bootstrap_linux(sandbox, home, instructions, skills).await, + Harness::Codex => codex::bootstrap_linux(sandbox, home, instructions, skills).await, + } +} + +/// Verifies that the declared harness installation exists, at the exact version when one is declared. +pub(crate) async fn verify_linux( + harness: Harness, + sandbox: &sandbox::SandboxHandle, + expected_version: Option<&str>, +) -> Result<(), Error> { + match harness { + Harness::ClaudeCode => claude_code::verify_linux(sandbox, expected_version).await, + Harness::Codex => codex::verify_linux(sandbox, expected_version).await, + } +} + +async fn version_output( + sandbox: &sandbox::SandboxHandle, + executable: &str, +) -> Result { + use sandbox::{SandboxPath, execution::ExecutionSpec}; + + let started = sandbox + .start_execution(sandbox::execution::StartExecutionRequest::new(ExecutionSpec::command( + SandboxPath::new("/usr/bin/env"), + [executable.to_owned(), "--version".into()], + ))) + .await?; + let execution_id = started.id.clone(); + match tokio::time::timeout(VERSION_PROBE_TIMEOUT, started.collect()).await { + Ok(output) => output.map_err(Error::from), + Err(_elapsed) => { + let _ignored = sandbox.kill_execution(&execution_id).await; + Err(Error::SandboxSetup(format!( + "`{executable} --version` did not finish within {}s", + VERSION_PROBE_TIMEOUT.as_secs() + ))) + } + } +} + +/// Harness-specific process and environment used by the Session runtime. +pub struct ProcessLaunch { + /// Shell command used to launch the harness. + pub command: String, + /// Environment added to the generic Agent session environment. + pub environment: Vec<(String, String)>, +} + +/// Harness-neutral inputs of one Session launch. +#[derive(Clone, Copy, Debug)] +pub struct LaunchRequest<'a> { + /// Guest home directory holding the harness configuration. + pub home: &'a str, + /// Harness-native conversation to continue instead of starting a fresh one. + pub resume: Option<&'a str>, + /// First prompt of a fresh conversation, passed as the harness's positional + /// prompt argument so it starts working immediately; ignored when resuming. + pub initial_prompt: Option<&'a str>, + /// Model and effort level the Session was created with. + pub model_selection: &'a ModelSelection, +} + +/// Resolves the selected harness's terminal launch configuration. +/// +/// Each adapter spells the request's model and effort in its own launch +/// vocabulary; both apply to fresh and resumed conversations alike. +#[must_use] +pub fn launch_linux(harness: Harness, request: &LaunchRequest<'_>) -> ProcessLaunch { + match harness { + Harness::ClaudeCode => claude_code::launch_linux(request), + Harness::Codex => codex::launch_linux(request), + } +} + +/// Model every Session of `harness` launched with before Sessions recorded a +/// model, when the adapter hardcoded one. Persistence records it for existing +/// Sessions when it adopts the Session selection columns. +pub(crate) const fn model_launched_before_selection(harness: Harness) -> Option<&'static str> { + match harness { + Harness::ClaudeCode => Some(claude_code::MODEL_LAUNCHED_BEFORE_SELECTION), + Harness::Codex => None, + } +} + +/// Quotes `value` as one POSIX shell word, safe for any content. +pub(crate) fn shell_single_quoted(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +/// Validates an initial prompt before it is persisted for an argv-based launch. +pub(crate) fn validate_initial_prompt(prompt: &str) -> Result<(), Error> { + if prompt.contains('\0') { + return Err(Error::Invalid("initial prompt must not contain NUL".into())); + } + let quoted_bytes = prompt + .len() + .saturating_add(prompt.bytes().filter(|byte| *byte == b'\'').count().saturating_mul(3)) + .saturating_add(2); + if quoted_bytes > MAX_INITIAL_PROMPT_ARGUMENT_BYTES { + return Err(Error::Invalid(format!( + "initial prompt is too large; its encoded launch argument must not exceed {} KiB", + MAX_INITIAL_PROMPT_ARGUMENT_BYTES / 1024 + ))); + } + Ok(()) +} + +/// Recognizes an initialized input line before a harness reports its conversation. +/// The runtime supplies the visible cursor line and pane title. +pub(crate) fn input_ready_without_report(harness: Harness, cursor_line: &str, title: &str) -> bool { + match harness { + Harness::ClaudeCode => false, + Harness::Codex => codex::input_ready_without_report(cursor_line, title), + } +} + +/// Parses harness transcript bytes into ordered, runtime-neutral turns. +/// +/// # Errors +/// +/// Returns an error when the transcript cannot be decoded. +pub(crate) fn parse_transcript(harness: Harness, bytes: &[u8]) -> Result, Error> { + match harness { + Harness::ClaudeCode => claude_code::transcript::parse(bytes), + Harness::Codex => codex::transcript::parse(bytes), + } +} + +/// Trims a transcript suffix to its first complete harness turn. +pub(crate) fn trim_partial_transcript(harness: Harness, bytes: &[u8]) -> &[u8] { + match harness { + Harness::ClaudeCode => claude_code::transcript::trim_partial(bytes), + Harness::Codex => codex::transcript::trim_partial(bytes), + } +} + +#[cfg(test)] +pub(crate) const fn test_harness() -> Harness { + Harness::ClaudeCode +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn launch_selections_are_opaque_but_command_line_safe() { + for value in [ + "fable", + "claude-fable-5", + "gpt-5.4-codex", + "us.anthropic.claude-v1:0", + "org/model@2", + "xhigh", + ] { + assert_eq!(Model::new(value).expect("valid model").as_str(), value); + assert_eq!(Effort::new(value).expect("valid effort").as_str(), value); + } + for value in [ + "", + " ", + "fable ", + "a b", + "it's", + "quote\"d", + "back\\slash", + "tab\t", + "ø", + ] { + assert!(Model::new(value).is_err(), "{value:?}"); + assert!(Effort::new(value).is_err(), "{value:?}"); + } + assert!(Model::new("m".repeat(MAX_SELECTION_CHARACTERS)).is_ok()); + assert!(Model::new("m".repeat(MAX_SELECTION_CHARACTERS + 1)).is_err()); + let error = Effort::new("").expect_err("empty effort"); + assert!(error.to_string().contains("effort must be 1-128")); + assert!(serde_json::from_str::("\"\"").is_err()); + assert_eq!( + serde_json::to_string(&Model::new("fable").expect("model")).expect("JSON"), + "\"fable\"" + ); + } + + #[test] + fn conflicts_name_only_explicitly_requested_differences() { + let recorded = ModelSelection { + model: Some(Model::new("fable").expect("model")), + effort: None, + }; + assert_eq!(recorded.conflict_with(&ModelSelection::default()), None); + assert_eq!(recorded.conflict_with(&recorded), None); + let other_model = ModelSelection { + model: Some(Model::new("opus").expect("model")), + effort: None, + }; + assert_eq!( + recorded.conflict_with(&other_model).as_deref(), + Some("already uses model \"fable\", not \"opus\"") + ); + let effort_only = ModelSelection { + model: None, + effort: Some(Effort::new("max").expect("effort")), + }; + assert_eq!( + recorded.conflict_with(&effort_only).as_deref(), + Some("leaves the effort to the harness default, not \"max\"") + ); + } + + #[test] + fn initial_prompt_validation_measures_the_shell_quoted_argument() { + validate_initial_prompt(&"a".repeat(MAX_INITIAL_PROMPT_ARGUMENT_BYTES - 2)).expect("boundary prompt"); + + let expanded = "'".repeat(MAX_INITIAL_PROMPT_ARGUMENT_BYTES / 4); + assert!(validate_initial_prompt(&expanded).is_err()); + assert!(validate_initial_prompt("before\0after").is_err()); + } +} diff --git a/agentctl/src/harness/skills.rs b/agentctl/src/harness/skills.rs new file mode 100644 index 0000000..b8fe705 --- /dev/null +++ b/agentctl/src/harness/skills.rs @@ -0,0 +1,74 @@ +//! Skill installation shared by the Linux harness bootstraps. +//! +//! A skill is a directory holding `SKILL.md`, read on the host from `spec.skills`. Each harness +//! discovers skills in its own user-level directory, so the adapter chooses the root and this +//! module does the placement. + +use sandbox::{SandboxHandle, SandboxPath, execution::ExecutionSpec}; + +use crate::{Error, sandbox::platform::files::write_if_changed}; + +/// One skill directory read on the host, keyed by its directory name. +pub(crate) struct Skill { + pub(crate) name: String, + /// Regular files below the skill directory, as `/`-separated relative paths. + pub(crate) files: Vec, +} + +/// One regular file of a skill. +pub(crate) struct SkillFile { + pub(crate) relative_path: String, + pub(crate) contents: Vec, +} + +/// Places every skill below `root` as `root//`, owned by the image user. +/// +/// Harnesses watch their skill directories live, so a file whose contents are already in place +/// is left alone rather than rewritten. Directories are created as the image user. Runtime file +/// transfer writes as the Sandbox supervisor, so exactly the managed files are chowned +/// afterwards: no recursive ownership walk. +pub(super) async fn install_linux(sandbox: &SandboxHandle, root: &str, skills: &[Skill]) -> Result<(), Error> { + for skill in skills { + let target = format!("{root}/{}", skill.name); + run_checked(sandbox, "/usr/bin/mkdir", ["-p".to_owned(), target.clone()]).await?; + let mut managed = Vec::with_capacity(skill.files.len()); + for file in &skill.files { + let path = format!("{target}/{}", file.relative_path); + if let Some((parent, _)) = file.relative_path.rsplit_once('/') { + run_checked( + sandbox, + "/usr/bin/mkdir", + ["-p".to_owned(), format!("{target}/{parent}")], + ) + .await?; + } + write_if_changed(sandbox, &path, &file.contents).await?; + managed.push(path); + } + if managed.is_empty() { + continue; + } + let mut chown = vec!["/usr/bin/chown".to_owned(), "agent:agent".to_owned()]; + chown.extend(managed); + run_checked(sandbox, "/usr/bin/sudo", chown).await?; + } + Ok(()) +} + +async fn run_checked( + sandbox: &SandboxHandle, + executable: &str, + args: impl IntoIterator, +) -> Result<(), Error> { + let output = sandbox + .run_execution(ExecutionSpec::command(SandboxPath::new(executable), args)) + .await?; + if output.status.success() { + Ok(()) + } else { + Err(Error::SandboxSetup(format!( + "command {executable:?} exited with code {}", + output.status.code + ))) + } +} diff --git a/agentctl/src/lib.rs b/agentctl/src/lib.rs new file mode 100644 index 0000000..9c6d22e --- /dev/null +++ b/agentctl/src/lib.rs @@ -0,0 +1,104 @@ +//! Agent automation and the local declarative control plane. +//! +//! This crate depends on the generic Sandbox crates. The reverse dependency is +//! deliberately impossible in the workspace graph. + +pub mod authorization; +pub mod control_api; +pub mod control_plane; +mod controller; +mod environment; +pub mod harness; +pub mod local; +pub mod manifest; +pub mod persistence; +pub mod platform_api; +pub mod progress; +pub mod resources; +pub mod sandbox; +pub mod sessions; +pub mod ssh; +pub mod upgrade; +pub mod vnc; + +pub use control_plane::AgentId; +pub use controller::{FailureKind, ReconcileFailure}; +pub use harness::{Effort, Harness, HarnessAuthMode, HarnessSpec, Model, ModelSelection}; +pub use manifest::{ + API_VERSION, AccessSpec, Agent, AgentVariant, AgentVariantName, Condition, ConditionStatus, EnvironmentSpec, + HomeSpec, InstructionsSpec, KIND, Metadata, MountSpec, NetworkAllow, NetworkMode, NetworkSpec, + PlatformManifestSpec, Provenance, ResolvedManifest, RunState, SandboxManifestSpec, SecretSpec, SkillSpec, Spec, + Status, VARIANT_KIND, +}; + +/// Version embedded in a matched `agentctl`/`agentd` build. +#[must_use] +pub const fn build_version() -> &'static str { + match release_version() { + Some(version) => version, + None => env!("CARGO_PKG_VERSION"), + } +} + +/// Release version embedded by packaging, absent from ordinary development builds. +#[must_use] +pub const fn release_version() -> Option<&'static str> { + option_env!("AGENT_VERSION") +} + +use thiserror::Error; + +/// Errors exposed by the Agent control plane. +#[derive(Debug, Error)] +pub enum Error { + /// The Agent resource is invalid: desired state must change before another + /// reconciliation pass can succeed, so waiters fail fast and nothing retries. + #[error("invalid Agent: {0}")] + Invalid(String), + /// The requested Agent does not exist. + #[error("Agent not found")] + NotFound, + /// An immutable desired-state field changed. + #[error("immutable Agent field changed: {0}")] + Immutable(&'static str), + /// A compare-and-swap operation observed a newer generation. + #[error("Agent resource changed concurrently")] + Conflict, + /// Persistent control-plane state could not be read or written. + #[error("control-plane database failed: {0}")] + Database(String), + /// Immutable Agent setup failed inside a running Sandbox. Treated as transient: + /// the background controller retries and waiters keep following. + #[error("Agent Sandbox setup failed: {0}")] + SandboxSetup(String), + /// The Sandbox's guest stopped making progress while its VM kept running. + /// Treated as transient: the guest may recover, and the background + /// controller keeps observing it. + #[error("Agent Sandbox is not responding: {0}")] + SandboxUnresponsive(String), + /// The named Agent is stopped, so nothing runs in its Sandbox until it is + /// started. Desired state must change, so waiters fail fast. + #[error("Agent {0:?} is stopped; run `agentctl start agent/{0}`")] + Stopped(String), + /// A generic Sandbox operation failed. + #[error("Sandbox operation failed: {0}")] + Sandbox(#[from] ::sandbox::Error), + /// Session lifecycle or attachment failed. + #[error("Session operation failed: {0}")] + Session(String), + /// Local persistence or transport failed. + #[error("I/O operation failed: {0}")] + Io(#[from] std::io::Error), + /// A required daemon subsystem stopped unexpectedly. + #[error("Agent daemon subsystem failed: {0}")] + Daemon(String), + /// A JSON protocol document was invalid. + #[error("invalid JSON: {0}")] + Json(#[from] serde_json::Error), + /// A YAML manifest was invalid. + #[error("invalid YAML: {0}")] + Yaml(#[from] serde_yaml_ng::Error), + /// The Agent Control API returned a protocol-level error. + #[error("Agent Control API error: {0}")] + Rpc(#[from] control_api::ResponseError), +} diff --git a/agentctl/src/local/home.rs b/agentctl/src/local/home.rs new file mode 100644 index 0000000..b0feaea --- /dev/null +++ b/agentctl/src/local/home.rs @@ -0,0 +1,229 @@ +//! Per-user storage paths, permissions, and single-daemon ownership. + +use std::{ + env, + fs::{self, File, OpenOptions}, + path::{Path, PathBuf}, +}; + +use crate::Error; + +const ENVIRONMENT_VARIABLE: &str = "AGENT_HOME"; + +/// Root of one local Agent Control Plane. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ControlPlaneHome(PathBuf); + +impl ControlPlaneHome { + /// Resolves an explicit home, the environment override, or the per-user default. + /// + /// # Errors + /// + /// Returns an error when an absolute path or the per-user configuration directory cannot be resolved. + pub fn resolve(configured: Option<&Path>) -> Result { + if let Some(path) = configured.filter(|path| !path.as_os_str().is_empty()) { + return absolute(path).map(Self); + } + if let Some(path) = env::var_os(ENVIRONMENT_VARIABLE).filter(|value| !value.is_empty()) { + return absolute(Path::new(&path)).map(Self); + } + default_home().map(Self) + } + + /// Returns the resolved home path. + #[must_use] + pub fn path(&self) -> &Path { + &self.0 + } + + /// Returns the fixed local API socket path. + /// + /// The socket lives in its own directory because Windows can leave a stale + /// `AF_UNIX` socket file that cannot be deleted or rebound until reboot; + /// recovery renames the directory aside, which works even then. + #[must_use] + pub fn socket_path(&self) -> PathBuf { + self.0.join("run").join("agentd.sock") + } + + /// Returns the daemon diagnostic log path used by automatic startup. + #[must_use] + pub fn daemon_log_path(&self) -> PathBuf { + self.0.join("agentd.log") + } + + /// Returns the directory holding SSH client configuration and key material. + #[must_use] + pub fn ssh_directory(&self) -> PathBuf { + self.0.join("ssh") + } + + /// Returns the durable marker requesting one post-upgrade Session relaunch pass. + #[must_use] + pub fn pending_session_relaunch_path(&self) -> PathBuf { + self.0.join("pending-session-relaunch.json") + } + + /// Opens the automatic-start diagnostic log with user-only access. + /// + /// # Errors + /// + /// Returns an error when the log cannot be opened or secured. + pub fn open_daemon_log(&self) -> Result { + let path = self.daemon_log_path(); + let file = OpenOptions::new().create(true).append(true).open(&path)?; + secure_file(&path)?; + Ok(file) + } + + /// Creates the home and restricts it to the current user. + /// + /// # Errors + /// + /// Returns an error when the directory cannot be created or secured. + pub fn prepare(&self) -> Result<(), Error> { + fs::create_dir_all(&self.0)?; + secure_directory(&self.0)?; + Ok(()) + } + + /// Acquires exclusive ownership of this control-plane home. + /// + /// # Errors + /// + /// Returns an error when the home cannot be prepared or another daemon owns its lock. + pub fn acquire_lock(&self) -> Result { + self.prepare()?; + let path = self.0.join("agentd.lock"); + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&path)?; + secure_file(&path)?; + match file.try_lock() { + Ok(()) => {} + Err(std::fs::TryLockError::WouldBlock) => { + return Err(Error::Io(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "control-plane home is already locked", + ))); + } + Err(std::fs::TryLockError::Error(error)) => return Err(Error::Io(error)), + } + Ok(Lock { _file: file }) + } +} + +/// Returns the current user's home directory as the host reports it. +/// +/// This is the directory OpenSSH expands `~` to and where `~/.ssh/config` +/// lives; it is unrelated to the control-plane home, which may be relocated. +#[must_use] +pub fn user_home_directory() -> Option { + env::var_os(HOME_VARIABLE) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +/// The environment variable the host uses for the user's home directory. +const HOME_VARIABLE: &str = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + +/// Held exclusive process lock for one local control-plane home. +#[derive(Debug)] +pub struct Lock { + _file: File, +} + +fn absolute(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Ok(env::current_dir()?.join(path)) + } +} + +pub(crate) fn secure_directory(path: &Path) -> Result<(), Error> { + secure_directory_for_host(path) +} + +pub(crate) fn secure_file(path: &Path) -> Result<(), Error> { + secure_file_for_host(path) +} + +/// `~/.agent`. On Windows that is below `USERPROFILE`, not `LOCALAPPDATA`: +/// endpoint-protection filters commonly applied to the `AppData` tree can leave +/// `AF_UNIX` sockets there unconnectable and their files undeletable, which +/// breaks the local API socket and Microsandbox. +fn default_home() -> Result { + if !cfg!(any(unix, windows)) { + return Err(Error::Invalid("unsupported host operating system".into())); + } + user_home_directory() + .map(|home| home.join(".agent")) + .ok_or_else(|| Error::Invalid(format!("{HOME_VARIABLE} is not set"))) +} + +#[cfg(unix)] +fn secure_directory_for_host(path: &Path) -> Result<(), Error> { + use std::os::unix::fs::PermissionsExt as _; + + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + Ok(()) +} + +#[cfg(unix)] +fn secure_file_for_host(path: &Path) -> Result<(), Error> { + use std::os::unix::fs::PermissionsExt as _; + + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +#[cfg(target_os = "windows")] +fn secure_directory_for_host(path: &Path) -> Result<(), Error> { + secure_windows_path(path, true) +} + +#[cfg(target_os = "windows")] +fn secure_file_for_host(path: &Path) -> Result<(), Error> { + secure_windows_path(path, false) +} + +#[cfg(target_os = "windows")] +fn secure_windows_path(path: &Path, inherit: bool) -> Result<(), Error> { + let user = env::var("USERNAME").map_err(|error| Error::Invalid(error.to_string()))?; + let grant = if inherit { + format!("{user}:(OI)(CI)F") + } else { + format!("{user}:F") + }; + let mut command = std::process::Command::new("icacls"); + command + .arg(path) + .arg("/inheritance:r") + .arg("/grant:r") + .arg(grant) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + super::process::configure_hidden(&mut command); + let status = command.status()?; + if !status.success() { + return Err(Error::Io(std::io::Error::other(format!( + "icacls failed for {} with {status}", + path.display() + )))); + } + Ok(()) +} + +#[cfg(not(any(unix, target_os = "windows")))] +fn secure_directory_for_host(_path: &Path) -> Result<(), Error> { + Err(Error::Invalid("unsupported host operating system".into())) +} + +#[cfg(not(any(unix, target_os = "windows")))] +fn secure_file_for_host(_path: &Path) -> Result<(), Error> { + Err(Error::Invalid("unsupported host operating system".into())) +} diff --git a/agentctl/src/local/mod.rs b/agentctl/src/local/mod.rs new file mode 100644 index 0000000..034c779 --- /dev/null +++ b/agentctl/src/local/mod.rs @@ -0,0 +1,4 @@ +//! Local daemon infrastructure and host integration. + +pub mod home; +pub mod process; diff --git a/agentctl/src/local/process.rs b/agentctl/src/local/process.rs new file mode 100644 index 0000000..33e695d --- /dev/null +++ b/agentctl/src/local/process.rs @@ -0,0 +1,69 @@ +//! Host-specific process launch behavior. + +/// Default `RUST_LOG` filter for `agentd` and every runtime process it spawns. +/// +/// Runtime helpers inherit the daemon's stderr and install their own tracing +/// subscriber, so the filter travels through the environment rather than code. +#[must_use] +pub fn daemon_log_filter() -> String { + format!("info,{}", crate::sandbox::microsandbox::LOG_DIRECTIVES) +} + +/// Gives a child daemon the default log filter unless the caller set `RUST_LOG`. +pub fn configure_logging(command: &mut std::process::Command) { + if std::env::var_os("RUST_LOG").is_none() { + command.env("RUST_LOG", daemon_log_filter()); + } +} + +/// Configures a child daemon to run independently of the invoking terminal. +#[cfg(windows)] +pub fn configure_detached(command: &mut std::process::Command) { + use std::os::windows::process::CommandExt as _; + + command.creation_flags(windows::CREATE_NEW_PROCESS_GROUP | windows::CREATE_NO_WINDOW); +} + +#[cfg(unix)] +pub fn configure_detached(command: &mut std::process::Command) { + use std::os::unix::process::CommandExt as _; + + command.process_group(0); +} + +#[cfg(windows)] +pub(super) fn configure_hidden(command: &mut std::process::Command) { + use std::os::windows::process::CommandExt as _; + + command.creation_flags(windows::CREATE_NO_WINDOW); +} + +#[cfg(windows)] +mod windows { + pub(super) const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + pub(super) const CREATE_NO_WINDOW: u32 = 0x0800_0000; +} + +#[cfg(all(test, unix))] +mod tests { + #[test] + fn detached_child_owns_its_process_group() { + let mut command = std::process::Command::new("/bin/sh"); + command + .args(["-c", "ps -o pgid= -p $$"]) + .stdout(std::process::Stdio::piped()); + super::configure_detached(&mut command); + let child = command.spawn().expect("child"); + let pid = child.id(); + let output = child.wait_with_output().expect("child output"); + assert!(output.status.success()); + assert_eq!( + String::from_utf8(output.stdout) + .expect("UTF-8") + .trim() + .parse::() + .expect("process group"), + pid + ); + } +} diff --git a/agentctl/src/manifest.rs b/agentctl/src/manifest.rs new file mode 100644 index 0000000..af9d326 --- /dev/null +++ b/agentctl/src/manifest.rs @@ -0,0 +1,1308 @@ +//! Declarative Agent manifest and observed resource status. + +use std::path::{Component, Path, PathBuf}; + +use time::OffsetDateTime; + +use ::sandbox::{ + ByteQuantity, Platform, RetentionPolicy, SandboxName, SandboxPath, SandboxResources, image::ImageSource, + init::InitSystem, mount::Mount, +}; +use serde::{Deserialize, Serialize}; + +use crate::{Error, HarnessSpec, harness}; + +/// The first supported Agent manifest API version. +pub const API_VERSION: &str = "agents.platform/v1alpha1"; +/// The manifest resource kind. +pub const KIND: &str = "Agent"; +/// Manifest kind used for a partial Agent configuration. +pub const VARIANT_KIND: &str = "AgentVariant"; +/// Maximum number of manifests in one inheritance chain, including the complete Agent. +pub const MAX_VARIANT_CHAIN: usize = 16; + +/// Declarative resource accepted by the agent control plane. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Agent { + /// Manifest schema version. + pub api_version: String, + /// Resource kind. + pub kind: String, + /// Resource identity and API-managed metadata. + pub metadata: Metadata, + /// Desired agent and sandbox configuration. + pub spec: Spec, + /// Most recently observed state. + #[serde(default, skip_serializing_if = "Status::is_empty")] + pub status: Status, +} + +/// A partial Agent manifest that inherits from a sibling manifest. +/// +/// `metadata` and `spec` remain YAML values until they have been merged with a +/// complete Agent. The expanded document is then decoded through [`Agent`], so +/// nested unknown fields are rejected by the same strict contract. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AgentVariant { + /// Manifest schema version, which must match every manifest in the chain. + pub api_version: String, + /// Resource kind. Always [`VARIANT_KIND`]. + pub kind: String, + /// Sibling manifest filename inherited by this variant. + pub extends: String, + /// Partial resource metadata. `name` is required in every variant. + pub metadata: serde_yaml_ng::Value, + /// Partial desired Agent configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spec: Option, +} + +/// Validated selector identifying an `agent..yaml` leaf. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(try_from = "String", into = "String")] +pub struct AgentVariantName(String); + +impl AgentVariantName { + /// Creates a validated Agent variant name. + /// + /// # Errors + /// + /// Returns an error unless the name matches `[a-z0-9]+(?:-[a-z0-9]+)*`. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() + || !value.split('-').all(|part| { + !part.is_empty() + && part + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + }) + { + return Err(Error::Invalid("variant must match [a-z0-9]+(?:-[a-z0-9]+)*".into())); + } + Ok(Self(value)) + } + + /// Returns the selector text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Returns the conventional leaf filename for this variant. + #[must_use] + pub fn filename(&self) -> String { + format!("agent.{self}.yaml") + } +} + +impl TryFrom for AgentVariantName { + type Error = Error; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(value: AgentVariantName) -> Self { + value.0 + } +} + +impl std::str::FromStr for AgentVariantName { + type Err = Error; + + fn from_str(value: &str) -> Result { + Self::new(value) + } +} + +impl std::fmt::Display for AgentVariantName { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +impl AsRef for AgentVariantName { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +/// A leaf manifest expanded to the complete Agent sent to the control plane. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResolvedManifest { + /// Expanded and strictly validated Agent. + pub agent: Agent, + /// Manifest paths from the leaf through to the complete base Agent. + pub chain: Vec, +} + +impl Agent { + /// Validates fields required at every API boundary. + /// + /// # Errors + /// + /// Returns an error when the resource version, kind, name, or sandbox specification is invalid. + pub fn validate(&self) -> Result<(), Error> { + if self.api_version != API_VERSION { + return Err(Error::Invalid(format!("apiVersion must be {API_VERSION:?}"))); + } + if self.kind != KIND { + return Err(Error::Invalid(format!("kind must be {KIND:?}"))); + } + SandboxName::new(self.metadata.name.clone()) + .map_err(|error| Error::Invalid(format!("metadata.name: {error}")))?; + self.spec + .sandbox + .validate() + .map_err(|error| Error::Invalid(format!("spec.sandbox: {error}")))?; + self.spec.validate() + } + + pub(crate) fn clear_managed_fields(&mut self) { + self.metadata.generation = 0; + self.metadata.deletion_timestamp = None; + self.status = Status::default(); + } +} + +/// Agent resource identity and API-managed metadata. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Metadata { + /// Stable resource name. + pub name: String, + /// Desired-state revision managed by the control plane. + #[serde(default, skip_serializing_if = "is_zero")] + pub generation: u64, + /// Time at which asynchronous deletion was requested. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "time::serde::rfc3339::option" + )] + pub deletion_timestamp: Option, +} + +/// Desired agent settings and exactly one generic sandbox specification. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Spec { + /// Whether the Agent's Sandbox runs. `agentctl stop` and `agentctl start` + /// change it. A manifest may set it; when it is omitted, `apply` keeps the + /// Agent's current run state, and a new Agent runs. Stored only when not + /// Running. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run_state: Option, + /// Generic sandbox configuration mapped to the lower-layer SDK. + pub sandbox: SandboxManifestSpec, + /// Host directory synchronized into the sandbox user's home at bootstrap. + pub home: HomeSpec, + /// Agent-wide guidance installed through every declared Harness Adapter, concatenated in order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub instructions: Vec, + /// Skill directories installed through every declared Harness Adapter. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub skills: Vec, + /// Harness installations available to Sessions in this Agent. + pub harnesses: Vec, + /// Deliberately selected non-secret values exposed in plaintext inside the Sandbox. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub environment: Vec, + /// Host-owned values made available only through mediated requests. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub secrets: Vec, + /// Ways the Agent's user reaches into the Sandbox besides Sessions and `exec`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub access: Vec, + /// Sandbox egress mediation policy. + pub network: NetworkSpec, +} + +/// Whether an Agent's Sandbox runs. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub enum RunState { + /// The Sandbox runs and serves Sessions and Executions. + #[default] + Running, + /// The Sandbox's VM is stopped. Its root filesystem, Volumes and identity + /// are kept, so a start boots the same disk and Sessions resume. + Stopped, +} + +/// One access capability the platform provides to the Agent's user. +/// +/// Access is an Agent-level capability like `harnesses` and `secrets`: the +/// platform owns the guest user, the transport and the key material, so a +/// variant carries no tunables. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase", tag = "type")] +pub enum AccessSpec { + /// OpenSSH access as the platform-owned guest user, reached through `agentctl ssh`. + /// + /// A struct variant so that `deny_unknown_fields` rejects tunables; serde + /// does not enforce it for unit variants of an internally tagged enum. + Ssh {}, + /// VNC access to the desktop the image runs, reached through `agentctl vnc`. + Vnc {}, +} + +/// Sandbox settings as supplied by an Agent manifest. +/// +/// Unlike the lower-layer [`sandbox::SandboxSpec`], this representation retains +/// an omitted architecture until the Agent creates the concrete Sandbox request. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SandboxManifestSpec { + /// Source of the immutable Image. + pub image: ImageSource, + /// Desired platform constraints. + pub platform: PlatformManifestSpec, + /// Desired mutable compute and writable root filesystem resources. + pub resources: SandboxResources, + /// Process responsible for initializing the Sandbox after backend setup. + #[serde(default)] + pub init_system: InitSystem, + /// Whether the Agent retains the Sandbox when releasing it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retention_policy: Option, + /// Host filesystem and in-memory attachments materialized with the Sandbox. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mounts: Vec, +} + +impl SandboxManifestSpec { + fn validate(&self) -> Result<(), sandbox::Error> { + self.image.validate()?; + self.platform.validate() + } + + /// Resolves manifest-relative sources and an omitted architecture for a concrete Provider request. + #[must_use] + pub fn resolve_from(&self, source_directory: &std::path::Path, default_architecture: &str) -> sandbox::SandboxSpec { + sandbox::SandboxSpec { + image: self.image.resolve_from(source_directory), + platform: self.platform.resolve(default_architecture), + resources: self.resources, + init_system: self.init_system, + retention_policy: self.resolved_retention_policy(), + } + } + + /// Returns the Agent-layer retention default used when the manifest omits it. + #[must_use] + pub fn resolved_retention_policy(&self) -> RetentionPolicy { + self.retention_policy.unwrap_or(RetentionPolicy::Delete) + } + + /// Converts validated, absolute Agent Mount inputs to the generic Sandbox SDK representation. + #[must_use] + pub fn resolved_mounts(&self) -> Vec { + self.mounts.iter().map(MountSpec::to_sandbox_mount).collect() + } +} + +/// One filesystem attachment declared by an Agent builder. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde( + deny_unknown_fields, + rename_all = "camelCase", + rename_all_fields = "camelCase", + tag = "type" +)] +pub enum MountSpec { + /// A caller-host directory mapped into the Sandbox. + Bind { + /// Host path, resolved relative to the manifest directory at apply time. + source: std::path::PathBuf, + /// Absolute path inside the Sandbox. + target: SandboxPath, + /// Whether the Sandbox may modify the host directory. + read_only: bool, + }, + /// Anonymous in-memory storage with an explicit capacity. + Tmpfs { + /// Absolute path inside the Sandbox. + target: SandboxPath, + /// Maximum storage capacity. + capacity: ByteQuantity, + }, +} + +impl MountSpec { + const fn target(&self) -> &SandboxPath { + match self { + Self::Bind { target, .. } | Self::Tmpfs { target, .. } => target, + } + } + + fn to_sandbox_mount(&self) -> Mount { + match self { + Self::Bind { + source, + target, + read_only, + } => Mount::Bind { + source: source.clone(), + target: target.clone(), + read_only: *read_only, + }, + Self::Tmpfs { target, capacity } => Mount::Tmpfs { + target: target.clone(), + capacity: *capacity, + }, + } + } +} + +/// Platform constraints retained exactly as supplied by an Agent manifest. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PlatformManifestSpec { + /// Operating system, such as `linux`. + pub os: String, + /// Optional CPU architecture; omission selects the provider's native architecture. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub architecture: Option, + /// Optional architecture variant. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub variant: Option, + /// Optional operating-system version constraint. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub os_version: Option, + /// Required operating-system features. + #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")] + pub os_features: std::collections::BTreeSet, +} + +impl PlatformManifestSpec { + fn validate(&self) -> Result<(), sandbox::Error> { + if self.os.is_empty() { + return Err(sandbox::Error::invalid("platform.os", "must not be empty")); + } + if self.architecture.as_ref().is_some_and(String::is_empty) { + return Err(sandbox::Error::invalid( + "platform.architecture", + "must not be empty when present", + )); + } + if self.variant.as_ref().is_some_and(String::is_empty) { + return Err(sandbox::Error::invalid( + "platform.variant", + "must not be empty when present", + )); + } + if self.os_version.as_ref().is_some_and(String::is_empty) { + return Err(sandbox::Error::invalid( + "platform.osVersion", + "must not be empty when present", + )); + } + if self.os_features.contains("") { + return Err(sandbox::Error::invalid( + "platform.osFeatures", + "must not contain an empty value", + )); + } + Ok(()) + } + + fn resolve(&self, default_architecture: &str) -> Platform { + Platform { + os: self.os.clone(), + architecture: self.architecture.clone().unwrap_or_else(|| default_architecture.into()), + variant: self.variant.clone(), + os_version: self.os_version.clone(), + os_features: self.os_features.clone(), + } + } +} + +impl Spec { + /// Returns the desired run state; an omitted one is Running. + #[must_use] + pub fn run_state(&self) -> RunState { + self.run_state.unwrap_or_default() + } + + /// Returns whether the Agent's Sandbox is meant to be stopped. + #[must_use] + pub fn is_stopped(&self) -> bool { + self.run_state() == RunState::Stopped + } + + /// Sets the desired run state in its stored form, which omits Running, so + /// specs that differ only in how they say Running compare equal. + pub(crate) fn set_run_state(&mut self, state: RunState) { + self.run_state = (state != RunState::Running).then_some(state); + } + + /// Returns the declared installation for `kind`. + #[must_use] + pub fn harness(&self, kind: crate::Harness) -> Option<&HarnessSpec> { + self.harnesses.iter().find(|harness| harness.kind == kind) + } + + /// Returns the installation selected for a new Session without an explicit harness. + #[must_use] + pub fn default_harness(&self) -> Option<&HarnessSpec> { + if self.harnesses.len() == 1 { + self.harnesses.first() + } else { + self.harnesses.iter().find(|harness| harness.default) + } + } + + /// Returns whether the Agent declares SSH access. + #[must_use] + pub fn ssh_access(&self) -> bool { + self.access.contains(&AccessSpec::Ssh {}) + } + + /// Returns whether the Agent declares VNC access. + #[must_use] + pub fn vnc_access(&self) -> bool { + self.access.contains(&AccessSpec::Vnc {}) + } + + fn validate(&self) -> Result<(), Error> { + let mut mount_targets = std::collections::BTreeSet::new(); + for (index, mount) in self.sandbox.mounts.iter().enumerate() { + if let MountSpec::Bind { source, .. } = mount + && source.as_os_str().is_empty() + { + return Err(Error::Invalid(format!( + "spec.sandbox.mounts[{index}].source must not be empty" + ))); + } + let target = mount.target().as_str(); + if !valid_sandbox_path(target) || !mount_targets.insert(target) { + return Err(Error::Invalid(format!( + "spec.sandbox.mounts[{index}].target must be a unique absolute normalized Sandbox path" + ))); + } + } + if self.home.source.as_os_str().is_empty() { + return Err(Error::Invalid("spec.home.source must not be empty".into())); + } + if let Some(index) = self + .instructions + .iter() + .position(|instructions| instructions.source.as_os_str().is_empty()) + { + return Err(Error::Invalid(format!( + "spec.instructions[{index}].source must not be empty" + ))); + } + self.validate_skills()?; + if self.harnesses.is_empty() { + return Err(Error::Invalid("spec.harnesses must not be empty".into())); + } + let mut harness_kinds = std::collections::BTreeSet::new(); + let mut duplicate_harness = None; + let mut default_count = 0; + for (index, harness) in self.harnesses.iter().enumerate() { + if harness.version.as_deref().is_some_and(str::is_empty) { + return Err(Error::Invalid(format!( + "spec.harnesses[{index}].version must not be empty" + ))); + } + if !harness_kinds.insert(harness.kind) { + duplicate_harness = Some(harness.kind); + } + default_count += usize::from(harness.default); + } + if default_count > 1 || (self.harnesses.len() > 1 && default_count != 1) { + return Err(Error::Invalid( + "spec.harnesses must declare exactly one default when multiple harnesses are installed".into(), + )); + } + if let Some(harness) = duplicate_harness { + return Err(Error::Invalid(format!( + "spec.harnesses contains duplicate harness kind {:?}", + harness.as_str() + ))); + } + self.validate_environment()?; + self.validate_secrets()?; + let mut access = std::collections::BTreeSet::new(); + if let Some(index) = self.access.iter().position(|capability| !access.insert(*capability)) { + return Err(Error::Invalid(format!( + "spec.access[{index}] duplicates an access capability" + ))); + } + if self.network.deny.iter().any(|host| !valid_host_pattern(host)) { + return Err(Error::Invalid( + "spec.network.deny contains an invalid host pattern".into(), + )); + } + Ok(()) + } +} + +impl Spec { + fn validate_secrets(&self) -> Result<(), Error> { + let mut environments = self + .environment + .iter() + .map(|variable| variable.name.as_str()) + .collect::>(); + let environment_sources = self + .environment + .iter() + .map(EnvironmentSpec::source) + .collect::>(); + let mut placeholders = std::collections::BTreeSet::new(); + for (index, secret) in self.secrets.iter().enumerate() { + let placeholder = secret.inert_value(); + if environments.contains(secret.environment.as_str()) || environment_sources.contains(secret.source()) { + return Err(Error::Invalid(format!( + "spec.environment collides with spec.secrets[{index}]" + ))); + } + if !valid_environment_variable(&secret.environment) + || secret + .source + .as_deref() + .is_some_and(|source| !valid_environment_variable(source)) + || secret.placeholder.as_ref().is_some_and(String::is_empty) + || self.harnesses.iter().any(|installation| { + harness::conflicts_with_managed_secret( + installation.kind, + &secret.environment, + secret.placeholder.as_deref(), + ) + }) + || secret.allowed_hosts.is_empty() + || !environments.insert(&secret.environment) + || !placeholders.insert(placeholder) + || secret.allowed_hosts.iter().any(|host| !valid_host_pattern(host)) + { + return Err(Error::Invalid(format!( + "spec.secrets[{index}] is invalid or duplicated" + ))); + } + } + Ok(()) + } + + fn validate_environment(&self) -> Result<(), Error> { + let mut names = std::collections::BTreeSet::new(); + for (index, variable) in self.environment.iter().enumerate() { + if !valid_environment_variable(&variable.name) + || variable + .source + .as_deref() + .is_some_and(|source| !valid_environment_variable(source)) + || self + .harnesses + .iter() + .any(|installation| harness::manages_environment(installation.kind, &variable.name)) + || !names.insert(variable.name.as_str()) + { + return Err(Error::Invalid(format!( + "spec.environment[{index}] is invalid, duplicated, or managed by a declared harness" + ))); + } + } + let has_git_name = names.contains("GIT_USER_NAME"); + let has_git_email = names.contains("GIT_USER_EMAIL"); + if has_git_name != has_git_email { + return Err(Error::Invalid( + "spec.environment must declare GIT_USER_NAME and GIT_USER_EMAIL together".into(), + )); + } + Ok(()) + } + + fn validate_skills(&self) -> Result<(), Error> { + let mut skill_names = std::collections::BTreeSet::new(); + for (index, skill) in self.skills.iter().enumerate() { + let Some(name) = skill.name() else { + return Err(Error::Invalid(format!( + "spec.skills[{index}] must declare a name or use a source ending in the skill's directory name" + ))); + }; + if name.is_empty() || name == "." || name == ".." || name.contains('/') || name.contains('\\') { + return Err(Error::Invalid(format!("spec.skills[{index}].name is invalid"))); + } + if !skill_names.insert(name) { + return Err(Error::Invalid(format!( + "spec.skills[{index}] duplicates skill {name:?}" + ))); + } + } + Ok(()) + } +} + +/// One explicitly selected non-secret value copied from the Agent environment file. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct EnvironmentSpec { + /// Environment variable exposed inside the Sandbox. + pub name: String, + /// Optional variable name in the Agent environment file; defaults to `name`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +impl EnvironmentSpec { + /// Returns the environment-file variable that supplies the plaintext value. + #[must_use] + pub fn source(&self) -> &str { + self.source.as_deref().unwrap_or(&self.name) + } +} + +fn valid_sandbox_path(path: &str) -> bool { + path.starts_with('/') + && path != "/" + && path + .split('/') + .skip(1) + .all(|component| !component.is_empty() && component != "." && component != "..") +} + +/// Host inputs synchronized into the sandbox user's home. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct HomeSpec { + /// Host path, resolved relative to the manifest directory. + pub source: std::path::PathBuf, +} + +/// One harness-neutral instruction file; several are concatenated in manifest order. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct InstructionsSpec { + /// Host file, resolved relative to the manifest directory. + pub source: std::path::PathBuf, +} + +/// One skill directory installed for every declared harness. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SkillSpec { + /// Host directory holding `SKILL.md`, resolved relative to the manifest directory. + pub source: std::path::PathBuf, + /// Installed skill directory name; defaults to the source directory name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +impl SkillSpec { + /// Returns the explicit skill name or the final component of the source directory. + #[must_use] + pub fn name(&self) -> Option<&str> { + self.name.as_deref().or_else(|| { + self.source + .file_name()? + .to_str() + .filter(|name| !name.is_empty() && *name != ".") + }) + } +} + +/// One host-owned value exposed to Sandbox processes only as an inert environment placeholder. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SecretSpec { + /// Guest environment variable and stable secret binding name. + pub environment: String, + /// Whether a missing or empty environment-file value omits this binding. + #[serde(default, skip_serializing_if = "is_false")] + pub optional: bool, + /// Optional inert value; the selected Network Backend generates one when omitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub placeholder: Option, + /// Hosts at which this secret may be substituted. + pub allowed_hosts: Vec, + /// Optional variable name in the manifest directory's `.env`; defaults to `environment`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +const fn is_false(value: &bool) -> bool { + !*value +} + +impl SecretSpec { + /// Returns the host `.env` variable that supplies the secret material. + #[must_use] + pub fn source(&self) -> &str { + self.source.as_deref().unwrap_or(&self.environment) + } + + /// Returns the explicit or provider-neutral generated value exposed inside the Sandbox. + #[must_use] + pub fn inert_value(&self) -> String { + self.placeholder + .clone() + .unwrap_or_else(|| format!("$AGENT_SECRET_{}", self.environment)) + } +} + +/// Required network mediation mode. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum NetworkMode { + /// Route sandbox traffic through the trusted mediation backend. + Mediated, +} + +/// Baseline egress policy. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum NetworkAllow { + /// Permit network operations except explicitly denied hosts. + All, +} + +/// Agent-layer network policy interpreted by the host Policy Engine. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct NetworkSpec { + /// Required mediation mode. + pub mode: NetworkMode, + /// Baseline egress decision. + pub allow: NetworkAllow, + /// Host patterns denied before the baseline decision. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub deny: Vec, +} + +fn valid_host_pattern(pattern: &str) -> bool { + !pattern.is_empty() + && !pattern.contains(['/', ':', '\\']) + && pattern + .strip_prefix("*.") + .unwrap_or(pattern) + .split('.') + .all(|label| !label.is_empty() && label.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')) +} + +fn valid_environment_variable(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .enumerate() + .all(|(index, byte)| byte == b'_' || byte.is_ascii_alphabetic() || (index > 0 && byte.is_ascii_digit())) +} + +/// Most recently observed Agent state. +/// +/// Unlike the rest of the manifest, unknown fields are tolerated so an older +/// client can read responses from a newer control plane; status is +/// API-managed and never authored by hand. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Status { + /// Desired generation observed by the reconciler. + #[serde(default, skip_serializing_if = "is_zero")] + pub observed_generation: u64, + /// Sticky selected Provider and optional materialized Sandbox identity. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + /// Normalized readiness conditions. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub conditions: Vec, + /// Classification of the reconciliation pass that recorded these + /// conditions, when it failed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub failure: Option, + /// Provisioning of the latest pass while it runs or after it failed. + /// Projected onto API responses from the daemon's in-memory state; stores + /// scrub it, so it is never persisted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub progress: Option, + /// Local origin of the desired state. Projected onto API responses from + /// the stored Agent record; stores scrub it, so it is never persisted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance: Option, +} + +impl Status { + /// Creates reconciler-observed state; progress and provenance stay API-projected. + #[must_use] + pub const fn observed( + observed_generation: u64, + sandbox: Option, + conditions: Vec, + ) -> Self { + Self { + observed_generation, + sandbox, + conditions, + failure: None, + progress: None, + provenance: None, + } + } + + const fn is_empty(&self) -> bool { + self.observed_generation == 0 + && self.sandbox.is_none() + && self.conditions.is_empty() + && self.failure.is_none() + && self.progress.is_none() + && self.provenance.is_none() + } + + /// Carries each condition's transition time forward from `previous`, the + /// stored status being replaced, and stamps `now` on conditions whose + /// status or reason changed. + /// + /// A message-only change is not a transition, so a retry that reports a + /// different error detail keeps the time the condition entered its state. + pub fn stamp_transitions(&mut self, previous: &Self, now: OffsetDateTime) { + for condition in &mut self.conditions { + let earlier = previous + .conditions + .iter() + .find(|earlier| earlier.kind == condition.kind); + condition.last_transition_time = match earlier { + Some(earlier) if earlier.status == condition.status && earlier.reason == condition.reason => { + earlier.last_transition_time + } + _ => Some(now), + }; + } + } + + /// Returns the `Ready` condition when the reconciler has reported one. + #[must_use] + pub fn ready_condition(&self) -> Option<&Condition> { + Condition::find_ready(&self.conditions) + } + + /// Returns whether the reconciler reported `Ready=True`. + #[must_use] + pub fn is_ready(&self) -> bool { + Condition::any_ready(&self.conditions) + } + + /// Returns whether a pass recorded the Agent's Sandbox as stopped. + #[must_use] + pub fn is_stopped(&self) -> bool { + self.ready_condition() + .is_some_and(|ready| ready.reason == Condition::REASON_STOPPED) + } + + /// Returns the `SandboxResponsive=False` condition when the Agent's guest + /// is recorded as unresponsive. + #[must_use] + pub fn unresponsive(&self) -> Option<&Condition> { + self.conditions.iter().find(|condition| { + condition.kind == Condition::SANDBOX_RESPONSIVE && condition.status == ConditionStatus::False + }) + } + + /// Returns the failure detail when desired state must change before + /// another pass can succeed. + #[must_use] + pub fn invalid(&self) -> Option { + if self.failure != Some(crate::FailureKind::Invalid) { + return None; + } + self.ready_condition() + .or_else(|| self.conditions.first()) + .map(Condition::detail) + } +} + +impl Condition { + /// Condition type summarizing whether the Agent can serve Sessions and Executions. + pub const READY: &'static str = "Ready"; + /// Condition type for the Sandbox lifecycle underneath `Ready`. + pub const SANDBOX_READY: &'static str = "SandboxReady"; + /// Condition type for whether the running Sandbox's guest still makes + /// progress, observed by the host without a round trip to the guest. + pub const SANDBOX_RESPONSIVE: &'static str = "SandboxResponsive"; + /// Condition type for declared SSH access underneath `Ready`. + pub const SSH_READY: &'static str = "SshReady"; + /// VNC access is reconciled and the bridge to the desktop is listening. + pub const VNC_READY: &'static str = "VncReady"; + + /// `Ready` reason while a pass stops the Agent's Sandbox, and after a stop failed. + pub const REASON_STOPPING: &'static str = "Stopping"; + /// `Ready` and `SandboxReady` reason once the Agent's Sandbox is stopped. + pub const REASON_STOPPED: &'static str = "Stopped"; + /// `Ready` reason while a pass starts a stopped Sandbox. + pub const REASON_STARTING: &'static str = "Starting"; + + /// Finds the `Ready` condition in a condition list. + #[must_use] + pub fn find_ready(conditions: &[Self]) -> Option<&Self> { + conditions.iter().find(|condition| condition.kind == Self::READY) + } + + /// Returns whether a condition list reports `Ready=True`. + #[must_use] + pub fn any_ready(conditions: &[Self]) -> bool { + Self::find_ready(conditions).is_some_and(|condition| condition.status == ConditionStatus::True) + } + + /// Returns `reason: message`, or whichever of the two is present, or `Unknown`. + #[must_use] + pub fn summary(&self) -> String { + match (self.reason.is_empty(), self.message.is_empty()) { + (false, false) => format!("{}: {}", self.reason, self.message), + (false, true) => self.reason.clone(), + (true, false) => self.message.clone(), + (true, true) => "Unknown".to_owned(), + } + } + + /// Returns the human-readable message, falling back to the reason. + #[must_use] + pub fn detail(&self) -> String { + if self.message.is_empty() { + self.reason.clone() + } else { + self.message.clone() + } + } +} + +/// Conventional Agent manifest filename, used when a record predates path recording. +pub const MANIFEST_FILE: &str = "agent.yaml"; + +/// Local origin of an Agent's desired state. +/// +/// Part of [`Status`], so unknown fields are tolerated for the same reason. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Provenance { + /// Absolute directory against which manifest-relative sources are resolved. + pub source_directory: std::path::PathBuf, + /// Absolute path of the manifest last applied, when the client reported it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manifest_path: Option, + /// Absolute path of the secret file, when it is not `.env` beside the manifest. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub env_file: Option, +} + +impl Provenance { + /// Returns the recorded manifest path, falling back to [`MANIFEST_FILE`] + /// in the source directory for records that predate path recording. + #[must_use] + pub fn manifest_or_default(&self) -> std::path::PathBuf { + self.manifest_path + .clone() + .unwrap_or_else(|| self.source_directory.join(MANIFEST_FILE)) + } +} + +/// One aspect of observed Agent state. +/// +/// Like [`Status`], unknown fields are tolerated, so a client or store reader +/// can read conditions written by a newer control plane. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Condition { + /// Stable condition type. + #[serde(rename = "type")] + pub kind: String, + /// Normalized truth value. + pub status: ConditionStatus, + /// Stable machine-readable reason. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub reason: String, + /// Optional human-readable detail. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub message: String, + /// When `status` or `reason` last changed. Stamped by the store; absent on + /// conditions recorded before transition times were tracked, until their + /// next transition. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "time::serde::rfc3339::option" + )] + pub last_transition_time: Option, +} + +/// Truth value of an Agent condition. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub enum ConditionStatus { + /// The condition is satisfied. + True, + /// The condition is not satisfied. + False, + /// The control plane cannot determine the value. + Unknown, +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +const fn is_zero(value: &u64) -> bool { + *value == 0 +} + +/// Decodes and validates a YAML or JSON Agent manifest. +/// +/// # Errors +/// +/// Returns an error when syntax, structure, or required values are invalid. +pub fn decode(bytes: &[u8]) -> Result { + let agent: Agent = serde_yaml_ng::from_slice(bytes)?; + agent.validate()?; + Ok(agent) +} + +/// Expands a complete Agent or chained Agent variant from `path`. +/// +/// Variant bases are restricted to conventional sibling filenames. Mappings +/// merge recursively, arrays and scalars replace inherited values, and `null` +/// removes inherited fields. +/// +/// # Errors +/// +/// Returns an error when a manifest cannot be read, a variant chain is invalid, +/// or the expanded Agent does not satisfy the strict Agent contract. +pub fn resolve(path: &Path) -> Result { + let leaf = absolute_lexical(path)?; + let mut chain = Vec::new(); + let mut versions = Vec::new(); + let value = resolve_value(&leaf, &mut chain, &mut versions).map_err(|error| with_chain(error, &chain))?; + let bytes = serde_yaml_ng::to_string(&value).map_err(Error::Yaml)?; + let agent = decode(bytes.as_bytes()).map_err(|error| manifest_error(&leaf, "expanded Agent", &error))?; + Ok(ResolvedManifest { agent, chain }) +} + +/// Extracts a variant selector from a conventional leaf filename. +#[must_use] +pub fn variant_from_filename(path: &Path) -> Option { + let name = path.file_name()?.to_str()?; + let variant = name.strip_prefix("agent.")?.strip_suffix(".yaml")?; + variant.parse().ok() +} + +/// Returns whether a filename is a conventional Agent or Agent variant manifest. +#[must_use] +pub fn is_manifest_filename(path: &Path) -> bool { + path.file_name().is_some_and(|name| name == MANIFEST_FILE) || variant_from_filename(path).is_some() +} + +fn resolve_value( + path: &Path, + chain: &mut Vec, + versions: &mut Vec<(PathBuf, String)>, +) -> Result { + if let Some(start) = chain.iter().position(|candidate| candidate == path) { + let cycle = chain[start..] + .iter() + .map(PathBuf::as_path) + .chain(std::iter::once(path)) + .map(display_leaf) + .collect::>() + .join(" -> "); + return Err(Error::Invalid(format!("variant inheritance cycle: {cycle}"))); + } + if chain.len() == MAX_VARIANT_CHAIN { + return Err(Error::Invalid(format!( + "{}: variant inheritance exceeds {MAX_VARIANT_CHAIN} manifests", + display_leaf(path) + ))); + } + + chain.push(path.to_path_buf()); + + let bytes = std::fs::read(path) + .map_err(|error| Error::Invalid(format!("{}: could not read manifest: {error}", display_leaf(path))))?; + let value: serde_yaml_ng::Value = serde_yaml_ng::from_slice(&bytes) + .map_err(|error| Error::Invalid(format!("{}: {error}", display_leaf(path))))?; + let mapping = value + .as_mapping() + .ok_or_else(|| Error::Invalid(format!("{}: manifest must be a mapping", display_leaf(path))))?; + let version = string_field(mapping, "apiVersion", path)?; + let kind = string_field(mapping, "kind", path)?; + if let Some((version_path, expected)) = versions.first() + && version != *expected + { + return Err(Error::Invalid(format!( + "{}: apiVersion {version:?} does not match {} ({expected:?})", + display_leaf(path), + display_leaf(version_path) + ))); + } + versions.push((path.to_path_buf(), version)); + match kind.as_str() { + KIND => { + decode(&bytes).map_err(|error| manifest_error(path, "complete Agent", &error))?; + Ok(value) + } + VARIANT_KIND => { + if variant_from_filename(path).is_none() { + return Err(Error::Invalid(format!( + "{}: AgentVariant filename must match agent..yaml", + display_leaf(path) + ))); + } + let variant: AgentVariant = serde_yaml_ng::from_slice(&bytes) + .map_err(|error| Error::Invalid(format!("{}: {error}", display_leaf(path))))?; + validate_variant(&variant, path)?; + let base = sibling_base(path, &variant.extends)?; + let mut inherited = resolve_value(&base, chain, versions)?; + let mut overlay = value; + let overlay_mapping = overlay + .as_mapping_mut() + .ok_or_else(|| Error::Invalid(format!("{}: manifest must be a mapping", display_leaf(path))))?; + overlay_mapping.remove(serde_yaml_ng::Value::String("extends".into())); + overlay_mapping.insert( + serde_yaml_ng::Value::String("kind".into()), + serde_yaml_ng::Value::String(KIND.into()), + ); + merge_value(&mut inherited, overlay); + let expanded = serde_yaml_ng::to_string(&inherited).map_err(Error::Yaml)?; + decode(expanded.as_bytes()).map_err(|error| manifest_error(path, "expanded variant", &error))?; + Ok(inherited) + } + _ => Err(Error::Invalid(format!( + "{}: kind must be {KIND:?} or {VARIANT_KIND:?}", + display_leaf(path) + ))), + } +} + +fn validate_variant(variant: &AgentVariant, path: &Path) -> Result<(), Error> { + if variant.api_version != API_VERSION { + return Err(Error::Invalid(format!( + "{}: apiVersion must be {API_VERSION:?}", + display_leaf(path) + ))); + } + if variant.kind != VARIANT_KIND { + return Err(Error::Invalid(format!( + "{}: kind must be {VARIANT_KIND:?}", + display_leaf(path) + ))); + } + let metadata = variant + .metadata + .as_mapping() + .ok_or_else(|| Error::Invalid(format!("{}: metadata must be a mapping", display_leaf(path))))?; + let name = string_field(metadata, "name", path).map_err(|_| { + Error::Invalid(format!( + "{}: metadata.name must be explicitly specified", + display_leaf(path) + )) + })?; + if name.is_empty() { + return Err(Error::Invalid(format!( + "{}: metadata.name must be explicitly specified", + display_leaf(path) + ))); + } + if let Some(spec) = &variant.spec + && !spec.is_mapping() + { + return Err(Error::Invalid(format!( + "{}: spec must be a mapping", + display_leaf(path) + ))); + } + Ok(()) +} + +fn sibling_base(path: &Path, extends: &str) -> Result { + let base = Path::new(extends); + let one_normal_component = { + let mut components = base.components(); + matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none() + }; + if !one_normal_component || !is_manifest_filename(base) { + return Err(Error::Invalid(format!( + "{}: extends must name agent.yaml or a sibling agent..yaml", + display_leaf(path) + ))); + } + let parent = path + .parent() + .ok_or_else(|| Error::Invalid(format!("{}: manifest path has no parent directory", display_leaf(path))))?; + Ok(parent.join(base)) +} + +fn merge_value(base: &mut serde_yaml_ng::Value, patch: serde_yaml_ng::Value) { + if let (Some(base_mapping), Some(patch_mapping)) = (base.as_mapping(), patch.as_mapping()) { + let discriminator = serde_yaml_ng::Value::String("type".into()); + if let (Some(base_type), Some(patch_type)) = ( + base_mapping.get(&discriminator).and_then(serde_yaml_ng::Value::as_str), + patch_mapping.get(&discriminator).and_then(serde_yaml_ng::Value::as_str), + ) && base_type != patch_type + { + // Internally tagged union values are one logical scalar choice. + // Changing the discriminator replaces the whole mapping so fields + // belonging to the previous variant cannot leak into the new one. + *base = patch; + return; + } + } + match (base, patch) { + (serde_yaml_ng::Value::Mapping(base), serde_yaml_ng::Value::Mapping(patch)) => { + for (key, value) in patch { + if value.is_null() { + if base.remove(&key).is_none() { + // Preserve a null for fields absent from the concrete base. + // Known optional fields still decode successfully, while + // strict deserialization rejects unknown null-valued fields. + base.insert(key, value); + } + } else if let Some(inherited) = base.get_mut(&key) { + merge_value(inherited, value); + } else { + base.insert(key, value); + } + } + } + (base, patch) => *base = patch, + } +} + +fn string_field(mapping: &serde_yaml_ng::Mapping, field: &str, path: &Path) -> Result { + mapping + .get(serde_yaml_ng::Value::String(field.into())) + .and_then(serde_yaml_ng::Value::as_str) + .map(str::to_owned) + .ok_or_else(|| Error::Invalid(format!("{}: {field} must be a string", display_leaf(path)))) +} + +fn absolute_lexical(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Ok(std::env::current_dir()?.join(path)) + } +} + +fn display_leaf(path: &Path) -> String { + path.file_name().map_or_else( + || path.display().to_string(), + |name| name.to_string_lossy().into_owned(), + ) +} + +fn manifest_error(path: &Path, context: &str, error: &Error) -> Error { + Error::Invalid(format!("{}: invalid {context}: {error}", display_leaf(path))) +} + +fn with_chain(error: Error, chain: &[PathBuf]) -> Error { + if chain.len() < 2 || error.to_string().contains("inheritance chain:") { + return error; + } + let mut lines = chain.iter().map(|path| display_leaf(path)); + let Some(first) = lines.next() else { + return error; + }; + let diagnostic = std::iter::once(first) + .chain(lines.map(|line| format!(" extends {line}"))) + .collect::>() + .join("\n"); + Error::Invalid(format!("{error}\ninheritance chain:\n{diagnostic}")) +} diff --git a/agentctl/src/persistence/agents.rs b/agentctl/src/persistence/agents.rs new file mode 100644 index 0000000..018234f --- /dev/null +++ b/agentctl/src/persistence/agents.rs @@ -0,0 +1,242 @@ +//! Agent resource persistence with one column group per write owner. + +use rusqlite::{Connection, OptionalExtension as _, params}; + +use crate::{Agent, AgentId, Error, Status, control_plane::AgentRecord}; + +use super::{database_error, secrets}; + +pub(super) fn get(connection: &Connection, id: AgentId) -> Result { + connection + .query_row( + "SELECT id, active_name, source_directory, desired_json, deletion_timestamp, status_json + FROM agents WHERE id = ?1 AND active_name IS NOT NULL", + [id.to_string()], + decode_row, + ) + .optional() + .map_err(database_error)? + .ok_or(Error::NotFound) +} + +pub(super) fn get_by_name(connection: &Connection, name: &str) -> Result { + let record = connection + .query_row( + "SELECT id, active_name, source_directory, desired_json, deletion_timestamp, status_json + FROM agents WHERE active_name = ?1", + [name], + decode_row, + ) + .optional() + .map_err(database_error)? + .ok_or(Error::NotFound)?; + if record.agent.metadata.name == name { + Ok(record) + } else { + Err(Error::Database( + "stored Agent name does not match its active-name index".into(), + )) + } +} + +pub(super) fn list(connection: &Connection) -> Result, Error> { + let mut statement = connection + .prepare( + "SELECT id, active_name, source_directory, desired_json, deletion_timestamp, status_json + FROM agents WHERE active_name IS NOT NULL ORDER BY active_name", + ) + .map_err(database_error)?; + statement + .query_map([], decode_row) + .map_err(database_error)? + .map(|row| row.map_err(database_error)) + .collect() +} + +pub(super) fn put(connection: &mut Connection, record: &AgentRecord, expected_generation: u64) -> Result<(), Error> { + let id = record.id; + let name = &record.agent.metadata.name; + let source = serde_json::to_string(&crate::Provenance { + source_directory: record.source_directory.clone(), + manifest_path: record.manifest_path.clone(), + env_file: record.env_file.clone(), + })?; + let desired = encode_desired(&record.agent)?; + let transaction = connection.transaction().map_err(database_error)?; + let changed = if expected_generation == 0 { + transaction + .execute( + "INSERT OR IGNORE INTO agents + (id, active_name, source_directory, desired_json, deletion_timestamp, status_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + id.to_string(), + name, + source, + desired, + deletion_timestamp(&record.agent), + encode_status(&record.agent.status)? + ], + ) + .map_err(database_error)? + } else { + let current = get(&transaction, id)?; + if current.agent.metadata.generation != expected_generation + || current.agent.metadata.name != *name + || current.agent.metadata.deletion_timestamp.is_some() + { + return Err(Error::Conflict); + } + transaction + .execute( + "UPDATE agents SET source_directory = ?1, desired_json = ?2 + WHERE id = ?3 AND active_name = ?4 AND deletion_timestamp IS NULL", + params![source, desired, id.to_string(), name], + ) + .map_err(database_error)? + }; + if changed != 1 { + return Err(Error::Conflict); + } + transaction.commit().map_err(database_error) +} + +pub(super) fn update_status( + connection: &mut Connection, + id: AgentId, + generation: u64, + mut status: Status, +) -> Result { + let transaction = connection.transaction().map_err(database_error)?; + let record = get(&transaction, id)?; + if record.agent.metadata.generation != generation { + return Err(Error::Conflict); + } + scrub(&mut status); + status.stamp_transitions(&record.agent.status, time::OffsetDateTime::now_utc()); + let changed = transaction + .execute( + "UPDATE agents SET status_json = ?1 WHERE id = ?2 AND active_name IS NOT NULL", + params![serde_json::to_string(&status)?, id.to_string()], + ) + .map_err(database_error)?; + if changed != 1 { + return Err(Error::Conflict); + } + transaction.commit().map_err(database_error)?; + Ok(status) +} + +pub(super) fn mark_deleting(connection: &mut Connection, name: &str) -> Result { + let transaction = connection.transaction().map_err(database_error)?; + let mut record = get_by_name(&transaction, name)?; + if record.agent.metadata.deletion_timestamp.is_none() { + let timestamp = time::OffsetDateTime::now_utc(); + let changed = transaction + .execute( + "UPDATE agents SET deletion_timestamp = ?1 WHERE id = ?2 AND active_name = ?3", + params![timestamp.unix_timestamp(), record.id.to_string(), name], + ) + .map_err(database_error)?; + if changed != 1 { + return Err(Error::Conflict); + } + record.agent.metadata.deletion_timestamp = Some(timestamp); + } + transaction.commit().map_err(database_error)?; + Ok(record) +} + +pub(super) fn finalize_deletion(connection: &mut Connection, id: AgentId, generation: u64) -> Result<(), Error> { + let transaction = connection.transaction().map_err(database_error)?; + let record = get(&transaction, id)?; + if record.agent.metadata.generation != generation || record.agent.metadata.deletion_timestamp.is_none() { + return Err(Error::Conflict); + } + let changed = transaction + .execute( + "UPDATE agents SET active_name = NULL WHERE id = ?1 AND active_name IS NOT NULL", + [id.to_string()], + ) + .map_err(database_error)?; + if changed != 1 { + return Err(Error::Conflict); + } + secrets::delete_agent_secrets(&transaction, id)?; + secrets::delete_secret(&transaction, &super::ssh_host_key_name(id))?; + transaction.commit().map_err(database_error) +} + +fn encode_desired(agent: &Agent) -> Result { + let mut desired = agent.clone(); + desired.metadata.deletion_timestamp = None; + desired.status = Status::default(); + serde_json::to_string(&desired).map_err(Error::from) +} + +/// Serializes status for storage, scrubbing API-projected progress and provenance. +fn encode_status(status: &Status) -> Result { + let mut status = status.clone(); + scrub(&mut status); + serde_json::to_string(&status).map_err(Error::from) +} + +/// Removes what is projected onto responses and never stored. +fn scrub(status: &mut Status) { + status.progress = None; + status.provenance = None; +} + +/// Source-column payload: current writes store [`crate::Provenance`]; rows +/// written before the manifest path was recorded hold a bare directory string. +#[derive(serde::Deserialize)] +#[serde(untagged)] +enum StoredSource { + Provenance(crate::Provenance), + Directory(std::path::PathBuf), +} + +fn deletion_timestamp(agent: &Agent) -> Option { + agent + .metadata + .deletion_timestamp + .map(time::OffsetDateTime::unix_timestamp) +} + +fn decode_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let id = row.get::<_, String>(0)?; + let active_name = row.get::<_, String>(1)?; + let source = row.get::<_, String>(2)?; + let desired = row.get::<_, String>(3)?; + let deletion = row.get::<_, Option>(4)?; + let status = row.get::<_, String>(5)?; + let id = id.parse::().map_err(conversion_error)?; + let (source_directory, manifest_path, env_file) = match serde_json::from_str(&source).map_err(conversion_error)? { + StoredSource::Provenance(provenance) => ( + provenance.source_directory, + provenance.manifest_path, + provenance.env_file, + ), + StoredSource::Directory(directory) => (directory, None, None), + }; + let mut agent = serde_json::from_str::(&desired).map_err(conversion_error)?; + if agent.metadata.name != active_name { + return Err(rusqlite::Error::InvalidQuery); + } + agent.metadata.deletion_timestamp = deletion + .map(time::OffsetDateTime::from_unix_timestamp) + .transpose() + .map_err(conversion_error)?; + agent.status = serde_json::from_str(&status).map_err(conversion_error)?; + Ok(AgentRecord { + id, + source_directory, + manifest_path, + env_file, + agent, + }) +} + +fn conversion_error(error: impl std::error::Error + Send + Sync + 'static) -> rusqlite::Error { + rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(error)) +} diff --git a/agentctl/src/persistence/mod.rs b/agentctl/src/persistence/mod.rs new file mode 100644 index 0000000..496a9b7 --- /dev/null +++ b/agentctl/src/persistence/mod.rs @@ -0,0 +1,1104 @@ +//! Persistent local control-plane state owned by one dedicated `SQLite` thread. + +use std::{path::Path, thread}; + +use rusqlite::Connection; +use tokio::sync::oneshot; +use zeroize::Zeroizing; + +use crate::{AgentId, Error, Status, control_plane::AgentRecord, local::home}; + +mod agents; +mod schema; +mod secrets; +mod sessions; + +/// Persistent Agent store backed by the shared control-plane database owner. +/// +/// Every successful write to Agents or Sessions advances [`Database::changes`]. +#[derive(Clone)] +pub struct Database { + sender: tokio::sync::mpsc::Sender, + changes: crate::resources::Changes, +} + +pub(crate) struct ProviderAccountWrite { + pub(crate) provider: String, + pub(crate) credentials: Vec, + pub(crate) metadata_json: String, +} + +pub(crate) struct StoredSecret { + pub(crate) name: String, + pub(crate) value: Zeroizing>, +} + +impl Database { + /// Opens a compatible database, then starts its dedicated owner thread. + /// + /// Call this during daemon startup, before entering the local async runtime. + /// + /// # Errors + /// + /// Returns an error when the database cannot be created, secured, or its + /// schema does not match this build. + pub fn open(path: &Path) -> Result { + let (sender, mut receiver) = tokio::sync::mpsc::channel(256); + let (ready_sender, ready_receiver) = std::sync::mpsc::sync_channel(1); + let path = path.to_path_buf(); + thread::Builder::new() + .name("agent-database".into()) + .spawn(move || database_thread(&path, &mut receiver, &ready_sender)) + .map_err(Error::Io)?; + ready_receiver + .recv() + .map_err(|_| Error::Database("database thread stopped during startup".into()))??; + Ok(Self { + sender, + changes: crate::resources::Changes::new(), + }) + } + + /// Returns the change history advanced by every Agent and Session write. + #[must_use] + pub fn changes(&self) -> crate::resources::Changes { + self.changes.clone() + } + + /// Applies pending schema migrations without starting a database owner thread. + /// + /// This is used while the updater exclusively owns the control-plane home. + /// Opening the database also creates the same pre-migration backup as daemon startup. + /// + /// # Errors + /// + /// Returns an error when backup, schema validation, or migration fails. + pub fn migrate(path: &Path) -> Result<(), Error> { + drop(open(path)?); + Ok(()) + } + + async fn request(&self, build: impl FnOnce(oneshot::Sender>) -> Command) -> Result { + let (response, receiver) = oneshot::channel(); + let command = build(response); + let observable = command.changes_resources(); + self.sender + .send(command) + .await + .map_err(|_| Error::Database("database thread stopped".into()))?; + let result = receiver + .await + .map_err(|_| Error::Database("database thread dropped a response".into()))?; + if observable && result.is_ok() { + self.changes.bump(); + } + result + } + + pub(crate) async fn put_provider_account(&self, account: ProviderAccountWrite) -> Result<(), Error> { + self.request(|response| Command::PutProviderAccount { account, response }) + .await + } + + pub(crate) async fn replace_agent_secrets( + &self, + id: AgentId, + secrets: Vec, + ) -> Result, Error> { + let references = secrets + .iter() + .map(|secret| sandbox::secret_store::SecretReference::from_opaque(agent_secret_name(id, &secret.name))) + .collect(); + self.request(|response| Command::ReplaceAgentSecrets { id, secrets, response }) + .await?; + Ok(references) + } + + pub(crate) async fn provider_account_exists(&self, provider: &str) -> Result { + self.request(|response| Command::ProviderAccountExists { + provider: provider.into(), + response, + }) + .await + } + + pub(crate) async fn provider_account_metadata(&self, provider: &str) -> Result, Error> { + self.request(|response| Command::ProviderAccountMetadata { + provider: provider.into(), + response, + }) + .await + } +} + +impl crate::sessions::SessionReports for Database { + fn record_session_start_for_launch<'a>( + &'a self, + id: crate::sessions::SessionId, + token: &'a crate::sessions::LaunchToken, + event_id: uuid::Uuid, + native: &'a str, + transcript_path: Option<&'a str>, + at: time::OffsetDateTime, + ) -> sandbox::LocalFuture<'a, Result, Error>> { + Box::pin(async move { + self.request(|response| Command::RecordSessionStartForLaunch { + id, + token: token.clone(), + event_id, + at, + native: native.into(), + transcript_path: transcript_path.map(str::to_owned), + response, + }) + .await + }) + } + + fn apply_session_activity_for_launch<'a>( + &'a self, + id: crate::sessions::SessionId, + token: &'a crate::sessions::LaunchToken, + event_id: uuid::Uuid, + event: crate::sessions::ActivityEvent, + at: time::OffsetDateTime, + ) -> sandbox::LocalFuture<'a, Result, Error>> { + Box::pin(async move { + self.request(|response| Command::ApplySessionActivityForLaunch { + id, + token: token.clone(), + event_id, + event, + at, + response, + }) + .await + }) + } +} + +impl crate::sessions::SessionStore for Database { + fn ensure_session<'a>( + &'a self, + agent: &'a str, + name: &'a crate::sessions::SessionName, + new: crate::sessions::NewSession, + ) -> sandbox::LocalFuture<'a, Result> { + Box::pin(async move { + self.request(|response| Command::EnsureSession { + agent: agent.into(), + name: name.clone(), + new, + response, + }) + .await + }) + } + + fn get_session( + &self, + id: crate::sessions::SessionId, + ) -> sandbox::LocalFuture<'_, Result> { + Box::pin(async move { self.request(|response| Command::GetSession { id, response }).await }) + } + + fn get_agent_session<'a>( + &'a self, + agent: &'a str, + name: &'a crate::sessions::SessionName, + ) -> sandbox::LocalFuture<'a, Result> { + Box::pin(async move { + self.request(|response| Command::GetSessionByName { + agent: agent.into(), + name: name.clone(), + response, + }) + .await + }) + } + + fn list_all_sessions(&self) -> sandbox::LocalFuture<'_, Result, Error>> { + Box::pin(async move { self.request(|response| Command::ListAllSessions { response }).await }) + } + + fn list_agent_sessions<'a>( + &'a self, + agent: &'a str, + ) -> sandbox::LocalFuture<'a, Result, Error>> { + Box::pin(async move { + self.request(|response| Command::ListSessions { + agent: agent.into(), + response, + }) + .await + }) + } + + fn update_session_lifecycle( + &self, + id: crate::sessions::SessionId, + lifecycle: crate::sessions::Lifecycle, + observed_activation_generation: u64, + ) -> sandbox::LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.request(|response| Command::UpdateSessionLifecycle { + id, + lifecycle, + observed_activation_generation, + response, + }) + .await + }) + } + + fn activate_session(&self, id: crate::sessions::SessionId) -> sandbox::LocalFuture<'_, Result> { + Box::pin(async move { self.request(|response| Command::ActivateSession { id, response }).await }) + } + + fn mark_session_deleting<'a>( + &'a self, + agent: &'a str, + name: &'a crate::sessions::SessionName, + ) -> sandbox::LocalFuture<'a, Result> { + Box::pin(async move { + self.request(|response| Command::MarkSessionDeleting { + agent: agent.into(), + name: name.clone(), + response, + }) + .await + }) + } + + fn set_session_archived<'a>( + &'a self, + agent: &'a str, + name: &'a crate::sessions::SessionName, + archived: bool, + ) -> sandbox::LocalFuture<'a, Result> { + Box::pin(async move { + self.request(|response| Command::SetSessionArchived { + agent: agent.into(), + name: name.clone(), + archived, + response, + }) + .await + }) + } + + fn finalize_session_deletion(&self, id: crate::sessions::SessionId) -> sandbox::LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.request(|response| Command::FinalizeSessionDeletion { id, response }) + .await + }) + } + + fn session_attach_target( + &self, + id: crate::sessions::SessionId, + ) -> sandbox::LocalFuture<'_, Result> { + Box::pin(async move { self.request(|response| Command::GetAttachTarget { id, response }).await }) + } + + fn clear_session_report(&self, id: crate::sessions::SessionId) -> sandbox::LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.request(|response| Command::ClearSessionReport { id, response }) + .await + }) + } + + fn record_session_launch( + &self, + id: crate::sessions::SessionId, + launch: crate::sessions::LaunchRecord, + ) -> sandbox::LocalFuture<'_, Result, Error>> { + Box::pin(async move { + self.request(|response| Command::RecordSessionLaunch { + id, + token: launch.token, + sandbox: launch.sandbox, + launched_at: launch.launched_at, + attempts: launch.attempts, + response, + }) + .await + }) + } + + fn session_launch_state( + &self, + id: crate::sessions::SessionId, + ) -> sandbox::LocalFuture<'_, Result, Error>> { + Box::pin(async move { + self.request(|response| Command::GetSessionLaunchState { id, response }) + .await + }) + } + + fn reset_session_launch_attempts( + &self, + id: crate::sessions::SessionId, + ) -> sandbox::LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.request(|response| Command::ResetSessionLaunchAttempts { id, response }) + .await + }) + } +} + +impl crate::control_plane::AgentStore for Database { + fn get(&self, id: AgentId) -> sandbox::LocalFuture<'_, Result> { + Box::pin(async move { self.request(|response| Command::Get { id, response }).await }) + } + + fn get_by_name<'a>(&'a self, name: &'a str) -> sandbox::LocalFuture<'a, Result> { + Box::pin(async move { + self.request(|response| Command::GetByName { + name: name.into(), + response, + }) + .await + }) + } + + fn list(&self) -> sandbox::LocalFuture<'_, Result, Error>> { + Box::pin(async move { self.request(|response| Command::List { response }).await }) + } + + fn put(&self, record: AgentRecord, expected_generation: u64) -> sandbox::LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.request(|response| Command::Put { + record: Box::new(record), + expected_generation, + response, + }) + .await + }) + } + + fn update_status( + &self, + id: AgentId, + generation: u64, + status: Status, + ) -> sandbox::LocalFuture<'_, Result> { + Box::pin(async move { + self.request(|response| Command::UpdateStatus { + id, + generation, + status: Box::new(status), + response, + }) + .await + }) + } + + fn mark_deleting<'a>(&'a self, name: &'a str) -> sandbox::LocalFuture<'a, Result> { + Box::pin(async move { + self.request(|response| Command::MarkDeleting { + name: name.into(), + response, + }) + .await + }) + } + + fn finalize_deletion(&self, id: AgentId, generation: u64) -> sandbox::LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.request(|response| Command::FinalizeDeletion { + id, + generation, + response, + }) + .await + }) + } +} + +impl sandbox::secret_store::SecretStore for Database { + fn set<'a>( + &'a self, + name: &'a str, + value: &'a [u8], + ) -> sandbox::LocalFuture<'a, Result> { + Box::pin(async move { + if name.is_empty() { + return Err(sandbox::Error::invalid("secret.name", "must not be empty")); + } + let value = Zeroizing::new(value.to_vec()); + self.request(|response| Command::SetSecret { + name: name.into(), + value, + response, + }) + .await + .map_err(secret_store_error)?; + Ok(sandbox::secret_store::SecretReference::from_opaque(name)) + }) + } + + fn resolve<'a>( + &'a self, + reference: &'a sandbox::secret_store::SecretReference, + ) -> sandbox::LocalFuture<'a, Result> { + Box::pin(async move { + self.request(|response| Command::ResolveSecret { + name: reference.as_str().into(), + response, + }) + .await + .map_err(secret_store_error) + }) + } +} + +impl crate::ssh::HostKeyStore for Database { + fn load_host_key(&self, id: AgentId) -> sandbox::LocalFuture<'_, Result>>, Error>> { + Box::pin(async move { + match self + .request(|response| Command::ResolveSecret { + name: ssh_host_key_name(id), + response, + }) + .await + { + Ok(material) => Ok(Some(Zeroizing::new(material.expose().to_vec()))), + Err(Error::NotFound) => Ok(None), + Err(error) => Err(error), + } + }) + } + + fn store_host_key(&self, id: AgentId, key: Zeroizing>) -> sandbox::LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.request(|response| Command::SetSecret { + name: ssh_host_key_name(id), + value: key, + response, + }) + .await + }) + } + + fn delete_host_key(&self, id: AgentId) -> sandbox::LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.request(|response| Command::DeleteSecret { + name: ssh_host_key_name(id), + response, + }) + .await + }) + } +} + +enum Command { + Get { + id: AgentId, + response: oneshot::Sender>, + }, + GetByName { + name: String, + response: oneshot::Sender>, + }, + List { + response: oneshot::Sender, Error>>, + }, + Put { + record: Box, + expected_generation: u64, + response: oneshot::Sender>, + }, + UpdateStatus { + id: AgentId, + generation: u64, + status: Box, + response: oneshot::Sender>, + }, + MarkDeleting { + name: String, + response: oneshot::Sender>, + }, + FinalizeDeletion { + id: AgentId, + generation: u64, + response: oneshot::Sender>, + }, + SetSecret { + name: String, + value: Zeroizing>, + response: oneshot::Sender>, + }, + DeleteSecret { + name: String, + response: oneshot::Sender>, + }, + ReplaceAgentSecrets { + id: AgentId, + secrets: Vec, + response: oneshot::Sender>, + }, + ResolveSecret { + name: String, + response: oneshot::Sender>, + }, + PutProviderAccount { + account: ProviderAccountWrite, + response: oneshot::Sender>, + }, + ProviderAccountExists { + provider: String, + response: oneshot::Sender>, + }, + ProviderAccountMetadata { + provider: String, + response: oneshot::Sender, Error>>, + }, + EnsureSession { + agent: String, + name: crate::sessions::SessionName, + new: crate::sessions::NewSession, + response: oneshot::Sender>, + }, + GetSession { + id: crate::sessions::SessionId, + response: oneshot::Sender>, + }, + GetSessionByName { + agent: String, + name: crate::sessions::SessionName, + response: oneshot::Sender>, + }, + ListAllSessions { + response: oneshot::Sender, Error>>, + }, + ListSessions { + agent: String, + response: oneshot::Sender, Error>>, + }, + UpdateSessionLifecycle { + id: crate::sessions::SessionId, + lifecycle: crate::sessions::Lifecycle, + observed_activation_generation: u64, + response: oneshot::Sender>, + }, + ActivateSession { + id: crate::sessions::SessionId, + response: oneshot::Sender>, + }, + MarkSessionDeleting { + agent: String, + name: crate::sessions::SessionName, + response: oneshot::Sender>, + }, + FinalizeSessionDeletion { + id: crate::sessions::SessionId, + response: oneshot::Sender>, + }, + SetSessionArchived { + agent: String, + name: crate::sessions::SessionName, + archived: bool, + response: oneshot::Sender>, + }, + GetAttachTarget { + id: crate::sessions::SessionId, + response: oneshot::Sender>, + }, + ClearSessionReport { + id: crate::sessions::SessionId, + response: oneshot::Sender>, + }, + RecordSessionStartForLaunch { + id: crate::sessions::SessionId, + token: crate::sessions::LaunchToken, + event_id: uuid::Uuid, + at: time::OffsetDateTime, + native: String, + transcript_path: Option, + response: oneshot::Sender, Error>>, + }, + ApplySessionActivityForLaunch { + id: crate::sessions::SessionId, + token: crate::sessions::LaunchToken, + event_id: uuid::Uuid, + event: crate::sessions::ActivityEvent, + at: time::OffsetDateTime, + response: oneshot::Sender, Error>>, + }, + RecordSessionLaunch { + id: crate::sessions::SessionId, + token: crate::sessions::LaunchToken, + sandbox: String, + launched_at: i64, + attempts: u32, + response: oneshot::Sender, Error>>, + }, + GetSessionLaunchState { + id: crate::sessions::SessionId, + response: oneshot::Sender, Error>>, + }, + ResetSessionLaunchAttempts { + id: crate::sessions::SessionId, + response: oneshot::Sender>, + }, +} + +impl Command { + /// Returns whether a successful execution changes an Agent or Session resource. + const fn changes_resources(&self) -> bool { + match self { + Self::Put { .. } + | Self::UpdateStatus { .. } + | Self::MarkDeleting { .. } + | Self::FinalizeDeletion { .. } + | Self::EnsureSession { .. } + | Self::UpdateSessionLifecycle { .. } + | Self::ActivateSession { .. } + | Self::MarkSessionDeleting { .. } + | Self::FinalizeSessionDeletion { .. } + | Self::SetSessionArchived { .. } + | Self::ClearSessionReport { .. } + | Self::RecordSessionStartForLaunch { .. } + | Self::ApplySessionActivityForLaunch { .. } + | Self::RecordSessionLaunch { .. } => true, + Self::Get { .. } + | Self::GetByName { .. } + | Self::List { .. } + | Self::SetSecret { .. } + | Self::DeleteSecret { .. } + | Self::ReplaceAgentSecrets { .. } + | Self::ResolveSecret { .. } + | Self::PutProviderAccount { .. } + | Self::ProviderAccountExists { .. } + | Self::ProviderAccountMetadata { .. } + | Self::GetSession { .. } + | Self::GetSessionByName { .. } + | Self::ListAllSessions { .. } + | Self::ListSessions { .. } + | Self::GetAttachTarget { .. } + | Self::GetSessionLaunchState { .. } + | Self::ResetSessionLaunchAttempts { .. } => false, + } + } +} + +fn database_thread( + path: &Path, + receiver: &mut tokio::sync::mpsc::Receiver, + ready: &std::sync::mpsc::SyncSender>, +) { + let connection = open(path); + let Ok(mut connection) = connection else { + let _ = ready.send(connection.map(|_| ())); + return; + }; + if ready.send(Ok(())).is_err() { + return; + } + while let Some(command) = receiver.blocking_recv() { + execute(&mut connection, command); + } +} + +fn open(path: &Path) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + home::secure_directory(parent)?; + } + let mut connection = Connection::open(path).map_err(database_error)?; + home::secure_file(path)?; + // Finish fallible connection setup before the transactional schema migration. + connection + .execute_batch("PRAGMA foreign_keys = ON; PRAGMA secure_delete = ON; PRAGMA journal_mode = WAL;") + .map_err(database_error)?; + if let Some(version) = schema::pending_version(&connection)? { + backup_database(path, version)?; + } + schema::initialize(&mut connection)?; + Ok(connection) +} + +fn backup_database(path: &Path, version: u32) -> Result<(), Error> { + let parent = path + .parent() + .ok_or_else(|| Error::Database("database path has no parent directory".into()))?; + let directory = parent.join("backups"); + std::fs::create_dir_all(&directory)?; + home::secure_directory(&directory)?; + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|error| Error::Database(format!("system clock precedes Unix epoch: {error}")))? + .as_nanos(); + let backup = directory.join(format!("agent-schema-{version}-{timestamp}.db")); + let backup_connection = Connection::open(path).map_err(database_error)?; + backup_connection + .execute("VACUUM INTO ?1", [backup.to_string_lossy().as_ref()]) + .map_err(database_error)?; + drop(backup_connection); + #[cfg(unix)] + { + home::secure_file(&backup)?; + std::fs::File::open(&backup)?.sync_all()?; + sync_directory(&directory)?; + } + // On Windows the file inherits the owner-only ACL from `directory`. + // SQLite commits and flushes VACUUM INTO before the connection closes; + // reopening its output immediately for ACL or flush operations is denied. + + let mut backups = std::fs::read_dir(&directory)? + .collect::, _>>()? + .into_iter() + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file())) + .filter_map(|entry| { + let name = entry.file_name(); + let timestamp = name + .to_str()? + .strip_prefix("agent-schema-")? + .strip_suffix(".db")? + .rsplit_once('-')? + .1 + .parse::() + .ok()?; + Some((timestamp, entry)) + }) + .collect::>(); + backups.sort_by_key(|(timestamp, _)| *timestamp); + let remove = backups.len().saturating_sub(3); + for (_, entry) in backups.into_iter().take(remove) { + std::fs::remove_file(entry.path())?; + } + Ok(()) +} + +#[cfg(unix)] +fn sync_directory(path: &Path) -> Result<(), Error> { + std::fs::File::open(path)?.sync_all()?; + Ok(()) +} + +fn execute(connection: &mut Connection, command: Command) { + match command { + Command::Get { id, response } => { + let _ = response.send(agents::get(connection, id)); + } + Command::GetByName { name, response } => { + let _ = response.send(agents::get_by_name(connection, &name)); + } + Command::List { response } => { + let _ = response.send(agents::list(connection)); + } + Command::Put { + record, + expected_generation, + response, + } => { + let _ = response.send(agents::put(connection, &record, expected_generation)); + } + Command::UpdateStatus { + id, + generation, + status, + response, + } => { + let _ = response.send(agents::update_status(connection, id, generation, *status)); + } + Command::MarkDeleting { name, response } => { + let _ = response.send(agents::mark_deleting(connection, &name)); + } + Command::FinalizeDeletion { + id, + generation, + response, + } => { + let _ = response.send(agents::finalize_deletion(connection, id, generation)); + } + Command::DeleteSecret { name, response } => { + let _ignored = response.send(secrets::delete_secret(connection, &name)); + } + Command::SetSecret { name, value, response } => { + let _ = response.send(secrets::set_secret(connection, &name, &value)); + } + Command::ReplaceAgentSecrets { id, secrets, response } => { + let _ = response.send(secrets::replace_agent_secrets(connection, id, &secrets)); + } + Command::ResolveSecret { name, response } => { + let _ = response.send(secrets::resolve_secret(connection, &name)); + } + Command::PutProviderAccount { account, response } => { + let _ = response.send(secrets::put_provider_account(connection, &account)); + } + Command::ProviderAccountExists { provider, response } => { + let _ = response.send(secrets::provider_account_exists(connection, &provider)); + } + Command::ProviderAccountMetadata { provider, response } => { + let _ = response.send(secrets::provider_account_metadata(connection, &provider)); + } + session_command => execute_session(connection, session_command), + } +} + +fn execute_session(connection: &mut Connection, command: Command) { + match command { + Command::EnsureSession { + agent, + name, + new, + response, + } => { + let _ = response.send(sessions::ensure(connection, &agent, &name, &new)); + } + Command::GetSession { id, response } => { + let _ = response.send(sessions::get(connection, id)); + } + Command::GetSessionByName { agent, name, response } => { + let _ = response.send(sessions::get_by_name(connection, &agent, &name)); + } + Command::ListAllSessions { response } => { + let _ = response.send(sessions::list_all(connection)); + } + Command::ListSessions { agent, response } => { + let _ = response.send(sessions::list_for_agent(connection, &agent)); + } + Command::UpdateSessionLifecycle { + id, + lifecycle, + observed_activation_generation, + response, + } => { + let _ = response.send(sessions::update_lifecycle( + connection, + id, + lifecycle, + observed_activation_generation, + )); + } + Command::ActivateSession { id, response } => { + let _ = response.send(sessions::activate(connection, id)); + } + Command::MarkSessionDeleting { agent, name, response } => { + let _ = response.send(sessions::mark_deleting(connection, &agent, &name)); + } + Command::FinalizeSessionDeletion { id, response } => { + let _ = response.send(sessions::finalize_deletion(connection, id)); + } + Command::SetSessionArchived { + agent, + name, + archived, + response, + } => { + let _ = response.send(sessions::set_archived(connection, &agent, &name, archived)); + } + Command::GetAttachTarget { id, response } => { + let _ = response.send(sessions::attach_target(connection, id)); + } + command @ (Command::ClearSessionReport { .. } + | Command::RecordSessionStartForLaunch { .. } + | Command::ApplySessionActivityForLaunch { .. }) => execute_session_report(connection, command), + Command::RecordSessionLaunch { + id, + token, + sandbox, + launched_at, + attempts, + response, + } => { + let _ = response.send(sessions::record_launch( + connection, + id, + &token, + &sandbox, + launched_at, + attempts, + )); + } + Command::GetSessionLaunchState { id, response } => { + let _ = response.send(sessions::launch_state(connection, id)); + } + Command::ResetSessionLaunchAttempts { id, response } => { + let _ = response.send(sessions::reset_launch_attempts(connection, id)); + } + // Every non-Session command is matched exhaustively by `execute`. + _ => unreachable!("non-Session command routed to the Session executor"), + } +} + +/// Executes the hook-route commands that write the reported half of a Session. +fn execute_session_report(connection: &mut Connection, command: Command) { + match command { + Command::ClearSessionReport { id, response } => { + let _ = response.send(sessions::clear_report(connection, id)); + } + Command::RecordSessionStartForLaunch { + id, + token, + event_id, + at, + native, + transcript_path, + response, + } => { + let _ = response.send(sessions::record_start_for_launch( + connection, + id, + &token, + event_id, + &native, + transcript_path.as_deref(), + at, + )); + } + Command::ApplySessionActivityForLaunch { + id, + token, + event_id, + event, + at, + response, + } => { + let _ = response.send(sessions::apply_activity_for_launch( + connection, id, &token, event_id, event, at, + )); + } + // Only report commands are routed here by `execute_session`. + _ => unreachable!("non-report command routed to the Session report executor"), + } +} + +pub(super) fn database_error(error: rusqlite::Error) -> Error { + let message = error.to_string(); + drop(error); + Error::Database(message) +} + +fn secret_store_error(error: Error) -> sandbox::Error { + match error { + Error::NotFound => sandbox::Error::not_found(sandbox::ResourceKind::Secret, "host secret"), + other => sandbox::Error::Backend(other.to_string()), + } +} + +fn agent_secret_prefix(id: AgentId) -> String { + format!("agent/{id}/") +} + +/// Secret row holding one incarnation's SSH host key. The name is outside the +/// `agent//` prefix so that replacing the manifest's secrets keeps it. +pub(crate) fn ssh_host_key_name(id: AgentId) -> String { + format!("agent-ssh/{id}/host-key") +} + +fn agent_secret_name(id: AgentId, name: &str) -> String { + format!("{}{name}", agent_secret_prefix(id)) +} + +#[cfg(test)] +mod tests { + use rusqlite::Connection; + use sandbox::secret_store::{SecretReference, SecretStore as _}; + use tempfile::TempDir; + use zeroize::Zeroizing; + + use super::{Database, StoredSecret, open}; + + #[test] + fn database_owner_enables_secure_deletion() { + let directory = TempDir::new().expect("temporary directory"); + let connection = open(&directory.path().join("agent.db")).expect("database connection"); + + assert_eq!( + connection + .query_row("PRAGMA secure_delete", [], |row| row.get::<_, u8>(0)) + .expect("secure-delete setting"), + 1 + ); + } + + #[test] + fn pending_migration_creates_and_prunes_owner_only_backups() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("agent.db"); + drop(open(&path).expect("current database")); + for _ in 0..4 { + let connection = Connection::open(&path).expect("database"); + connection + .pragma_update(None, "user_version", super::schema::VERSION - 1) + .expect("old version"); + drop(connection); + Database::migrate(&path).expect("adopt the expanded previous version after backup"); + } + let backups = std::fs::read_dir(directory.path().join("backups")) + .expect("backups") + .collect::, _>>() + .expect("backup entries"); + assert_eq!(backups.len(), 3); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!( + std::fs::metadata(directory.path().join("backups")) + .expect("directory metadata") + .permissions() + .mode() + & 0o777, + 0o700 + ); + assert!( + backups + .iter() + .all(|entry| entry.metadata().expect("backup metadata").permissions().mode() & 0o777 == 0o600) + ); + } + } + + #[tokio::test(flavor = "local")] + async fn replacing_agent_secrets_prunes_stale_rows_without_touching_provider_credentials() { + let directory = TempDir::new().expect("temporary directory"); + let database = Database::open(&directory.path().join("agent.db")).expect("database"); + let agent_id = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + let github = SecretReference::from_opaque(format!("agent/{agent_id}/github-token")); + let studio = SecretReference::from_opaque(format!("agent/{agent_id}/studio-token")); + let provider = database + .set("provider-access-token", b"provider-secret") + .await + .expect("provider secret"); + + database + .replace_agent_secrets( + agent_id, + vec![ + StoredSecret { + name: "github-token".into(), + value: Zeroizing::new(b"github-secret".to_vec()), + }, + StoredSecret { + name: "studio-token".into(), + value: Zeroizing::new(b"studio-secret".to_vec()), + }, + ], + ) + .await + .expect("initial Agent secrets"); + database + .replace_agent_secrets( + agent_id, + vec![StoredSecret { + name: "studio-token".into(), + value: Zeroizing::new(b"rotated-studio-secret".to_vec()), + }], + ) + .await + .expect("replacement Agent secrets"); + + assert!(database.resolve(&github).await.is_err()); + assert_eq!( + database.resolve(&studio).await.expect("retained secret").expose(), + b"rotated-studio-secret" + ); + assert_eq!( + database.resolve(&provider).await.expect("provider secret").expose(), + b"provider-secret" + ); + } +} diff --git a/agentctl/src/persistence/schema.rs b/agentctl/src/persistence/schema.rs new file mode 100644 index 0000000..ff8a228 --- /dev/null +++ b/agentctl/src/persistence/schema.rs @@ -0,0 +1,575 @@ +//! Ordered, transactional `SQLite` schema migrations. + +use std::collections::{BTreeMap, BTreeSet}; + +use rusqlite::{Connection, Transaction}; + +use crate::Error; + +use super::database_error; + +pub(crate) const VERSION: u32 = 5; + +const PREVIEW_1_SQL: &str = " + CREATE TABLE agents ( + id TEXT PRIMARY KEY NOT NULL, + active_name TEXT UNIQUE, + source_directory TEXT NOT NULL, + desired_json TEXT NOT NULL, + deletion_timestamp INTEGER, + status_json TEXT NOT NULL DEFAULT '{}' + ); + CREATE TABLE secrets ( + name TEXT PRIMARY KEY NOT NULL, + value BLOB NOT NULL + ); + CREATE TABLE provider_accounts ( + provider TEXT PRIMARY KEY NOT NULL, + metadata_json TEXT NOT NULL + ); + CREATE TABLE sessions ( + id TEXT PRIMARY KEY NOT NULL, + agent_id TEXT NOT NULL REFERENCES agents(id), + name TEXT NOT NULL, + harness TEXT NOT NULL, + created_at INTEGER NOT NULL, + activation_generation INTEGER NOT NULL DEFAULT 0, + lifecycle_json TEXT NOT NULL DEFAULT '{}', + harness_native_id TEXT, + launch_token TEXT UNIQUE, + launch_sandbox TEXT, + launched_at INTEGER, + launch_attempts INTEGER NOT NULL DEFAULT 0, + UNIQUE (agent_id, name) + ); +"; + +const SESSION_COLUMNS_SQL: &str = " + ALTER TABLE sessions ADD COLUMN initial_prompt TEXT; + ALTER TABLE sessions ADD COLUMN harness_transcript_path TEXT; + ALTER TABLE sessions ADD COLUMN activity_json TEXT NOT NULL DEFAULT '{}'; +"; + +const SESSION_ACTIVITY_REPORTS_SQL: &str = " + CREATE TABLE IF NOT EXISTS session_activity_reports ( + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + launch_token TEXT NOT NULL, + event_id TEXT NOT NULL, + PRIMARY KEY (session_id, launch_token, event_id) + ); +"; + +const SESSION_SELECTION_COLUMNS_SQL: &str = " + ALTER TABLE sessions ADD COLUMN model TEXT; + ALTER TABLE sessions ADD COLUMN effort TEXT; +"; + +const SESSION_DELETION_COLUMN_SQL: &str = " + ALTER TABLE sessions ADD COLUMN deletion_timestamp INTEGER; +"; + +const SESSION_ARCHIVE_COLUMN_SQL: &str = " + ALTER TABLE sessions ADD COLUMN archived_at INTEGER; +"; + +struct Migration { + version: u32, + name: &'static str, + schema: &'static [&'static str], + apply: fn(&Transaction<'_>) -> Result<(), Error>, +} + +const MIGRATIONS: &[Migration] = &[ + Migration { + version: 1, + name: "preview 1 baseline", + schema: &[PREVIEW_1_SQL], + apply: create_preview_1, + }, + Migration { + version: 2, + name: "session management", + schema: &[SESSION_COLUMNS_SQL, SESSION_ACTIVITY_REPORTS_SQL], + apply: add_session_management, + }, + Migration { + version: 3, + name: "session model and effort", + schema: &[SESSION_SELECTION_COLUMNS_SQL], + apply: add_session_selections, + }, + Migration { + version: 4, + name: "session deletion", + schema: &[SESSION_DELETION_COLUMN_SQL], + apply: add_session_deletion, + }, + Migration { + version: 5, + name: "session archive", + schema: &[SESSION_ARCHIVE_COLUMN_SQL], + apply: add_session_archive, + }, +]; + +pub(super) fn initialize(connection: &mut Connection) -> Result<(), Error> { + let current = schema_version(connection)?; + if current > VERSION { + return Err(Error::Database(format!( + "Agent database schema {current} is newer than the supported schema {VERSION}" + ))); + } + if current == 0 && !user_tables(connection)?.is_empty() { + return Err(unknown_schema(0, "the database contains unversioned tables")); + } + if current == VERSION { + return verify_schema(connection, VERSION); + } + + apply_pending_migrations(connection, current, MIGRATIONS, VERSION) +} + +fn apply_pending_migrations( + connection: &mut Connection, + current: u32, + migrations: &[Migration], + target: u32, +) -> Result<(), Error> { + let transaction = connection.transaction().map_err(database_error)?; + for migration in migrations.iter().filter(|migration| migration.version > current) { + (migration.apply)(&transaction).map_err(|error| { + Error::Database(format!( + "failed to apply Agent database migration {} ({}): {error}", + migration.version, migration.name + )) + })?; + } + transaction + .pragma_update(None, "user_version", target) + .map_err(database_error)?; + verify_schema_with(&transaction, migrations, target)?; + transaction.commit().map_err(database_error) +} + +pub(super) fn pending_version(connection: &Connection) -> Result, Error> { + let version = schema_version(connection)?; + Ok((version > 0 && version < VERSION).then_some(version)) +} + +fn create_preview_1(transaction: &Transaction<'_>) -> Result<(), Error> { + transaction.execute_batch(PREVIEW_1_SQL).map_err(database_error) +} + +fn add_session_management(transaction: &Transaction<'_>) -> Result<(), Error> { + if schema_difference(transaction, 2)?.is_none() { + return migrate_agent_instructions(transaction); + } + if schema_difference(transaction, 1)?.is_some() { + verify_intermediate_schema(transaction)?; + } + migrate_agent_instructions(transaction)?; + transaction.execute_batch(SESSION_COLUMNS_SQL).map_err(database_error)?; + transaction + .execute_batch(SESSION_ACTIVITY_REPORTS_SQL) + .map_err(database_error) +} + +/// Adds the model and effort a Session was created with. Sessions from earlier +/// schemas never chose either; an adapter that hardcoded a launch model until now +/// reports it, and that model is recorded for its existing Sessions so they keep +/// launching on one known model once the choice is a Session property. +fn add_session_selections(transaction: &Transaction<'_>) -> Result<(), Error> { + if schema_difference(transaction, 3)?.is_none() { + return Ok(()); + } + transaction + .execute_batch(SESSION_SELECTION_COLUMNS_SQL) + .map_err(database_error)?; + let harnesses = { + let mut statement = transaction + .prepare("SELECT DISTINCT harness FROM sessions WHERE model IS NULL") + .map_err(database_error)?; + statement + .query_map([], |row| row.get::<_, String>(0)) + .map_err(database_error)? + .collect::, _>>() + .map_err(database_error)? + }; + for value in harnesses { + let Some(model) = value + .parse::() + .ok() + .and_then(crate::harness::model_launched_before_selection) + else { + continue; + }; + transaction + .execute( + "UPDATE sessions SET model = ?1 WHERE harness = ?2 AND model IS NULL", + rusqlite::params![model, value], + ) + .map_err(database_error)?; + } + Ok(()) +} + +/// Adds the marker that requests a Session's release. Sessions from earlier +/// schemas were never deletable, so every existing row starts unmarked. +fn add_session_deletion(transaction: &Transaction<'_>) -> Result<(), Error> { + if schema_difference(transaction, 4)?.is_none() { + return Ok(()); + } + transaction + .execute_batch(SESSION_DELETION_COLUMN_SQL) + .map_err(database_error) +} + +/// Adds the request to archive a Session. Sessions from earlier schemas were +/// never archived, so every existing row starts active. +fn add_session_archive(transaction: &Transaction<'_>) -> Result<(), Error> { + if schema_difference(transaction, 5)?.is_none() { + return Ok(()); + } + transaction + .execute_batch(SESSION_ARCHIVE_COLUMN_SQL) + .map_err(database_error) +} + +fn migrate_agent_instructions(transaction: &Transaction<'_>) -> Result<(), Error> { + let rows = { + let mut statement = transaction + .prepare("SELECT id, desired_json FROM agents ORDER BY id") + .map_err(database_error)?; + statement + .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))) + .map_err(database_error)? + .collect::, _>>() + .map_err(database_error)? + }; + for (id, encoded) in rows { + let mut desired: serde_json::Value = serde_json::from_str(&encoded).map_err(|error| { + Error::Database(format!( + "Agent {id} has invalid desired state during migration: {error}" + )) + })?; + let spec = desired + .get_mut("spec") + .and_then(serde_json::Value::as_object_mut) + .ok_or_else(|| Error::Database(format!("Agent {id} desired state has no object-valued spec")))?; + let Some(instructions) = spec.get_mut("instructions") else { + continue; + }; + match instructions { + serde_json::Value::Array(_) => continue, + serde_json::Value::Null => *instructions = serde_json::Value::Array(Vec::new()), + serde_json::Value::Object(_) => { + *instructions = serde_json::Value::Array(vec![instructions.take()]); + } + _ => { + return Err(Error::Database(format!( + "Agent {id} desired state has an unexpected preview 1 instructions value" + ))); + } + } + transaction + .execute( + "UPDATE agents SET desired_json = ?1 WHERE id = ?2", + rusqlite::params![serde_json::to_string(&desired)?, id], + ) + .map_err(database_error)?; + } + Ok(()) +} + +fn schema_version(connection: &Connection) -> Result { + connection + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .map_err(database_error) +} + +fn verify_schema(connection: &Connection, version: u32) -> Result<(), Error> { + verify_schema_with(connection, MIGRATIONS, version) +} + +fn verify_schema_with(connection: &Connection, migrations: &[Migration], version: u32) -> Result<(), Error> { + schema_difference_with_migrations(connection, migrations, version)? + .map_or(Ok(()), |detail| Err(unknown_schema(version, &detail))) +} + +fn schema_difference(connection: &Connection, version: u32) -> Result, Error> { + schema_difference_with_migrations(connection, MIGRATIONS, version) +} + +fn schema_difference_with_migrations( + connection: &Connection, + migrations: &[Migration], + version: u32, +) -> Result, Error> { + let statements = migrations + .iter() + .filter(|migration| migration.version <= version) + .flat_map(|migration| migration.schema.iter().copied()) + .collect::>(); + schema_difference_with(connection, &statements) +} + +fn verify_intermediate_schema(connection: &Connection) -> Result<(), Error> { + schema_difference_with(connection, &[PREVIEW_1_SQL, SESSION_ACTIVITY_REPORTS_SQL])? + .map_or(Ok(()), |detail| Err(unknown_schema(1, &detail))) +} + +fn schema_difference_with(connection: &Connection, statements: &[&str]) -> Result, Error> { + let expected = Connection::open_in_memory().map_err(database_error)?; + for sql in statements { + expected.execute_batch(sql).map_err(database_error)?; + } + let actual_tables = user_tables(connection)?; + let expected_tables = user_tables(&expected)?; + if actual_tables != expected_tables { + return Ok(Some(format!( + "expected tables {expected_tables:?}, found {actual_tables:?}" + ))); + } + for table in expected_tables { + let actual = inspect_table(connection, &table)?; + if actual != inspect_table(&expected, &table)? { + return Ok(Some(format!("table {table:?} has an unexpected definition"))); + } + } + Ok(None) +} + +fn user_tables(connection: &Connection) -> Result, Error> { + let mut statement = connection + .prepare( + "SELECT name FROM sqlite_schema \ + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ) + .map_err(database_error)?; + statement + .query_map([], |row| row.get(0)) + .map_err(database_error)? + .collect::>() + .map_err(database_error) +} + +#[derive(Debug, Eq, PartialEq)] +struct TableDefinition { + columns: BTreeMap, + unique_keys: Vec>, + foreign_keys: Vec, +} + +#[derive(Debug, Eq, PartialEq)] +struct ColumnDefinition { + declared_type: String, + not_null: bool, + default: Option, + primary_key_position: u32, +} + +#[derive(Debug, Eq, Ord, PartialEq, PartialOrd)] +struct ForeignKeyDefinition { + table: String, + from: String, + to: String, + on_update: String, + on_delete: String, +} + +fn inspect_table(connection: &Connection, table: &str) -> Result { + let columns = { + let mut statement = connection + .prepare(&format!("PRAGMA table_info({table})")) + .map_err(database_error)?; + statement + .query_map([], |row| { + Ok(( + row.get(1)?, + ColumnDefinition { + declared_type: row.get(2)?, + not_null: row.get::<_, u32>(3)? != 0, + default: row.get(4)?, + primary_key_position: row.get(5)?, + }, + )) + }) + .map_err(database_error)? + .collect::, _>>() + .map_err(database_error)? + }; + let unique_keys = inspect_unique_keys(connection, table)?; + let foreign_keys = { + let mut statement = connection + .prepare(&format!("PRAGMA foreign_key_list({table})")) + .map_err(database_error)?; + let mut keys = statement + .query_map([], |row| { + Ok(ForeignKeyDefinition { + table: row.get(2)?, + from: row.get(3)?, + to: row.get(4)?, + on_update: row.get(5)?, + on_delete: row.get(6)?, + }) + }) + .map_err(database_error)? + .collect::, _>>() + .map_err(database_error)?; + keys.sort(); + keys + }; + Ok(TableDefinition { + columns, + unique_keys, + foreign_keys, + }) +} + +fn inspect_unique_keys(connection: &Connection, table: &str) -> Result>, Error> { + let indices = { + let mut statement = connection + .prepare(&format!("PRAGMA index_list({table})")) + .map_err(database_error)?; + statement + .query_map([], |row| Ok((row.get::<_, String>(1)?, row.get::<_, u32>(2)? != 0))) + .map_err(database_error)? + .filter_map(|row| match row { + Ok((name, true)) => Some(Ok(name)), + Ok((_, false)) => None, + Err(error) => Some(Err(error)), + }) + .collect::, _>>() + .map_err(database_error)? + }; + let mut keys = Vec::with_capacity(indices.len()); + for index in indices { + let escaped = index.replace('"', "\"\""); + let mut statement = connection + .prepare(&format!("PRAGMA index_info(\"{escaped}\")")) + .map_err(database_error)?; + keys.push( + statement + .query_map([], |row| row.get(2)) + .map_err(database_error)? + .collect::, _>>() + .map_err(database_error)?, + ); + } + keys.sort(); + Ok(keys) +} + +fn unknown_schema(version: u32, detail: &str) -> Error { + Error::Database(format!( + "Agent database schema {version} is not a recognized released schema: {detail}; select a new AGENT_HOME or restore a supported backup" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_first(transaction: &Transaction<'_>) -> Result<(), Error> { + transaction + .execute_batch("CREATE TABLE first (id INTEGER PRIMARY KEY);") + .map_err(database_error) + } + + fn fail_second(_transaction: &Transaction<'_>) -> Result<(), Error> { + Err(Error::Database("injected second migration failure".into())) + } + + fn create_third(transaction: &Transaction<'_>) -> Result<(), Error> { + transaction + .execute_batch("CREATE TABLE third (id INTEGER PRIMARY KEY);") + .map_err(database_error) + } + + fn create_unexpected(transaction: &Transaction<'_>) -> Result<(), Error> { + transaction + .execute_batch("CREATE TABLE unexpected (id INTEGER PRIMARY KEY);") + .map_err(database_error) + } + + #[test] + fn all_pending_migrations_roll_back_together() { + let mut connection = Connection::open_in_memory().expect("database"); + let migrations = [ + Migration { + version: 1, + name: "first", + schema: &[], + apply: create_first, + }, + Migration { + version: 2, + name: "failure", + schema: &[], + apply: fail_second, + }, + ]; + + let error = apply_pending_migrations(&mut connection, 0, &migrations, 2).expect_err("second migration fails"); + + assert!(error.to_string().contains("injected second migration failure")); + assert_eq!(schema_version(&connection).expect("schema version"), 0); + assert!(user_tables(&connection).expect("tables").is_empty()); + } + + #[test] + fn expanded_version_1_continues_through_later_migrations() { + let mut connection = Connection::open_in_memory().expect("database"); + connection.execute_batch(PREVIEW_1_SQL).expect("preview 1 schema"); + connection + .execute_batch(SESSION_COLUMNS_SQL) + .expect("expanded Session columns"); + connection + .execute_batch(SESSION_ACTIVITY_REPORTS_SQL) + .expect("expanded reports table"); + connection.pragma_update(None, "user_version", 1).expect("version 1"); + let migrations = [ + Migration { + version: 1, + name: "preview 1 baseline", + schema: &[PREVIEW_1_SQL], + apply: create_preview_1, + }, + Migration { + version: 2, + name: "session management", + schema: &[SESSION_COLUMNS_SQL, SESSION_ACTIVITY_REPORTS_SQL], + apply: add_session_management, + }, + Migration { + version: 3, + name: "third", + schema: &["CREATE TABLE third (id INTEGER PRIMARY KEY);"], + apply: create_third, + }, + ]; + + apply_pending_migrations(&mut connection, 1, &migrations, 3).expect("migrations"); + + assert_eq!(schema_version(&connection).expect("schema version"), 3); + assert!(user_tables(&connection).expect("tables").contains("third")); + } + + #[test] + fn final_schema_validation_rolls_back_the_migration() { + let mut connection = Connection::open_in_memory().expect("database"); + let migrations = [Migration { + version: 1, + name: "unexpected schema", + schema: &[], + apply: create_unexpected, + }]; + + let error = + apply_pending_migrations(&mut connection, 0, &migrations, 1).expect_err("final schema validation fails"); + + assert!(error.to_string().contains("not a recognized released schema")); + assert_eq!(schema_version(&connection).expect("schema version"), 0); + assert!(user_tables(&connection).expect("tables").is_empty()); + } +} diff --git a/agentctl/src/persistence/secrets.rs b/agentctl/src/persistence/secrets.rs new file mode 100644 index 0000000..15aa77d --- /dev/null +++ b/agentctl/src/persistence/secrets.rs @@ -0,0 +1,123 @@ +//! Host-owned secret and provider-account persistence. + +use std::collections::BTreeSet; + +use rusqlite::{Connection, OptionalExtension as _, params}; + +use crate::{AgentId, Error}; + +use super::{ProviderAccountWrite, StoredSecret, agent_secret_name, agent_secret_prefix, database_error}; + +pub(super) fn set_secret(connection: &Connection, name: &str, value: &[u8]) -> Result<(), Error> { + connection + .execute( + "INSERT INTO secrets (name, value) VALUES (?1, ?2) + ON CONFLICT(name) DO UPDATE SET value = excluded.value", + params![name, value], + ) + .map(|_| ()) + .map_err(database_error) +} + +pub(super) fn resolve_secret( + connection: &Connection, + name: &str, +) -> Result { + connection + .query_row("SELECT value FROM secrets WHERE name = ?1", [name], |row| { + row.get::<_, Vec>(0) + }) + .optional() + .map_err(database_error)? + .map(sandbox::secret_store::SecretMaterial::new) + .ok_or(Error::NotFound) +} + +pub(super) fn replace_agent_secrets( + connection: &mut Connection, + id: AgentId, + secrets: &[StoredSecret], +) -> Result<(), Error> { + let transaction = connection.transaction().map_err(database_error)?; + let mut desired = BTreeSet::new(); + for secret in secrets { + let name = agent_secret_name(id, &secret.name); + if !desired.insert(name.clone()) { + return Err(Error::Invalid(format!("duplicate Agent secret {:?}", secret.name))); + } + set_secret(&transaction, &name, &secret.value)?; + } + + let prefix = agent_secret_prefix(id); + let existing = { + let mut statement = transaction + .prepare("SELECT name FROM secrets WHERE substr(name, 1, length(?1)) = ?1") + .map_err(database_error)?; + statement + .query_map([&prefix], |row| row.get::<_, String>(0)) + .map_err(database_error)? + .collect::, _>>() + .map_err(database_error)? + }; + for name in existing { + if !desired.contains(&name) { + transaction + .execute("DELETE FROM secrets WHERE name = ?1", [&name]) + .map_err(database_error)?; + } + } + transaction.commit().map_err(database_error) +} + +pub(super) fn delete_secret(connection: &Connection, name: &str) -> Result<(), Error> { + connection + .execute("DELETE FROM secrets WHERE name = ?1", [name]) + .map(|_| ()) + .map_err(database_error) +} + +pub(super) fn delete_agent_secrets(connection: &Connection, id: AgentId) -> Result<(), Error> { + let prefix = agent_secret_prefix(id); + connection + .execute("DELETE FROM secrets WHERE substr(name, 1, length(?1)) = ?1", [&prefix]) + .map(|_| ()) + .map_err(database_error) +} + +pub(super) fn put_provider_account(connection: &mut Connection, account: &ProviderAccountWrite) -> Result<(), Error> { + let transaction = connection.transaction().map_err(database_error)?; + for credential in &account.credentials { + set_secret(&transaction, &credential.name, &credential.value)?; + } + transaction + .execute( + "INSERT INTO provider_accounts (provider, metadata_json) VALUES (?1, ?2) + ON CONFLICT(provider) DO UPDATE SET metadata_json = excluded.metadata_json", + params![account.provider, account.metadata_json], + ) + .map_err(database_error)?; + transaction.commit().map_err(database_error) +} + +pub(super) fn provider_account_exists(connection: &Connection, provider: &str) -> Result { + connection + .query_row( + "SELECT 1 FROM provider_accounts WHERE provider = ?1", + [provider], + |_| Ok(()), + ) + .optional() + .map(|row| row.is_some()) + .map_err(database_error) +} + +pub(super) fn provider_account_metadata(connection: &Connection, provider: &str) -> Result, Error> { + connection + .query_row( + "SELECT metadata_json FROM provider_accounts WHERE provider = ?1", + [provider], + |row| row.get(0), + ) + .optional() + .map_err(database_error) +} diff --git a/agentctl/src/persistence/sessions.rs b/agentctl/src/persistence/sessions.rs new file mode 100644 index 0000000..43b2a84 --- /dev/null +++ b/agentctl/src/persistence/sessions.rs @@ -0,0 +1,555 @@ +//! Session persistence with one column group per write owner. + +use rusqlite::{Connection, OptionalExtension as _, params}; +use serde::{Deserialize, Serialize}; + +use crate::{ + AgentId, Error, + sandbox::Assignment, + sessions::{ + Activity, ActivityEvent, AttachTarget, LaunchState, LaunchToken, Lifecycle, LifecycleState, NewSession, + Reported, Session, SessionId, SessionName, Status, + }, +}; + +use super::{agents, database_error}; + +const SESSION_COLUMNS: &str = "sessions.id, sessions.agent_id, agents.active_name, sessions.name, \ + sessions.harness, sessions.created_at, sessions.activation_generation, sessions.lifecycle_json, \ + sessions.harness_native_id, sessions.harness_transcript_path, sessions.activity_json, \ + sessions.model, sessions.effort, sessions.deletion_timestamp, sessions.archived_at"; + +/// Reconciler-owned column: the lifecycle half of the status plus the +/// activation revision it was observed at. +#[derive(Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct LifecycleRow { + #[serde(default)] + state: LifecycleState, + #[serde(default, skip_serializing_if = "Option::is_none")] + failure: Option, + #[serde(default)] + observed_activation_generation: u64, + /// When `state` last changed. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "time::serde::rfc3339::option" + )] + since: Option, +} + +pub(super) fn ensure( + connection: &mut Connection, + agent: &str, + name: &SessionName, + new: &NewSession, +) -> Result { + let transaction = connection.transaction().map_err(database_error)?; + let owner = agents::get_by_name(&transaction, agent)?; + if owner.agent.metadata.deletion_timestamp.is_some() { + return Err(Error::Conflict); + } + let agent_id = owner.id; + if let Some(session) = query_named(&transaction, agent_id, name)? { + // The name is free again only once the reconciler has released the + // harness and removed the row, so recreating it now would revive a + // Session that is already going away. + if session.is_deleting() { + return Err(Error::Invalid(format!( + "Session \"{name}\" is being deleted; its name is free once its harness has stopped" + ))); + } + // Two callers may both find no Session and both resolve one; the first + // recorded selections bind, so a loser that explicitly chose differently + // learns about it, while one that chose nothing gets the Session as is. + if session.harness != new.harness { + return Err(Error::Invalid(format!( + "Session \"{name}\" already uses harness {:?}, not {:?}", + session.harness.as_str(), + new.harness.as_str() + ))); + } + if let Some(conflict) = session.model_selection.conflict_with(&new.requested) { + return Err(Error::Invalid(format!("Session \"{name}\" {conflict}"))); + } + transaction.commit().map_err(database_error)?; + return Ok(session); + } + let id = SessionId::generate(); + let created_at = time::OffsetDateTime::now_utc().unix_timestamp(); + transaction + .execute( + "INSERT INTO sessions (id, agent_id, name, harness, created_at, initial_prompt, model, effort) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + id.to_string(), + agent_id.to_string(), + name.as_str(), + new.harness.as_str(), + created_at, + new.initial_prompt.as_deref(), + new.model_selection.model_str(), + new.model_selection.effort_str(), + ], + ) + .map_err(database_error)?; + let session = query_named(&transaction, agent_id, name)?.ok_or(Error::NotFound)?; + transaction.commit().map_err(database_error)?; + Ok(session) +} + +pub(super) fn get(connection: &Connection, id: SessionId) -> Result { + connection + .query_row( + &format!( + "SELECT {SESSION_COLUMNS} FROM sessions JOIN agents ON agents.id = sessions.agent_id \ + WHERE sessions.id = ?1 AND agents.active_name IS NOT NULL" + ), + [id.to_string()], + decode_row, + ) + .optional() + .map_err(database_error)? + .ok_or(Error::NotFound) +} + +pub(super) fn get_by_name(connection: &Connection, agent: &str, name: &SessionName) -> Result { + let owner = agents::get_by_name(connection, agent)?; + query_named(connection, owner.id, name)?.ok_or(Error::NotFound) +} + +pub(super) fn list_all(connection: &Connection) -> Result, Error> { + query_many( + connection, + &format!( + "SELECT {SESSION_COLUMNS} FROM sessions JOIN agents ON agents.id = sessions.agent_id \ + WHERE agents.active_name IS NOT NULL ORDER BY agents.active_name, sessions.name" + ), + [], + ) +} + +pub(super) fn list_for_agent(connection: &Connection, agent: &str) -> Result, Error> { + query_many( + connection, + &format!( + "SELECT {SESSION_COLUMNS} FROM sessions JOIN agents ON agents.id = sessions.agent_id \ + WHERE agents.active_name = ?1 ORDER BY sessions.name" + ), + [agent], + ) +} + +pub(super) fn activate(connection: &Connection, id: SessionId) -> Result { + let changed = connection + .execute( + "UPDATE sessions SET activation_generation = activation_generation + 1 WHERE id = ?1", + [id.to_string()], + ) + .map_err(database_error)?; + if changed != 1 { + return Err(Error::NotFound); + } + connection + .query_row( + "SELECT activation_generation FROM sessions WHERE id = ?1", + [id.to_string()], + |row| { + let generation = row.get::<_, i64>(0)?; + u64::try_from(generation).map_err(conversion_error) + }, + ) + .map_err(database_error) +} + +/// Records the first release request for one named Session; repeating it +/// returns the Session already marked. +pub(super) fn mark_deleting(connection: &mut Connection, agent: &str, name: &SessionName) -> Result { + let transaction = connection.transaction().map_err(database_error)?; + let owner = agents::get_by_name(&transaction, agent)?; + let mut session = query_named(&transaction, owner.id, name)?.ok_or(Error::NotFound)?; + if !session.is_deleting() { + let changed = transaction + .execute( + "UPDATE sessions SET deletion_timestamp = ?1 WHERE id = ?2 AND deletion_timestamp IS NULL", + params![time::OffsetDateTime::now_utc().unix_timestamp(), session.id.to_string()], + ) + .map_err(database_error)?; + if changed != 1 { + return Err(Error::Conflict); + } + // Read the marker back so callers see the stored second, not a + // higher-precision value this Session would never report again. + session = query_named(&transaction, owner.id, name)?.ok_or(Error::NotFound)?; + } + transaction.commit().map_err(database_error)?; + Ok(session) +} + +/// Records whether one named Session is archived. Archiving an archived +/// Session keeps its original time; unarchiving clears it. +pub(super) fn set_archived( + connection: &mut Connection, + agent: &str, + name: &SessionName, + archived: bool, +) -> Result { + let transaction = connection.transaction().map_err(database_error)?; + let owner = agents::get_by_name(&transaction, agent)?; + let session = query_named(&transaction, owner.id, name)?.ok_or(Error::NotFound)?; + if session.is_deleting() { + return Err(Error::NotFound); + } + if session.is_archived() != archived { + let archived_at = archived.then(|| time::OffsetDateTime::now_utc().unix_timestamp()); + transaction + .execute( + "UPDATE sessions SET archived_at = ?1 WHERE id = ?2", + params![archived_at, session.id.to_string()], + ) + .map_err(database_error)?; + } + let session = query_named(&transaction, owner.id, name)?.ok_or(Error::NotFound)?; + transaction.commit().map_err(database_error)?; + Ok(session) +} + +/// Removes a released Session. Rows keyed to it, such as its activity reports, +/// cascade with it. +pub(super) fn finalize_deletion(connection: &Connection, id: SessionId) -> Result<(), Error> { + let changed = connection + .execute( + "DELETE FROM sessions WHERE id = ?1 AND deletion_timestamp IS NOT NULL", + [id.to_string()], + ) + .map_err(database_error)?; + if changed == 1 { Ok(()) } else { Err(Error::Conflict) } +} + +pub(super) fn update_lifecycle( + connection: &Connection, + id: SessionId, + lifecycle: Lifecycle, + observed_activation_generation: u64, +) -> Result<(), Error> { + let current = connection + .query_row( + "SELECT lifecycle_json FROM sessions WHERE id = ?1", + params![id.to_string()], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(database_error)? + .ok_or(Error::NotFound)?; + let current = serde_json::from_str::(¤t)?; + let since = if current.state == lifecycle.state { + current.since + } else { + Some(time::OffsetDateTime::now_utc()) + }; + let row = LifecycleRow { + state: lifecycle.state, + failure: lifecycle.failure, + observed_activation_generation, + since, + }; + let changed = connection + .execute( + "UPDATE sessions SET lifecycle_json = ?1 WHERE id = ?2", + params![serde_json::to_string(&row)?, id.to_string()], + ) + .map_err(database_error)?; + if changed == 1 { Ok(()) } else { Err(Error::NotFound) } +} + +/// Clears every reported column: the previous harness incarnation's +/// conversation no longer exists, so neither do its ID, transcript or activity. +pub(super) fn clear_report(connection: &Connection, id: SessionId) -> Result<(), Error> { + let changed = connection + .execute( + "UPDATE sessions SET harness_native_id = NULL, harness_transcript_path = NULL, activity_json = '{}' \ + WHERE id = ?1", + params![id.to_string()], + ) + .map_err(database_error)?; + if changed == 1 { Ok(()) } else { Err(Error::NotFound) } +} + +/// Folds one activity event into the Session, guarded by the current launch token. +/// +/// A stale token (an earlier launch, or a deleted Agent) matches no row and is a +/// silent no-op returning `None`, so the harness hook does not retry a report it +/// can never land. +pub(super) fn apply_activity_for_launch( + connection: &mut Connection, + id: SessionId, + token: &LaunchToken, + event_id: uuid::Uuid, + event: ActivityEvent, + at: time::OffsetDateTime, +) -> Result, Error> { + let (transaction, activity) = match begin_report(connection, id, token, event_id) { + Ok(Some(report)) => report, + Ok(None) | Err(Error::NotFound) => return Ok(None), + Err(error) => return Err(error), + }; + commit_report(transaction, id, activity.folded(event, at)).map(Some) +} + +/// Records start metadata and activity as one deduplicated report. +pub(super) fn record_start_for_launch( + connection: &mut Connection, + id: SessionId, + token: &LaunchToken, + event_id: uuid::Uuid, + native: &str, + transcript_path: Option<&str>, + at: time::OffsetDateTime, +) -> Result, Error> { + let Some((transaction, activity)) = begin_report(connection, id, token, event_id)? else { + return Ok(None); + }; + transaction + .execute( + "UPDATE sessions SET harness_native_id = ?1, harness_transcript_path = ?2 WHERE id = ?3", + params![native, transcript_path, id.to_string()], + ) + .map_err(database_error)?; + commit_report(transaction, id, activity.folded(ActivityEvent::SessionStart, at)).map(Some) +} + +/// Authenticates the launch and claims the event ID inside its write transaction. +/// Duplicate reports return `None`; stale launches return `Error::NotFound`. +fn begin_report<'a>( + connection: &'a mut Connection, + id: SessionId, + token: &LaunchToken, + event_id: uuid::Uuid, +) -> Result, Activity)>, Error> { + let transaction = connection.transaction().map_err(database_error)?; + let current = transaction + .query_row( + "SELECT activity_json FROM sessions \ + WHERE id = ?1 AND launch_token = ?2 \ + AND EXISTS ( \ + SELECT 1 FROM agents \ + WHERE agents.id = sessions.agent_id AND agents.active_name IS NOT NULL \ + )", + params![id.to_string(), token.expose()], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(database_error)? + .ok_or(Error::NotFound)?; + let inserted = transaction + .execute( + "INSERT INTO session_activity_reports (session_id, launch_token, event_id) VALUES (?1, ?2, ?3) \ + ON CONFLICT (session_id, launch_token, event_id) DO NOTHING", + params![id.to_string(), token.expose(), event_id.to_string()], + ) + .map_err(database_error)?; + if inserted == 0 { + return Ok(None); + } + Ok(Some((transaction, serde_json::from_str(¤t)?))) +} + +fn commit_report(transaction: rusqlite::Transaction<'_>, id: SessionId, activity: Activity) -> Result { + transaction + .execute( + "UPDATE sessions SET activity_json = ?1 WHERE id = ?2", + params![serde_json::to_string(&activity)?, id.to_string()], + ) + .map_err(database_error)?; + transaction.commit().map_err(database_error)?; + Ok(activity) +} + +pub(super) fn record_launch( + connection: &mut Connection, + id: SessionId, + token: &LaunchToken, + sandbox: &str, + launched_at: i64, + attempts: u32, +) -> Result, Error> { + let transaction = connection.transaction().map_err(database_error)?; + let prompt: Option = transaction + .query_row( + "SELECT initial_prompt FROM sessions WHERE id = ?1", + [id.to_string()], + |row| row.get(0), + ) + .optional() + .map_err(database_error)? + .ok_or(Error::NotFound)?; + transaction + .execute( + "UPDATE sessions SET launch_token = ?1, launch_sandbox = ?2, launched_at = ?3, launch_attempts = ?4, \ + activity_json = '{}', initial_prompt = NULL WHERE id = ?5", + params![token.expose(), sandbox, launched_at, attempts, id.to_string()], + ) + .map_err(database_error)?; + // Reports from previous launches can no longer authenticate, so their IDs can be discarded. + transaction + .execute( + "DELETE FROM session_activity_reports WHERE session_id = ?1", + [id.to_string()], + ) + .map_err(database_error)?; + transaction.commit().map_err(database_error)?; + Ok(prompt) +} + +pub(super) fn launch_state(connection: &Connection, id: SessionId) -> Result, Error> { + connection + .query_row( + "SELECT launch_token, launch_sandbox, launched_at, launch_attempts + FROM sessions WHERE id = ?1", + [id.to_string()], + |row| { + let token = row + .get::<_, Option>(0)? + .map(|token| token.parse().map_err(conversion_error)) + .transpose()?; + let sandbox = row.get::<_, Option>(1)?; + let launched_at = row.get::<_, Option>(2)?; + let attempts = row.get::<_, u32>(3)?; + let (Some(token), Some(sandbox), Some(launched_at)) = (token, sandbox, launched_at) else { + return Ok(None); + }; + Ok(Some(LaunchState { + token, + sandbox, + launched_at, + attempts, + })) + }, + ) + .optional() + .map_err(database_error)? + .ok_or(Error::NotFound) +} + +pub(super) fn reset_launch_attempts(connection: &Connection, id: SessionId) -> Result<(), Error> { + let changed = connection + .execute( + "UPDATE sessions SET launch_attempts = 0 WHERE id = ?1", + [id.to_string()], + ) + .map_err(database_error)?; + if changed == 1 { Ok(()) } else { Err(Error::NotFound) } +} + +pub(super) fn attach_target(connection: &Connection, id: SessionId) -> Result { + let session = get(connection, id)?; + if session.status.lifecycle.state != LifecycleState::Running { + return Err(session.not_running_error()); + } + let agent = agents::get(connection, session.agent_id)?; + let ready = agent.agent.status.is_ready(); + let Some(sandbox @ Assignment::Materialized { .. }) = agent.agent.status.sandbox else { + return Err(Error::Invalid(format!( + "Agent {:?} is not ready", + agent.agent.metadata.name + ))); + }; + if !ready || agent.agent.metadata.deletion_timestamp.is_some() { + return Err(Error::Invalid(format!( + "Agent {:?} is not ready", + agent.agent.metadata.name + ))); + } + Ok(AttachTarget { session, sandbox }) +} + +fn query_named(connection: &Connection, agent: AgentId, name: &SessionName) -> Result, Error> { + connection + .query_row( + &format!( + "SELECT {SESSION_COLUMNS} FROM sessions JOIN agents ON agents.id = sessions.agent_id \ + WHERE sessions.agent_id = ?1 AND sessions.name = ?2 AND agents.active_name IS NOT NULL" + ), + params![agent.to_string(), name.as_str()], + decode_row, + ) + .optional() + .map_err(database_error) +} + +fn query_many

(connection: &Connection, sql: &str, params: P) -> Result, Error> +where + P: rusqlite::Params, +{ + let mut statement = connection.prepare(sql).map_err(database_error)?; + statement + .query_map(params, decode_row) + .map_err(database_error)? + .map(|row| row.map_err(database_error)) + .collect() +} + +fn decode_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let id = row.get::<_, String>(0)?.parse().map_err(conversion_error)?; + let agent_id = row.get::<_, String>(1)?.parse().map_err(conversion_error)?; + let agent = row.get::<_, String>(2)?; + let name = SessionName::new(row.get::<_, String>(3)?).map_err(conversion_error)?; + let harness = row.get::<_, String>(4)?.parse().map_err(conversion_error)?; + let created_at = time::OffsetDateTime::from_unix_timestamp(row.get::<_, i64>(5)?).map_err(conversion_error)?; + let activation_generation = u64::try_from(row.get::<_, i64>(6)?).map_err(conversion_error)?; + let lifecycle = serde_json::from_str::(&row.get::<_, String>(7)?).map_err(conversion_error)?; + let harness_session_id = row.get::<_, Option>(8)?; + let harness_transcript_path = row.get::<_, Option>(9)?; + let activity = serde_json::from_str::(&row.get::<_, String>(10)?).map_err(conversion_error)?; + let model = row + .get::<_, Option>(11)? + .map(crate::Model::new) + .transpose() + .map_err(conversion_error)?; + let effort = row + .get::<_, Option>(12)? + .map(crate::Effort::new) + .transpose() + .map_err(conversion_error)?; + let deletion_timestamp = row + .get::<_, Option>(13)? + .map(time::OffsetDateTime::from_unix_timestamp) + .transpose() + .map_err(conversion_error)?; + let archived_at = row + .get::<_, Option>(14)? + .map(time::OffsetDateTime::from_unix_timestamp) + .transpose() + .map_err(conversion_error)?; + Ok(Session { + id, + agent_id, + agent, + name, + harness, + model_selection: crate::ModelSelection { model, effort }, + created_at, + deletion_timestamp, + archived_at, + status: Status::observed( + Lifecycle { + state: lifecycle.state, + failure: lifecycle.failure, + }, + Reported { + harness_session_id, + harness_transcript_path, + activity, + }, + lifecycle.since, + archived_at, + ), + activation_generation, + observed_activation_generation: lifecycle.observed_activation_generation, + }) +} + +fn conversion_error(error: impl std::error::Error + Send + Sync + 'static) -> rusqlite::Error { + rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(error)) +} diff --git a/agentctl/src/platform_api/mod.rs b/agentctl/src/platform_api/mod.rs new file mode 100644 index 0000000..e5d7006 --- /dev/null +++ b/agentctl/src/platform_api/mod.rs @@ -0,0 +1,349 @@ +//! Sandbox-facing Platform API endpoint. +//! +//! Harness processes inside a Sandbox reach the host through the mediated +//! Network Backend's host alias, which rewrites to host loopback. This module +//! owns the loopback listener and its session-report route: per-launch reports +//! carrying the harness-native conversation ID and transcript location (on +//! start) and activity signals folded into Session status. The same listener +//! is the growth point for later platform tools (MCP), so nothing here assumes +//! the report route is the only one. +//! +//! Requests originate inside Sandboxes and are untrusted: parsing is bounded, +//! authentication is a per-launch bearer token, and failures return nothing +//! but a status code. + +use std::{io, path::Path, rc::Rc}; + +use futures_util::{FutureExt as _, StreamExt as _, stream::FuturesUnordered}; +use tokio::{ + io::{AsyncReadExt as _, AsyncWriteExt as _}, + net::{TcpListener, TcpStream}, +}; + +use crate::{Error, sessions}; + +/// Upper bound for the request line and headers. +const MAX_HEAD_BYTES: usize = 8_192; + +/// Upper bound for a request body. +const MAX_BODY_BYTES: usize = 4_096; + +/// Time budget for one connection, request and response included. +const CONNECTION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); +const MAX_CONCURRENT_CONNECTIONS: usize = 64; +const MAX_NATIVE_SESSION_ID_BYTES: usize = 1_024; +const MAX_TRANSCRIPT_PATH_BYTES: usize = 4_096; +type ConnectionFuture = futures_util::future::LocalBoxFuture<'static, ()>; + +/// Binds the Platform API listener on loopback, reusing the previously bound port. +/// +/// The port is persisted at `port_path` so Sandbox environments composed at +/// earlier launches keep pointing at a live endpoint across daemon restarts. +/// +/// # Errors +/// +/// Returns an error when the persisted port is invalid or unavailable, no +/// loopback port can be bound on first use, or that first port cannot be persisted. +pub async fn bind_persistent(port_path: &Path) -> Result { + let preferred = match tokio::fs::read_to_string(port_path).await { + Ok(content) => { + let port = content.trim().parse::().map_err(|error| { + Error::Io(io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid persisted Platform API port: {error}"), + )) + })?; + if port == 0 { + return Err(Error::Io(io::Error::new( + io::ErrorKind::InvalidData, + "persisted Platform API port must not be zero", + ))); + } + Some(port) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(Error::Io(error)), + }; + if let Some(port) = preferred { + return TcpListener::bind(("127.0.0.1", port)).await.map_err(|error| { + Error::Io(io::Error::new( + error.kind(), + format!("persisted Platform API port {port} is unavailable: {error}"), + )) + }); + } + let listener = TcpListener::bind(("127.0.0.1", 0)).await?; + tokio::fs::write(port_path, format!("{}\n", listener.local_addr()?.port())).await?; + Ok(listener) +} + +/// Serves Platform API requests from Sandboxes until the listener fails. +pub struct Server { + sessions: Rc, + on_error: Rc, +} + +impl Server { + /// Creates a Platform API server that records what harnesses report; it + /// holds no other Session capability. + #[must_use] + pub fn new(sessions: Rc, on_error: Rc) -> Self { + Self { sessions, on_error } + } + + /// Accepts and handles connections until the listener fails. + /// + /// # Errors + /// + /// Returns an error when accepting connections fails permanently. + pub async fn serve(self: Rc, listener: TcpListener) -> Result<(), Error> { + let mut connections = FuturesUnordered::::new(); + loop { + tokio::select! { + accepted = listener.accept(), if connections.len() < MAX_CONCURRENT_CONNECTIONS => { + let (stream, _) = accepted?; + let server = self.clone(); + connections.push(async move { + let outcome = tokio::time::timeout(CONNECTION_TIMEOUT, server.handle(stream)).await; + match outcome { + Ok(Ok(())) => {} + Ok(Err(error)) => (server.on_error)(&error), + Err(_) => { + (server.on_error)(&Error::Session("Platform API connection timed out".into())); + } + } + }.boxed_local()); + } + Some(()) = connections.next(), if !connections.is_empty() => {} + } + } + } + + async fn handle(&self, mut stream: TcpStream) -> Result<(), Error> { + let request = match read_request(&mut stream).await { + Ok(request) => request, + Err(status) => return respond(&mut stream, status).await, + }; + let status = self.dispatch(&request).await; + respond(&mut stream, status).await + } + + async fn dispatch(&self, request: &Request) -> u16 { + if request.method != "POST" { + return 405; + } + if request.target != "/v1/session/hooks" { + return 404; + } + let Some(token) = request.bearer_token() else { + return 401; + }; + let Ok(token) = token.parse::() else { + return 401; + }; + let Ok(report) = serde_json::from_slice::(&request.body) else { + return 400; + }; + self.accept_report(&token, &report).await + } + + /// Applies one authenticated session report: it records the native session + /// ID and transcript location on start and folds the reported activity event. + /// + /// The per-launch token rejects reports from earlier harness incarnations. + /// Sessions in one Agent share a Unix identity and are not mutually + /// isolated security principals. A stale token on an activity-only report is + /// a silent no-op; a stale token on a start report is rejected so the ID is + /// never attributed to the wrong launch. + async fn accept_report(&self, token: &sessions::LaunchToken, report: &SessionReport) -> u16 { + let at = time::OffsetDateTime::now_utc(); + let applied = if report.event == sessions::ActivityEvent::SessionStart { + if report.native_session_id.is_empty() || report.native_session_id.len() > MAX_NATIVE_SESSION_ID_BYTES { + return 400; + } + // A transcript is read inside the reporting Sandbox and must be an absolute path. + let transcript_path = report.transcript_path.as_deref().filter(|path| !path.is_empty()); + if transcript_path.is_some_and(|path| !path.starts_with('/') || path.len() > MAX_TRANSCRIPT_PATH_BYTES) { + return 400; + } + self.sessions + .record_session_start_for_launch( + report.session_id, + token, + report.event_id, + &report.native_session_id, + transcript_path, + at, + ) + .await + } else { + self.sessions + .apply_session_activity_for_launch(report.session_id, token, report.event_id, report.event, at) + .await + }; + match applied { + Ok(_) => 204, + Err(Error::NotFound) => 401, + Err(error) => { + (self.on_error)(&error); + 500 + } + } + } +} + +/// One per-launch session report from the harness hook. +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct SessionReport { + session_id: sessions::SessionId, + event_id: uuid::Uuid, + /// The reported activity signal; `sessionStart` also carries the native ID + /// and, when the harness exposes one, its transcript location. + event: sessions::ActivityEvent, + #[serde(default)] + native_session_id: String, + #[serde(default)] + transcript_path: Option, + #[serde(default)] + #[allow(dead_code, reason = "accepted for diagnostics; not used for authorization")] + source: String, +} + +struct Request { + method: String, + target: String, + authorization: Option, + body: Vec, +} + +impl Request { + fn bearer_token(&self) -> Option<&str> { + let value = self.authorization.as_deref()?; + let token = value + .strip_prefix("Bearer ") + .or_else(|| value.strip_prefix("bearer "))?; + (!token.is_empty() && token.len() <= 128).then_some(token) + } +} + +/// Reads one bounded HTTP/1.x request; the error is the response status code. +async fn read_request(stream: &mut TcpStream) -> Result { + let mut buffer = Vec::with_capacity(1_024); + let head_end = loop { + if let Some(position) = find_head_end(&buffer) { + break position; + } + if buffer.len() >= MAX_HEAD_BYTES { + return Err(431); + } + let mut chunk = [0_u8; 1_024]; + let read = stream.read(&mut chunk).await.map_err(|_| 400_u16)?; + if read == 0 { + return Err(400); + } + buffer.extend_from_slice(&chunk[..read]); + }; + + let head = std::str::from_utf8(&buffer[..head_end]).map_err(|_| 400_u16)?; + let mut lines = head.split("\r\n"); + let request_line = lines.next().ok_or(400_u16)?; + let mut parts = request_line.split(' '); + let method = parts.next().ok_or(400_u16)?.to_owned(); + let target = parts.next().ok_or(400_u16)?.to_owned(); + + let mut authorization = None; + let mut content_length = 0_usize; + for line in lines { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + let value = value.trim(); + if name.eq_ignore_ascii_case("authorization") { + authorization = Some(value.to_owned()); + } else if name.eq_ignore_ascii_case("content-length") { + content_length = value.parse().map_err(|_| 400_u16)?; + } + } + if content_length > MAX_BODY_BYTES { + return Err(413); + } + + let mut body = buffer[head_end + 4..].to_vec(); + if body.len() > content_length { + return Err(400); + } + while body.len() < content_length { + let mut chunk = vec![0_u8; content_length - body.len()]; + let read = stream.read(&mut chunk).await.map_err(|_| 400_u16)?; + if read == 0 { + return Err(400); + } + body.extend_from_slice(&chunk[..read]); + } + Ok(Request { + method, + target, + authorization, + body, + }) +} + +fn find_head_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") +} + +async fn respond(stream: &mut TcpStream, status: u16) -> Result<(), Error> { + let reason = match status { + 204 => "No Content", + 400 => "Bad Request", + 401 => "Unauthorized", + 404 => "Not Found", + 405 => "Method Not Allowed", + 413 => "Content Too Large", + 431 => "Request Header Fields Too Large", + _ => "Internal Server Error", + }; + let response = format!("HTTP/1.1 {status} {reason}\r\nconnection: close\r\ncontent-length: 0\r\n\r\n"); + stream.write_all(response.as_bytes()).await?; + stream.shutdown().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn an_unavailable_persisted_port_is_not_replaced() { + let directory = tempfile::TempDir::new().expect("temporary directory"); + let path = directory.path().join("platform-api-port"); + let occupied = TcpListener::bind(("127.0.0.1", 0)).await.expect("occupied port"); + let port = occupied.local_addr().expect("address").port(); + tokio::fs::write(&path, format!("{port}\n")).await.expect("port file"); + + let error = bind_persistent(&path).await.expect_err("occupied persisted port"); + assert!(error.to_string().contains(&format!("port {port} is unavailable"))); + assert_eq!( + tokio::fs::read_to_string(&path).await.expect("port file"), + format!("{port}\n") + ); + + drop(occupied); + let rebound = bind_persistent(&path).await.expect("same port after release"); + assert_eq!(rebound.local_addr().expect("address").port(), port); + } + + #[tokio::test] + async fn the_first_bound_port_is_persisted() { + let directory = tempfile::TempDir::new().expect("temporary directory"); + let path = directory.path().join("platform-api-port"); + + let listener = bind_persistent(&path).await.expect("first bind"); + + assert_eq!( + tokio::fs::read_to_string(path).await.expect("port file"), + format!("{}\n", listener.local_addr().expect("address").port()) + ); + } +} diff --git a/agentctl/src/progress/mod.rs b/agentctl/src/progress/mod.rs new file mode 100644 index 0000000..f327754 --- /dev/null +++ b/agentctl/src/progress/mod.rs @@ -0,0 +1,47 @@ +//! Observable Agent provisioning progress. +//! +//! The Sandbox SDK reports progress as events and defines what they mean as a +//! folded [`::sandbox::progress::Progress`]. The reconciler folds every event of +//! a pass into the Agent's [`Provisioning`] in [`ProvisioningState`]; readers +//! see its current value, and a daemon-wide revision tells them when it +//! changed. A resync of a Ready Agent is published only if it fails. Durable +//! readiness and failure are the Agent's stored conditions. + +mod observer; +mod state; + +pub use observer::SandboxObserver; +pub use state::{Provisioning, ProvisioningState}; + +/// Platform setup inside the Sandbox: harnesses, home, instructions and Skills. +pub const SETUP: ::sandbox::Phase = ::sandbox::Phase::new("agentSetup", "Set up Agent"); +/// Declared SSH access to the Sandbox. +pub const SSH_ACCESS: ::sandbox::Phase = ::sandbox::Phase::new("sshAccess", "Configure SSH access"); +/// Stopping the Sandbox of an Agent whose run state is Stopped. +pub const SANDBOX_STOP: ::sandbox::Phase = ::sandbox::Phase::new("sandboxStop", "Stop Sandbox"); +/// Declared VNC access to the Agent's desktop. +pub const VNC_ACCESS: ::sandbox::Phase = ::sandbox::Phase::new("vncAccess", "Configure VNC access"); + +/// One Agent's stored status and the progress of its latest pass, as of one +/// revision, returned by `agents.v1.progress`. +#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AgentProgress { + /// Revision to follow from next. + pub revision: crate::resources::Revision, + /// Stored status: conditions and failure class. + pub status: crate::Status, + /// Progress of the latest pass, with the output the caller has not seen. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provisioning: Option, +} + +/// Where a follower is in one pass's output. +#[derive(Clone, Copy, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct OutputPosition { + /// Pass the position belongs to. + pub pass: crate::resources::Revision, + /// First output line the follower has not seen. + pub sequence: u64, +} diff --git a/agentctl/src/progress/observer.rs b/agentctl/src/progress/observer.rs new file mode 100644 index 0000000..5e6b168 --- /dev/null +++ b/agentctl/src/progress/observer.rs @@ -0,0 +1,110 @@ +//! Records one reconciliation pass's progress. + +use std::cell::Cell; + +use ::sandbox::ProgressReporter; + +use crate::{AgentId, ReconcileFailure}; + +use super::ProvisioningState; + +/// One reconciliation pass of an Agent, folded into the Agent's provisioning. +/// +/// The pass ends as succeeded or failed. A pass dropped without either, when +/// reconciliation returns an error first, ends as failed. +pub struct SandboxObserver { + state: ProvisioningState, + id: AgentId, + ended: Cell, +} + +impl SandboxObserver { + /// Starts recording a new pass for an Agent. + #[must_use] + pub fn new(id: AgentId, state: ProvisioningState) -> Self { + state.begin(id); + Self { + state, + id, + ended: Cell::new(false), + } + } + + /// Starts recording a resync of a Ready Agent, which replaces the Agent's + /// latest pass only if it fails. + #[must_use] + pub fn resync(id: AgentId, state: ProvisioningState) -> Self { + state.begin_resync(id); + Self { + state, + id, + ended: Cell::new(false), + } + } + + /// Returns the reporter for the pass's progress. + #[must_use] + pub fn reporter(&self) -> ProgressReporter { + let state = self.state.clone(); + let id = self.id; + ProgressReporter::from_callback(move |event| state.apply(id, &event)) + } + + /// Records that the pass succeeded. + pub fn succeeded(&self) { + self.ended.set(true); + self.state.succeed(self.id); + } + + /// Records that the pass failed. + pub fn failed(&self, failure: &ReconcileFailure) { + self.ended.set(true); + self.state.fail(self.id, &failure.message); + } +} + +impl Drop for SandboxObserver { + fn drop(&mut self) { + if !self.ended.get() { + self.state.fail( + self.id, + "reconciliation stopped before the pass finished; agentd retries it", + ); + } + } +} + +#[cfg(test)] +mod tests { + use ::sandbox::progress::OperationStatus; + + use super::*; + + #[test] + fn a_pass_dropped_without_an_outcome_ends_as_failed() { + let state = ProvisioningState::default(); + let id = AgentId::generate(); + drop(SandboxObserver::new(id, state.clone())); + assert!(matches!( + state.get(id).expect("pass").progress.status(), + OperationStatus::Failed { .. } + )); + + let observer = SandboxObserver::new(id, state.clone()); + observer.succeeded(); + drop(observer); + assert_eq!( + state.get(id).expect("pass").progress.status(), + &OperationStatus::Succeeded + ); + + drop(SandboxObserver::resync(id, state.clone())); + assert!( + matches!( + state.get(id).expect("pass").progress.status(), + OperationStatus::Failed { .. } + ), + "a resync that stops early is published as failed" + ); + } +} diff --git a/agentctl/src/progress/state.rs b/agentctl/src/progress/state.rs new file mode 100644 index 0000000..c76c24b --- /dev/null +++ b/agentctl/src/progress/state.rs @@ -0,0 +1,247 @@ +//! Provisioning progress of each Agent's latest provisioning pass. +//! +//! The reconciler folds every Sandbox and Agent progress event into the +//! Agent's [`Provisioning`]. It lives in memory only: after a daemon restart no +//! pass is running, and the durable outcome of the last pass is the Agent's +//! conditions and failure class. +//! +//! A resync, a pass that only reconfirms a Ready Agent at its current +//! generation, is folded out of sight and published only if it fails. The +//! periodic resync would otherwise replace the pass that provisioned the Agent +//! within seconds, and show a healthy Agent as provisioning while it runs. + +use std::{cell::RefCell, collections::HashMap, rc::Rc}; + +use ::sandbox::progress::{OperationStatus, Progress}; + +use crate::{ + AgentId, + resources::{Changes, Revision}, +}; + +/// Output lines kept in the summary projected onto an Agent whose pass failed. +const FAILURE_OUTPUT_LINES: usize = 40; + +/// Progress of one Agent's latest pass that created, changed or retried its +/// Sandbox, or of a resync that failed. +#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Provisioning { + /// Identity of the pass: the revision at which it was published, unique + /// across daemon processes. A new identity means a new pass, so observers + /// start their cursor over. + pub pass: Revision, + /// The pass's progress. + pub progress: Progress, +} + +/// Latest provisioning of every Agent, shared by the reconciler and readers. +#[derive(Clone, Default)] +pub struct ProvisioningState { + agents: Rc>>, + resyncs: Rc>>, + changes: Changes, +} + +impl ProvisioningState { + /// Creates empty state whose every change advances `changes`. + #[must_use] + pub fn new(changes: Changes) -> Self { + Self { + changes, + ..Self::default() + } + } + + /// Returns the complete progress of the Agent's latest pass. + #[must_use] + pub fn get(&self, id: AgentId) -> Option { + self.agents.borrow().get(&id).cloned() + } + + /// Returns a summary of a pass that is running or failed, for listings. + /// + /// A succeeded pass is omitted: the Agent's conditions report it. + #[must_use] + pub fn summary(&self, id: AgentId) -> Option { + let agents = self.agents.borrow(); + let provisioning = agents.get(&id)?; + let output = match provisioning.progress.status() { + OperationStatus::Succeeded => return None, + OperationStatus::Failed { .. } => FAILURE_OUTPUT_LINES, + OperationStatus::Running => 0, + }; + Some(Provisioning { + progress: provisioning.progress.summary(output), + ..provisioning.clone() + }) + } + + /// Starts a new pass, replacing what the previous one left behind. + pub(crate) fn begin(&self, id: AgentId) { + self.resyncs.borrow_mut().remove(&id); + self.publish(id, Progress::new()); + } + + /// Starts a resync, which leaves the latest pass in place unless it fails. + pub(crate) fn begin_resync(&self, id: AgentId) { + self.resyncs.borrow_mut().insert(id, Progress::new()); + } + + pub(crate) fn apply(&self, id: AgentId, event: &::sandbox::ProgressEvent) { + if let Some(progress) = self.resyncs.borrow_mut().get_mut(&id) { + progress.apply(event); + return; + } + self.update(id, |provisioning| provisioning.progress.apply(event)); + } + + pub(crate) fn succeed(&self, id: AgentId) { + if self.resyncs.borrow_mut().remove(&id).is_some() { + return; + } + self.update(id, |provisioning| provisioning.progress.succeed()); + } + + pub(crate) fn fail(&self, id: AgentId, detail: &str) { + let resync = self.resyncs.borrow_mut().remove(&id); + if let Some(mut progress) = resync { + progress.fail(detail); + self.publish(id, progress); + return; + } + self.update(id, |provisioning| provisioning.progress.fail(detail)); + } + + /// Drops a deleted Agent's state. + pub(crate) fn forget(&self, id: AgentId) { + self.resyncs.borrow_mut().remove(&id); + if self.agents.borrow_mut().remove(&id).is_some() { + self.changes.bump(); + } + } + + fn publish(&self, id: AgentId, progress: Progress) { + self.changes.bump(); + let pass = self.changes.revision(); + self.agents.borrow_mut().insert(id, Provisioning { pass, progress }); + } + + fn update(&self, id: AgentId, change: impl FnOnce(&mut Provisioning)) { + let updated = self.agents.borrow_mut().get_mut(&id).map(change).is_some(); + if updated { + self.changes.bump(); + } + } +} + +#[cfg(test)] +mod tests { + use ::sandbox::{ProgressEvent, SandboxPhase}; + + use super::*; + + fn started() -> ProgressEvent { + ProgressEvent::PhaseStarted { + phase: SandboxPhase::ImageResolve.phase(), + } + } + + #[test] + fn each_pass_gets_a_new_number_and_every_change_advances_the_revision() { + let changes = Changes::new(); + let state = ProvisioningState::new(changes.clone()); + let id = AgentId::generate(); + let before = changes.revision(); + state.apply(id, &started()); + assert_eq!(changes.revision(), before, "no pass has begun"); + + state.begin(id); + state.apply(id, &started()); + let first = state.get(id).expect("first pass"); + assert!(first.progress.current().is_some()); + assert_ne!(changes.revision(), before); + + state.begin(id); + let second = state.get(id).expect("second pass"); + assert_ne!(second.pass, first.pass); + assert!(second.progress.current().is_none(), "a new pass starts empty"); + + let other = ProvisioningState::new(Changes::new()); + other.begin(id); + assert_ne!( + other.get(id).expect("pass of another daemon").pass, + first.pass, + "passes of another daemon process never compare as the same pass" + ); + } + + #[test] + fn a_resync_stays_out_of_sight_unless_it_fails() { + let changes = Changes::new(); + let state = ProvisioningState::new(changes.clone()); + let id = AgentId::generate(); + state.begin(id); + state.apply(id, &started()); + state.succeed(id); + let provisioned = state.get(id).expect("provisioning pass"); + + let before = changes.revision(); + state.begin_resync(id); + state.apply(id, &started()); + state.succeed(id); + assert_eq!( + changes.revision(), + before, + "a resync that succeeds changes nothing observable" + ); + assert_eq!(state.get(id), Some(provisioned.clone()), "the provisioning pass stays"); + assert!(state.summary(id).is_none()); + + state.begin_resync(id); + state.apply(id, &started()); + state.fail(id, "Sandbox stopped"); + let failed = state.get(id).expect("failed resync"); + assert_ne!( + failed.pass, provisioned.pass, + "a failed resync is published as a new pass" + ); + assert!( + failed + .progress + .finished() + .iter() + .any(|phase| phase.phase.id == SandboxPhase::ImageResolve.phase().id) + ); + assert!( + matches!(state.summary(id), Some(summary) if matches!(summary.progress.status(), OperationStatus::Failed { .. })) + ); + assert_ne!(changes.revision(), before); + } + + #[test] + fn listings_show_running_and_failed_passes_but_not_succeeded_ones() { + let state = ProvisioningState::default(); + let id = AgentId::generate(); + state.begin(id); + state.apply(id, &started()); + assert!(state.summary(id).is_some()); + + state.fail(id, "pull failed"); + let failed = state.summary(id).expect("failed pass"); + assert_eq!( + failed.progress.status(), + &OperationStatus::Failed { + detail: "pull failed".into() + } + ); + + state.begin(id); + state.succeed(id); + assert!(state.summary(id).is_none()); + assert!(state.get(id).is_some(), "followers still read the finished pass"); + + state.forget(id); + assert!(state.get(id).is_none()); + } +} diff --git a/agentctl/src/resources.rs b/agentctl/src/resources.rs new file mode 100644 index 0000000..a4a6185 --- /dev/null +++ b/agentctl/src/resources.rs @@ -0,0 +1,212 @@ +//! Level-triggered change notification for the Agent and Session resources. +//! +//! Every durable Agent or Session write and every provisioning update advances +//! one daemon-wide revision. Watchers never receive the changes themselves: +//! they wait for the revision to move past the one they last saw and then read +//! the current state, so a slow watcher can skip intermediate states but never miss the +//! latest one. + +use std::{fmt, str::FromStr, time::Duration}; + +use tokio::sync::watch; + +/// Daemon-wide change history of the Agent and Session resources. +#[derive(Clone)] +pub struct Changes { + epoch: uuid::Uuid, + sequence: watch::Sender, +} + +/// Position in one daemon's change history. +/// +/// The epoch is chosen when the daemon starts, so a revision from an earlier +/// daemon process never compares as current. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Revision { + epoch: uuid::Uuid, + sequence: u64, +} + +impl Changes { + /// Starts a new change history. + #[must_use] + pub fn new() -> Self { + Self { + epoch: uuid::Uuid::new_v4(), + sequence: watch::Sender::new(0), + } + } + + /// Records that observable state changed. + pub fn bump(&self) { + self.sequence + .send_modify(|sequence| *sequence = sequence.wrapping_add(1)); + } + + /// Returns the current position. + #[must_use] + pub fn revision(&self) -> Revision { + Revision { + epoch: self.epoch, + sequence: *self.sequence.borrow(), + } + } + + /// Waits until state has changed since `after`, then for `settle` more so + /// that a burst of changes produces one wake-up. + /// + /// Returns immediately when `after` is absent, belongs to another daemon + /// process, or is already behind. Returns `false` when `timeout` passes + /// without a change. + pub async fn changed_since(&self, after: Option, settle: Duration, timeout: Duration) -> bool { + let Some(after) = after.filter(|after| after.epoch == self.epoch) else { + return true; + }; + let mut receiver = self.sequence.subscribe(); + if *receiver.borrow_and_update() != after.sequence { + return true; + } + // `wait_for` returns a read guard on the sequence. Drop it before + // settling: a change made while it is held would block its writer, and + // with it the single-threaded runtime this guard is waiting on. + let changed = tokio::time::timeout(timeout, receiver.wait_for(|sequence| *sequence != after.sequence)) + .await + .is_ok_and(|changed| changed.is_ok()); + if changed { + tokio::time::sleep(settle).await; + } + changed + } +} + +impl Default for Changes { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Display for Revision { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}:{}", self.epoch, self.sequence) + } +} + +impl FromStr for Revision { + type Err = crate::Error; + + fn from_str(value: &str) -> Result { + let invalid = || crate::Error::Invalid(format!("invalid resource revision {value:?}")); + let (epoch, sequence) = value.split_once(':').ok_or_else(invalid)?; + Ok(Self { + epoch: epoch.parse().map_err(|_| invalid())?, + sequence: sequence.parse().map_err(|_| invalid())?, + }) + } +} + +impl serde::Serialize for Revision { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(self) + } +} + +impl<'de> serde::Deserialize<'de> for Revision { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + value.parse().map_err(serde::de::Error::custom) + } +} + +/// Every Agent and Session as of one revision. +/// +/// The revision is taken before the state is read, so a change made while it +/// is read is also reported by the next watch from this revision. +#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, serde::Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Resources { + /// Revision to watch from next. + pub revision: Revision, + /// Active Agents ordered by name, with provisioning progress projected. + pub agents: Vec, + /// Durable Sessions of those Agents. + pub sessions: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + const SETTLE: Duration = Duration::from_millis(150); + const TIMEOUT: Duration = Duration::from_secs(30); + + #[test] + fn revision_round_trips_through_its_wire_form() { + let changes = Changes::new(); + changes.bump(); + let revision = changes.revision(); + assert_eq!(revision.to_string().parse::().expect("revision"), revision); + let json = serde_json::to_value(revision).expect("revision JSON"); + assert_eq!( + serde_json::from_value::(json).expect("revision from JSON"), + revision + ); + assert!("not-a-revision".parse::().is_err()); + } + + #[tokio::test(start_paused = true)] + async fn missing_foreign_or_stale_revisions_return_immediately() { + let changes = Changes::new(); + assert!(changes.changed_since(None, SETTLE, TIMEOUT).await); + assert!( + changes + .changed_since(Some(Changes::new().revision()), SETTLE, TIMEOUT) + .await + ); + let stale = changes.revision(); + changes.bump(); + let started = tokio::time::Instant::now(); + assert!(changes.changed_since(Some(stale), SETTLE, TIMEOUT).await); + assert_eq!(started.elapsed(), Duration::ZERO); + } + + #[tokio::test(start_paused = true)] + async fn current_revision_waits_for_a_change_or_times_out() { + let changes = Changes::new(); + let current = changes.revision(); + assert!(!changes.changed_since(Some(current), SETTLE, TIMEOUT).await); + + let bumper = changes.clone(); + let waiting = changes.changed_since(Some(current), SETTLE, TIMEOUT); + let bump = async { + tokio::time::sleep(Duration::from_secs(1)).await; + bumper.bump(); + bumper.bump(); + }; + let started = tokio::time::Instant::now(); + let (woke, ()) = tokio::join!(waiting, bump); + assert!(woke); + assert_eq!( + started.elapsed(), + Duration::from_secs(1) + SETTLE, + "the watch settles after the first change so a burst wakes it once" + ); + assert_eq!(changes.revision().sequence, current.sequence + 2); + } + + #[tokio::test(flavor = "local", start_paused = true)] + async fn changes_during_the_settle_window_do_not_block_the_writer() { + let changes = Changes::new(); + let current = changes.revision(); + let bumper = changes.clone(); + let waiting = changes.changed_since(Some(current), SETTLE, TIMEOUT); + let bump = async { + tokio::time::sleep(Duration::from_secs(1)).await; + bumper.bump(); + tokio::time::sleep(SETTLE / 2).await; + bumper.bump(); + }; + let (woke, ()) = tokio::join!(waiting, bump); + assert!(woke); + assert_eq!(changes.revision().sequence, current.sequence + 2); + } +} diff --git a/agentctl/src/sandbox/execution.rs b/agentctl/src/sandbox/execution.rs new file mode 100644 index 0000000..9c28f3b --- /dev/null +++ b/agentctl/src/sandbox/execution.rs @@ -0,0 +1,96 @@ +//! Transient command execution against an Agent-owned Sandbox. + +use std::{path::Path, rc::Rc}; + +use ::sandbox::execution; +use serde::{Deserialize, Serialize}; + +use crate::{Error, control_plane, control_plane::WaitPolicy}; + +use super::Assignment; + +/// Exact materialized Sandbox selected after Agent convergence. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ExecutionTarget { + /// Sticky Provider and immutable Sandbox identity. + pub sandbox: Assignment, + /// Sandbox operating system used to construct the Execution. + pub operating_system: String, +} + +/// Resolves transient executions without taking ownership of Sandbox lifecycle effects. +pub struct ExecutionService { + agents: Rc, + convergence: control_plane::Convergence, +} + +impl ExecutionService { + /// Creates an execution-target resolver over the Agent controller. + #[must_use] + pub const fn new(agents: Rc, convergence: control_plane::Convergence) -> Self { + Self { agents, convergence } + } + + /// Wakes Agent convergence and returns its exact ready Sandbox assignment. + /// + /// # Errors + /// + /// Returns an error when the Agent is missing, deleting, or invalid; with + /// [`WaitPolicy::FirstPass`] also when the single pass fails or leaves the + /// Agent without a ready materialized Sandbox. + pub async fn ensure(&self, name: &str, wait: WaitPolicy) -> Result { + // A stopped Agent runs nothing, so it is refused before anything is woken. + let record = self.agents.get_by_name(name).await?; + if record.agent.metadata.deletion_timestamp.is_some() { + return Err(Error::Conflict); + } + record.reject_stopped()?; + let record = self.convergence.converge(name, wait).await?; + record.reject_stopped()?; + target(record, name) + } +} + +fn target(record: control_plane::AgentRecord, name: &str) -> Result { + let ready = record.agent.status.ready_condition(); + if !record.agent.status.is_ready() { + let detail = ready.map_or_else( + || "no Ready condition was reported".to_owned(), + crate::Condition::summary, + ); + return Err(Error::Invalid(format!("Agent {name:?} is not Ready: {detail}"))); + } + let sandbox = record + .agent + .status + .sandbox + .filter(|assignment| assignment.id().is_some()) + .ok_or_else(|| Error::Invalid(format!("Agent {name:?} has no materialized Sandbox")))?; + Ok(ExecutionTarget { + sandbox, + operating_system: record.agent.spec.sandbox.platform.os, + }) +} + +/// Starts a non-interactive Execution through the recorded Sandbox Provider. +/// +/// The returned stream belongs to the exact Sandbox lifecycle ID in `target`; +/// this function does not create, start, or otherwise reconcile a Sandbox. +/// +/// # Errors +/// +/// Returns an error when the Provider is unsupported by this client or the +/// exact Sandbox cannot start the Execution. +pub async fn start_execution( + home: &Path, + target: &ExecutionTarget, + spec: execution::ExecutionSpec, +) -> Result { + match target.sandbox.provider().as_str() { + super::microsandbox::PROVIDER_ID => super::microsandbox::start_execution(home, &target.sandbox, spec).await, + provider => Err(Error::Invalid(format!( + "command execution is not supported through Sandbox Provider {provider:?}" + ))), + } +} diff --git a/agentctl/src/sandbox/forward.rs b/agentctl/src/sandbox/forward.rs new file mode 100644 index 0000000..6237b01 --- /dev/null +++ b/agentctl/src/sandbox/forward.rs @@ -0,0 +1,267 @@ +//! Client-owned connections into Agent Sandboxes. +//! +//! Port forwards follow the k9s model: they live in the client process, accept +//! connections on a local listener, and dial the guest through the Sandbox +//! agent relay. They end when the process exits or the forward is stopped. +//! [`relay_guest_port`] is the single-connection form behind +//! `agentctl ssh-proxy`: one fresh dial, relayed over an arbitrary byte +//! stream pair such as the process's standard input and output. + +use std::{cell::RefCell, net::IpAddr, path::PathBuf, rc::Rc}; + +use tokio::net::TcpListener; + +use crate::Error; + +use super::{Assignment, GuestDialer}; + +/// One requested local-to-guest port mapping. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ForwardSpec { + /// Local interface address accepting connections. + pub address: IpAddr, + /// Local port to bind; zero selects an ephemeral port. + pub local_port: u16, + /// Guest port that receives forwarded connections. + pub guest_port: u16, +} + +impl ForwardSpec { + /// Parses `GUEST`, `LOCAL:GUEST`, or `ADDRESS:LOCAL:GUEST`. + /// + /// An empty local port (`:GUEST`) selects an ephemeral local port. + /// + /// # Errors + /// + /// Returns a message describing the malformed mapping. + pub fn parse(text: &str) -> Result { + let parts = text.split(':').collect::>(); + let (address, local, guest) = match parts.as_slice() { + [guest] => (None, *guest, *guest), + [local, guest] => (None, *local, *guest), + [address, local, guest] => (Some(*address), *local, *guest), + _ => return Err(format!("{text:?} is not GUEST, LOCAL:GUEST, or ADDRESS:LOCAL:GUEST")), + }; + let address = match address { + None => IpAddr::from([127, 0, 0, 1]), + Some(text) => text + .parse::() + .map_err(|_| format!("{text:?} is not a local IP address"))?, + }; + let local_port = if local.is_empty() { 0 } else { parse_port(local)? }; + let guest_port = parse_port(guest)?; + if guest_port == 0 { + return Err("guest port must not be zero".into()); + } + Ok(Self { + address, + local_port, + guest_port, + }) + } +} + +fn parse_port(text: &str) -> Result { + text.parse::().map_err(|_| format!("{text:?} is not a port")) +} + +/// One running forward with its local listener task. +pub struct PortForward { + spec: ForwardSpec, + assignment: Assignment, + local: std::net::SocketAddr, + task: tokio::task::JoinHandle<()>, + status: Rc>>, +} + +impl PortForward { + /// Binds the local listener and serves connections until stopped. + /// + /// # Errors + /// + /// Returns an error when the local address cannot be bound. + pub async fn start(home: PathBuf, assignment: Assignment, spec: ForwardSpec) -> Result { + let listener = TcpListener::bind((spec.address, spec.local_port)) + .await + .map_err(Error::from)?; + let local = listener.local_addr().map_err(Error::from)?; + let status = Rc::new(RefCell::new(None)); + let task = tokio::task::spawn_local(accept_loop( + home, + assignment.clone(), + spec.guest_port, + listener, + Rc::clone(&status), + )); + Ok(Self { + spec, + assignment, + local, + task, + status, + }) + } + + /// Returns the requested mapping. + #[must_use] + pub const fn spec(&self) -> &ForwardSpec { + &self.spec + } + + /// Returns the Sandbox assignment the forward dials. + #[must_use] + pub const fn assignment(&self) -> &Assignment { + &self.assignment + } + + /// Returns the bound local address, with any ephemeral port resolved. + #[must_use] + pub const fn local_address(&self) -> std::net::SocketAddr { + self.local + } + + /// Returns the most recent connection failure, when one occurred. + #[must_use] + pub fn status(&self) -> Option { + self.status.borrow().clone() + } + + /// Reports whether the listener task has ended and stopped serving. + #[must_use] + pub fn finished(&self) -> bool { + self.task.is_finished() + } + + /// Stops the listener and drops in-flight relays. + pub fn stop(&self) { + self.task.abort(); + } +} + +impl Drop for PortForward { + fn drop(&mut self) { + self.stop(); + } +} + +async fn accept_loop( + home: PathBuf, + assignment: Assignment, + guest_port: u16, + listener: TcpListener, + status: Rc>>, +) { + // The dialer multiplexes streams over one agent connection; it is replaced + // when a connect fails, which re-reaches a Sandbox whose runtime restarted. + let mut dialer: Option> = None; + // Relays live in the accept task's JoinSet, so aborting the accept task + // drops the set and aborts every in-flight connection with it. + let mut relays = tokio::task::JoinSet::new(); + loop { + while relays.try_join_next().is_some() {} + let stream = match listener.accept().await { + Ok((stream, _)) => stream, + Err(error) => { + *status.borrow_mut() = Some(format!("accept failed: {error}")); + return; + } + }; + if dialer.is_none() { + match super::guest_tcp_dialer(&home, &assignment).await { + Ok(connected) => dialer = Some(Rc::new(connected)), + Err(error) => { + *status.borrow_mut() = Some(error.to_string()); + continue; + } + } + } + let Some(connected) = &dialer else { continue }; + match connected.connect("127.0.0.1", guest_port).await { + Ok(guest) => { + *status.borrow_mut() = None; + let status = Rc::clone(&status); + relays.spawn_local(async move { + if let Err(error) = guest.relay(stream).await { + *status.borrow_mut() = Some(error.to_string()); + } + }); + } + Err(error) => { + *status.borrow_mut() = Some(error.to_string()); + dialer = None; + } + } + } +} + +/// Dials one guest loopback port and relays it over `reader` and `writer` +/// until both directions close. +/// +/// Every call dials fresh through the recorded Sandbox Provider, so a +/// connection made after a Sandbox runtime restart needs no recovery logic. +/// +/// # Errors +/// +/// Returns an error when the Sandbox cannot be reached, the guest refuses the +/// connection, or the relay fails. +pub async fn relay_guest_port( + home: &std::path::Path, + assignment: &Assignment, + guest_port: u16, + reader: R, + writer: W, +) -> Result<(), Error> +where + R: tokio::io::AsyncRead + Unpin, + W: tokio::io::AsyncWrite + Unpin, +{ + let dialer = super::guest_tcp_dialer(home, assignment).await?; + let guest = dialer.connect("127.0.0.1", guest_port).await?; + guest.relay_io(reader, writer).await +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::*; + + #[test] + fn specs_follow_kubectl_shapes() { + assert_eq!( + ForwardSpec::parse("80").expect("guest-only spec"), + ForwardSpec { + address: IpAddr::from([127, 0, 0, 1]), + local_port: 80, + guest_port: 80, + } + ); + assert_eq!( + ForwardSpec::parse("9090:80").expect("local and guest spec"), + ForwardSpec { + address: IpAddr::from([127, 0, 0, 1]), + local_port: 9090, + guest_port: 80, + } + ); + assert_eq!( + ForwardSpec::parse("0.0.0.0:80:80").expect("address spec"), + ForwardSpec { + address: IpAddr::from([0, 0, 0, 0]), + local_port: 80, + guest_port: 80, + } + ); + assert_eq!( + ForwardSpec::parse(":80").expect("ephemeral local port"), + ForwardSpec { + address: IpAddr::from([127, 0, 0, 1]), + local_port: 0, + guest_port: 80, + } + ); + assert!(ForwardSpec::parse("web:80").is_err()); + assert!(ForwardSpec::parse("1:2:3:4").is_err()); + assert!(ForwardSpec::parse("8080:0").is_err()); + } +} diff --git a/agentctl/src/sandbox/microsandbox/execution.rs b/agentctl/src/sandbox/microsandbox/execution.rs new file mode 100644 index 0000000..966415f --- /dev/null +++ b/agentctl/src/sandbox/microsandbox/execution.rs @@ -0,0 +1,29 @@ +//! Direct transient Execution transport for the Microsandbox Backend. + +use sandbox::{execution, provider::SandboxProvider as _}; +use sandbox_microsandbox::MicrosandboxProvider; + +use crate::{Error, sandbox::Assignment}; + +/// Starts an Execution in an already-materialized Microsandbox. +/// +/// # Errors +/// +/// Returns an error when the assignment is not materialized or the exact +/// Microsandbox cannot be inspected or start the Execution. +pub(crate) async fn start_execution( + home: &std::path::Path, + assignment: &Assignment, + spec: execution::ExecutionSpec, +) -> Result { + let provider = MicrosandboxProvider::open(home.join("microsandbox")).await?; + let Assignment::Materialized { id, .. } = assignment else { + return Err(Error::Invalid("Execution target Sandbox is not materialized".into())); + }; + provider.backend().inspect(id).await?; + provider + .backend() + .start_execution(id, execution::StartExecutionRequest::new(spec)) + .await + .map_err(Error::from) +} diff --git a/agentctl/src/sandbox/microsandbox/forward.rs b/agentctl/src/sandbox/microsandbox/forward.rs new file mode 100644 index 0000000..433f057 --- /dev/null +++ b/agentctl/src/sandbox/microsandbox/forward.rs @@ -0,0 +1,72 @@ +//! Guest TCP dialing transport for the Microsandbox Backend. + +use sandbox_microsandbox::{GuestTcpDialer, GuestTcpStream, MicrosandboxProvider}; + +use crate::{Error, sandbox::Assignment}; + +/// Dials TCP connections from inside one Agent Sandbox. +/// +/// The dialer multiplexes streams over a single Sandbox connection, so opening +/// many concurrent connections through one dialer is cheap. It stops working +/// when the Sandbox runtime restarts; create a replacement when a connect fails. +pub struct GuestDialer(GuestTcpDialer); + +/// One open TCP stream dialed from inside an Agent Sandbox. +pub struct GuestConnection(GuestTcpStream); + +impl GuestDialer { + /// Opens one TCP connection dialed from inside the guest. + /// + /// # Errors + /// + /// Returns an error when the stream cannot be opened or the guest dial is + /// rejected. + pub async fn connect(&self, host: &str, port: u16) -> Result { + Ok(GuestConnection(self.0.connect(host, port).await?)) + } +} + +impl GuestConnection { + /// Pipes bytes between a host socket and the guest connection until either + /// side closes. + /// + /// # Errors + /// + /// Returns an error when either side of the relay fails. + pub async fn relay(self, stream: tokio::net::TcpStream) -> Result<(), Error> { + self.0.relay(stream).await.map_err(Error::from) + } + + /// Pipes bytes between a host reader/writer pair, such as this process's + /// standard input and output, and the guest connection until both sides + /// close. + /// + /// # Errors + /// + /// Returns an error when either side of the relay fails. + pub async fn relay_io(self, reader: R, writer: W) -> Result<(), Error> + where + R: tokio::io::AsyncRead + Unpin, + W: tokio::io::AsyncWrite + Unpin, + { + self.0.relay_io(reader, writer).await.map_err(Error::from) + } +} + +/// Connects a TCP dialer to an already-materialized Microsandbox. +/// +/// # Errors +/// +/// Returns an error when the assignment is not materialized or the exact +/// Microsandbox is not running. +pub(crate) async fn guest_tcp_dialer(home: &std::path::Path, assignment: &Assignment) -> Result { + let provider = MicrosandboxProvider::open(home.join("microsandbox")).await?; + let Assignment::Materialized { id, .. } = assignment else { + return Err(Error::Invalid("forward target Sandbox is not materialized".into())); + }; + provider + .guest_tcp_dialer(id) + .await + .map(GuestDialer) + .map_err(Error::from) +} diff --git a/agentctl/src/sandbox/microsandbox/mod.rs b/agentctl/src/sandbox/microsandbox/mod.rs new file mode 100644 index 0000000..8c8c6a9 --- /dev/null +++ b/agentctl/src/sandbox/microsandbox/mod.rs @@ -0,0 +1,218 @@ +//! Microsandbox integration for the Agent layer. + +use std::{path::Path, rc::Rc}; + +use ::sandbox::{EnsureSandboxRequest, ErrorKind, LocalFuture, Platform, SandboxHandle, SandboxService, SandboxState}; +use sandbox_microsandbox::{MicrosandboxNetworkBackend, MicrosandboxProvider}; + +use crate::{Error, authorization::AgentPolicyEngine, control_plane::AgentRecord, persistence}; + +mod execution; +mod forward; +mod preparation; +mod terminal; + +pub(super) use execution::start_execution; +pub(super) use forward::guest_tcp_dialer; +pub use forward::{GuestConnection, GuestDialer}; +pub use terminal::attach_terminal; + +use preparation::Preparation; + +use super::{Provider, ProviderEnsureOutcome, ProviderId}; + +/// `RUST_LOG` directives that keep this Provider's runtime helper processes quiet at the default level. +pub const LOG_DIRECTIVES: &str = sandbox_microsandbox::LOG_DIRECTIVES; + +pub(super) const PROVIDER_ID: &str = "microsandbox"; + +/// How long `agentd` keeps an image no Agent uses after its last use, so an Agent deleted and +/// applied again, even after a weekend, does not download its image again. +const UNUSED_IMAGE_RETENTION: std::time::Duration = std::time::Duration::from_hours(72); + +/// How often an idle `agentd` removes unused images. +const UNUSED_IMAGE_SWEEP: std::time::Duration = std::time::Duration::from_hours(1); + +/// Removes unused images while `agentd` runs, so they go even when no Agent changes. +async fn remove_unused_images_periodically(provider: std::rc::Weak) { + let mut ticker = tokio::time::interval_at(tokio::time::Instant::now() + UNUSED_IMAGE_SWEEP, UNUSED_IMAGE_SWEEP); + loop { + ticker.tick().await; + let Some(provider) = provider.upgrade() else { + return; + }; + provider.remove_unused_images().await; + } +} + +/// Sandbox-resolvable name of the Microsandbox Network Backend's host alias. +/// +/// The Backend's DNS answers this name with the per-Sandbox gateway address +/// and rewrites gateway-bound connections to host loopback at dial time, so +/// this is how processes inside a Sandbox reach the Platform API endpoint. +pub const HOST_ALIAS: &str = "host.microsandbox.internal"; + +/// Runtime-selectable Agent adapter for a Microsandbox Provider. +pub struct Adapter { + id: ProviderId, + service: SandboxService, + preparation: Preparation, + default_architecture: String, + platform_port: u16, +} + +impl Adapter { + /// Opens one configured Microsandbox Provider and its mediated Network Backend. + /// + /// # Errors + /// + /// Returns an error when Provider state cannot be opened. + pub async fn open( + home: &Path, + database: persistence::Database, + secret_store: Rc, + policy: Rc, + platform_port: u16, + ) -> Result { + let network = Rc::new(MicrosandboxNetworkBackend::new(policy.clone()).with_secret_store(secret_store)); + let service = { + let provider = Rc::new( + MicrosandboxProvider::builder(home.join("microsandbox")) + .remove_unused_images_after(UNUSED_IMAGE_RETENTION) + .open() + .await?, + ); + tokio::task::spawn_local(remove_unused_images_periodically(Rc::downgrade(&provider))); + SandboxService::new(provider) + } + .with_network_backend(network.clone()); + policy.set_platform_endpoint(HOST_ALIAS, platform_port); + Ok(Self { + id: ProviderId::new(PROVIDER_ID)?, + service, + preparation: Preparation::new(database, policy, network), + default_architecture: Platform::native("linux").architecture, + platform_port, + }) + } + + /// Resolves a platform route to the URL reachable from this Provider's Sandboxes. + /// + /// # Errors + /// + /// Returns an error unless `path` is an absolute HTTP path. + pub fn platform_url(&self, path: &str) -> Result { + if !path.starts_with('/') || path.starts_with("//") { + return Err(Error::Invalid( + "platform endpoint path must start with exactly one '/'".into(), + )); + } + Ok(format!("http://{HOST_ALIAS}:{}{path}", self.platform_port)) + } + + fn sandbox_spec(&self, record: &AgentRecord) -> ::sandbox::SandboxSpec { + record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &self.default_architecture) + } + + fn sandbox_mounts(record: &AgentRecord) -> Vec<::sandbox::mount::Mount> { + record.agent.spec.sandbox.resolved_mounts() + } +} + +impl Provider for Adapter { + fn id(&self) -> &ProviderId { + &self.id + } + + fn supports<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result> { + Box::pin(async move { + match self.service.capabilities(&self.sandbox_spec(record).platform).await { + Ok(capabilities) => Ok(Self::sandbox_mounts(record) + .iter() + .all(|mount| capabilities.mount_kinds().contains(mount.kind()))), + Err(error) if error.kind() == ErrorKind::Unsupported => Ok(false), + Err(error) => Err(error.into()), + } + }) + } + + fn ensure<'a>( + &'a self, + record: &'a AgentRecord, + mut environment: std::collections::BTreeMap, + progress: ::sandbox::ProgressReporter, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + let running_before = record + .agent + .status + .sandbox + .as_ref() + .and_then(super::Assignment::id) + .is_some_and(|id| self.preparation.network_is_running(id)); + let prepared = self.preparation.prepare(record).await?; + let harnesses = prepared.harnesses; + for (name, value) in prepared.environment { + if environment.insert(name.clone(), value).is_some() { + return Err(Error::Invalid(format!( + "Sandbox environment variable {name:?} collides with a mediated secret" + ))); + } + } + let sandbox_name = record.sandbox_name()?; + // Ensure starts a stopped Sandbox, and restarts a running one to replace its environment. + let runtime_restarted = match self.service.inspect(&sandbox_name).await { + Ok(sandbox) => sandbox.state == SandboxState::Stopped || sandbox.environment != environment, + Err(error) if error.is_not_found() => false, + Err(error) => return Err(error.into()), + }; + let request = EnsureSandboxRequest::new(sandbox_name, self.sandbox_spec(record)) + .with_hostname(record.sandbox_hostname()?) + .with_mounts(Self::sandbox_mounts(record)) + .with_environment(environment); + let mut sandbox = self.service.ensure(&request).forward(&progress).await?; + if prepared.bindings_changed && running_before { + self.preparation.restart_network(&sandbox).await?; + // Re-ensure starts the stopped Network with the replacement handshake bindings. + sandbox = self.service.ensure(&request).forward(&progress).await?; + } + Ok(ProviderEnsureOutcome { + sandbox, + runtime_restarted, + harnesses, + }) + }) + } + + fn stop<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { self.service.stop(&record.sandbox_name()?).await.map_err(Error::from) }) + } + + fn open<'a>( + &'a self, + record: &'a AgentRecord, + id: &'a ::sandbox::SandboxId, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + self.service + .open(id, record.agent.spec.sandbox.resolved_retention_policy()) + .await + .map_err(Error::from) + }) + } + + fn release<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + let name = record.sandbox_name()?; + self.service + .release(&name, record.agent.spec.sandbox.resolved_retention_policy()) + .await?; + self.preparation.remove(&name); + Ok(()) + }) + } +} diff --git a/agentctl/src/sandbox/microsandbox/preparation.rs b/agentctl/src/sandbox/microsandbox/preparation.rs new file mode 100644 index 0000000..11b15d1 --- /dev/null +++ b/agentctl/src/sandbox/microsandbox/preparation.rs @@ -0,0 +1,204 @@ +//! Host mediation setup for the Microsandbox Network Backend. + +use std::{collections::BTreeMap, rc::Rc}; + +use crate::{Error, authorization::AgentPolicyEngine, control_plane, environment, harness, persistence}; +use ::sandbox::{SandboxHandle, SandboxId, SandboxName, network::NetworkBackend as _}; +use sandbox_microsandbox::{MicrosandboxNetworkBackend, SecretBinding}; + +/// Connects Agent policy and host-owned secrets to the Microsandbox Network Backend. +pub(super) struct Preparation { + database: persistence::Database, + policy: Rc, + network: Rc, +} + +pub(super) struct PreparedNetwork { + pub(super) bindings_changed: bool, + pub(super) environment: BTreeMap, + pub(super) harnesses: Vec, +} + +impl Preparation { + /// Creates the Agent-side Microsandbox mediation adapter. + #[must_use] + pub(super) const fn new( + database: persistence::Database, + policy: Rc, + network: Rc, + ) -> Self { + Self { + database, + policy, + network, + } + } +} + +impl Preparation { + pub(super) fn network_is_running(&self, sandbox: &SandboxId) -> bool { + self.network.is_running(sandbox) + } + + pub(super) async fn restart_network(&self, sandbox: &SandboxHandle) -> Result<(), Error> { + self.network.stop(sandbox.id()).await.map_err(Error::from) + } + + pub(super) async fn prepare(&self, record: &control_plane::AgentRecord) -> Result { + let sandbox_name = record.sandbox_name()?; + let result = async { + let secrets = &record.agent.spec.secrets; + let environment = if secrets.is_empty() { + BTreeMap::new() + } else if secrets.iter().all(|secret| secret.optional) { + environment::read_or_empty(&record.env_file_path()).await? + } else { + environment::read(&record.env_file_path()).await? + }; + let mut configured_secrets = Vec::with_capacity(secrets.len()); + let mut secret_writes = Vec::with_capacity(secrets.len()); + for secret in secrets { + let value = secret_value(&environment, secret)?; + let Some(value) = value else { + continue; + }; + configured_secrets.push(secret); + secret_writes.push(persistence::StoredSecret { + name: secret.environment.clone(), + value: zeroize::Zeroizing::new(value.as_bytes().to_vec()), + }); + } + let references = self.database.replace_agent_secrets(record.id, secret_writes).await?; + let mut bindings = Vec::with_capacity(configured_secrets.len() + 1); + for (secret, reference) in configured_secrets.into_iter().zip(references) { + let binding = SecretBinding::with_placeholder(&secret.environment, secret.inert_value(), reference)?; + bindings.push(binding); + } + let mut managed_secrets = Vec::new(); + let mut managed_environments = BTreeMap::new(); + let mut managed_placeholders = BTreeMap::new(); + let mut installed = Vec::with_capacity(record.agent.spec.harnesses.len()); + for installation in &record.agent.spec.harnesses { + // Re-evaluated every pass, so signing in later installs it with no manifest change. + if installation.optional && !harness::authentication_ready(installation.kind, &self.database).await? { + continue; + } + installed.push(installation.kind); + for secret in harness::prepare(installation.kind, &self.database).await? { + if let Some(existing) = managed_environments.insert(secret.environment, installation.kind.as_str()) + { + return Err(Error::Invalid(format!( + "harnesses {:?} and {:?} use the same managed environment {:?}", + existing, + installation.kind.as_str(), + secret.environment + ))); + } + if let Some(existing) = + managed_placeholders.insert(secret.placeholder.clone(), installation.kind.as_str()) + { + return Err(Error::Invalid(format!( + "harnesses {:?} and {:?} use the same managed placeholder {:?}", + existing, + installation.kind.as_str(), + secret.placeholder + ))); + } + managed_secrets.push(secret); + } + } + self.policy.set_agent( + &sandbox_name, + &record.agent, + managed_secrets + .iter() + .map(|secret| (secret.environment.into(), secret.allowed_hosts.clone())), + ); + for secret in managed_secrets { + bindings.push(SecretBinding::with_placeholder( + secret.environment, + &secret.placeholder, + secret.reference, + )?); + } + let guest_environment = bindings + .iter() + .map(|binding| { + let (name, value) = binding.guest_environment(); + (name.to_owned(), value.to_owned()) + }) + .collect(); + let bindings_changed = self.network.set_secret_bindings(sandbox_name.clone(), bindings)?; + Ok(PreparedNetwork { + bindings_changed, + environment: guest_environment, + harnesses: installed, + }) + } + .await; + if result.is_err() { + self.remove(&sandbox_name); + } + result + } + + pub(super) fn remove(&self, sandbox: &SandboxName) { + self.policy.remove_agent(sandbox); + self.network.remove_secret_bindings(sandbox); + } +} + +fn secret_value<'a>( + environment: &'a BTreeMap>, + secret: &crate::SecretSpec, +) -> Result, Error> { + if secret.optional { + Ok(environment::optional(environment, secret.source())) + } else { + Ok(Some(environment::required(environment, secret.source())?)) + } +} + +#[cfg(test)] +mod tests { + use super::secret_value; + use crate::SecretSpec; + use std::collections::BTreeMap; + use zeroize::Zeroizing; + + fn secret(optional: bool) -> SecretSpec { + SecretSpec { + environment: "API_TOKEN".into(), + optional, + placeholder: None, + allowed_hosts: vec!["example.com".into()], + source: None, + } + } + + #[test] + fn optional_secret_omits_missing_and_empty_values() -> Result<(), crate::Error> { + let mut environment = BTreeMap::new(); + assert_eq!(secret_value(&environment, &secret(true))?, None); + + environment.insert("API_TOKEN".into(), Zeroizing::new(String::new())); + assert_eq!(secret_value(&environment, &secret(true))?, None); + Ok(()) + } + + #[test] + fn optional_secret_selects_a_present_value() -> Result<(), crate::Error> { + let environment = BTreeMap::from([("API_TOKEN".into(), Zeroizing::new("token".into()))]); + + assert_eq!(secret_value(&environment, &secret(true))?, Some("token")); + Ok(()) + } + + #[test] + fn required_secret_still_rejects_a_missing_value() { + let environment = BTreeMap::new(); + let error = secret_value(&environment, &secret(false)); + + assert!(matches!(error, Err(crate::Error::Invalid(message)) if message.contains("API_TOKEN"))); + } +} diff --git a/agentctl/src/sandbox/microsandbox/terminal.rs b/agentctl/src/sandbox/microsandbox/terminal.rs new file mode 100644 index 0000000..4fbdec3 --- /dev/null +++ b/agentctl/src/sandbox/microsandbox/terminal.rs @@ -0,0 +1,30 @@ +//! Terminal attachment transport for the Microsandbox Backend. + +use sandbox::{ + provider::SandboxProvider as _, + terminal::{AttachTerminalRequest, TerminalAttachOutcome}, +}; +use sandbox_microsandbox::MicrosandboxProvider; + +use crate::{Error, sandbox::Assignment}; + +/// Attaches the caller's terminal to an Execution in a materialized Sandbox. +/// +/// # Errors +/// +/// Returns an error when the Microsandbox cannot be inspected or attached. +pub async fn attach_terminal( + home: &std::path::Path, + assignment: &Assignment, + request: AttachTerminalRequest, +) -> Result { + // TODO: Route attachment through the daemon once it can proxy an interactive terminal stream. + let provider = MicrosandboxProvider::open(home.join("microsandbox")).await?; + let Assignment::Materialized { id, .. } = assignment else { + return Err(Error::Invalid("Session target Sandbox is not materialized".into())); + }; + let sandbox = provider.backend().inspect(id).await?; + let outcome = provider.backend().attach_terminal(&sandbox.id, request).await?; + drop(provider); + Ok(outcome) +} diff --git a/agentctl/src/sandbox/mod.rs b/agentctl/src/sandbox/mod.rs new file mode 100644 index 0000000..40256a1 --- /dev/null +++ b/agentctl/src/sandbox/mod.rs @@ -0,0 +1,459 @@ +//! Runtime selection and lifecycle integration for Agent Sandboxes. + +use std::{collections::BTreeSet, path::Path, rc::Rc}; + +use ::sandbox::{LocalFuture, Platform, SandboxHandle, SandboxId}; +use serde::{Deserialize, Serialize}; + +use crate::{Error, control_plane::AgentRecord}; + +mod execution; +pub mod forward; +pub mod microsandbox; +pub mod platform; +pub mod responsiveness; + +pub use execution::{ExecutionService, ExecutionTarget, start_execution}; +pub use microsandbox::{GuestConnection, GuestDialer}; +pub use responsiveness::UNRESPONSIVE_AFTER; + +/// What watching a guest's heartbeat found. +enum Heartbeat { + Stalled, + Advanced, +} + +/// Stable identity of one configured Sandbox Provider. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(try_from = "String", into = "String")] +pub struct ProviderId(String); + +impl ProviderId { + /// Creates a validated Provider identity. + /// + /// # Errors + /// + /// Returns an error when the identity is empty or not a portable identifier. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() + || !value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err(Error::Invalid( + "Sandbox Provider ID must contain lowercase ASCII letters, digits, or '-'".into(), + )); + } + Ok(Self(value)) + } + + /// Returns the identity as text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom for ProviderId { + type Error = Error; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(value: ProviderId) -> Self { + value.0 + } +} + +impl std::fmt::Display for ProviderId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +/// Sticky runtime assignment for one Agent's Sandbox. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase", tag = "state")] +pub enum Assignment { + /// A Provider has been durably selected before external effects begin. + Selected { + /// Configured Provider identity. + provider: ProviderId, + }, + /// The selected Provider has materialized the Sandbox. + Materialized { + /// Configured Provider identity. + provider: ProviderId, + /// Provider-owned Sandbox identity. + id: SandboxId, + /// Harness installations convergence put in this Sandbox. An optional installation whose + /// host login was absent is missing here, and is installed by a later pass once it exists. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + harnesses: Vec, + }, +} + +impl Assignment { + /// Returns the sticky Provider selection. + #[must_use] + pub const fn provider(&self) -> &ProviderId { + match self { + Self::Selected { provider } | Self::Materialized { provider, .. } => provider, + } + } + + /// Returns the materialized Sandbox identity when available. + #[must_use] + pub const fn id(&self) -> Option<&SandboxId> { + match self { + Self::Selected { .. } => None, + Self::Materialized { id, .. } => Some(id), + } + } + + /// Returns the harnesses installed in the materialized Sandbox, when it exists. + #[must_use] + pub fn installed_harnesses(&self) -> Option<&[crate::Harness]> { + match self { + Self::Selected { .. } => None, + Self::Materialized { harnesses, .. } => Some(harnesses), + } + } +} + +/// One runtime-selectable implementation of Agent Sandbox lifecycle effects. +pub trait Provider { + /// Returns the configured Provider identity. + fn id(&self) -> &ProviderId; + + /// Reports whether this Provider can satisfy the Agent requirements. + fn supports<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result>; + + /// Idempotently ensures the Sandbox and its Provider-specific host integration. + fn ensure<'a>( + &'a self, + record: &'a AgentRecord, + environment: std::collections::BTreeMap, + progress: ::sandbox::ProgressReporter, + ) -> LocalFuture<'a, Result>; + + /// Idempotently stops the Sandbox and its Provider-specific host integration, + /// keeping both for a later [`Self::ensure`]. + fn stop<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>>; + + /// Opens the exact already-materialized Sandbox without lifecycle effects. + fn open<'a>(&'a self, record: &'a AgentRecord, id: &'a SandboxId) -> LocalFuture<'a, Result>; + + /// Idempotently releases the Sandbox and its Provider-specific host integration. + fn release<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>>; +} + +/// Provider result retaining lifecycle information needed by dependent Sessions. +pub struct ProviderEnsureOutcome { + pub sandbox: SandboxHandle, + /// The pass started the Sandbox's runtime, so harness processes from before are gone. + pub runtime_restarted: bool, + /// Harness installations this pass prepared. + pub harnesses: Vec, +} + +/// Materialized Sandbox and relevant lifecycle transition. +pub struct EnsureOutcome { + pub id: SandboxId, + pub runtime_restarted: bool, + /// The running Sandbox, for Agent-level setup that follows platform setup. + pub sandbox: SandboxHandle, + /// Harness installations this pass prepared. + pub harnesses: Vec, +} + +/// Runtime-selectable setup for an operating system reported by a materialized Sandbox. +pub trait PlatformAdapter { + /// Reports whether this adapter supports the resolved Sandbox platform. + fn supports(&self, platform: &Platform) -> bool; + + /// Idempotently applies Agent and harness setup inside the Sandbox, + /// reporting its steps through `steps`. + fn setup<'a>( + &'a self, + record: &'a AgentRecord, + sandbox: &'a SandboxHandle, + harnesses: &'a [crate::Harness], + steps: &'a ::sandbox::SandboxProgress, + ) -> LocalFuture<'a, Result<(), Error>>; +} + +/// Resolves Agent requirements against configured Providers and dispatches lifecycle effects. +pub struct Service { + providers: Vec>, + platforms: Vec>, + responsiveness: responsiveness::Tracker, +} + +impl Service { + /// Creates a runtime Provider registry. + /// + /// # Errors + /// + /// Returns an error when no Providers are configured or an identity is duplicated. + pub fn new( + providers: impl IntoIterator>, + platforms: impl IntoIterator>, + ) -> Result { + let mut identities = BTreeSet::new(); + let mut configured = Vec::new(); + for provider in providers { + if !identities.insert(provider.id().clone()) { + return Err(Error::Invalid("duplicate Sandbox Provider identity".into())); + } + configured.push(provider); + } + if configured.is_empty() { + return Err(Error::Invalid("at least one Sandbox Provider is required".into())); + } + let platforms = platforms.into_iter().collect::>(); + if platforms.is_empty() { + return Err(Error::Invalid( + "at least one Sandbox platform adapter is required".into(), + )); + } + Ok(Self { + providers: configured, + platforms, + responsiveness: responsiveness::Tracker::default(), + }) + } + + /// Selects the first configured Provider that supports the Agent requirements. + /// + /// # Errors + /// + /// Returns an error when capability discovery fails or no Provider supports the Agent. + pub async fn resolve(&self, record: &AgentRecord) -> Result { + for provider in &self.providers { + if provider.supports(record).await? { + return Ok(provider.id().clone()); + } + } + Err(Error::Invalid(format!( + "no configured Sandbox Provider supports platform {:?}", + record.agent.spec.sandbox.platform + ))) + } + + /// Runs the selected Provider and resolved Sandbox-platform setup idempotently. + /// + /// # Errors + /// + /// Returns an error when the assignment is missing, its Provider is unavailable, or setup fails. + pub async fn ensure( + &self, + record: &AgentRecord, + progress: ::sandbox::ProgressReporter, + ) -> Result { + let provider = self.assigned_provider(record)?; + let environment = crate::environment::resolve(record).await?; + let outcome = provider.ensure(record, environment, progress.clone()).await?; + let sandbox = outcome.sandbox; + let resolved_platform = &sandbox.snapshot().image.platform; + let adapter = self + .platforms + .iter() + .find(|adapter| adapter.supports(resolved_platform)) + .ok_or_else(|| { + Error::Invalid(format!( + "no Agent setup adapter supports resolved Sandbox platform {resolved_platform:?}" + )) + })?; + // The snapshot is fresh from this pass, which may have booted the + // guest again, so a heartbeat recorded before it no longer applies. + self.responsiveness + .observe(sandbox.snapshot(), tokio::time::Instant::now()); + let phase = progress.start_phase(crate::progress::SETUP).await; + self.guard_guest( + record, + &sandbox.snapshot().id, + adapter.setup(record, &sandbox, &outcome.harnesses, &progress.steps()), + ) + .await?; + phase.complete().await; + Ok(EnsureOutcome { + id: sandbox.snapshot().id.clone(), + runtime_restarted: outcome.runtime_restarted, + sandbox, + harnesses: outcome.harnesses, + }) + } + + /// Stops the Agent's Sandbox, keeping its identity and storage for a later + /// [`Self::ensure`]. An Agent without an assigned Provider has nothing to stop. + /// + /// # Errors + /// + /// Returns an error when the selected Provider is unavailable or the stop fails. + pub async fn stop(&self, record: &AgentRecord) -> Result<(), Error> { + let Some(assignment) = &record.agent.status.sandbox else { + return Ok(()); + }; + self.provider(assignment.provider())?.stop(record).await?; + if let Some(id) = assignment.id() { + self.responsiveness.forget(id); + } + Ok(()) + } + + /// Opens the persisted materialized Sandbox without lifecycle or setup effects. + /// + /// # Errors + /// + /// Returns an error unless the assignment is materialized through a configured Provider. + pub async fn open(&self, record: &AgentRecord) -> Result { + let Some(Assignment::Materialized { provider, id, .. }) = &record.agent.status.sandbox else { + return Err(Error::Invalid("Agent has no materialized Sandbox assignment".into())); + }; + self.provider(provider)?.open(record, id).await + } + + /// Whether the Sandbox's guest reported a heartbeat when last inspected. + #[must_use] + pub fn reports_heartbeat(&self, sandbox: &SandboxId) -> bool { + self.responsiveness.heartbeat(sandbox).is_some() + } + + /// Runs work that reaches into a Sandbox's guest, inspecting the Sandbox + /// every [`responsiveness::OBSERVATION_INTERVAL`] without a round trip to + /// the guest, and ends the work once the guest has stalled. + /// + /// A guest already known to be stalled is not reached at all. When the work + /// fails, such as a command timing out inside a guest that just stalled, + /// the heartbeat decides whether the failure is the stall: an advancing + /// heartbeat keeps the failure. Dropping the work closes its guest + /// connections. + /// + /// # Errors + /// + /// Returns [`Error::SandboxUnresponsive`] when the guest stalls, and + /// otherwise the work's error. + pub async fn guard_guest( + &self, + record: &AgentRecord, + sandbox: &SandboxId, + work: impl Future>, + ) -> Result { + if self.responsiveness.stalled(sandbox, tokio::time::Instant::now()) { + return Err(responsiveness::stalled()); + } + let provider = self.assigned_provider(record)?; + let result = tokio::select! { + biased; + _stalled = self.watch_heartbeat(provider, record, sandbox, None) => return Err(responsiveness::stalled()), + result = work => result, + }; + let current = self.responsiveness.heartbeat(sandbox); + match result { + Err(error) if current.is_some() => match self.watch_heartbeat(provider, record, sandbox, current).await { + Heartbeat::Stalled => Err(responsiveness::stalled()), + Heartbeat::Advanced => Err(error), + }, + result => result, + } + } + + /// Inspects the Sandbox until its guest has stalled, or, given `from`, + /// until its heartbeat moves past `from` or is no longer reported. + async fn watch_heartbeat( + &self, + provider: &dyn Provider, + record: &AgentRecord, + sandbox: &SandboxId, + from: Option<::sandbox::GuestHeartbeat>, + ) -> Heartbeat { + let interval = responsiveness::OBSERVATION_INTERVAL; + let mut ticker = tokio::time::interval_at(tokio::time::Instant::now() + interval, interval); + loop { + ticker.tick().await; + // A failed inspection is retried at the next tick. + if let Ok(inspected) = provider.open(record, sandbox).await { + self.responsiveness + .observe(inspected.snapshot(), tokio::time::Instant::now()); + } + if self.responsiveness.stalled(sandbox, tokio::time::Instant::now()) { + return Heartbeat::Stalled; + } + if from.is_some() && self.responsiveness.heartbeat(sandbox) != from { + return Heartbeat::Advanced; + } + } + } + + /// Releases the selected Provider idempotently. An unassigned Agent has no effect to release. + /// + /// # Errors + /// + /// Returns an error when the selected Provider is unavailable or release fails. + pub async fn release(&self, record: &AgentRecord) -> Result<(), Error> { + let Some(assignment) = &record.agent.status.sandbox else { + return Ok(()); + }; + self.provider(assignment.provider())?.release(record).await?; + if let Some(id) = assignment.id() { + self.responsiveness.forget(id); + } + Ok(()) + } + + fn assigned_provider(&self, record: &AgentRecord) -> Result<&dyn Provider, Error> { + let assignment = record + .agent + .status + .sandbox + .as_ref() + .ok_or_else(|| Error::Invalid("Agent has no Sandbox Provider assignment".into()))?; + self.provider(assignment.provider()) + } + + fn provider(&self, id: &ProviderId) -> Result<&dyn Provider, Error> { + self.providers + .iter() + .find(|provider| provider.id() == id) + .map(Rc::as_ref) + .ok_or_else(|| Error::Invalid(format!("assigned Sandbox Provider {id:?} is not configured"))) + } +} + +/// Connects a guest TCP dialer through the recorded Sandbox Provider. +/// +/// # Errors +/// +/// Returns an error when the Provider is unsupported by this client or the +/// Sandbox is not running. +pub async fn guest_tcp_dialer(home: &Path, assignment: &Assignment) -> Result { + match assignment.provider().as_str() { + microsandbox::PROVIDER_ID => microsandbox::guest_tcp_dialer(home, assignment).await, + provider => Err(Error::Invalid(format!( + "guest TCP forwarding is not supported through Sandbox Provider {provider:?}" + ))), + } +} + +/// Attaches a terminal through the recorded Sandbox Provider. +/// +/// # Errors +/// +/// Returns an error when the Provider is unsupported by this client or attachment fails. +pub async fn attach_terminal( + home: &Path, + assignment: &Assignment, + request: ::sandbox::terminal::AttachTerminalRequest, +) -> Result<::sandbox::terminal::TerminalAttachOutcome, Error> { + match assignment.provider().as_str() { + microsandbox::PROVIDER_ID => microsandbox::attach_terminal(home, assignment, request).await, + provider => Err(Error::Invalid(format!( + "terminal attachment is not supported through Sandbox Provider {provider:?}" + ))), + } +} diff --git a/agentctl/src/sandbox/platform/files.rs b/agentctl/src/sandbox/platform/files.rs new file mode 100644 index 0000000..79c7e38 --- /dev/null +++ b/agentctl/src/sandbox/platform/files.rs @@ -0,0 +1,130 @@ +//! Convergent file placement for Agent setup. +//! +//! Setup reruns on every reconciliation pass, so a file the Agent's harness watches must not be +//! rewritten unless its contents changed: every write is a new inode the harness reloads. The +//! Sandbox SDK makes each write atomic; this module additionally skips writes that would not +//! change anything. +//! +//! The files compared here are writable by the Sandbox user, so every read is bounded: a file +//! grown or redirected to something unbounded counts as changed and is replaced. + +use std::io::Cursor; + +use ::sandbox::{SandboxHandle, SandboxPath}; +use tokio::io::AsyncReadExt as _; + +use crate::Error; + +/// Reads up to `limit` bytes of a Sandbox file, or `None` when it cannot be read or is longer. +/// +/// Any read failure counts as absent: the following write reports the real problem if the +/// file is genuinely inaccessible. +pub(crate) async fn read_existing(sandbox: &SandboxHandle, path: &str, limit: usize) -> Option> { + let reader = sandbox.read_file(&SandboxPath::new(path)).await.ok()?; + let mut contents = Vec::new(); + let bound = u64::try_from(limit).ok()?.checked_add(1)?; + reader.take(bound).read_to_end(&mut contents).await.ok()?; + (contents.len() <= limit).then_some(contents) +} + +/// Writes `contents` to `path` unless the file already holds exactly those bytes. +/// +/// Returns whether the file was written. +/// +/// # Errors +/// +/// Returns an error when the Sandbox cannot write the file. +pub(crate) async fn write_if_changed(sandbox: &SandboxHandle, path: &str, contents: &[u8]) -> Result { + if read_existing(sandbox, path, contents.len()).await.as_deref() == Some(contents) { + return Ok(false); + } + sandbox + .write_file(&SandboxPath::new(path), Box::pin(Cursor::new(contents.to_vec()))) + .await?; + Ok(true) +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use ::sandbox::{ + EnsureSandboxRequest, Platform, RootFilesystem, SandboxHandle, SandboxName, SandboxPath, SandboxResources, + SandboxService, SandboxSpec, image::ImageSource, memory, + }; + + use super::{read_existing, write_if_changed}; + + async fn sandbox() -> (Rc, SandboxHandle) { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend.clone()); + let spec = SandboxSpec { + image: ImageSource::Build { + context: std::path::PathBuf::from("."), + dockerfile: std::path::PathBuf::from("Dockerfile"), + target: None, + }, + platform: Platform::native("linux"), + resources: SandboxResources::new( + "1".parse().expect("CPU"), + "512Mi".parse().expect("memory"), + RootFilesystem::layered("1Gi".parse().expect("root filesystem")), + ), + init_system: ::sandbox::init::InitSystem::Backend, + retention_policy: ::sandbox::RetentionPolicy::Retain, + }; + let sandbox = service + .ensure(&EnsureSandboxRequest::new( + SandboxName::new("files").expect("Sandbox name"), + spec, + )) + .await + .expect("Sandbox"); + (backend, sandbox) + } + + #[tokio::test(flavor = "local")] + async fn identical_contents_are_not_rewritten() { + let (backend, sandbox) = sandbox().await; + assert!( + write_if_changed(&sandbox, "/etc/agent.conf", b"one") + .await + .expect("write") + ); + assert!( + !write_if_changed(&sandbox, "/etc/agent.conf", b"one") + .await + .expect("compare") + ); + assert!( + write_if_changed(&sandbox, "/etc/agent.conf", b"two") + .await + .expect("rewrite") + ); + assert_eq!(backend.file_writes().len(), 2); + } + + #[tokio::test(flavor = "local")] + async fn reads_stop_at_the_bound() { + let (_, sandbox) = sandbox().await; + sandbox + .write_file( + &SandboxPath::new("/etc/large"), + Box::pin(std::io::Cursor::new(vec![b'x'; 1024])), + ) + .await + .expect("write"); + assert_eq!( + read_existing(&sandbox, "/etc/large", 1024).await, + Some(vec![b'x'; 1024]) + ); + assert_eq!(read_existing(&sandbox, "/etc/large", 1023).await, None); + assert_eq!(read_existing(&sandbox, "/etc/missing", 1024).await, None); + // A longer file than the desired contents is different without reading it all. + assert!( + write_if_changed(&sandbox, "/etc/large", b"short") + .await + .expect("rewrite") + ); + } +} diff --git a/agentctl/src/sandbox/platform/linux.rs b/agentctl/src/sandbox/platform/linux.rs new file mode 100644 index 0000000..f7d51a2 --- /dev/null +++ b/agentctl/src/sandbox/platform/linux.rs @@ -0,0 +1,625 @@ +//! Idempotent Agent setup for Linux Sandboxes. + +use std::{io::Cursor, path::Path}; + +use ::sandbox::{LocalFuture, Platform, SandboxHandle, SandboxPath, execution::ExecutionSpec}; +use ignore::WalkBuilder; + +use crate::{Error, control_plane, harness}; + +use super::{super::PlatformAdapter, files::write_if_changed}; + +/// The platform-owned Sandbox user every Session, Execution and SSH login runs as. +pub(crate) const USER: &str = "agent"; +pub(crate) const HOME: &str = "/home/agent"; +pub(crate) const WORKING_DIRECTORY: &str = "/home/agent/code"; +pub(crate) const CONTAINER_HOST: &str = "unix:///run/podman/podman.sock"; +const HOME_ARCHIVE: &str = "/tmp/agent-home.tar"; +/// Locale every Sandbox process runs with; the image ships it, so UTF-8 output +/// renders regardless of the host's locale. +pub(crate) const UTF8_LOCALE: &str = "C.UTF-8"; +/// Terminal type every Sandbox terminal runs with. Host-specific TERM names are +/// not necessarily installed in the Sandbox image; this baseline is. +pub(crate) const PORTABLE_TERMINAL: &str = "xterm-256color"; +const PODMAN: &str = "/usr/bin/podman"; +const SETUP_STDERR_LINES: usize = 3; +const SYSTEMD_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(90); +const SYSTEMD_READY_POLL: std::time::Duration = std::time::Duration::from_secs(1); +/// `systemctl`, the only supported guest service manager today. +pub(super) const SYSTEMCTL: &str = "/usr/bin/systemctl"; +/// Present exactly when systemd is the running init; the marker systemd documents for this purpose. +pub(super) const SYSTEMD_RUNNING: &str = "/run/systemd/system"; +// Podman reads these files when it creates containers. The default mount also +// reaches Buildah RUN containers and exposes the guest's superset bundle at a +// path no distro package owns. Distro trust paths are populated by an OCI hook +// that copies the bundle into the container root filesystem: a bind mount +// there would make the file a mount point, and package managers replacing the +// bundle (`apt-get install ca-certificates`) then fail with EBUSY. The hook +// also drops the mediator CA as an anchor into the distro's source directory +// so a regenerated bundle keeps trusting mediation. This is fail-open +// convenience; mediated networking remains the enforcement boundary if a +// workload bypasses the configuration. +const PODMAN_CONTAINERS_CONF: &str = "/etc/containers/containers.conf.d/50-agent-ca.conf"; +const PODMAN_RUNTIME_CONF: &str = "/etc/containers/containers.conf.d/51-agent-runtime.conf"; +const PODMAN_MOUNTS_CONF: &str = "/etc/containers/mounts.conf"; +const PODMAN_REGISTRIES_CONF: &str = "/etc/containers/registries.conf.d/50-agent-docker-hub.conf"; +const PODMAN_SOCKET_DROP_IN: &str = "/etc/systemd/system/podman.socket.d/50-agent-access.conf"; +const PODMAN_HOOKS_DIR: &str = "/etc/containers/oci/hooks.d"; +const PODMAN_CA_HOOK_CONF: &str = "/etc/containers/oci/hooks.d/50-agent-ca.json"; +const PODMAN_CA_HOOK: &str = "/usr/local/libexec/agent-container-ca"; +const PODMAN_CONTAINERS_CONF_CONTENTS: &[u8] = br#"[containers] +env = [ + "SSL_CERT_FILE=/run/agent/tls/ca-bundle.pem", + "CURL_CA_BUNDLE=/run/agent/tls/ca-bundle.pem", + "REQUESTS_CA_BUNDLE=/run/agent/tls/ca-bundle.pem", + "NODE_EXTRA_CA_CERTS=/run/agent/tls/ca-bundle.pem", + "GIT_SSL_CAINFO=/run/agent/tls/ca-bundle.pem", + "NPM_CONFIG_CAFILE=/run/agent/tls/ca-bundle.pem", +] +"#; +// The minimal systemd guest has no D-Bus system bus. Podman's default systemd +// cgroup manager therefore made crun fail with `cannot open sd-bus`; cgroupfs +// keeps ownership inside Podman instead of relying on unavailable systemd APIs. +// Sandbox teardown owns final cleanup, rather than systemd tracking these +// container cgroups as units. +// The compatibility API must apply Docker's implicit docker.io resolution as +// well; it does not consult registries.conf for that behavior. +// Implicit hook directories are deprecated, so the directory is named explicitly. +const PODMAN_RUNTIME_CONF_CONTENTS: &[u8] = b"[engine]\ncgroup_manager = \"cgroupfs\"\ncompat_api_enforce_docker_hub = true\nhooks_dir = [\"/etc/containers/oci/hooks.d\"]\n"; +const PODMAN_MOUNTS_CONF_CONTENTS: &[u8] = b"/etc/ssl/certs/ca-certificates.crt:/run/agent/tls/ca-bundle.pem\n"; +const PODMAN_CA_HOOK_CONF_CONTENTS: &[u8] = br#"{"version":"1.0.0","hook":{"path":"/usr/local/libexec/agent-container-ca"},"when":{"always":true},"stages":["createRuntime"]} +"#; +// Runs as an OCI `createRuntime` hook with the container state on stdin and +// the root filesystem mounted. It must not depend on tools the guest image may +// lack, so it is POSIX sh plus sed. Failures are swallowed: trust wiring is a +// convenience and must never stop a container from starting. +const PODMAN_CA_HOOK_CONTENTS: &[u8] = br#"#!/bin/sh +# Installed by agentd. Copies the mediated CA bundle into distro trust paths of a +# starting container and adds the mediator CA as an anchor for bundle regeneration. +set -u +bundle_dir=$(cat | tr -d '\n' | sed -n 's/.*"bundle"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') +[ -n "$bundle_dir" ] && [ -f "$bundle_dir/config.json" ] || exit 0 +rootfs=$(tr -d '\n' <"$bundle_dir/config.json" \ + | sed -n 's/.*"root"[[:space:]]*:[[:space:]]*{[^}]*"path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') +[ -n "$rootfs" ] || exit 0 +case "$rootfs" in /*) ;; *) rootfs="$bundle_dir/$rootfs" ;; esac +[ -d "$rootfs" ] || exit 0 +bundle=/etc/ssl/certs/ca-certificates.crt +anchor=/.msb/tls/ca.pem +[ -f "$bundle" ] || exit 0 +install_copy() { + rm -f "$2" 2>/dev/null + cp "$1" "$2" 2>/dev/null && chmod 0644 "$2" 2>/dev/null +} +for target in etc/ssl/certs/ca-certificates.crt etc/pki/tls/certs/ca-bundle.crt etc/ssl/cert.pem; do + directory="$rootfs/${target%/*}" + [ -d "$directory" ] || continue + if [ "$target" = etc/ssl/certs/ca-certificates.crt ] || [ -e "$rootfs/$target" ] || [ -L "$rootfs/$target" ]; then + install_copy "$bundle" "$rootfs/$target" + fi +done +if [ -f "$anchor" ]; then + if [ -d "$rootfs/usr/local/share" ]; then + mkdir -p "$rootfs/usr/local/share/ca-certificates" 2>/dev/null \ + && install_copy "$anchor" "$rootfs/usr/local/share/ca-certificates/agent-mediator.crt" + fi + if [ -d "$rootfs/etc/pki/ca-trust/source/anchors" ]; then + install_copy "$anchor" "$rootfs/etc/pki/ca-trust/source/anchors/agent-mediator.crt" + fi +fi +exit 0 +"#; +// One search registry is deterministic in enforcing mode and reproduces +// Docker's implicit docker.io[/library] normalization without alias upkeep. +const PODMAN_REGISTRIES_CONF_CONTENTS: &[u8] = + b"unqualified-search-registries = [\"docker.io\"]\nshort-name-mode = \"enforcing\"\n"; +const PODMAN_SOCKET_DROP_IN_CONTENTS: &[u8] = b"[Socket]\nDirectoryMode=0755\nSocketGroup=agent\nSocketMode=0660\n"; + +/// Agent setup for Linux Sandboxes. +pub struct Linux; + +pub(super) fn execution_spec(command: &[String], terminal: bool) -> Result { + let (executable, arguments) = command + .split_first() + .ok_or_else(|| Error::Invalid("command is required".into()))?; + let mut environment = vec![ + ("HOME".into(), HOME.into()), + ("LANG".into(), UTF8_LOCALE.into()), + ("CONTAINER_HOST".into(), CONTAINER_HOST.into()), + ]; + if terminal { + // Host-specific TERM names are not necessarily installed in the guest. + environment.push(("TERM".into(), PORTABLE_TERMINAL.into())); + } + Ok( + ExecutionSpec::command(SandboxPath::new(executable), arguments.iter().cloned()) + .with_working_directory(SandboxPath::new(WORKING_DIRECTORY)) + .with_environment(environment), + ) +} + +impl PlatformAdapter for Linux { + fn supports(&self, platform: &Platform) -> bool { + platform.os == "linux" + } + + fn setup<'a>( + &'a self, + record: &'a control_plane::AgentRecord, + sandbox: &'a SandboxHandle, + harnesses: &'a [crate::Harness], + steps: &'a ::sandbox::SandboxProgress, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(self.setup(record, sandbox, harnesses, steps)) + } +} + +impl Linux { + /// Sets up only the harnesses preparation reported installing, so setup and preparation + /// cannot disagree about an optional installation whose host login was absent. + async fn setup( + &self, + record: &control_plane::AgentRecord, + sandbox: &SandboxHandle, + harnesses: &[crate::Harness], + steps: &::sandbox::SandboxProgress, + ) -> Result<(), Error> { + let installations: Vec<&crate::HarnessSpec> = record + .agent + .spec + .harnesses + .iter() + .filter(|installation| harnesses.contains(&installation.kind)) + .collect(); + for installation in &installations { + let step = steps.start_step(format!("Verify {}", installation.kind.as_str())).await; + harness::verify_linux(installation.kind, sandbox, installation.version.as_deref()).await?; + step.complete().await; + } + let step = steps.start_step("Prepare workspace and Podman").await; + run_checked(sandbox, "/usr/bin/install", ["-d", "-m", "0755", WORKING_DIRECTORY]).await?; + configure_podman(sandbox).await?; + step.complete().await; + let step = steps.start_step("Sync home").await; + let archive = archive_home(record.source_directory.clone(), record.agent.spec.home.source.clone()).await?; + sync_home(sandbox, archive).await?; + configure_git_identity(sandbox).await?; + step.complete().await; + let instructions = read_instructions(record).await?; + let skills = read_skills(record).await?; + for installation in &installations { + let step = steps + .start_step(format!( + "Install {} instructions and Skills", + installation.kind.as_str() + )) + .await; + harness::bootstrap_linux(installation.kind, sandbox, HOME, instructions.as_deref(), &skills).await?; + step.complete().await; + } + Ok(()) + } +} + +async fn configure_git_identity(sandbox: &SandboxHandle) -> Result<(), Error> { + let environment = &sandbox.snapshot().environment; + let (Some(name), Some(email)) = (environment.get("GIT_USER_NAME"), environment.get("GIT_USER_EMAIL")) else { + if environment.contains_key("GIT_USER_NAME") || environment.contains_key("GIT_USER_EMAIL") { + return Err(Error::Invalid( + "GIT_USER_NAME and GIT_USER_EMAIL must both be configured".into(), + )); + } + return Ok(()); + }; + let present = sandbox + .run_execution(ExecutionSpec::command( + SandboxPath::new("/usr/bin/env"), + ["git".into(), "--version".into()], + )) + .await?; + match present.status.code { + 127 => return Ok(()), + 0 => {} + code => { + return Err(Error::SandboxSetup(format!( + "Git presence check exited with code {code}" + ))); + } + } + run_git_config(sandbox, "user.name", name).await?; + run_git_config(sandbox, "user.email", email).await +} + +async fn run_git_config(sandbox: &SandboxHandle, key: &str, value: &str) -> Result<(), Error> { + let args = ["git", "config", "--global", key, value]; + let output = sandbox + .run_execution( + ExecutionSpec::command(SandboxPath::new("/usr/bin/env"), args.into_iter().map(str::to_owned)) + .with_environment([("HOME".into(), HOME.into())]), + ) + .await?; + checked_output("/usr/bin/env", &args, &output) +} + +async fn configure_podman(sandbox: &SandboxHandle) -> Result<(), Error> { + let present = sandbox + .run_execution(ExecutionSpec::command( + SandboxPath::new("/usr/bin/test"), + ["-x".into(), PODMAN.into()], + )) + .await?; + match present.status.code { + 1 => return Ok(()), + 0 => {} + code => { + return Err(Error::SandboxSetup(format!( + "Podman presence check exited with code {code}" + ))); + } + } + + wait_for_systemd(sandbox).await?; + run_checked( + sandbox, + "/usr/bin/sudo", + [ + "-n", + "/usr/bin/install", + "-d", + "-m", + "0755", + "/etc/containers/containers.conf.d", + "/etc/containers/registries.conf.d", + "/etc/systemd/system/podman.socket.d", + PODMAN_HOOKS_DIR, + "/usr/local/libexec", + ], + ) + .await?; + write_if_changed(sandbox, PODMAN_CONTAINERS_CONF, PODMAN_CONTAINERS_CONF_CONTENTS).await?; + write_if_changed(sandbox, PODMAN_RUNTIME_CONF, PODMAN_RUNTIME_CONF_CONTENTS).await?; + write_if_changed(sandbox, PODMAN_MOUNTS_CONF, PODMAN_MOUNTS_CONF_CONTENTS).await?; + write_if_changed(sandbox, PODMAN_REGISTRIES_CONF, PODMAN_REGISTRIES_CONF_CONTENTS).await?; + write_if_changed(sandbox, PODMAN_SOCKET_DROP_IN, PODMAN_SOCKET_DROP_IN_CONTENTS).await?; + write_if_changed(sandbox, PODMAN_CA_HOOK_CONF, PODMAN_CA_HOOK_CONF_CONTENTS).await?; + write_if_changed(sandbox, PODMAN_CA_HOOK, PODMAN_CA_HOOK_CONTENTS).await?; + run_checked(sandbox, "/usr/bin/sudo", ["-n", "/bin/chmod", "0755", PODMAN_CA_HOOK]).await?; + run_checked( + sandbox, + "/usr/bin/sudo", + ["-n", "/usr/bin/install", "-d", "-m", "0755", "/run/podman"], + ) + .await?; + run_checked(sandbox, "/usr/bin/sudo", ["-n", "/usr/bin/systemctl", "daemon-reload"]).await?; + // An already-listening socket retains its old mode until the next Sandbox + // boot; the compile-time drop-in is not changed independently at runtime. + run_checked( + sandbox, + "/usr/bin/sudo", + ["-n", "/usr/bin/systemctl", "enable", "--now", "podman.socket"], + ) + .await +} + +/// Waits until systemd is PID 1 and has finished booting the guest. +/// +/// Sandbox setup starts as soon as the Sandbox accepts Executions, which on an +/// image-init guest is before the image entrypoint has become systemd; `systemctl` +/// then reports "System has not been booted with systemd". `is-system-running +/// --wait` blocks until startup finishes once systemd is up. It runs as root +/// because the guest has no D-Bus system bus and only root reaches systemd's +/// private socket. A `degraded` system counts as ready: a failed optional unit, +/// such as a best-effort workspace clone, must not block the Podman configuration. +pub(super) async fn wait_for_systemd(sandbox: &SandboxHandle) -> Result<(), Error> { + let deadline = tokio::time::Instant::now() + SYSTEMD_READY_TIMEOUT; + loop { + // `--wait` blocks for as long as boot takes, so the deadline bounds the wait itself + // and a still-running check is killed rather than left behind in the guest. + let started = sandbox + .start_execution(::sandbox::execution::StartExecutionRequest::new( + ExecutionSpec::command( + SandboxPath::new("/usr/bin/sudo"), + ["-n", "/usr/bin/systemctl", "is-system-running", "--wait"].map(str::to_owned), + ), + )) + .await?; + let execution_id = started.id.clone(); + let output = match tokio::time::timeout_at(deadline, started.collect()).await { + Ok(output) => output?, + Err(_elapsed) => { + let _ = sandbox.kill_execution(&execution_id).await; + return Err(Error::SandboxSetup(format!( + "systemd did not finish booting within {}s", + SYSTEMD_READY_TIMEOUT.as_secs() + ))); + } + }; + let state = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + if output.status.success() || matches!(state.as_str(), "running" | "degraded") { + return Ok(()); + } + if tokio::time::Instant::now() >= deadline { + let last = if state.is_empty() { + String::from_utf8_lossy(&output.stderr).trim().to_owned() + } else { + state + }; + return Err(Error::SandboxSetup(format!( + "systemd did not become ready within {}s: {last}", + SYSTEMD_READY_TIMEOUT.as_secs() + ))); + } + tokio::time::sleep(SYSTEMD_READY_POLL).await; + } +} + +/// Concatenates the instruction files in manifest order, each terminated by a newline and +/// separated by a blank line, so independent documents read as sections of one file. +async fn read_instructions(record: &control_plane::AgentRecord) -> Result>, Error> { + let mut combined = Vec::new(); + for (index, spec) in record.agent.spec.instructions.iter().enumerate() { + let source = if spec.source.is_absolute() { + spec.source.clone() + } else { + record.source_directory.join(&spec.source) + }; + let metadata = tokio::fs::metadata(&source).await?; + if !metadata.is_file() { + return Err(Error::Invalid(format!( + "spec.instructions[{index}].source must identify a file" + ))); + } + let contents = tokio::fs::read(source).await?; + if !combined.is_empty() { + combined.push(b'\n'); + } + combined.extend_from_slice(contents.strip_suffix(b"\n").unwrap_or(&contents)); + combined.push(b'\n'); + } + Ok((!combined.is_empty()).then_some(combined)) +} + +async fn read_skills(record: &control_plane::AgentRecord) -> Result, Error> { + let mut skills = Vec::with_capacity(record.agent.spec.skills.len()); + for (index, spec) in record.agent.spec.skills.iter().enumerate() { + let field = format!("spec.skills[{index}].source"); + let name = spec + .name() + .ok_or_else(|| Error::Invalid(format!("{field} must end in the skill's directory name")))?; + let source = resolve_source(&record.source_directory, &spec.source, &field)?; + if !source.join("SKILL.md").is_file() { + return Err(Error::Invalid(format!("{field} must contain SKILL.md"))); + } + let files = tokio::task::spawn_blocking(move || read_skill_files(&source, &field)) + .await + .map_err(|error| Error::Daemon(format!("Agent skill scan task failed: {error}")))??; + skills.push(harness::Skill { + name: name.to_owned(), + files, + }); + } + Ok(skills) +} + +fn read_skill_files(source: &Path, field: &str) -> Result, Error> { + let mut files = Vec::new(); + walk_source(source, field, |relative, path, is_dir| { + if is_dir { + return Ok(()); + } + let relative_path = relative + .components() + .map(|component| component.as_os_str().to_str()) + .collect::>>() + .ok_or_else(|| Error::Invalid(format!("{field} contains a non-UTF-8 file name")))? + .join("/"); + files.push(harness::SkillFile { + relative_path, + contents: std::fs::read(path)?, + }); + Ok(()) + })?; + Ok(files) +} + +async fn archive_home(manifest_directory: std::path::PathBuf, source: std::path::PathBuf) -> Result, Error> { + let source = resolve_source(&manifest_directory, &source, "spec.home.source")?; + archive_directory(source, "spec.home.source".to_owned()).await +} + +/// Archives a resolved host directory on a blocking thread; `field` names the manifest field in errors. +async fn archive_directory(source: std::path::PathBuf, field: String) -> Result, Error> { + let task = format!("Agent {field} scan task failed"); + tokio::task::spawn_blocking(move || archive_directory_blocking(&source, &field)) + .await + .map_err(|error| Error::Daemon(format!("{task}: {error}")))? +} + +fn archive_directory_blocking(source: &Path, field: &str) -> Result, Error> { + let mut archive = tar::Builder::new(Vec::new()); + walk_source(source, field, |relative, path, is_dir| { + if is_dir { + archive.append_dir(relative, path)?; + } else { + archive.append_path_with_name(path, relative)?; + } + Ok(()) + })?; + archive.into_inner().map_err(Error::from) +} + +/// Visits every entry below `source` with its relative path, rejecting symbolic links. +fn walk_source( + source: &Path, + field: &str, + mut visit: impl FnMut(&Path, &Path, bool) -> Result<(), Error>, +) -> Result<(), Error> { + for result in WalkBuilder::new(source) + .hidden(false) + .ignore(false) + .git_ignore(false) + .git_exclude(false) + .parents(false) + .follow_links(false) + .build() + { + let entry = result.map_err(|error| Error::Invalid(format!("cannot traverse {field}: {error}")))?; + let relative = entry + .path() + .strip_prefix(source) + .map_err(|_| Error::Invalid(format!("{field} traversal escaped its root")))?; + if relative.as_os_str().is_empty() { + continue; + } + // Only directories and regular files are carried into the Sandbox. A symbolic link would + // point at host content outside the source, and reading a FIFO or device would block setup. + let kind = entry + .file_type() + .ok_or_else(|| Error::Invalid(format!("{field} entry {} has no file type", relative.display())))?; + if kind.is_symlink() { + return Err(Error::Invalid(format!( + "{field} contains unsupported symbolic link {}", + relative.display() + ))); + } + if !kind.is_dir() && !kind.is_file() { + return Err(Error::Invalid(format!( + "{field} contains unsupported non-regular file {}", + relative.display() + ))); + } + visit(relative, entry.path(), kind.is_dir())?; + } + Ok(()) +} + +async fn sync_home(sandbox: &SandboxHandle, archive: Vec) -> Result<(), Error> { + sandbox + .write_file(&SandboxPath::new(HOME_ARCHIVE), Box::pin(Cursor::new(archive))) + .await?; + run_checked(sandbox, "/usr/bin/tar", ["-xf", HOME_ARCHIVE, "-C", HOME]).await +} + +/// Runs one setup command in the Sandbox and fails with what it ran and what it printed. +/// +/// # Errors +/// +/// Returns an error when the Execution cannot start or exits unsuccessfully. +pub(crate) async fn run_checked>( + sandbox: &SandboxHandle, + executable: &str, + args: impl IntoIterator, +) -> Result<(), Error> { + let args: Vec = args.into_iter().map(|arg| arg.as_ref().to_owned()).collect(); + let output = sandbox + .run_execution(ExecutionSpec::command(SandboxPath::new(executable), args.clone())) + .await?; + checked_output(executable, &args, &output) +} + +/// Runs `test ` in the guest: whether the path exists in the tested form. +/// +/// # Errors +/// +/// Returns an error when the Execution cannot start or `test` fails for any +/// reason other than the path being absent. +pub(super) async fn path_exists(sandbox: &SandboxHandle, test: &str, path: &str) -> Result { + let output = sandbox + .run_execution(ExecutionSpec::command( + SandboxPath::new("/usr/bin/test"), + [test.to_owned(), path.to_owned()], + )) + .await?; + match output.status.code { + 0 => Ok(true), + 1 => Ok(false), + code => Err(Error::SandboxSetup(format!( + "presence check `test {test} {path}` exited with code {code}" + ))), + } +} + +/// Whether the guest has `systemctl` and runs systemd as its init. +/// +/// # Errors +/// +/// Returns an error when either presence check cannot run. +pub(super) async fn systemd_available(sandbox: &SandboxHandle) -> Result { + Ok(path_exists(sandbox, "-x", SYSTEMCTL).await? && path_exists(sandbox, "-d", SYSTEMD_RUNNING).await?) +} + +fn checked_output( + executable: &str, + args: &[impl AsRef], + output: &::sandbox::execution::ExecutionOutput, +) -> Result<(), Error> { + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = stderr.trim(); + let detail = if stderr.is_empty() { + String::new() + } else { + let tail = stderr + .lines() + .rev() + .take(SETUP_STDERR_LINES) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join(" | "); + format!(": {tail}") + }; + Err(Error::SandboxSetup(format!( + "command `{executable} {}` exited with code {}{detail}", + args.iter().map(AsRef::as_ref).collect::>().join(" "), + output.status.code + ))) +} + +fn resolve_source(manifest_directory: &Path, source: &Path, field: &str) -> Result { + let source = if source.is_absolute() { + source.to_path_buf() + } else { + manifest_directory.join(source) + }; + let source = std::fs::canonicalize(source)?; + if !source.is_dir() { + return Err(Error::Invalid(format!("{field} must identify a directory"))); + } + Ok(source) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use ::sandbox::execution::Program; + + #[test] + fn transient_execution_uses_agent_home_and_portable_terminal() { + let command = ["bash".to_owned(), "-l".to_owned()]; + let spec = super::execution_spec(&command, true).expect("Linux Execution spec"); + assert_eq!( + spec.working_directory().map(::sandbox::SandboxPath::as_str), + Some("/home/agent/code") + ); + assert_eq!(spec.environment().get("HOME").map(String::as_str), Some("/home/agent")); + assert_eq!(spec.environment().get("LANG").map(String::as_str), Some("C.UTF-8")); + assert_eq!( + spec.environment().get("CONTAINER_HOST").map(String::as_str), + Some("unix:///run/podman/podman.sock") + ); + assert_eq!( + spec.environment().get("TERM").map(String::as_str), + Some("xterm-256color") + ); + assert!(matches!( + spec.program(), + Program::Command { executable, args } + if executable.as_str() == "bash" && args == &["-l"] + )); + } +} diff --git a/agentctl/src/sandbox/platform/linux_ssh.rs b/agentctl/src/sandbox/platform/linux_ssh.rs new file mode 100644 index 0000000..d7e572f --- /dev/null +++ b/agentctl/src/sandbox/platform/linux_ssh.rs @@ -0,0 +1,516 @@ +//! OpenSSH server state written into Linux Sandboxes for SSH access. +//! +//! The image provides OpenSSH, systemd and the platform user. This module owns +//! the complete server policy, unit, keys, client authorization and SSH login +//! environment, or removes all of it when access is withdrawn. Server state +//! lives under `/var/lib/agent/ssh`; the login environment uses OpenSSH's +//! standard per-user file in `/home/agent/.ssh`. + +use std::collections::BTreeMap; + +use ::sandbox::{SandboxHandle, SandboxPath, execution::ExecutionSpec}; + +use crate::{Error, ssh::GuestMaterial}; + +use super::{ + files::write_if_changed, + linux::{SYSTEMCTL, SYSTEMD_RUNNING, path_exists, run_checked, systemd_available, wait_for_systemd}, +}; + +/// Directory holding every server file `agentd` writes. +pub(crate) const STATE_DIRECTORY: &str = "/var/lib/agent/ssh"; +/// Host private key; also the unit's `ConditionPathExists`. +pub(crate) const HOST_KEY: &str = "/var/lib/agent/ssh/ssh_host_ed25519_key"; +/// Host public key, kept beside the private key as OpenSSH expects. +pub(crate) const HOST_KEY_PUBLIC: &str = "/var/lib/agent/ssh/ssh_host_ed25519_key.pub"; +/// Keys allowed to log in as the guest user. +pub(crate) const AUTHORIZED_KEYS: &str = "/var/lib/agent/ssh/authorized_keys"; +/// Platform-owned policy passed to every server invocation. +pub(crate) const SERVER_CONFIG: &str = "/var/lib/agent/ssh/sshd_config"; +/// OpenSSH privilege-separation directory, needed even when only evaluating policy. +const SERVER_RUNTIME_DIRECTORY: &str = "/run/sshd"; +/// Directory OpenSSH reads the Agent user's login environment from. +const USER_SSH_DIRECTORY: &str = "/home/agent/.ssh"; +/// Effective Sandbox environment inherited by every new SSH shell or command. +pub(crate) const USER_ENVIRONMENT: &str = "/home/agent/.ssh/environment"; +/// The server executable the image must provide. +pub(crate) const SERVER: &str = "/usr/sbin/sshd"; +/// The platform-owned unit running the server on the guest loopback. +pub(crate) const UNIT: &str = "agent-ssh.service"; +/// Where the platform installs the unit. +pub(crate) const UNIT_FILE: &str = "/etc/systemd/system/agent-ssh.service"; +const ENVIRONMENT_PROGRAM: &str = "/usr/bin/env"; +const MAX_ENVIRONMENT_ENTRIES: usize = 1000; + +/// Confirms the image has the server and systemd runtime needed by the +/// platform-owned configuration and unit. +/// +/// systemd is one init system among several a Sandbox could boot; nothing here +/// assumes it beyond checking for it, and an image without it cannot run the +/// platform-managed unit. +/// +/// # Errors +/// +/// Returns `Error::Invalid` naming the missing piece: the image is immutable for +/// the incarnation, so retrying cannot change that. +pub(crate) async fn verify_server(sandbox: &SandboxHandle) -> Result<(), Error> { + let contract = [ + ("-x", SERVER, "/usr/sbin/sshd is missing"), + ("-x", SYSTEMCTL, "systemctl is missing"), + ( + "-d", + SYSTEMD_RUNNING, + "systemd is not the running init, and the unit needs it", + ), + ]; + for (test, path, what) in contract { + if !path_exists(sandbox, test, path).await? { + return Err(Error::Invalid(crate::ssh::image_contract_missing(what))); + } + } + Ok(()) +} + +/// Writes the host key and `authorized_keys`, then enables and starts the server. +/// +/// Idempotent: unchanged files are not rewritten and a running server is only +/// restarted when its host key, policy or unit changes. +/// +/// # Errors +/// +/// Returns an error when a file cannot be written or a setup command fails. +pub(crate) async fn install_server_state(sandbox: &SandboxHandle, material: &GuestMaterial) -> Result<(), Error> { + wait_for_systemd(sandbox).await?; + run_checked( + sandbox, + "/usr/bin/sudo", + [ + "-n", + "/usr/bin/install", + "-d", + "-m", + "0755", + "-o", + "root", + "-g", + "root", + STATE_DIRECTORY, + // systemd normally creates this for the service, but `sshd -T` + // needs it before the service can be validated or started. + SERVER_RUNTIME_DIRECTORY, + ], + ) + .await?; + let host_key_changed = write_if_changed(sandbox, HOST_KEY, &material.host_private_key).await?; + write_if_changed( + sandbox, + HOST_KEY_PUBLIC, + format!("{}\n", material.host_public_key).as_bytes(), + ) + .await?; + let config_changed = write_if_changed(sandbox, SERVER_CONFIG, render_server_config().as_bytes()).await?; + let unit_changed = write_if_changed(sandbox, UNIT_FILE, render_unit().as_bytes()).await?; + run_checked( + sandbox, + "/usr/bin/sudo", + [ + "-n", + "/bin/chown", + "root:root", + HOST_KEY, + HOST_KEY_PUBLIC, + SERVER_CONFIG, + UNIT_FILE, + ], + ) + .await?; + run_checked(sandbox, "/usr/bin/sudo", ["-n", "/bin/chmod", "0600", HOST_KEY]).await?; + run_checked( + sandbox, + "/usr/bin/sudo", + ["-n", "/bin/chmod", "0644", HOST_KEY_PUBLIC, SERVER_CONFIG, UNIT_FILE], + ) + .await?; + verify_effective_policy(sandbox).await?; + + let environment = capture_login_environment(sandbox).await?; + run_checked( + sandbox, + "/usr/bin/sudo", + [ + "-n", + "/usr/bin/install", + "-d", + "-m", + "0700", + "-o", + super::linux::USER, + "-g", + super::linux::USER, + USER_SSH_DIRECTORY, + ], + ) + .await?; + write_if_changed(sandbox, AUTHORIZED_KEYS, material.authorized_keys.as_bytes()).await?; + write_if_changed(sandbox, USER_ENVIRONMENT, &environment).await?; + // StrictModes requires authorized_keys and its directory to be owned by + // root or the user and writable by no one else. + run_checked( + sandbox, + "/usr/bin/sudo", + ["-n", "/bin/chown", "root:root", AUTHORIZED_KEYS, USER_ENVIRONMENT], + ) + .await?; + run_checked( + sandbox, + "/usr/bin/sudo", + ["-n", "/bin/chmod", "0644", AUTHORIZED_KEYS, USER_ENVIRONMENT], + ) + .await?; + // A prior pass can fail after writing the unit but before systemd reloads + // it, so convergence cannot depend only on whether this pass changed it. + run_checked(sandbox, "/usr/bin/sudo", ["-n", SYSTEMCTL, "daemon-reload"]).await?; + run_checked(sandbox, "/usr/bin/sudo", ["-n", SYSTEMCTL, "enable", UNIT]).await?; + let action = if host_key_changed || config_changed || unit_changed { + "restart" + } else { + "start" + }; + run_checked(sandbox, "/usr/bin/sudo", ["-n", SYSTEMCTL, action, UNIT]).await +} + +fn render_server_config() -> String { + format!( + "# Managed by agentd; changes are replaced during reconciliation.\n\ + AddressFamily inet\n\ + ListenAddress 127.0.0.1\n\ + Port {}\n\ + \n\ + HostKey {HOST_KEY}\n\ + AuthorizedKeysFile {AUTHORIZED_KEYS}\n\ + PidFile /run/agent-sshd.pid\n\ + \n\ + AllowUsers {}\n\ + PubkeyAuthentication yes\n\ + PasswordAuthentication no\n\ + KbdInteractiveAuthentication no\n\ + PermitEmptyPasswords no\n\ + PermitRootLogin no\n\ + StrictModes yes\n\ + UsePAM no\n\ + PermitUserEnvironment yes\n\ + \n\ + AllowAgentForwarding no\n\ + AllowTcpForwarding yes\n\ + GatewayPorts no\n\ + X11Forwarding no\n\ + PermitTunnel no\n\ + \n\ + AcceptEnv LANG LC_*\n\ + PrintMotd no\n\ + LogLevel INFO\n\ + Subsystem sftp internal-sftp\n", + crate::ssh::GUEST_PORT, + super::linux::USER + ) +} + +fn render_unit() -> String { + format!( + "[Unit]\n\ + Description=OpenSSH server for Agent access on the guest loopback\n\ + Documentation=https://github.com/digdir/digdir-agents/tree/main/agentctl\n\ + ConditionPathExists={HOST_KEY}\n\ + After=network.target\n\ + \n\ + [Service]\n\ + RuntimeDirectory=sshd\n\ + RuntimeDirectoryMode=0755\n\ + ExecStartPre={SERVER} -t -f {SERVER_CONFIG}\n\ + ExecStart={SERVER} -D -e -f {SERVER_CONFIG}\n\ + ExecReload=/bin/kill -HUP $MAINPID\n\ + Restart=on-failure\n\ + RestartPreventExitStatus=255\n\ + \n\ + [Install]\n\ + WantedBy=multi-user.target\n" + ) +} + +/// Asks OpenSSH for the policy it will apply to the Agent's loopback connection. +/// +/// The actual host key is installed before this check because `sshd -T` refuses +/// to evaluate a configuration without at least one readable host key. Login +/// state and the service are installed only after the effective policy passes. +async fn verify_effective_policy(sandbox: &SandboxHandle) -> Result<(), Error> { + let connection = format!( + "user={},host=localhost,addr=127.0.0.1,laddr=127.0.0.1,lport={}", + super::linux::USER, + crate::ssh::GUEST_PORT + ); + let args = [ + "-n".to_owned(), + SERVER.to_owned(), + "-T".to_owned(), + "-f".to_owned(), + SERVER_CONFIG.to_owned(), + "-C".to_owned(), + connection, + ]; + let output = sandbox + .run_execution(ExecutionSpec::command(SandboxPath::new("/usr/bin/sudo"), args)) + .await?; + if !output.status.success() { + return Err(Error::Invalid(crate::ssh::image_contract_missing(&format!( + "{SERVER} could not evaluate {SERVER_CONFIG}: {}", + String::from_utf8_lossy(&output.stderr).trim() + )))); + } + + let policy = String::from_utf8_lossy(&output.stdout); + for (name, value, directive) in [ + ("permituserenvironment", "yes", "PermitUserEnvironment yes"), + ("usepam", "no", "UsePAM no"), + ] { + let effective = policy.lines().find_map(|line| { + let (key, value) = line.trim().split_once(char::is_whitespace)?; + key.eq_ignore_ascii_case(name).then_some(value.trim()) + }); + if effective != Some(value) { + return Err(Error::Invalid(crate::ssh::image_contract_missing(&format!( + "the platform-owned SSH policy did not effectively set {directive:?}" + )))); + } + } + Ok(()) +} + +/// Stops and disables the server and removes its state, when any exists. +/// +/// The state is removed only after the server is confirmed stopped, so a +/// failed stop is retried on the next pass instead of leaving a running server +/// behind an empty directory. Without systemd as the running init nothing +/// could have started the unit, so only the files are removed. +/// +/// # Errors +/// +/// Returns an error when the state cannot be inspected, the server cannot be +/// stopped, or the state cannot be removed. +pub(crate) async fn remove_server_state(sandbox: &SandboxHandle) -> Result<(), Error> { + let state_exists = path_exists(sandbox, "-e", STATE_DIRECTORY).await?; + let environment_exists = path_exists(sandbox, "-e", USER_ENVIRONMENT).await?; + if !state_exists && !environment_exists { + return Ok(()); + } + if state_exists && systemd_available(sandbox).await? { + wait_for_systemd(sandbox).await?; + let args = ["-n", SYSTEMCTL, "disable", "--now", UNIT]; + let output = sandbox + .run_execution(ExecutionSpec::command( + SandboxPath::new("/usr/bin/sudo"), + args.map(str::to_owned), + )) + .await?; + // A failed setup may not have loaded the unit yet; every other failure + // means the server may still be running. + if !output.status.success() && !unit_is_missing(&output) { + return Err(Error::SandboxSetup(format!( + "command `/usr/bin/sudo {}` exited with code {}: {}", + args.join(" "), + output.status.code, + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + } + if state_exists { + run_checked(sandbox, "/usr/bin/sudo", ["-n", "/bin/rm", "-f", UNIT_FILE]).await?; + run_checked(sandbox, "/usr/bin/sudo", ["-n", "/bin/rm", "-rf", STATE_DIRECTORY]).await?; + if systemd_available(sandbox).await? { + run_checked(sandbox, "/usr/bin/sudo", ["-n", SYSTEMCTL, "daemon-reload"]).await?; + } + } + if environment_exists { + run_checked(sandbox, "/usr/bin/sudo", ["-n", "/bin/rm", "-f", USER_ENVIRONMENT]).await?; + } + Ok(()) +} + +/// Captures the environment an ordinary Sandbox Execution inherits and renders +/// it in OpenSSH's `~/.ssh/environment` format. SSH supplies identity and +/// terminal variables itself; platform defaults fill the only values added by +/// `agentctl exec` and Session launch rather than the Sandbox runtime. +async fn capture_login_environment(sandbox: &SandboxHandle) -> Result, Error> { + let output = sandbox + .run_execution(ExecutionSpec::command( + SandboxPath::new(ENVIRONMENT_PROGRAM), + ["-0".into()], + )) + .await?; + if !output.status.success() { + return Err(Error::SandboxSetup(format!( + "command `{ENVIRONMENT_PROGRAM} -0` exited with code {}: {}", + output.status.code, + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + let mut environment = parse_environment(&output.stdout)?; + environment.insert("LANG".into(), super::linux::UTF8_LOCALE.into()); + environment.insert("CONTAINER_HOST".into(), super::linux::CONTAINER_HOST.into()); + render_environment(&environment) +} + +fn parse_environment(bytes: &[u8]) -> Result, Error> { + let mut environment = BTreeMap::new(); + for entry in bytes.split(|byte| *byte == 0).filter(|entry| !entry.is_empty()) { + let text = std::str::from_utf8(entry) + .map_err(|_| Error::Invalid("the Sandbox environment contains a non-UTF-8 value".into()))?; + let (name, value) = text + .split_once('=') + .ok_or_else(|| Error::Invalid(format!("the Sandbox environment contains an invalid entry {text:?}")))?; + if !portable_name(name) { + return Err(Error::Invalid(format!( + "the Sandbox environment contains an invalid variable name {name:?}" + ))); + } + if !ssh_supplies(name) { + environment.insert(name.into(), value.into()); + } + } + Ok(environment) +} + +fn render_environment(environment: &BTreeMap) -> Result, Error> { + if environment.len() > MAX_ENVIRONMENT_ENTRIES { + return Err(Error::Invalid(format!( + "the Sandbox environment has {} entries; OpenSSH accepts at most {MAX_ENVIRONMENT_ENTRIES}", + environment.len() + ))); + } + let mut rendered = String::new(); + for (name, value) in environment { + if value.contains(['\n', '\r']) { + return Err(Error::Invalid(format!( + "Sandbox environment variable {name:?} contains a line break that OpenSSH cannot represent" + ))); + } + rendered.push_str(name); + rendered.push('='); + rendered.push_str(value); + rendered.push('\n'); + } + Ok(rendered.into_bytes()) +} + +fn portable_name(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .enumerate() + .all(|(index, byte)| byte == b'_' || byte.is_ascii_alphabetic() || (index > 0 && byte.is_ascii_digit())) +} + +fn ssh_supplies(name: &str) -> bool { + matches!( + name, + "HOME" | "LOGNAME" | "PWD" | "SHELL" | "SHLVL" | "TERM" | "USER" | "_" + ) || name.starts_with("SSH_") + || name.starts_with("AGENT_SESSION_") +} + +/// Recognizes systemd's report that a unit file does not exist. +fn unit_is_missing(output: &::sandbox::execution::ExecutionOutput) -> bool { + let stderr = String::from_utf8_lossy(&output.stderr); + stderr.contains("does not exist") || stderr.contains("not found") || stderr.contains("No such file") +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use std::collections::BTreeMap; + + fn directives(text: &str) -> BTreeMap<&str, Vec<&str>> { + let mut directives = BTreeMap::<&str, Vec<&str>>::new(); + for line in text.lines().map(str::trim) { + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once(char::is_whitespace) else { + continue; + }; + directives.entry(key).or_default().push(value.trim()); + } + directives + } + + #[test] + fn platform_owned_server_policy_is_complete_and_not_extensible() { + let config = super::render_server_config(); + let directives = directives(&config); + let single = |key: &str| { + let values = directives.get(key).unwrap_or_else(|| panic!("{key} is set")); + assert_eq!(values.len(), 1, "{key} is set once"); + values[0] + }; + + assert_eq!(single("ListenAddress"), "127.0.0.1"); + assert_eq!(single("Port"), crate::ssh::GUEST_PORT.to_string()); + assert_eq!(single("HostKey"), super::HOST_KEY); + assert_eq!(single("AuthorizedKeysFile"), super::AUTHORIZED_KEYS); + assert_eq!(single("AllowUsers"), super::super::linux::USER); + assert_eq!(single("PubkeyAuthentication"), "yes"); + assert_eq!(single("PasswordAuthentication"), "no"); + assert_eq!(single("KbdInteractiveAuthentication"), "no"); + assert_eq!(single("PermitEmptyPasswords"), "no"); + assert_eq!(single("PermitRootLogin"), "no"); + assert_eq!(single("UsePAM"), "no"); + assert_eq!(single("PermitUserEnvironment"), "yes"); + assert_eq!(single("AllowAgentForwarding"), "no"); + assert_eq!(single("AllowTcpForwarding"), "yes"); + assert_eq!(single("GatewayPorts"), "no"); + assert_eq!(single("X11Forwarding"), "no"); + assert_eq!(single("PermitTunnel"), "no"); + assert_eq!(single("Subsystem"), "sftp internal-sftp"); + assert!(!directives.contains_key("Include")); + } + + #[test] + fn platform_owned_unit_runs_only_the_platform_policy() { + let unit = super::render_unit(); + assert!(unit.contains(&format!("ConditionPathExists={}", super::HOST_KEY))); + assert!(unit.contains(&format!( + "ExecStart={} -D -e -f {}", + super::SERVER, + super::SERVER_CONFIG + ))); + assert!(unit.contains("RuntimeDirectory=sshd")); + assert!(unit.contains("WantedBy=multi-user.target")); + } + + #[test] + fn ssh_environment_preserves_values_and_excludes_process_local_state() { + let parsed = super::parse_environment( + b"PATH=/usr/local/bin:/usr/bin\0GIT_USER_NAME=Agent #1 \"reviewer\"\0EMPTY=\0TERM=dumb\0HOME=/image-home\0AGENT_SESSION_ID=session\0", + ) + .expect("environment"); + assert_eq!( + parsed, + BTreeMap::from([ + ("EMPTY".into(), String::new()), + ("GIT_USER_NAME".into(), "Agent #1 \"reviewer\"".into()), + ("PATH".into(), "/usr/local/bin:/usr/bin".into()), + ]) + ); + assert_eq!( + super::render_environment(&parsed).expect("rendered"), + b"EMPTY=\nGIT_USER_NAME=Agent #1 \"reviewer\"\nPATH=/usr/local/bin:/usr/bin\n" + ); + } + + #[test] + fn ssh_environment_rejects_values_openssh_cannot_represent() { + let environment = BTreeMap::from([("MULTILINE".into(), "one\ntwo".into())]); + let error = super::render_environment(&environment).expect_err("line break"); + assert!(error.to_string().contains("line break"), "{error}"); + } +} diff --git a/agentctl/src/sandbox/platform/linux_vnc.rs b/agentctl/src/sandbox/platform/linux_vnc.rs new file mode 100644 index 0000000..0c41be3 --- /dev/null +++ b/agentctl/src/sandbox/platform/linux_vnc.rs @@ -0,0 +1,349 @@ +//! VNC access granted and withdrawn in Linux Sandboxes: the units the image lists in +//! `/etc/agent-access.d/vnc.conf` are enabled or disabled, then the result is checked. A grant +//! waits for the declared ports to listen; a withdrawal checks that the units are inactive. The +//! ports themselves are not checked on withdrawal, because the Agent may use them. + +use ::sandbox::{SandboxHandle, SandboxPath, execution::ExecutionSpec}; + +use crate::Error; + +use super::linux::{SYSTEMCTL, SYSTEMD_RUNNING, path_exists, run_checked, systemd_available, wait_for_systemd}; + +/// The image contract: what this image provides for `access: [{type: vnc}]`. +pub(crate) const DESCRIPTOR: &str = "/etc/agent-access.d/vnc.conf"; +/// Reads listening sockets, so a grant can be asserted rather than assumed. +const SS: &str = "/usr/bin/ss"; +/// A descriptor larger than this is not the one the contract describes. +const MAX_DESCRIPTOR_BYTES: usize = 64 * 1024; +/// More units than any one access capability has any business owning. +const MAX_UNITS: usize = 8; +/// How long an enabled unit may take to start listening. A viewer is an ordinary service that +/// systemd reports started once it has forked, before it has bound its port. +const LISTEN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const LISTEN_POLL: std::time::Duration = std::time::Duration::from_millis(100); + +/// What the image declares it provides. +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct Capability { + /// Units the platform enables to grant access and disables to withdraw it. + pub(crate) units: Vec, + /// Guest loopback port carrying the RFB stream. + pub(crate) port: u16, + /// Guest loopback port serving the browser viewer; an image may offer the RFB port alone. + pub(crate) web_port: Option, +} + +impl Capability { + /// The ports the image promised, which are what a grant is checked against. + fn ports(&self) -> Vec { + std::iter::once(self.port).chain(self.web_port).collect() + } +} + +/// Confirms the image declares VNC access and returns what it declares. A missing piece is +/// `Error::Invalid`, since retrying cannot change the image. +async fn verify_capability(sandbox: &SandboxHandle) -> Result { + let contract = [ + ("-x", SYSTEMCTL, "systemctl is missing"), + ( + "-d", + SYSTEMD_RUNNING, + "systemd is not the running init, and the access units need it", + ), + ("-x", SS, "ss is missing, so a grant could not be verified"), + ("-f", DESCRIPTOR, "/etc/agent-access.d/vnc.conf is missing"), + ]; + for (test, path, what) in contract { + if !path_exists(sandbox, test, path).await? { + return Err(Error::Invalid(crate::vnc::image_contract_missing(what))); + } + } + parse_descriptor(&read_descriptor(sandbox).await?) +} + +/// Checks the image, enables its access units and waits for the declared ports to listen. +/// Idempotent: `enable --now` leaves a running unit alone. +/// +/// # Errors +/// +/// Returns an error when the image declares no VNC access, a unit cannot be enabled, or a +/// declared port is not listening within [`LISTEN_TIMEOUT`]. +pub(crate) async fn grant(sandbox: &SandboxHandle) -> Result { + let capability = verify_capability(sandbox).await?; + systemctl(sandbox, "enable", &capability).await?; + let deadline = tokio::time::Instant::now() + LISTEN_TIMEOUT; + for port in capability.ports() { + while !port_is_listening(sandbox, port).await? { + if tokio::time::Instant::now() >= deadline { + return Err(Error::SandboxSetup(format!( + "the image's VNC access units were enabled but nothing is listening on guest port {port} \ + after {}s", + LISTEN_TIMEOUT.as_secs() + ))); + } + tokio::time::sleep(LISTEN_POLL).await; + } + } + Ok(capability) +} + +/// Disables the image's access units, then checks that systemd reports them inactive. +/// +/// # Errors +/// +/// Returns an error when the units cannot be disabled, or when one is still active afterwards. +pub(crate) async fn withdraw(sandbox: &SandboxHandle) -> Result<(), Error> { + if !systemd_available(sandbox).await? || !path_exists(sandbox, "-f", DESCRIPTOR).await? { + // An image that declares no VNC access never had any units to turn off. + return Ok(()); + } + let capability = parse_descriptor(&read_descriptor(sandbox).await?)?; + systemctl(sandbox, "disable", &capability).await?; + let still_active = active_units(sandbox, &capability).await?; + if still_active.is_empty() { + return Ok(()); + } + Err(Error::SandboxSetup(format!( + "VNC access was withdrawn but {} still active", + still_active.join(", ") + ))) +} + +/// Returns the image's access units that systemd does not report inactive. +async fn active_units(sandbox: &SandboxHandle, capability: &Capability) -> Result, Error> { + let arguments = ["-n", SYSTEMCTL, "is-active"] + .into_iter() + .chain(capability.units.iter().map(String::as_str)) + .map(ToOwned::to_owned) + .collect::>(); + // `is-active` exits non-zero whenever a unit is not active, so its exit status says nothing + // here; the one state line it prints per unit, in argument order, is the answer. + let output = sandbox + .run_execution(ExecutionSpec::command(SandboxPath::new("/usr/bin/sudo"), arguments)) + .await?; + let stdout = String::from_utf8_lossy(&output.stdout); + let states: Vec<&str> = stdout.lines().map(str::trim).collect(); + if states.len() != capability.units.len() { + return Err(Error::SandboxSetup(format!( + "`systemctl is-active` reported {} states for {} units: {}", + states.len(), + capability.units.len(), + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + Ok(capability + .units + .iter() + .zip(states) + .filter(|(_, state)| !matches!(*state, "inactive" | "failed")) + .map(|(unit, state)| format!("{unit} is {state}")) + .collect()) +} + +/// Runs `systemctl --now` on the image's access units once systemd has booted. +async fn systemctl(sandbox: &SandboxHandle, action: &str, capability: &Capability) -> Result<(), Error> { + wait_for_systemd(sandbox).await?; + let arguments = ["-n", SYSTEMCTL, action, "--now"] + .into_iter() + .chain(capability.units.iter().map(String::as_str)); + run_checked(sandbox, "/usr/bin/sudo", arguments).await +} + +async fn read_descriptor(sandbox: &SandboxHandle) -> Result { + let output = sandbox + .run_execution(ExecutionSpec::command( + SandboxPath::new("/bin/cat"), + [DESCRIPTOR.to_owned()], + )) + .await?; + if !output.status.success() { + return Err(Error::Invalid(crate::vnc::image_contract_missing(&format!( + "{DESCRIPTOR} could not be read: {}", + String::from_utf8_lossy(&output.stderr).trim() + )))); + } + if output.stdout.len() > MAX_DESCRIPTOR_BYTES { + return Err(Error::Invalid(crate::vnc::image_contract_missing(&format!( + "{DESCRIPTOR} is larger than {MAX_DESCRIPTOR_BYTES} bytes" + )))); + } + String::from_utf8(output.stdout.to_vec()).map_err(|_| { + Error::Invalid(crate::vnc::image_contract_missing(&format!( + "{DESCRIPTOR} is not UTF-8" + ))) + }) +} + +fn setting<'a>(descriptor: &'a str, key: &str) -> Option<&'a str> { + descriptor + .lines() + .map(str::trim) + .filter(|line| !line.starts_with('#')) + .find_map(|line| { + let (name, value) = line.split_once('=')?; + (name.trim() == key).then(|| value.trim()) + }) +} + +fn missing(key: &str) -> Error { + Error::Invalid(crate::vnc::image_contract_missing(&format!( + "{DESCRIPTOR} does not set {key}" + ))) +} + +/// Parses the descriptor, refusing unit names that are not plain units, since they reach +/// `systemctl` as arguments, and ports other than the agreed ones. +fn parse_descriptor(descriptor: &str) -> Result { + let units: Vec = setting(descriptor, "units") + .ok_or_else(|| missing("units"))? + .split_whitespace() + .map(ToOwned::to_owned) + .collect(); + if units.is_empty() || units.len() > MAX_UNITS { + return Err(Error::Invalid(crate::vnc::image_contract_missing(&format!( + "{DESCRIPTOR} must declare between 1 and {MAX_UNITS} units" + )))); + } + for unit in &units { + if !valid_unit_name(unit) { + return Err(Error::Invalid(crate::vnc::image_contract_missing(&format!( + "{unit:?} is not a systemd socket or service unit name" + )))); + } + } + let port = declared_port(descriptor, "port", crate::vnc::GUEST_PORT)?.ok_or_else(|| missing("port"))?; + let web_port = declared_port(descriptor, "web-port", crate::vnc::WEB_GUEST_PORT)?; + Ok(Capability { units, port, web_port }) +} + +/// Reads a declared port, which must be the one both sides agree on when it is declared at all. +fn declared_port(descriptor: &str, key: &str, expected: u16) -> Result, Error> { + let Some(value) = setting(descriptor, key) else { + return Ok(None); + }; + let parsed: u16 = value.parse().map_err(|_| { + Error::Invalid(crate::vnc::image_contract_missing(&format!( + "{DESCRIPTOR} sets {key} to {value:?}, which is not a port" + ))) + })?; + if parsed != expected { + return Err(Error::Invalid(crate::vnc::image_contract_missing(&format!( + "{DESCRIPTOR} sets {key} to {parsed}, but VNC access uses guest port {expected}" + )))); + } + Ok(Some(parsed)) +} + +fn valid_unit_name(unit: &str) -> bool { + let Some(stem) = unit.strip_suffix(".socket").or_else(|| unit.strip_suffix(".service")) else { + return false; + }; + // A leading `-` would reach `systemctl` as an option rather than a unit. + !stem.is_empty() + && !stem.starts_with('-') + && stem.len() <= 200 + && stem + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.' | '@')) +} + +async fn port_is_listening(sandbox: &SandboxHandle, port: u16) -> Result { + let output = sandbox + .run_execution(ExecutionSpec::command( + SandboxPath::new(SS), + [ + "-ltnH".to_owned(), + "sport".to_owned(), + "=".to_owned(), + format!(":{port}"), + ], + )) + .await?; + if !output.status.success() { + return Err(Error::SandboxSetup(format!( + "`ss -ltnH sport = :{port}` exited with code {}: {}", + output.status.code, + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty()) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::{Capability, parse_descriptor, valid_unit_name}; + + const VALID: &str = "# a comment\nunits=agent-vnc.socket agent-vnc-web.service\nport=5900\nweb-port=6080\n"; + + #[test] + fn the_units_and_ports_come_from_the_image_descriptor() { + assert_eq!( + parse_descriptor(VALID).expect("descriptor"), + Capability { + units: vec!["agent-vnc.socket".to_owned(), "agent-vnc-web.service".to_owned()], + port: 5900, + web_port: Some(6080), + } + ); + } + + #[test] + fn an_image_may_offer_the_rfb_port_without_a_browser_viewer() { + let capability = parse_descriptor("units=agent-vnc.socket\nport=5900\n").expect("descriptor"); + assert_eq!(capability.web_port, None); + assert_eq!(capability.ports(), vec![5900], "only the declared port is promised"); + } + + #[test] + fn a_descriptor_that_could_not_be_acted_on_is_refused() { + for (descriptor, expected) in [ + ("port=5900\nweb-port=6080\n", "does not set units"), + ("units=agent-vnc.socket\nweb-port=6080\n", "does not set port"), + ( + "units=\nport=5900\nweb-port=6080\n", + "must declare between 1 and 8 units", + ), + ( + "units=agent-vnc.socket --now other\nport=5900\nweb-port=6080\n", + "is not a systemd socket or service unit name", + ), + ( + "units=agent-vnc.socket\nport=5901\nweb-port=6080\n", + "but VNC access uses guest port 5900", + ), + ( + "units=agent-vnc.socket\nport=nope\nweb-port=6080\n", + "which is not a port", + ), + ( + "# units=agent-vnc.socket\nport=5900\nweb-port=6080\n", + "does not set units", + ), + ] { + let error = parse_descriptor(descriptor).expect_err("refused"); + assert!(error.to_string().contains(expected), "{descriptor:?} -> {error}"); + } + } + + #[test] + fn unit_names_that_could_become_systemctl_options_are_rejected() { + assert!(valid_unit_name("agent-vnc.socket")); + assert!(valid_unit_name("agent-vnc-web.service")); + assert!(valid_unit_name("getty@tty1.service")); + for rejected in [ + "--now", + "--now.service", + "-H.socket", + "-f", + "agent-vnc", + "agent-vnc.timer", + ".socket", + "a b.service", + "../x.service", + ] { + assert!(!valid_unit_name(rejected), "accepted {rejected:?}"); + } + } +} diff --git a/agentctl/src/sandbox/platform/mod.rs b/agentctl/src/sandbox/platform/mod.rs new file mode 100644 index 0000000..b372f91 --- /dev/null +++ b/agentctl/src/sandbox/platform/mod.rs @@ -0,0 +1,28 @@ +//! Sandbox operating-system-specific adapters. + +pub(crate) mod files; +mod linux; +pub(crate) mod linux_ssh; +pub(crate) mod linux_vnc; + +use ::sandbox::execution::ExecutionSpec; + +use crate::Error; + +pub use linux::Linux; +pub(crate) use linux::{CONTAINER_HOST, HOME, PORTABLE_TERMINAL, USER, UTF8_LOCALE, WORKING_DIRECTORY, run_checked}; + +/// Builds the Agent-conventional Execution environment for one Sandbox OS. +/// +/// # Errors +/// +/// Returns an error when the command is empty or the Sandbox operating system +/// has no Agent execution adapter. +pub fn execution_spec(os: &str, command: &[String], terminal: bool) -> Result { + match os { + "linux" => linux::execution_spec(command, terminal), + os => Err(Error::Invalid(format!( + "command execution is not supported on Sandbox operating system {os:?}" + ))), + } +} diff --git a/agentctl/src/sandbox/responsiveness.rs b/agentctl/src/sandbox/responsiveness.rs new file mode 100644 index 0000000..217eec2 --- /dev/null +++ b/agentctl/src/sandbox/responsiveness.rs @@ -0,0 +1,193 @@ +//! Whether each running Sandbox's guest still makes progress. +//! +//! A Sandbox whose guest has stalled keeps its VM process, so its lifecycle +//! state stays running while every Execution into it waits forever. The +//! Backend reports the guest's heartbeat without a round trip to the guest; +//! this tracker records when each heartbeat last changed on the host clock and +//! calls a guest stalled once it has not changed for [`UNRESPONSIVE_AFTER`]. +//! Guest-written times are never compared: the guest clock falls behind the +//! host's while the guest is stalled. + +use std::{cell::RefCell, collections::HashMap, time::Duration}; + +use ::sandbox::{GuestHeartbeat, Sandbox, SandboxId, SandboxState}; +use tokio::time::Instant; + +/// How long a running guest's heartbeat may stay unchanged before the guest +/// counts as stalled. The guest agent beats about once a second, also while +/// its vCPUs are saturated. +pub const UNRESPONSIVE_AFTER: Duration = Duration::from_secs(15); + +/// How often guest-touching work inspects its Sandbox's heartbeat. +pub const OBSERVATION_INTERVAL: Duration = Duration::from_secs(2); + +/// Describes a stalled guest for conditions and errors. +#[must_use] +pub fn stall_detail() -> String { + format!( + "the guest has not reported progress for more than {}s; stopping and starting the Agent restarts it", + UNRESPONSIVE_AFTER.as_secs() + ) +} + +/// The error that ends guest-touching work once its guest has stalled. +#[must_use] +pub fn stalled() -> crate::Error { + crate::Error::SandboxUnresponsive(stall_detail()) +} + +/// When each running Sandbox's heartbeat last changed, keyed by Sandbox. +#[derive(Default)] +pub struct Tracker { + sandboxes: RefCell>, +} + +struct Tracked { + heartbeat: GuestHeartbeat, + /// When `heartbeat` was first observed. + since: Instant, +} + +impl Tracker { + /// Records one inspection of a Sandbox. A Sandbox that is not running, or + /// reports no heartbeat, has no evidence and is forgotten. + pub fn observe(&self, sandbox: &Sandbox, now: Instant) { + let mut sandboxes = self.sandboxes.borrow_mut(); + let Some(heartbeat) = sandbox + .guest_heartbeat + .filter(|_| sandbox.state == SandboxState::Running) + else { + sandboxes.remove(&sandbox.id); + return; + }; + if sandboxes + .get(&sandbox.id) + .is_none_or(|tracked| tracked.heartbeat != heartbeat) + { + sandboxes.insert(sandbox.id.clone(), Tracked { heartbeat, since: now }); + } + } + + /// The Sandbox's last observed heartbeat, if it reports one. + #[must_use] + pub fn heartbeat(&self, id: &SandboxId) -> Option { + self.sandboxes.borrow().get(id).map(|tracked| tracked.heartbeat) + } + + /// Whether the Sandbox's heartbeat has not changed for [`UNRESPONSIVE_AFTER`]. + /// Only a changed heartbeat clears a stall, so a pause in observation can + /// delay, but never hide, one. + #[must_use] + pub fn stalled(&self, id: &SandboxId, now: Instant) -> bool { + self.sandboxes + .borrow() + .get(id) + .is_some_and(|tracked| now.saturating_duration_since(tracked.since) >= UNRESPONSIVE_AFTER) + } + + /// Forgets a released Sandbox. + pub fn forget(&self, id: &SandboxId) { + self.sandboxes.borrow_mut().remove(id); + } +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use std::collections::BTreeMap; + + use ::sandbox::{ + ByteQuantity, CpuQuantity, GuestHeartbeat, Hostname, Platform, RootFilesystem, Sandbox, SandboxName, + SandboxResources, SandboxState, image, init::InitSystem, + }; + use tokio::time::{Duration, Instant}; + + use super::{Tracker, UNRESPONSIVE_AFTER}; + + fn sandbox(state: SandboxState, heartbeat: Option) -> Sandbox { + Sandbox { + image: image::ResolvedImage { + source: image::ImageSource::Reference { + reference: "example.test/agent:latest".into(), + }, + platform: Platform::new("linux", "amd64"), + manifest_digest: "sha256:1234".into(), + }, + id: "00000000-0000-4000-8000-000000000001".parse().expect("test Sandbox ID"), + name: SandboxName::new("worker").expect("test Sandbox name"), + hostname: Hostname::new("worker").expect("test hostname"), + resources: SandboxResources::new( + "1".parse::().expect("test CPU"), + "512Mi".parse::().expect("test memory"), + RootFilesystem::layered("4Gi".parse::().expect("test root filesystem")), + ), + init_system: InitSystem::Backend, + state, + guest_heartbeat: heartbeat.map(GuestHeartbeat::new), + mounts: Vec::new(), + environment: BTreeMap::new(), + network: None, + } + } + + fn running(heartbeat: u64) -> Sandbox { + sandbox(SandboxState::Running, Some(heartbeat)) + } + + #[test] + fn a_heartbeat_that_stops_advancing_makes_the_guest_stalled() { + let tracker = Tracker::default(); + let start = Instant::now(); + let id = running(1).id; + + tracker.observe(&running(1), start); + tracker.observe(&running(2), start + Duration::from_secs(1)); + let stalled = start + Duration::from_secs(1) + UNRESPONSIVE_AFTER; + tracker.observe(&running(2), stalled - Duration::from_millis(1)); + assert!(!tracker.stalled(&id, stalled - Duration::from_millis(1))); + assert!(tracker.stalled(&id, stalled)); + + tracker.observe(&running(3), stalled + Duration::from_secs(1)); + assert!(!tracker.stalled(&id, stalled + Duration::from_secs(1))); + } + + #[test] + fn a_guest_first_observed_stalled_becomes_stalled() { + let tracker = Tracker::default(); + let start = Instant::now(); + let id = running(48).id; + + tracker.observe(&running(48), start); + tracker.observe(&running(48), start + UNRESPONSIVE_AFTER); + + assert!(tracker.stalled(&id, start + UNRESPONSIVE_AFTER)); + } + + #[test] + fn a_restarted_guest_starts_over_and_its_reset_sequence_counts_as_progress() { + let tracker = Tracker::default(); + let start = Instant::now(); + let id = running(1).id; + tracker.observe(&running(40), start); + assert!(tracker.stalled(&id, start + UNRESPONSIVE_AFTER)); + + // The runtime removes the heartbeat before every boot, and the new + // boot counts from the start again. + tracker.observe(&sandbox(SandboxState::Running, None), start + UNRESPONSIVE_AFTER); + assert!(!tracker.stalled(&id, start + UNRESPONSIVE_AFTER)); + tracker.observe(&running(1), start + UNRESPONSIVE_AFTER * 2); + assert!(!tracker.stalled(&id, start + UNRESPONSIVE_AFTER * 2)); + } + + #[test] + fn a_stopped_sandbox_has_no_evidence_even_with_a_recorded_heartbeat() { + let tracker = Tracker::default(); + let start = Instant::now(); + let id = running(1).id; + tracker.observe(&running(1), start); + + tracker.observe(&sandbox(SandboxState::Stopped, Some(1)), start + UNRESPONSIVE_AFTER); + + assert!(!tracker.stalled(&id, start + UNRESPONSIVE_AFTER)); + } +} diff --git a/agentctl/src/sessions/activity.rs b/agentctl/src/sessions/activity.rs new file mode 100644 index 0000000..0bc560a --- /dev/null +++ b/agentctl/src/sessions/activity.rs @@ -0,0 +1,155 @@ +//! Harness activity, folded from the reports a running harness makes about itself. + +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; + +/// Coarse phase of the harness's current work, derived from activity events. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Phase { + /// No activity event has been observed yet. + #[default] + Unknown, + /// The harness is mid-turn: a prompt was submitted or a tool is running. + Working, + /// The harness finished a turn or is blocked on the operator. + WaitingForInput, +} + +/// Harness activity folded from the harness's reports. +/// +/// Shaped after the Agent Host Protocol's per-chat `activity`/`status` so a +/// later AHP runtime can populate it by field mapping. Owned by the +/// authenticated report handler; see [`super::Reported`]. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Activity { + /// Current coarse work phase. + #[serde(default)] + pub phase: Phase, + /// Reported turn endings (including interruption) observed for the running harness launch. + #[serde(default)] + pub turns: u64, + /// Time of the most recent activity event, when one has been observed. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "time::serde::rfc3339::option" + )] + pub last_event_at: Option, + /// When `phase` last changed. A repeated signal of the same phase, such as + /// an idle notification while waiting for input, does not move it. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "time::serde::rfc3339::option" + )] + pub phase_since: Option, +} + +/// One activity signal a harness reports, before it is folded into [`Activity`]. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ActivityEvent { + /// The harness process started (also carries the native session ID). + SessionStart, + /// The operator submitted a prompt; a turn began. + TurnStarted, + /// The harness ended a turn and is awaiting input; this does not imply success. + TurnCompleted, + /// The harness is blocked awaiting an operator decision. + WaitingForInput, +} + +impl Activity { + /// Folds one activity event into the accumulated activity, at `at`. + #[must_use] + pub fn folded(mut self, event: ActivityEvent, at: OffsetDateTime) -> Self { + let previous = self.phase; + match event { + // A delayed or duplicate start report must not move a Session back + // from a later activity state. + ActivityEvent::SessionStart if !matches!(self.phase, Phase::Unknown) => return self, + ActivityEvent::SessionStart | ActivityEvent::TurnStarted => { + self.phase = Phase::Working; + } + ActivityEvent::TurnCompleted => { + self.phase = Phase::WaitingForInput; + self.turns = self.turns.saturating_add(1); + } + ActivityEvent::WaitingForInput => self.phase = Phase::WaitingForInput, + } + if self.phase != previous || self.phase_since.is_none() { + self.phase_since = Some(at); + } + self.last_event_at = Some(at); + self + } +} + +#[cfg(test)] +mod tests { + use time::OffsetDateTime; + + use super::{Activity, ActivityEvent, Phase}; + + fn at(seconds: i64) -> OffsetDateTime { + OffsetDateTime::from_unix_timestamp(seconds).expect("timestamp") + } + + #[test] + fn the_phase_keeps_its_start_time_through_repeated_signals() { + let at = |seconds| OffsetDateTime::from_unix_timestamp(seconds).expect("timestamp"); + let waiting = Activity::default() + .folded(ActivityEvent::TurnStarted, at(1)) + .folded(ActivityEvent::TurnCompleted, at(10)); + assert_eq!(waiting.phase_since, Some(at(10))); + let notified = waiting.folded(ActivityEvent::WaitingForInput, at(70)); + assert_eq!( + notified.phase_since, + Some(at(10)), + "an idle notification does not restart the wait" + ); + assert_eq!(notified.last_event_at, Some(at(70))); + assert_eq!( + notified.folded(ActivityEvent::TurnStarted, at(80)).phase_since, + Some(at(80)) + ); + } + + #[test] + fn folds_events_into_phase_and_turn_count() { + let cases: &[(ActivityEvent, Phase, u64)] = &[ + (ActivityEvent::SessionStart, Phase::Working, 0), + (ActivityEvent::TurnStarted, Phase::Working, 0), + (ActivityEvent::WaitingForInput, Phase::WaitingForInput, 0), + (ActivityEvent::TurnCompleted, Phase::WaitingForInput, 1), + (ActivityEvent::TurnStarted, Phase::Working, 1), + (ActivityEvent::TurnCompleted, Phase::WaitingForInput, 2), + ]; + let mut activity = Activity::default(); + for (index, (event, phase, turns)) in cases.iter().enumerate() { + let now = at(i64::try_from(index).expect("index") + 1); + activity = activity.folded(*event, now); + assert_eq!(activity.phase, *phase, "phase after {event:?}"); + assert_eq!(activity.turns, *turns, "turns after {event:?}"); + assert_eq!(activity.last_event_at, Some(now), "timestamp after {event:?}"); + } + } + + #[test] + fn turn_count_saturates() { + let activity = Activity { + turns: u64::MAX, + ..Activity::default() + }; + assert_eq!(activity.folded(ActivityEvent::TurnCompleted, at(1)).turns, u64::MAX); + } + + #[test] + fn a_late_start_does_not_regress_waiting_activity() { + let waiting = Activity::default().folded(ActivityEvent::WaitingForInput, at(1)); + + assert_eq!(waiting.clone().folded(ActivityEvent::SessionStart, at(2)), waiting); + } +} diff --git a/agentctl/src/sessions/controller.rs b/agentctl/src/sessions/controller.rs new file mode 100644 index 0000000..0245487 --- /dev/null +++ b/agentctl/src/sessions/controller.rs @@ -0,0 +1,101 @@ +//! Session specialization of the generic keyed reconciliation controller. + +use std::{rc::Rc, time::Duration}; + +use crate::{Error, controller}; + +use super::{SessionId, SharedStore}; + +/// Observes recoverable Session reconciliation errors without stopping the controller. +pub type ErrorHandler = controller::ErrorHandler; + +/// A handle for requesting immediate Session convergence. +pub type Wakeup = controller::Wakeup; + +struct Source(SharedStore); + +impl controller::Source for Source { + fn list_keys(&self) -> ::sandbox::LocalFuture<'_, Result, Error>> { + Box::pin(async move { + self.0 + .list_all_sessions() + .await + .map(|sessions| sessions.into_iter().map(|session| session.id).collect()) + }) + } +} + +/// Generic keyed reconciliation specialized for durable Sessions. +pub struct Controller(controller::Controller); + +/// Wakes Sessions affected by an Agent readiness or Sandbox transition. +pub struct AgentNotifier { + store: SharedStore, + wakeup: Wakeup, + on_error: Rc, +} + +impl AgentNotifier { + /// Creates a notifier over durable Sessions and their controller. + #[must_use] + pub fn new(store: SharedStore, wakeup: Wakeup, on_error: Rc) -> Self { + Self { + store, + wakeup, + on_error, + } + } +} + +impl crate::control_plane::SessionNotifier for AgentNotifier { + fn notify(&self, id: crate::AgentId) { + let store = self.store.clone(); + let wakeup = self.wakeup.clone(); + let on_error = self.on_error.clone(); + tokio::task::spawn_local(async move { + match store.list_all_sessions().await { + Ok(sessions) => { + for session in sessions.into_iter().filter(|session| session.agent_id == id) { + wakeup.notify(session.id); + } + } + Err(error) => on_error(&error), + } + }); + } + + fn settle(&self, id: crate::AgentId) -> ::sandbox::LocalFuture<'_, ()> { + Box::pin(async move { + let sessions = match self.store.list_all_sessions().await { + Ok(sessions) => sessions, + Err(error) => return (self.on_error)(&error), + }; + let passes = sessions + .into_iter() + .filter(|session| session.agent_id == id) + .map(|session| self.wakeup.reconcile(session.id)); + // A failed pass is reported by the controller and retried; it still ends the wait. + futures_util::future::join_all(passes).await; + }) + } +} + +impl Controller { + /// Creates a Session controller and its independently shareable wake-up handle. + #[must_use] + pub fn new( + store: SharedStore, + reconciler: Rc>, + interval: Duration, + on_error: ErrorHandler, + ) -> (Self, Wakeup) { + let (controller, wakeup) = + controller::Controller::new(Rc::new(Source(store)), reconciler, interval, "Session", on_error); + (Self(controller), wakeup) + } + + /// Reconciles existing Sessions immediately and then continuously. + pub async fn run(self) { + self.0.run().await; + } +} diff --git a/agentctl/src/sessions/mod.rs b/agentctl/src/sessions/mod.rs new file mode 100644 index 0000000..7f9afa1 --- /dev/null +++ b/agentctl/src/sessions/mod.rs @@ -0,0 +1,866 @@ +//! Durable, runtime-driven Sessions owned by the Agent daemon. + +mod activity; +mod controller; +mod reconciler; +mod runtime; +mod sandboxes; +mod service; +mod transcript; + +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{AgentId, Error, Harness, ModelSelection, sandbox}; + +pub use crate::controller::Reconcile; +pub use activity::{Activity, ActivityEvent, Phase}; +pub use controller::{AgentNotifier, Controller, ErrorHandler, Wakeup}; +pub use reconciler::Reconciler; +pub use runtime::{Observation, SessionRuntime, Tmux}; +pub use sandboxes::AgentSandboxes; +pub use service::{Service, UpgradeReadiness}; +pub use transcript::{Message, Part, Role, Turn}; + +/// Immutable identity of one Session incarnation. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct SessionId(Uuid); + +impl SessionId { + pub(crate) fn generate() -> Self { + Self(Uuid::new_v4()) + } +} + +impl std::fmt::Display for SessionId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(formatter) + } +} + +impl std::str::FromStr for SessionId { + type Err = uuid::Error; + + fn from_str(value: &str) -> Result { + value.parse().map(Self) + } +} + +/// Validated persistent name of one Session. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(try_from = "String", into = "String")] +pub struct SessionName(String); + +impl SessionName { + /// Creates a validated Session name. + /// + /// # Errors + /// + /// Returns an error unless the name is 1–64 portable ASCII characters. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() + || value.len() > 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(Error::Invalid( + "Session name must be 1-64 ASCII letters, digits, '-' or '_'".into(), + )); + } + Ok(Self(value)) + } + + /// Returns the name as text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom for SessionName { + type Error = Error; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(value: SessionName) -> Self { + value.0 + } +} + +impl std::fmt::Display for SessionName { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +/// Lifecycle state observed by the Session reconciler: whether the harness +/// process is meant to be, and is, running. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum LifecycleState { + /// The Session has not yet reached a running harness. + #[default] + Starting, + /// A resumed harness is running but has not reached its input prompt. + Resuming, + /// The harness process is running in its Sandbox. + Running, + /// The harness was deliberately stopped after inactivity. + Idle, + /// The harness is stopped and stays stopped until the Session is unarchived. + Archived, + /// Reconciliation most recently failed. + Failed, +} + +/// The one Session state operators and orchestrators read. +/// +/// Derived from the reconciler's lifecycle and the harness's reports; the two +/// halves it is computed from stay available for diagnosis. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum State { + /// The current launch has not reported in yet: not launched, or booting. + #[default] + Starting, + /// The harness is mid-turn. + Working, + /// The harness is idle at its prompt or blocked on the operator. + WaitingForInput, + /// The harness was deliberately stopped after inactivity. + Idle, + /// The Session was archived, but its harness has not stopped yet: a turn + /// in progress is finishing, or stopping it failed and is retried. + Archiving, + /// The Session was archived: its harness is stopped until it is unarchived. + Archived, + /// Reconciliation most recently failed. + Failed, +} + +/// Most recently observed Session state. +/// +/// Two writers own two halves: the lifecycle reconciler writes [`Lifecycle`] +/// and the harness's own reports write [`Reported`]. Persistence stores +/// them in separate columns, so a lifecycle write can never clobber a report. +/// [`Status::state`] is derived from both at read time. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Status { + /// Derived Session state; see [`State`]. + #[serde(default)] + pub state: State, + /// When the Session entered `state`, from the half that decides it, when known. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "time::serde::rfc3339::option" + )] + pub state_since: Option, + /// Lifecycle observed by the reconciler. + #[serde(default)] + pub lifecycle: Lifecycle, + /// Facts the running harness reported about itself. + #[serde(default)] + pub reported: Reported, +} + +impl Status { + /// Combines the two halves of a Session that is not archived and whose + /// lifecycle change time is unknown; see [`Status::observed`]. + #[must_use] + pub const fn new(lifecycle: Lifecycle, reported: Reported) -> Self { + Self::observed(lifecycle, reported, None, None) + } + + /// Derives the Session state, and when it was entered, from both halves, + /// when the lifecycle state last changed and when the Session was archived. + /// + /// An archived Session is Archived once its harness has stopped and + /// Archiving until then, since the archive was requested. An unarchived + /// Session whose harness an archive stopped is Idle, as unarchiving never + /// launches it. One whose stop failed may still run, so its reports decide + /// until the reconciler settles it. Any other state is entered when the + /// activity phase changed while the harness runs and reports, otherwise + /// when the lifecycle state changed. + #[must_use] + pub const fn observed( + lifecycle: Lifecycle, + reported: Reported, + lifecycle_since: Option, + archived_at: Option, + ) -> Self { + let lifecycle_archived = matches!(lifecycle.state, LifecycleState::Archived); + let stop_failed = lifecycle_archived && lifecycle.failure.is_some(); + let (state, state_since) = match archived_at { + Some(_) if lifecycle_archived && !stop_failed => (State::Archived, lifecycle_since), + Some(since) => (State::Archiving, Some(since)), + None if stop_failed => Self::entered(Self::running_state(&reported), &reported, lifecycle_since), + None if lifecycle_archived => (State::Idle, lifecycle_since), + None => Self::entered( + Self::harness_state_of(&lifecycle, &reported), + &reported, + lifecycle_since, + ), + }; + Self { + state, + state_since, + lifecycle, + reported, + } + } + + /// The state the harness itself is in, whether or not the Session is + /// archived: what an upgrade must not interrupt. + #[must_use] + pub const fn harness_state(&self) -> State { + Self::harness_state_of(&self.lifecycle, &self.reported) + } + + const fn harness_state_of(lifecycle: &Lifecycle, reported: &Reported) -> State { + match lifecycle.state { + LifecycleState::Failed => State::Failed, + LifecycleState::Idle => State::Idle, + LifecycleState::Archived => State::Archived, + LifecycleState::Starting | LifecycleState::Resuming => State::Starting, + LifecycleState::Running => Self::running_state(reported), + } + } + + /// A running harness's state from its reports. A start report always + /// folds to `Working`, so an `Unknown` phase means the current launch has + /// not reported yet, even when an earlier launch left a native ID behind + /// for resumption. + const fn running_state(reported: &Reported) -> State { + if reported.harness_session_id.is_none() { + return State::Starting; + } + match reported.activity.phase { + Phase::Unknown => State::Starting, + Phase::WaitingForInput => State::WaitingForInput, + Phase::Working => State::Working, + } + } + + const fn entered( + state: State, + reported: &Reported, + lifecycle_since: Option, + ) -> (State, Option) { + match state { + State::Working | State::WaitingForInput => (state, reported.activity.phase_since), + State::Starting | State::Idle | State::Archiving | State::Archived | State::Failed => { + (state, lifecycle_since) + } + } + } +} + +/// Lifecycle half of [`Status`], written only by the Session reconciler. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Lifecycle { + /// Normalized lifecycle state. + #[serde(default)] + pub state: LifecycleState, + /// Failure from the latest reconciliation attempt. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub failure: Option, +} + +impl Lifecycle { + /// A running harness with no failure. + #[must_use] + pub const fn running() -> Self { + Self { + state: LifecycleState::Running, + failure: None, + } + } + + /// A deliberately stopped harness. + #[must_use] + pub const fn idle() -> Self { + Self { + state: LifecycleState::Idle, + failure: None, + } + } + + /// A stopped harness of an archived Session. + #[must_use] + pub const fn archived() -> Self { + Self { + state: LifecycleState::Archived, + failure: None, + } + } + + /// An archived Session whose harness could not be stopped yet. + pub fn archived_with(failure: impl Into) -> Self { + Self { + state: LifecycleState::Archived, + failure: Some(failure.into()), + } + } + + /// A resumed harness waiting to reach its input prompt. + #[must_use] + pub const fn resuming() -> Self { + Self { + state: LifecycleState::Resuming, + failure: None, + } + } + + /// A resumed harness whose latest readiness attempt was interrupted. + pub fn resuming_with(failure: impl Into) -> Self { + Self { + state: LifecycleState::Resuming, + failure: Some(failure.into()), + } + } + + /// Not yet running, with the reason. + pub fn starting(failure: impl Into) -> Self { + Self { + state: LifecycleState::Starting, + failure: Some(failure.into()), + } + } + + /// The latest reconciliation failed, with the reason. + pub fn failed(failure: impl Into) -> Self { + Self { + state: LifecycleState::Failed, + failure: Some(failure.into()), + } + } +} + +/// Report half of [`Status`]: what the running harness said about itself. +/// +/// Recorded only for the current launch (see [`SessionReports`]) and cleared +/// by the reconciler when the Sandbox carrying the conversation is replaced. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Reported { + /// Harness-native conversation ID reported by the running harness. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harness_session_id: Option, + /// Harness-native transcript location inside the Sandbox, when the harness + /// reports one. Opaque to everything but the Session runtime. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harness_transcript_path: Option, + /// Harness activity folded from its reports. + #[serde(default)] + pub activity: Activity, +} + +/// Durable bookkeeping for the most recent harness launch of one Session. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LaunchState { + /// Bearer token authenticating reports from this launch. + pub(crate) token: LaunchToken, + /// Sandbox ID the harness was launched in. + pub sandbox: String, + /// Launch time as Unix seconds. + pub launched_at: i64, + /// Consecutive launches without a sustained healthy observation. + pub attempts: u32, +} + +/// Opaque bearer token authenticating one exact harness launch. +#[derive(Clone, Eq, PartialEq)] +pub struct LaunchToken(Uuid); + +impl LaunchToken { + pub(crate) fn generate() -> Self { + Self(Uuid::new_v4()) + } + + pub(crate) fn expose(&self) -> String { + self.0.to_string() + } +} + +impl std::fmt::Debug for LaunchToken { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("LaunchToken([redacted])") + } +} + +impl std::str::FromStr for LaunchToken { + type Err = uuid::Error; + + fn from_str(value: &str) -> Result { + value.parse().map(Self) + } +} + +/// One new harness launch to persist before its external effects begin. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LaunchRecord { + /// Per-launch bearer token a harness report must carry to be accepted. + pub token: LaunchToken, + /// Sandbox ID the harness is being launched in. + pub sandbox: String, + /// Launch time as Unix seconds. + pub launched_at: i64, + /// Consecutive launches without a sustained healthy observation. + pub attempts: u32, +} + +/// Persistent identity and observed state of one named Session. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Session { + /// Immutable Session identity. + pub id: SessionId, + /// Immutable identity of the owning Agent incarnation. + pub agent_id: AgentId, + /// Owning Agent name. + pub agent: String, + /// User-facing name scoped to the Agent incarnation. + pub name: SessionName, + /// Immutable harness installation selected for this Session. + pub harness: Harness, + /// Immutable model and effort level resolved when the Session was created: + /// the caller's request, then the installation's manifest defaults. Every + /// launch of the harness applies it; an unselected field leaves the harness default. + #[serde(default, skip_serializing_if = "ModelSelection::is_empty")] + pub model_selection: ModelSelection, + /// First time the Session was requested. + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, + /// When release was requested. A marked Session is no longer listed or + /// resolvable by name; the reconciler stops its harness and then removes it. + #[serde( + default, + with = "time::serde::rfc3339::option", + skip_serializing_if = "Option::is_none" + )] + pub deletion_timestamp: Option, + /// When archiving was requested. The reconciler stops the harness of an + /// archived Session and never relaunches it until it is unarchived. + #[serde( + default, + with = "time::serde::rfc3339::option", + skip_serializing_if = "Option::is_none" + )] + pub archived_at: Option, + /// Most recently observed driver state. + #[serde(default)] + pub status: Status, + /// Desired activation revision, written only by explicit Session ensure. + #[serde(skip)] + pub(crate) activation_generation: u64, + /// Activation revision observed by the lifecycle reconciler. + #[serde(skip)] + pub(crate) observed_activation_generation: u64, +} + +impl Session { + /// Whether release of this Session has been requested. + #[must_use] + pub const fn is_deleting(&self) -> bool { + self.deletion_timestamp.is_some() + } + + /// Whether archiving this Session has been requested. + #[must_use] + pub const fn is_archived(&self) -> bool { + self.archived_at.is_some() + } + + /// Describes why an operation cannot use this Session's running harness. + pub(crate) fn not_running_error(&self) -> Error { + if self.is_archived() { + return self.archived_error(); + } + let detail = self + .status + .lifecycle + .failure + .as_deref() + .unwrap_or(match self.status.lifecycle.state { + LifecycleState::Starting => "its lifecycle is starting", + LifecycleState::Resuming => "its harness is resuming", + LifecycleState::Idle => "its lifecycle is idle", + LifecycleState::Archived => "its harness was stopped when it was archived", + LifecycleState::Failed => "its lifecycle failed without a recorded reason", + LifecycleState::Running => "its harness has not reported readiness", + }); + Error::Invalid(format!("Session \"{}\" is not running: {detail}", self.name)) + } + + /// Refuses an operation on an archived Session, whatever its harness does. + pub(crate) fn archived_error(&self) -> Error { + Error::Invalid(format!("Session \"{}\" is archived", self.name)) + } +} + +/// What a caller may choose when ensuring a Session. Every field is optional. +/// +/// The selections apply only when the call creates the Session: an omitted +/// harness, model or effort falls back to the Agent's default installation and +/// that installation's manifest defaults, and the resolved values become the +/// Session's immutable properties. For an existing Session, an explicit value +/// that differs from the recorded one is rejected; omitted ones are ignored. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SessionRequest { + /// Harness installation to bind. + pub harness: Option, + /// Model and effort level the harness launches with. + pub model_selection: ModelSelection, + /// First prompt, handed to the harness at its first launch without replay. + pub initial_prompt: Option, +} + +/// Resolved, immutable selections recorded when a Session is created. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NewSession { + /// Harness installation the Session binds to. + pub harness: Harness, + /// Model and effort level the harness launches with, as requested or defaulted. + pub model_selection: ModelSelection, + /// The part of [`Self::model_selection`] the caller chose explicitly. When the + /// Session already exists, only these fields may conflict with what it recorded. + pub requested: ModelSelection, + /// First prompt, handed to the harness at its first launch without replay. + pub initial_prompt: Option, +} + +impl NewSession { + /// A Session bound to `harness` with every other selection left to the harness. + #[must_use] + pub const fn for_harness(harness: Harness) -> Self { + Self { + harness, + model_selection: ModelSelection { + model: None, + effort: None, + }, + requested: ModelSelection { + model: None, + effort: None, + }, + initial_prompt: None, + } + } + + /// Resolves `requested` against an installation's manifest `defaults`. + #[must_use] + pub fn resolved(harness: Harness, requested: ModelSelection, defaults: &ModelSelection) -> Self { + Self { + harness, + model_selection: requested.clone().or(defaults), + requested, + initial_prompt: None, + } + } +} + +/// Non-secret information required for a terminal attachment. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AttachTarget { + /// Persistent Session metadata and driver assignment. + pub session: Session, + // TODO: Replace this provider assignment with a daemon-owned attachment capability. + /// Provider-qualified materialized Sandbox assignment. + pub sandbox: sandbox::Assignment, +} + +/// Persistent Session operations required by reconciliation. +pub trait SessionStore: SessionReports { + /// Creates or gets one named Session for the active Agent incarnation. + /// + /// `new` is recorded only when the Session is created: its harness, model and + /// effort become the Session's immutable properties, and its initial prompt is + /// handed to the harness at the first launch attempt, without automatic replay. + /// An existing Session is returned as recorded, unless `new` names another + /// harness or its explicitly requested model or effort differs, so concurrent + /// creations cannot silently drop one caller's choice. + fn ensure_session<'a>( + &'a self, + agent: &'a str, + name: &'a SessionName, + new: NewSession, + ) -> ::sandbox::LocalFuture<'a, Result>; + + /// Gets one Session by immutable identity. + fn get_session(&self, id: SessionId) -> ::sandbox::LocalFuture<'_, Result>; + + /// Gets one named Session from the active incarnation of an Agent. + fn get_agent_session<'a>( + &'a self, + agent: &'a str, + name: &'a SessionName, + ) -> ::sandbox::LocalFuture<'a, Result>; + + /// Lists every persistent Session. + fn list_all_sessions(&self) -> ::sandbox::LocalFuture<'_, Result, Error>>; + + /// Lists Sessions for the active incarnation of one Agent name. + fn list_agent_sessions<'a>(&'a self, agent: &'a str) -> ::sandbox::LocalFuture<'a, Result, Error>>; + + /// Replaces the lifecycle half of the status for the desired activation + /// revision observed by the reconciler; the reported half is untouched. + fn update_session_lifecycle( + &self, + id: SessionId, + lifecycle: Lifecycle, + observed_activation_generation: u64, + ) -> ::sandbox::LocalFuture<'_, Result<(), Error>>; + + /// Requests that an Idle Session become active and returns the new desired revision. + fn activate_session(&self, id: SessionId) -> ::sandbox::LocalFuture<'_, Result>; + + /// Atomically records the first release request for one named Session of an + /// active Agent incarnation, and returns it as marked. + fn mark_session_deleting<'a>( + &'a self, + agent: &'a str, + name: &'a SessionName, + ) -> ::sandbox::LocalFuture<'a, Result>; + + /// Records whether one named Session of an active Agent incarnation should be + /// archived, keeping the first archive time, and returns it as recorded. + fn set_session_archived<'a>( + &'a self, + agent: &'a str, + name: &'a SessionName, + archived: bool, + ) -> ::sandbox::LocalFuture<'a, Result>; + + /// Removes a marked Session once its harness has been released. Everything + /// keyed to the Session, including its activity reports, goes with it. + fn finalize_session_deletion(&self, id: SessionId) -> ::sandbox::LocalFuture<'_, Result<(), Error>>; + + /// Resolves a ready Session into a terminal attachment target. + fn session_attach_target(&self, id: SessionId) -> ::sandbox::LocalFuture<'_, Result>; + + /// Clears everything the previous harness incarnation reported: the native + /// conversation ID, its transcript location and the folded activity. + fn clear_session_report(&self, id: SessionId) -> ::sandbox::LocalFuture<'_, Result<(), Error>>; + + /// Durably records a new harness launch and consumes its initial prompt atomically. + /// Returns the consumed prompt for this attempt. It is never restored, even + /// if launch fails; recovery can therefore start an empty conversation. + /// The previous launch's activity is reset so the Session reads as + /// [`State::Starting`] until this launch reports; the native ID and + /// transcript location survive because a resumed conversation keeps them. + fn record_session_launch( + &self, + id: SessionId, + launch: LaunchRecord, + ) -> ::sandbox::LocalFuture<'_, Result, Error>>; + + /// Reads the most recent launch bookkeeping, when one exists. + fn session_launch_state(&self, id: SessionId) -> ::sandbox::LocalFuture<'_, Result, Error>>; + + /// Resets the consecutive-launch counter after a sustained healthy observation. + fn reset_session_launch_attempts(&self, id: SessionId) -> ::sandbox::LocalFuture<'_, Result<(), Error>>; +} + +/// What a running harness reports about itself, recorded for its exact launch. +/// +/// This is the only write capability the harness-facing surface holds: it can +/// say what a launch reported, and nothing else about a Session. How the +/// reports travel (today, harness hooks posting to the Platform API) is a +/// transport detail below this trait. +pub trait SessionReports { + /// Atomically records start identity, transcript location and activity for + /// this launch. Duplicate event IDs return `None`; stale tokens return `Error::NotFound`. + fn record_session_start_for_launch<'a>( + &'a self, + id: SessionId, + token: &'a LaunchToken, + event_id: uuid::Uuid, + native: &'a str, + transcript_path: Option<&'a str>, + at: time::OffsetDateTime, + ) -> ::sandbox::LocalFuture<'a, Result, Error>>; + + /// Folds one activity event into the Session's activity, only when `token` + /// still identifies this exact launch, and returns the folded activity. + /// A stale token or duplicate event ID is a no-op that returns `None`. + fn apply_session_activity_for_launch<'a>( + &'a self, + id: SessionId, + token: &'a LaunchToken, + event_id: uuid::Uuid, + event: ActivityEvent, + at: OffsetDateTime, + ) -> ::sandbox::LocalFuture<'a, Result, Error>>; +} + +pub(crate) type SharedStore = std::rc::Rc; + +/// Attaches a local terminal to the Session's runtime. +/// +/// Delegates to the M0 [`Tmux`] runtime through the [`SessionRuntime`] seam; +/// only the runtime knows how a Session is carried inside the Sandbox. +/// +/// # Errors +/// +/// Returns an error when the Session is not ready or the recorded Sandbox +/// Provider cannot carry the attachment. +pub async fn attach(home: &std::path::Path, target: &AttachTarget) -> Result<(), Error> { + runtime::Tmux.attach(home, target).await +} + +#[cfg(test)] +mod tests { + use super::{Activity, Lifecycle, LifecycleState, Phase, Reported, State, Status}; + + #[test] + fn an_archived_session_is_refused_as_archived_whatever_its_harness_does() { + let session = |archived_at| super::Session { + id: "dd4cdbaf-9ea0-477e-96dd-bbd6b1e4f7dc".parse().expect("Session ID"), + agent_id: "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"), + agent: "worker".into(), + name: "s1".to_string().try_into().expect("Session name"), + harness: crate::harness::test_harness(), + model_selection: crate::ModelSelection::default(), + created_at: time::OffsetDateTime::UNIX_EPOCH, + deletion_timestamp: None, + archived_at, + status: Status::new(Lifecycle::running(), Reported::default()), + activation_generation: 0, + observed_activation_generation: 0, + }; + let refused = session(Some(time::OffsetDateTime::UNIX_EPOCH)) + .not_running_error() + .to_string(); + assert!( + refused.ends_with("Session \"s1\" is archived"), + "an archive that arrives while a prompt waits for input readiness: {refused}" + ); + assert!( + session(None) + .not_running_error() + .to_string() + .contains("has not reported readiness") + ); + } + + #[test] + fn an_archive_decides_the_state_over_the_harness() { + let at = |seconds| time::OffsetDateTime::from_unix_timestamp(seconds).expect("timestamp"); + let working = Reported { + harness_session_id: Some("native".into()), + harness_transcript_path: None, + activity: Activity { + phase: Phase::Working, + phase_since: Some(at(1)), + ..Activity::default() + }, + }; + let status = + |lifecycle: Lifecycle, archived_at| Status::observed(lifecycle, working.clone(), Some(at(2)), archived_at); + + let archiving = status(Lifecycle::running(), Some(at(3))); + assert_eq!(archiving.state, State::Archiving, "the turn is finishing"); + assert_eq!(archiving.state_since, Some(at(3)), "since the archive was requested"); + assert_eq!(archiving.harness_state(), State::Working, "the harness still works"); + assert_eq!( + status(Lifecycle::archived_with("unreachable"), Some(at(3))).state, + State::Archiving, + "a stop that failed is retried" + ); + let archived = status(Lifecycle::archived(), Some(at(3))); + assert_eq!((archived.state, archived.state_since), (State::Archived, Some(at(2)))); + for lifecycle in [Lifecycle::idle(), Lifecycle::failed("boom")] { + assert_eq!( + status(lifecycle, Some(at(3))).state, + State::Archiving, + "until a pass records the harness as stopped" + ); + } + assert_eq!( + status(Lifecycle::archived(), None).state, + State::Idle, + "unarchived before the reconciler settles it" + ); + let unarchived_after_failed_stop = status(Lifecycle::archived_with("unreachable"), None); + assert_eq!( + ( + unarchived_after_failed_stop.state, + unarchived_after_failed_stop.state_since + ), + (State::Working, Some(at(1))), + "the harness a failed stop left running reports for itself" + ); + assert_eq!(status(Lifecycle::running(), None).state, State::Working); + } + + #[test] + fn a_session_entered_its_state_when_the_half_that_decides_it_changed() { + let at = |seconds| time::OffsetDateTime::from_unix_timestamp(seconds).expect("timestamp"); + let reported = Reported { + harness_session_id: Some("native".into()), + harness_transcript_path: None, + activity: Activity { + phase: Phase::WaitingForInput, + phase_since: Some(at(10)), + ..Activity::default() + }, + }; + let waiting = Status::observed(Lifecycle::running(), reported.clone(), Some(at(1)), None); + assert_eq!( + waiting.state_since, + Some(at(10)), + "a running Session is in its activity phase" + ); + let failed = Status::observed(Lifecycle::failed("boom"), reported, Some(at(20)), None); + assert_eq!(failed.state_since, Some(at(20)), "otherwise the lifecycle decides"); + } + + #[test] + fn state_is_derived_from_both_halves() { + let reported = |phase: Phase| Reported { + harness_session_id: Some("native".into()), + harness_transcript_path: None, + activity: Activity { + phase, + ..Activity::default() + }, + }; + let cases = [ + (Lifecycle::default(), Reported::default(), State::Starting), + (Lifecycle::resuming(), Reported::default(), State::Starting), + (Lifecycle::running(), Reported::default(), State::Starting), + (Lifecycle::running(), reported(Phase::Working), State::Working), + (Lifecycle::running(), reported(Phase::Unknown), State::Starting), + ( + Lifecycle::running(), + reported(Phase::WaitingForInput), + State::WaitingForInput, + ), + (Lifecycle::idle(), reported(Phase::WaitingForInput), State::Idle), + (Lifecycle::failed("boom"), reported(Phase::Working), State::Failed), + ( + Lifecycle::starting("not ready"), + reported(Phase::Working), + State::Starting, + ), + ]; + for (lifecycle, reported, expected) in cases { + let status = Status::new(lifecycle.clone(), reported); + assert_eq!(status.state, expected, "{lifecycle:?}"); + assert_eq!(status.lifecycle, lifecycle); + } + assert_eq!(Lifecycle::idle().state, LifecycleState::Idle); + } +} diff --git a/agentctl/src/sessions/reconciler.rs b/agentctl/src/sessions/reconciler.rs new file mode 100644 index 0000000..c636a84 --- /dev/null +++ b/agentctl/src/sessions/reconciler.rs @@ -0,0 +1,468 @@ +//! At-least-once convergence of one durable Session. + +use std::{rc::Rc, time::Duration}; + +use ::sandbox::LocalFuture; + +use crate::Error; + +use super::{ + Activity, ActivityEvent, AgentSandboxes, LaunchRecord, LaunchToken, Lifecycle, LifecycleState, Phase, Session, + SessionId, SessionRuntime, SharedStore, runtime::Observation, +}; + +/// A launch is considered healthy after surviving this long, resetting backoff. +const HEALTHY_AFTER_SECONDS: i64 = 60; + +/// Longest wait between relaunches of a repeatedly exiting harness. +const MAX_BACKOFF_SECONDS: i64 = 600; + +/// Stop an unattached harness after this long without terminal output, +/// transcript writes or reported activity. +const IDLE_AFTER_SECONDS: u64 = 30 * 60; + +/// A turn whose terminal and transcript stay quiet this long is not waited for +/// when archiving: the harness is stuck, gone, or was never prompted. +const ARCHIVE_TURN_QUIET_SECONDS: u64 = 60; + +/// Maximum time for a resumed harness to reach its empty input prompt. +const RESUME_READY_TIMEOUT: Duration = Duration::from_secs(15); +const RESUME_READY_POLL: Duration = Duration::from_millis(100); + +/// Converges persistent Sessions onto the tmux runtime in their Agent's Sandbox. +pub struct Reconciler { + sessions: SharedStore, + sandboxes: Rc, + runtime: Rc, + session_hook_url: String, +} + +impl Reconciler { + /// Creates a Session reconciler over durable state and the Agent Sandbox service. + /// + /// `session_hook_url` is the Sandbox-reachable start-hook endpoint handed to + /// every harness launch. + #[must_use] + pub fn new( + sessions: SharedStore, + sandboxes: Rc, + runtime: Rc, + session_hook_url: String, + ) -> Self { + Self { + sessions, + sandboxes, + runtime, + session_hook_url, + } + } + + /// Stops the harness of a Session marked for release and then removes it. + /// + /// A Sandbox that is gone, unmaterialized or stopped took the harness + /// process with it, so there is nothing left to stop; anything else is an + /// error, and the Session stays marked until a later pass can release it. + async fn release(&self, session: &Session) -> Result<(), Error> { + if let Some(sandbox) = self.release_sandbox(session).await? { + self.runtime.stop(session, &sandbox).await?; + } + self.sessions.finalize_session_deletion(session.id).await + } + + /// The Sandbox still holding this Session's harness, if one does. + async fn release_sandbox(&self, session: &Session) -> Result, Error> { + let agent = match self.sandboxes.agent(session.agent_id).await { + Ok(agent) => agent, + Err(Error::NotFound) => return Ok(None), + Err(error) => return Err(error), + }; + if agent.agent.metadata.deletion_timestamp.is_some() + || !matches!( + agent.agent.status.sandbox, + Some(crate::sandbox::Assignment::Materialized { .. }) + ) + { + return Ok(None); + } + let sandbox = match self.sandboxes.open(&agent).await { + Ok(sandbox) => sandbox, + Err(Error::Sandbox(error)) if error.is_not_found() => return Ok(None), + Err(error) => return Err(error), + }; + if sandbox.snapshot().state == ::sandbox::SandboxState::Stopped { + return Ok(None); + } + Ok(Some(sandbox)) + } + + /// Stops the harness of an archived Session and keeps it stopped. + /// + /// A turn in progress is waited for, so archiving never cuts one short; a + /// later pass retries. A turn waiting for approval is not waited for, as + /// nobody answers an archived Session. A Sandbox that is gone, stopped or + /// unmaterialized has no harness left to stop. + async fn converge_archive(&self, session: &Session) -> Result { + if session.status.lifecycle.state == LifecycleState::Archived && session.status.lifecycle.failure.is_none() { + return Ok(Lifecycle::archived()); + } + if let Some(sandbox) = self.release_sandbox(session).await? { + if self.mid_turn(session, &sandbox).await? { + return Ok(session.status.lifecycle.clone()); + } + self.runtime.stop(session, &sandbox).await?; + } + self.sessions.reset_session_launch_attempts(session.id).await?; + Ok(Lifecycle::archived()) + } + + /// Whether the harness is visibly working on a turn: it reports working, or + /// has not reported yet, is still running, and its terminal or transcript + /// moved recently. The report alone can be stale, for example after a crash. + /// + /// The harness's own report decides, not the derived state, which reads + /// Archived once an earlier archive pass has failed. + async fn mid_turn(&self, session: &Session, sandbox: &::sandbox::SandboxHandle) -> Result { + if !matches!(session.status.reported.activity.phase, Phase::Working | Phase::Unknown) { + return Ok(false); + } + let Observation::Alive { idle_seconds, .. } = self.runtime.observe(session, sandbox).await? else { + return Ok(false); + }; + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + Ok(effective_idle_seconds(&session.status.reported.activity, idle_seconds, now) < ARCHIVE_TURN_QUIET_SECONDS) + } + + /// Settles a Session unarchived before its archive could stop the harness: + /// a harness still running is adopted, otherwise the Session is Idle. + /// Nothing is launched until the next attach. + async fn settle_unarchived(&self, session: &Session) -> Result { + if let Some(sandbox) = self.release_sandbox(session).await? + && matches!( + self.runtime.observe(session, &sandbox).await?, + Observation::Alive { .. } + ) + { + return Ok(Lifecycle::running()); + } + Ok(Lifecycle::idle()) + } + + async fn converge(&self, session: &Session) -> Result { + if session.activation_generation == session.observed_activation_generation { + // An unarchived Session stays stopped, like an Idle one, until it is attached. + match (&session.status.lifecycle.state, &session.status.lifecycle.failure) { + (LifecycleState::Idle, _) | (LifecycleState::Archived, None) => return Ok(Lifecycle::idle()), + (LifecycleState::Archived, Some(_)) => return self.settle_unarchived(session).await, + _ => {} + } + } + let agent = self.sandboxes.agent(session.agent_id).await?; + // A stopped Agent's harnesses stop with its VM. The Session is Idle, as after + // inactivity, so the next attach after a start resumes its conversation. + // A recorded stop counts too: a start may already be asked for while the + // stop waits for its Sessions to see it. + if agent.agent.spec.is_stopped() || agent.agent.status.is_stopped() { + self.sessions.reset_session_launch_attempts(session.id).await?; + return Ok(Lifecycle::idle()); + } + if let Some(held) = launch_blocked(&agent, session) { + return Ok(held); + } + let sandbox = self.sandboxes.open(&agent).await?; + let platform = &sandbox.snapshot().image.platform; + // TODO: Generalize the Session runtime when a concrete non-Linux driver establishes its required contract. + if platform.os != "linux" { + return Err(Error::Session(format!( + "tmux Sessions require a Linux Sandbox, but the materialized platform is {:?}", + platform.os + ))); + } + let sandbox_id = sandbox.snapshot().id.to_string(); + let launch = self.sessions.session_launch_state(session.id).await?; + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + + if let Observation::Alive { attached, idle_seconds } = self.runtime.observe(session, &sandbox).await? { + if !attached + && effective_idle_seconds(&session.status.reported.activity, idle_seconds, now) >= IDLE_AFTER_SECONDS + { + self.runtime.stop(session, &sandbox).await?; + self.sessions.reset_session_launch_attempts(session.id).await?; + return Ok(Lifecycle::idle()); + } + if let Some(state) = &launch + && state.attempts > 0 + && now - state.launched_at >= HEALTHY_AFTER_SECONDS + { + self.sessions.reset_session_launch_attempts(session.id).await?; + } + if session.status.lifecycle.state == LifecycleState::Resuming { + let state = launch + .as_ref() + .ok_or_else(|| Error::Session("resumed harness has no launch record".into()))?; + if state.sandbox != sandbox_id { + return Err(Error::Session("resumed harness belongs to a replaced Sandbox".into())); + } + self.wait_for_resumed_input(session, &sandbox, &state.token).await?; + } + return Ok(Lifecycle::running()); + } + + let mut attempts = 0; + let mut resume = session.status.reported.harness_session_id.clone(); + if let Some(state) = launch { + if state.sandbox == sandbox_id { + attempts = state.attempts; + let wait = backoff_seconds(attempts); + if attempts > 0 && now < state.launched_at + wait { + return Ok(Lifecycle::failed(format!( + "harness exited; relaunching after up to {wait}s of backoff" + ))); + } + } else { + // The Sandbox was replaced, and the harness conversation state + // lived inside it. Start a fresh conversation instead of + // resuming an ID whose files no longer exist. + resume = None; + attempts = 0; + self.sessions.clear_session_report(session.id).await?; + } + } + self.launch( + session, + &sandbox, + LaunchRecord { + token: LaunchToken::generate(), + sandbox: sandbox_id, + launched_at: now, + attempts: attempts + 1, + }, + resume.as_deref(), + ) + .await + } + + /// Consumes the first prompt before launch; recovery never replays it. + async fn launch( + &self, + session: &Session, + sandbox: &::sandbox::SandboxHandle, + record: LaunchRecord, + resume: Option<&str>, + ) -> Result { + let token = record.token.clone(); + let initial_prompt = self.sessions.record_session_launch(session.id, record).await?; + if resume.is_some() { + self.sessions + .update_session_lifecycle(session.id, Lifecycle::resuming(), session.activation_generation) + .await?; + } + self.runtime + .start( + session, + sandbox, + &self.session_hook_url, + &token, + resume, + initial_prompt.as_deref().filter(|_| resume.is_none()), + ) + .await?; + if resume.is_some() { + self.wait_for_resumed_input(session, sandbox, &token).await?; + } + Ok(Lifecycle::running()) + } + + async fn wait_for_resumed_input( + &self, + session: &Session, + sandbox: &::sandbox::SandboxHandle, + token: &LaunchToken, + ) -> Result<(), Error> { + let waiting = async { + loop { + let current = self.sessions.get_session(session.id).await?; + let ready = match current.status.reported.activity.phase { + Phase::WaitingForInput => return Ok(()), + Phase::Unknown | Phase::Working => { + self.runtime.input_ready(¤t, sandbox).await.unwrap_or(false) + } + }; + if ready { + let applied = self + .sessions + .apply_session_activity_for_launch( + session.id, + token, + uuid::Uuid::new_v4(), + ActivityEvent::WaitingForInput, + time::OffsetDateTime::now_utc(), + ) + .await?; + return applied.map(|_| ()).ok_or_else(|| { + Error::Session("resumed harness launch changed while waiting for input".into()) + }); + } + tokio::time::sleep(RESUME_READY_POLL).await; + } + }; + if let Ok(result) = tokio::time::timeout(RESUME_READY_TIMEOUT, waiting).await { + return result; + } + let current = self.sessions.get_session(session.id).await?; + self.runtime.stop(¤t, sandbox).await?; + let error = Error::Session(format!( + "resumed harness did not become ready for input within {} seconds", + RESUME_READY_TIMEOUT.as_secs() + )); + self.sessions + .update_session_lifecycle( + session.id, + Lifecycle::failed(error.to_string()), + session.activation_generation, + ) + .await?; + Err(error) + } +} + +impl crate::controller::Reconcile for Reconciler { + fn reconcile(&self, id: SessionId) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + let session = match self.sessions.get_session(id).await { + Ok(session) => session, + Err(Error::NotFound) => return Ok(()), + Err(error) => return Err(error), + }; + if session.is_deleting() { + return self.release(&session).await; + } + let converged = if session.is_archived() { + self.converge_archive(&session).await + } else { + self.converge(&session).await + }; + match converged { + Ok(lifecycle) => { + self.sessions + .update_session_lifecycle(session.id, lifecycle, session.activation_generation) + .await + } + Err(error) => { + let current = self.sessions.get_session(session.id).await?; + let lifecycle = if current.is_archived() { + // A failed archive stays archived, so unarchiving never relaunches the harness. + Lifecycle::archived_with(error.to_string()) + } else if current.status.lifecycle.state == LifecycleState::Resuming { + Lifecycle::resuming_with(error.to_string()) + } else { + Lifecycle::failed(error.to_string()) + }; + self.sessions + .update_session_lifecycle(session.id, lifecycle, session.activation_generation) + .await?; + Err(error) + } + } + }) + } +} + +/// Seconds a Session has been inactive, taking the smaller of runtime +/// inactivity (terminal or transcript) and time since the last reported event. +fn effective_idle_seconds(activity: &Activity, runtime_idle_seconds: u64, now: i64) -> u64 { + activity.last_event_at.map_or(runtime_idle_seconds, |at| { + let since_event = u64::try_from((now - at.unix_timestamp()).max(0)).unwrap_or(u64::MAX); + runtime_idle_seconds.min(since_event) + }) +} + +/// Seconds to wait after launch attempt `attempts` before relaunching. +fn backoff_seconds(attempts: u32) -> i64 { + if attempts == 0 { + return 0; + } + let exponent = (attempts - 1).min(6); + let wait = 10_i64 << exponent; + if wait > MAX_BACKOFF_SECONDS { + MAX_BACKOFF_SECONDS + } else { + wait + } +} + +/// Holds a Session short of launching while its Agent cannot run it. +/// +/// The image ships every harness binary, so launching one convergence never installed starts a +/// process that sits at a login prompt nobody can answer and reports the Session as running. +fn launch_blocked(agent: &crate::control_plane::AgentRecord, session: &Session) -> Option { + let installed = agent + .agent + .status + .sandbox + .as_ref() + .and_then(crate::sandbox::Assignment::installed_harnesses); + let name = &agent.agent.metadata.name; + let reason = if agent.agent.metadata.deletion_timestamp.is_some() { + format!("Agent {name:?} is being deleted") + } else if !agent.agent.status.is_ready() { + // Says why, such as a guest that stopped responding. + agent.agent.status.ready_condition().map_or_else( + || format!("Agent {name:?} is not ready"), + |ready| format!("Agent {name:?} is not ready: {}", ready.detail().trim_end()), + ) + } else if !installed.is_some_and(|installed| installed.contains(&session.harness)) { + format!( + "Agent {:?} does not carry harness {:?}; sign in on the host and the next Agent \ + convergence installs it", + agent.agent.metadata.name, + session.harness.as_str() + ) + } else { + return None; + }; + Some(if session.status.lifecycle.state == LifecycleState::Resuming { + Lifecycle::resuming_with(reason) + } else { + Lifecycle::starting(reason) + }) +} + +#[cfg(test)] +mod tests { + use super::{Activity, effective_idle_seconds}; + + #[test] + fn idle_age_is_the_terminal_age_until_the_harness_reports_activity() { + assert_eq!(effective_idle_seconds(&Activity::default(), 1_900, 10_000), 1_900); + } + + #[test] + fn idle_age_is_the_fresher_of_terminal_and_reported_activity() { + let reported = Activity { + last_event_at: Some(time::OffsetDateTime::from_unix_timestamp(9_940).expect("timestamp")), + ..Activity::default() + }; + assert_eq!( + effective_idle_seconds(&reported, 1_900, 10_000), + 60, + "a recent report counts as activity" + ); + assert_eq!( + effective_idle_seconds(&reported, 5, 10_000), + 5, + "terminal output counts too" + ); + // A report stamped ahead of the daemon clock never yields a negative age. + assert_eq!(effective_idle_seconds(&reported, 30, 9_000), 0); + } + + #[test] + fn backoff_grows_and_caps() { + assert_eq!(super::backoff_seconds(0), 0); + assert_eq!(super::backoff_seconds(1), 10); + assert_eq!(super::backoff_seconds(2), 20); + assert_eq!(super::backoff_seconds(5), 160); + assert_eq!(super::backoff_seconds(7), super::MAX_BACKOFF_SECONDS); + assert_eq!(super::backoff_seconds(u32::MAX), super::MAX_BACKOFF_SECONDS); + } +} diff --git a/agentctl/src/sessions/runtime/deliver.sh b/agentctl/src/sessions/runtime/deliver.sh new file mode 100644 index 0000000..f8ee317 --- /dev/null +++ b/agentctl/src/sessions/runtime/deliver.sh @@ -0,0 +1,8 @@ +file=$1 +buffer=$2 +target=$3 +trap '/usr/bin/tmux delete-buffer -b "$buffer" 2>/dev/null; /bin/rm -f -- "$file"' EXIT +/usr/bin/tmux load-buffer -b "$buffer" "$file" && + /usr/bin/tmux paste-buffer -d -p -b "$buffer" -t "$target" && + /bin/sleep 0.2 && + /usr/bin/tmux send-keys -t "$target" Enter diff --git a/agentctl/src/sessions/runtime/mod.rs b/agentctl/src/sessions/runtime/mod.rs new file mode 100644 index 0000000..fbfdf22 --- /dev/null +++ b/agentctl/src/sessions/runtime/mod.rs @@ -0,0 +1,107 @@ +//! Session runtime seam: how a harness is carried inside a Sandbox. +//! +//! The runtime owns process lifecycle, terminal state, operator input and the +//! conversation record for a Session. Tmux is the M0 Unix implementation; +//! nothing above this trait names tmux, panes, buffers or transcript files, so +//! a later Agent Host Protocol runtime can replace [`Tmux`] without touching +//! the reconciler, the Session service, the control API, the CLI or persistence. + +mod tmux; + +pub use tmux::Tmux; + +use ::sandbox::SandboxHandle; + +use crate::Error; + +use super::{AttachTarget, LaunchToken, Session, Turn}; + +/// Runtime-observed liveness and inactivity for a Session. +/// +/// The idle age is calculated against the guest clock so host/microVM skew +/// cannot make an active Session look idle. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Observation { + /// The Session's process is not present in the runtime. + Missing, + /// The Session is present, with attachment and activity age. + Alive { + /// Whether a client terminal is attached. + attached: bool, + /// Seconds since the last terminal activity or transcript write. + idle_seconds: u64, + }, +} + +/// One harness Session carried inside a Sandbox. +/// +/// Every method takes the materialized [`SandboxHandle`]; the runtime holds no +/// Sandbox state of its own, mirroring how tmux is addressed per execution. +pub trait SessionRuntime { + /// Observes runtime liveness, attachment and inactivity. + fn observe<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + ) -> ::sandbox::LocalFuture<'a, Result>; + + /// Starts the harness process for `session`. + /// + /// `resume` continues that harness-native conversation. `initial_prompt` + /// is the first operator prompt of a fresh conversation, handed to the + /// harness at launch so it starts working immediately; it is never + /// combined with `resume`. + fn start<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + session_hook_url: &'a str, + token: &'a LaunchToken, + resume: Option<&'a str>, + initial_prompt: Option<&'a str>, + ) -> ::sandbox::LocalFuture<'a, Result<(), Error>>; + + /// Stops a deliberately idle Session. + fn stop<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + ) -> ::sandbox::LocalFuture<'a, Result<(), Error>>; + + /// Whether a running harness can accept input. Some harnesses do not create + /// a conversation until the first prompt arrives. + fn input_ready<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + ) -> ::sandbox::LocalFuture<'a, Result>; + + /// Submits `prompt` to the running harness as operator input. + /// Completes submission before returning; the service serializes delivery + /// and starts the completion timeout afterwards. + fn prompt<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + prompt: &'a str, + ) -> ::sandbox::LocalFuture<'a, Result<(), Error>>; + + /// Reads the Session's conversation so far as ordered turns, optionally + /// limiting work and output to the last `last` complete turns. + /// + /// A conversation that has not produced a record yet is empty, not an error. + fn turns<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + last: Option, + ) -> ::sandbox::LocalFuture<'a, Result, Error>>; + + /// Attaches a local terminal to the Session; a client capability distinct + /// from daemon-owned convergence. It never creates or resumes a Session. + fn attach<'a>( + &'a self, + home: &'a std::path::Path, + target: &'a AttachTarget, + ) -> ::sandbox::LocalFuture<'a, Result<(), Error>>; +} diff --git a/agentctl/src/sessions/runtime/observe.sh b/agentctl/src/sessions/runtime/observe.sh new file mode 100644 index 0000000..53bd77e --- /dev/null +++ b/agentctl/src/sessions/runtime/observe.sh @@ -0,0 +1,16 @@ +transcript=$2 +values=$(/usr/bin/tmux list-sessions -F '#{session_attached} #{session_activity}' -f "#{==:#{session_name},$1}") +status=$? +case $status in 0) ;; 1) exit 10 ;; *) exit 11 ;; esac +set -- $values +[ "$#" -eq 0 ] && exit 10 +[ "$#" -eq 2 ] || exit 11 +latest=$2 +if [ -f "$transcript" ]; then + modified=$(/usr/bin/stat -c %Y -- "$transcript") || exit 11 + [ "$modified" -le "$latest" ] || latest=$modified +fi +now=$(/usr/bin/date +%s) || exit 11 +age=$((now - latest)) +[ "$age" -ge 0 ] || age=0 +printf '%s %s\n' "$1" "$age" diff --git a/agentctl/src/sessions/runtime/stop.sh b/agentctl/src/sessions/runtime/stop.sh new file mode 100644 index 0000000..30ad535 --- /dev/null +++ b/agentctl/src/sessions/runtime/stop.sh @@ -0,0 +1,4 @@ +/usr/bin/tmux kill-session -t "$1" 2>/dev/null && exit 0 +# A Session that is already gone, or a server that is no longer running, is stopped. +/usr/bin/tmux has-session -t "$1" 2>/dev/null && exit 1 +exit 0 diff --git a/agentctl/src/sessions/runtime/tmux.rs b/agentctl/src/sessions/runtime/tmux.rs new file mode 100644 index 0000000..c17f919 --- /dev/null +++ b/agentctl/src/sessions/runtime/tmux.rs @@ -0,0 +1,873 @@ +//! Linux tmux Session runtime and terminal capability. +//! +//! Tmux is the M0 Unix Sandbox implementation detail behind Sessions: it owns +//! the harness PTY, retained terminal state, normal-screen scrollback and client +//! attachment. Nothing tmux-native is persisted; the tmux session name is +//! derived from the platform `SessionId`. The conversation record is the +//! harness's own transcript file, located by the path the harness reported. + +use ::sandbox::{ + SandboxHandle, SandboxPath, + execution::{ExecutionOutput, ExecutionSpec, ExitStatus, StartExecutionRequest}, + terminal::{AttachTerminalRequest, TerminalAttachOutcome}, +}; + +use crate::{ + Error, harness, + sandbox::platform::{PORTABLE_TERMINAL, UTF8_LOCALE}, +}; + +use super::Observation; +use crate::sessions::{Activity, AttachTarget, LaunchToken, LifecycleState, Phase, Session, Turn}; + +/// Quiet period after the latest hook event before input is pasted into a +/// harness that is not waiting for input. The start hook fires before the +/// harness TUI's input loop is up, and a paste that lands in that gap is lost; +/// tmux has no readiness signal of its own, so recent hook activity stands in. +const INPUT_READY_GRACE: std::time::Duration = std::time::Duration::from_secs(2); +/// Bound on every Session runtime execution in the guest, so a stalled guest +/// fails the operation instead of holding it. +const LIFECYCLE_EXECUTION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); +const LIFECYCLE_EXECUTION_KILL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); +const DETACH_KEYS: &str = "ctrl-b,d"; + +// Set history-limit before pane creation; reapply on attach for existing servers. +// Mouse mode routes wheels to copy mode or the application: https://man.openbsd.org/tmux.1#mouse +// Reserve index 99: appending would grow terminal-features on every attach. +// Ctrl-Z would stop the harness with no shell to resume it, so swallow it in a Session's +// own harness pane (a pane started with a command). It is bound on the shared tmux server, +// so the guard is scoped to agent-session-* names; every other pane, including a shell +// opened with Ctrl-b c, keeps normal job control and has Ctrl-Z forwarded. +fn terminal_options() -> Vec { + [ + "set-option", + "-g", + "history-limit", + "50000", + ";", + "set-option", + "-g", + "mouse", + "on", + ";", + "set-option", + "-s", + "focus-events", + "on", + ";", + "set-option", + "-s", + "extended-keys", + "on", + ";", + "set-option", + "-s", + "terminal-features[99]", + "xterm*:extkeys", + ";", + "bind-key", + "-n", + "C-z", + "if-shell", + "-F", + "#{&&:#{m:agent-session-*,#{session_name}},#{!=:#{pane_start_command},}}", + "", + "send-keys C-z", + ";", + ] + .into_iter() + .map(str::to_owned) + .collect() +} + +fn session_name(session: &Session) -> String { + format!("agent-session-{}", session.id) +} + +fn exact_target(session: &Session) -> String { + format!("={}", session_name(session)) +} + +/// Exact-session pane target (`=name:`) for commands that address a pane rather +/// than a session; a bare `=name` resolves only for session-targeting commands. +fn pane_target(session: &Session) -> String { + format!("={}:", session_name(session)) +} + +// Both timestamps come from the Sandbox. A missing transcript is normal before +// the harness creates its conversation; its contents are not needed here. +const OBSERVE_SCRIPT: &str = include_str!("observe.sh"); + +// Stopping is repeated after an interrupted release, so a Session that is +// already gone counts as stopped. +const STOP_SCRIPT: &str = include_str!("stop.sh"); + +/// Observes attachment and the freshest terminal or transcript activity. +async fn observe(session: &Session, sandbox: &SandboxHandle) -> Result { + let inspected = run_lifecycle_execution( + sandbox, + ExecutionSpec::command( + SandboxPath::new("/bin/sh"), + [ + "-c".into(), + OBSERVE_SCRIPT.into(), + "agent-session-observe".into(), + session_name(session), + session + .status + .reported + .harness_transcript_path + .clone() + .unwrap_or_default(), + ], + ), + "tmux observation", + ) + .await?; + classify_observation(inspected.status, &inspected.stdout) +} + +fn classify_observation(status: ExitStatus, stdout: &[u8]) -> Result { + if status.code == 10 { + return Ok(Observation::Missing); + } + if !status.success() { + return Err(Error::Session(format!( + "tmux observation failed with exit code {}", + status.code + ))); + } + let output = std::str::from_utf8(stdout) + .map_err(|error| Error::Session(format!("tmux returned non-UTF-8 observation: {error}")))?; + parse_observation(output) +} + +fn parse_observation(output: &str) -> Result { + let mut fields = output.split_ascii_whitespace(); + let attached = fields + .next() + .ok_or_else(|| Error::Session("tmux returned an empty observation".into()))?; + let idle_seconds = fields + .next() + .ok_or_else(|| Error::Session("tmux omitted its activity age".into()))? + .parse::() + .map_err(|error| Error::Session(format!("tmux returned an invalid activity age: {error}")))?; + if fields.next().is_some() || !matches!(attached, "0" | "1") { + return Err(Error::Session("tmux returned an invalid observation".into())); + } + Ok(Observation::Alive { + attached: attached == "1", + idle_seconds, + }) +} + +/// Stops a tmux Session, succeeding when it is already gone. +async fn stop(session: &Session, sandbox: &SandboxHandle) -> Result<(), Error> { + let stopped = run_lifecycle_execution( + sandbox, + ExecutionSpec::command( + SandboxPath::new("/bin/sh"), + [ + "-c".into(), + STOP_SCRIPT.into(), + "agent-session-stop".into(), + exact_target(session), + ], + ), + "tmux stop", + ) + .await?; + if stopped.status.success() { + Ok(()) + } else { + Err(Error::Session(format!( + "tmux failed to stop Session {} with exit code {}", + session.id, stopped.status.code + ))) + } +} + +async fn run_lifecycle_execution( + sandbox: &SandboxHandle, + spec: ExecutionSpec, + operation: &str, +) -> Result { + let deadline = tokio::time::Instant::now() + LIFECYCLE_EXECUTION_TIMEOUT; + let started = tokio::time::timeout_at(deadline, sandbox.start_execution(StartExecutionRequest::new(spec))) + .await + .map_err(|_| Error::Session(format!("{operation} timed out while starting")))??; + let execution_id = started.id.clone(); + let mut collecting = std::pin::pin!(started.collect()); + if let Ok(output) = tokio::time::timeout_at(deadline, &mut collecting).await { + output.map_err(Error::from) + } else { + match tokio::time::timeout(LIFECYCLE_EXECUTION_KILL_TIMEOUT, sandbox.kill_execution(&execution_id)).await { + Ok(Ok(())) => {} + Ok(Err(error)) => { + tracing::warn!(%error, %execution_id, "failed to kill timed-out Session runtime execution"); + } + Err(_) => tracing::warn!(%execution_id, "timed out killing Session runtime execution"), + } + Err(Error::Session(format!("{operation} timed out"))) + } +} + +/// Builds the tmux `new-session` arguments for one launch of `session`'s harness. +/// +/// Per-launch values travel as tmux session environment (`-e`) rather than +/// becoming defaults for subsequently created sessions. Sessions share one +/// Unix identity and tmux server, so this is not a security boundary between +/// sibling Sessions; the token only rejects stale or accidental reports. The +/// Session's recorded model selection is part of every launch, resumed or not. +fn launch_arguments( + session: &Session, + session_hook_url: &str, + token: &LaunchToken, + resume: Option<&str>, + initial_message: Option<&str>, +) -> Vec { + let launch = harness::launch_linux( + session.harness, + &harness::LaunchRequest { + home: crate::sandbox::platform::HOME, + resume, + initial_prompt: initial_message, + model_selection: &session.model_selection, + }, + ); + let mut arguments = terminal_options(); + arguments.extend(["new-session".into(), "-d".into(), "-s".into(), session_name(session)]); + let session_environment = launch.environment.iter().cloned().chain([ + ("CONTAINER_HOST".into(), crate::sandbox::platform::CONTAINER_HOST.into()), + ("AGENT_SESSION_ID".into(), session.id.to_string()), + ("AGENT_SESSION_TOKEN".into(), token.expose()), + ("AGENT_SESSION_HOOK_URL".into(), session_hook_url.into()), + ]); + for (name, value) in session_environment { + arguments.push("-e".into()); + arguments.push(format!("{name}={value}")); + } + arguments.push(launch.command); + arguments +} + +/// Creates the named detached tmux session running the harness. +async fn launch( + session: &Session, + sandbox: &SandboxHandle, + session_hook_url: &str, + token: &LaunchToken, + resume: Option<&str>, + initial_message: Option<&str>, +) -> Result<(), Error> { + let arguments = launch_arguments(session, session_hook_url, token, resume, initial_message); + let created = run_lifecycle_execution( + sandbox, + ExecutionSpec::command(SandboxPath::new("/usr/bin/tmux"), arguments) + .with_working_directory(SandboxPath::new(crate::sandbox::platform::WORKING_DIRECTORY)) + .with_environment([ + ("HOME".into(), crate::sandbox::platform::HOME.into()), + ("LANG".into(), UTF8_LOCALE.into()), + ]), + "tmux Session launch", + ) + .await?; + if created.status.success() { + return Ok(()); + } + // Concurrent creation is excluded by per-Session serialization, but an + // "already exists" result from a raced earlier pass still converges. + if matches!(observe(session, sandbox).await?, Observation::Alive { .. }) { + return Ok(()); + } + Err(Error::Session(format!( + "tmux failed to create Session {} with exit code {}", + session.id, created.status.code + ))) +} + +/// Attaches a local terminal to an existing tmux-backed Session. +/// +/// This client capability is separate from daemon-owned lifecycle +/// convergence. It never creates or resumes a Session. +/// +/// # Errors +/// +/// Returns an error unless the Session is ready and the Sandbox Provider +/// supports direct terminal attachment. +async fn attach_terminal(home: &std::path::Path, target: &AttachTarget) -> Result<(), Error> { + if target.session.status.lifecycle.state != LifecycleState::Running { + return Err(target.session.not_running_error()); + } + let request = attach_request(&target.session); + match crate::sandbox::attach_terminal(home, &target.sandbox, request).await? { + TerminalAttachOutcome::Exited(status) if status.success() => Ok(()), + TerminalAttachOutcome::Detached => Ok(()), + TerminalAttachOutcome::Exited(status) => Err(Error::Session(format!( + "tmux attachment exited with code {}", + status.code + ))), + _ => Err(Error::Session( + "terminal attachment returned an unsupported outcome".into(), + )), + } +} + +fn attach_arguments(session: &Session) -> Vec { + let mut arguments = terminal_options(); + // A session-local override can shadow the global default on older sessions. + arguments.extend([ + "set-option".into(), + "-t".into(), + pane_target(session), + "mouse".into(), + "on".into(), + ";".into(), + "attach-session".into(), + "-t".into(), + exact_target(session), + ]); + arguments +} + +fn attach_spec(session: &Session) -> ExecutionSpec { + ExecutionSpec::command(SandboxPath::new("/usr/bin/tmux"), attach_arguments(session)) + // Host-specific TERM names are not necessarily installed in the guest. + // Use the broadly available baseline while tmux mediates the terminal. + .with_environment([ + ("LANG".into(), UTF8_LOCALE.into()), + ("TERM".into(), PORTABLE_TERMINAL.into()), + ]) +} + +fn attach_request(session: &Session) -> AttachTerminalRequest { + AttachTerminalRequest::new(attach_spec(session)).with_detach_keys(DETACH_KEYS) +} + +/// The M0 Unix Session runtime backed by tmux. +#[derive(Clone, Copy, Debug, Default)] +pub struct Tmux; + +impl super::SessionRuntime for Tmux { + fn observe<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + ) -> ::sandbox::LocalFuture<'a, Result> { + Box::pin(observe(session, sandbox)) + } + + fn start<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + session_hook_url: &'a str, + token: &'a LaunchToken, + resume: Option<&'a str>, + initial_prompt: Option<&'a str>, + ) -> ::sandbox::LocalFuture<'a, Result<(), Error>> { + Box::pin(launch( + session, + sandbox, + session_hook_url, + token, + resume, + initial_prompt, + )) + } + + fn stop<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + ) -> ::sandbox::LocalFuture<'a, Result<(), Error>> { + Box::pin(stop(session, sandbox)) + } + + fn input_ready<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + ) -> ::sandbox::LocalFuture<'a, Result> { + Box::pin(async move { + match session.status.reported.activity.phase { + Phase::WaitingForInput => return Ok(true), + Phase::Working => { + return Ok( + input_ready_in(&session.status.reported.activity, time::OffsetDateTime::now_utc()).is_none(), + ); + } + Phase::Unknown => {} + } + let output = run_lifecycle_execution(sandbox, ExecutionSpec::command( + SandboxPath::new("/bin/sh"), + ["-c".into(), + "/usr/bin/tmux display-message -p -t \"$1\" '#{cursor_flag} #{cursor_y} #{pane_title}' && /usr/bin/tmux capture-pane -p -t \"$1\"".into(), + "agent-input-ready".into(), pane_target(session)], + ), "input readiness check").await?; + // Provisioning publishes the launch before its pane necessarily exists. + // tmux exits 1 while there is no server or target pane to inspect. + if output.status.code == 1 { + return Ok(false); + } + if !output.status.success() { + return Err(Error::Session("could not inspect the harness input readiness".into())); + } + let screen = std::str::from_utf8(&output.stdout) + .map_err(|error| Error::Session(format!("invalid terminal input state: {error}")))?; + Ok(ready_input(screen) + .is_some_and(|(line, title)| harness::input_ready_without_report(session.harness, line, title))) + }) + } + + fn prompt<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + prompt: &'a str, + ) -> ::sandbox::LocalFuture<'a, Result<(), Error>> { + Box::pin(deliver(session, sandbox, prompt)) + } + + fn turns<'a>( + &'a self, + session: &'a Session, + sandbox: &'a SandboxHandle, + last: Option, + ) -> ::sandbox::LocalFuture<'a, Result, Error>> { + Box::pin(turns(session, sandbox, last)) + } + + fn attach<'a>( + &'a self, + home: &'a std::path::Path, + target: &'a AttachTarget, + ) -> ::sandbox::LocalFuture<'a, Result<(), Error>> { + Box::pin(attach_terminal(home, target)) + } +} + +/// Selects the current input cursor's line from a terminal snapshot. Readiness +/// must not be inferred from a prompt retained elsewhere in terminal history. +fn ready_input(screen: &str) -> Option<(&str, &str)> { + let mut lines = screen.lines(); + let mut cursor = lines.next()?.split_whitespace(); + if cursor.next()? != "1" { + return None; + } + let row = cursor.next()?.parse::().ok()?; + let title = cursor.next()?; + if cursor.next().is_some() { + return None; + } + lines.nth(row).map(|line| (line, title)) +} + +/// Delivers operator input to a running tmux Session. +/// +/// Input is held for [`INPUT_READY_GRACE`] after the latest reported hook +/// event unless the harness is waiting for input (see [`input_ready_in`]). The +/// prompt is then written to a Sandbox file and loaded into a private tmux +/// buffer, pasted into the Session's pane with bracketed paste so newlines +/// stay literal input, then submitted with a trailing Enter after a short settling +/// interval so the TUI can consume the paste before handling submission. Bracketed paste +/// is why a multi-line prompt is not submitted line by line by the harness TUI. +/// +/// File and buffer carry a per-delivery name, so two deliveries in flight for +/// the same Session cannot overwrite each other's payload; the Session service +/// additionally serializes deliveries per Session. +async fn deliver(session: &Session, sandbox: &SandboxHandle, prompt: &str) -> Result<(), Error> { + use std::io::Cursor; + + let buffer = format!("agent-prompt-{}-{}", session.id, uuid::Uuid::new_v4()); + let file = format!("/tmp/{buffer}"); + if let Some(quiet) = input_ready_in(&session.status.reported.activity, time::OffsetDateTime::now_utc()) { + tokio::time::sleep(quiet).await; + } + tokio::time::timeout( + LIFECYCLE_EXECUTION_TIMEOUT, + sandbox.write_file( + &SandboxPath::new(file.clone()), + Box::pin(Cursor::new(prompt.as_bytes().to_vec())), + ), + ) + .await + .map_err(|_| Error::Session("writing the prompt into the Sandbox timed out".into()))??; + let delivered = run_lifecycle_execution( + sandbox, + ExecutionSpec::command( + SandboxPath::new("/bin/sh"), + [ + "-c".into(), + include_str!("deliver.sh").into(), + "agent-session-deliver".into(), + file, + buffer, + pane_target(session), + ], + ), + "prompt delivery", + ) + .await + .map_err(|error| match error { + Error::Session(message) if message == "prompt delivery timed out" => Error::Session( + "prompt delivery timed out; the prompt may have reached the harness, so check turns before retrying".into(), + ), + error => error, + })?; + if delivered.status.success() { + Ok(()) + } else { + Err(Error::Session(format!( + "tmux failed to deliver input to Session {} with exit code {}", + session.id, delivered.status.code + ))) + } +} + +/// How much longer to hold input for a harness whose latest hook event is +/// recent, or `None` when it can take input now: a harness waiting for input +/// is ready by definition, and one whose last event is older than +/// [`INPUT_READY_GRACE`] has had its input loop up for at least that long. +fn input_ready_in(activity: &Activity, now: time::OffsetDateTime) -> Option { + if activity.phase == Phase::WaitingForInput { + return None; + } + let last = activity.last_event_at?; + let quiet = now - last; + let grace = time::Duration::try_from(INPUT_READY_GRACE).ok()?; + if quiet >= grace { + None + } else { + std::time::Duration::try_from(grace - quiet).ok() + } +} + +/// Reads the harness transcript the Session reported and parses it into turns. +/// +/// The path travels as a positional argument, never interpolated into shell +/// text. A harness that has not reported a transcript yet, or has reported one +/// it has not created yet, has an empty conversation. +async fn turns(session: &Session, sandbox: &SandboxHandle, last: Option) -> Result, Error> { + const MAX_TRANSCRIPT_BYTES: usize = 2 * 1024 * 1024; + const SCRIPT: &str = "[ -f \"$1\" ] || exit 0; exec /usr/bin/tail -c \"$2\" -- \"$1\""; + let Some(path) = session.status.reported.harness_transcript_path.as_deref() else { + return Ok(Vec::new()); + }; + if last == Some(0) { + return Ok(Vec::new()); + } + let read = run_lifecycle_execution( + sandbox, + ExecutionSpec::command( + SandboxPath::new("/bin/sh"), + [ + "-c".into(), + SCRIPT.into(), + "agent-session-transcript".into(), + path.into(), + (MAX_TRANSCRIPT_BYTES + 1).to_string(), + ], + ), + "reading the Session conversation", + ) + .await?; + if !read.status.success() { + return Err(Error::Session(format!( + "reading the conversation of Session {} failed with exit code {}", + session.id, read.status.code + ))); + } + parse_transcript_suffix(session.harness, &read.stdout, last, MAX_TRANSCRIPT_BYTES) +} + +fn parse_transcript_suffix( + kind: crate::Harness, + bytes: &[u8], + last: Option, + max_bytes: usize, +) -> Result, Error> { + let truncated = bytes.len() > max_bytes; + if truncated && last.is_none() { + return Err(Error::Session(format!( + "conversation exceeds the {} MiB read limit; retry with --last", + max_bytes / 1024 / 1024 + ))); + } + let bytes = if truncated { + let after_partial_line = bytes + .iter() + .position(|byte| *byte == b'\n') + .map_or_else(|| &bytes[bytes.len()..], |newline| &bytes[newline + 1..]); + harness::trim_partial_transcript(kind, after_partial_line) + } else { + bytes + }; + let mut turns = harness::parse_transcript(kind, bytes)?; + if let Some(last) = last { + if truncated && turns.len() < last { + return Err(Error::Session(format!( + "the last {last} complete turns do not fit within the {} MiB transcript read limit; request fewer turns", + max_bytes / 1024 / 1024 + ))); + } + if turns.len() > last { + turns.drain(0..turns.len() - last); + } + } + Ok(turns) +} + +#[cfg(test)] +mod tests { + use sandbox::execution::ExitStatus; + use time::OffsetDateTime; + + use crate::{ + harness, + sessions::{Activity, Lifecycle, Part, Phase, Reported, Status}, + }; + + use super::{Observation, Session, input_ready_in, parse_transcript_suffix}; + + #[test] + fn a_truncated_suffix_starts_at_the_first_complete_turn() { + let transcript = concat!( + "partial record\n", + r#"{"type":"assistant","message":{"id":"old","content":[{"type":"text","text":"partial answer"}]}}"#, + "\n", + r#"{"type":"user","message":{"content":"latest prompt"}}"#, + "\n", + r#"{"type":"assistant","message":{"id":"new","content":[{"type":"text","text":"latest answer"}]}}"#, + "\n", + ); + let turns = parse_transcript_suffix( + harness::test_harness(), + transcript.as_bytes(), + Some(1), + transcript.len() - 1, + ) + .expect("last complete turn"); + assert!(matches!( + turns[0].messages[0].parts[0], + Part::Text { ref text } if text == "latest prompt" + )); + } + + #[test] + fn a_truncated_transcript_requires_a_satisfiable_last_bound() { + let transcript = concat!( + "partial record\n", + r#"{"type":"user","message":{"content":"only complete prompt"}}"#, + "\n", + ); + let max_bytes = transcript.len() - 1; + + let unbounded = parse_transcript_suffix(harness::test_harness(), transcript.as_bytes(), None, max_bytes) + .expect_err("unbounded truncated transcript"); + assert!(unbounded.to_string().contains("retry with --last")); + + let too_many = parse_transcript_suffix(harness::test_harness(), transcript.as_bytes(), Some(2), max_bytes) + .expect_err("too many complete turns"); + assert!(too_many.to_string().contains("request fewer turns")); + } + + #[test] + #[ignore = "requires Node.js and tmux; exercises input in an isolated terminal server"] + fn delivery_and_transcript_freshness_in_a_real_terminal() { + let output = std::process::Command::new("node") + .arg(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/tmux_delivery.mjs")) + .arg(concat!(env!("CARGO_MANIFEST_DIR"), "/src/sessions/runtime/deliver.sh")) + .arg(concat!(env!("CARGO_MANIFEST_DIR"), "/src/sessions/runtime/observe.sh")) + .arg(concat!(env!("CARGO_MANIFEST_DIR"), "/src/sessions/runtime/stop.sh")) + .output() + .expect("Node.js"); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn readiness_requires_a_visible_cursor_on_the_current_line() { + assert_eq!( + super::ready_input("1 1 title\nold prompt\ncurrent input\n"), + Some(("current input", "title")) + ); + assert_eq!(super::ready_input("0 1 title\nold prompt\ncurrent input\n"), None); + assert_eq!(super::ready_input("1 8 title\nold prompt\n"), None); + assert_eq!(super::ready_input("invalid\nold prompt\n"), None); + } + + #[test] + fn input_waits_out_the_grace_after_a_recent_event_unless_the_harness_is_waiting() { + let now = time::OffsetDateTime::from_unix_timestamp(10_000).expect("timestamp"); + let just_started = Activity { + phase: Phase::Working, + last_event_at: Some(now - time::Duration::milliseconds(500)), + ..Activity::default() + }; + assert_eq!( + input_ready_in(&just_started, now), + Some(std::time::Duration::from_millis(1_500)) + ); + let quiet = Activity { + phase: Phase::Working, + last_event_at: Some(now - time::Duration::seconds(30)), + ..Activity::default() + }; + assert_eq!(input_ready_in(&quiet, now), None); + let waiting = Activity { + phase: Phase::WaitingForInput, + ..just_started + }; + assert_eq!( + input_ready_in(&waiting, now), + None, + "a completed turn means the input loop is up" + ); + assert_eq!( + input_ready_in(&Activity::default(), now), + None, + "nothing reported yet imposes no grace" + ); + } + + #[test] + fn parses_guest_calculated_idle_age() { + let Observation::Alive { attached, idle_seconds } = + super::parse_observation("0 301\n").expect("valid observation") + else { + panic!("expected a live Session"); + }; + assert!(!attached); + assert_eq!(idle_seconds, 301); + } + + #[test] + fn distinguishes_a_missing_session_from_an_observation_failure() { + assert!(matches!( + super::classify_observation(ExitStatus { code: 10 }, &[]).expect("missing observation"), + Observation::Missing + )); + + let Err(error) = super::classify_observation(ExitStatus { code: 2 }, &[]) else { + panic!("tmux failure must not look like a missing Session"); + }; + assert!(error.to_string().contains("exit code 2")); + } + + fn test_session(model_selection: crate::ModelSelection) -> Session { + Session { + id: "dd4cdbaf-9ea0-477e-96dd-bbd6b1e4f7dc".parse().expect("Session ID"), + agent_id: "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"), + agent: "worker".into(), + name: "s1".to_string().try_into().expect("Session name"), + harness: crate::harness::test_harness(), + model_selection, + created_at: OffsetDateTime::UNIX_EPOCH, + deletion_timestamp: None, + archived_at: None, + status: Status::new(Lifecycle::running(), Reported::default()), + activation_generation: 0, + observed_activation_generation: 0, + } + } + + #[test] + fn every_launch_carries_the_recorded_model_selection() { + let selection = crate::ModelSelection { + model: Some(crate::Model::new("haiku").expect("model")), + effort: Some(crate::Effort::new("low").expect("effort")), + }; + let session = test_session(selection); + let token = super::LaunchToken::generate(); + for resume in [None, Some("160cdb4b-5997-464c-9d22-602786eb45d4")] { + let arguments = super::launch_arguments(&session, "http://hook", &token, resume, None); + let command = arguments.last().expect("tmux command"); + assert!(command.contains("'haiku'") && command.contains("'low'"), "{command}"); + assert_eq!(command.contains("--resume"), resume.is_some(), "{command}"); + } + let plain = super::launch_arguments( + &test_session(crate::ModelSelection::default()), + "http://hook", + &token, + None, + None, + ); + assert!(!plain.last().expect("tmux command").contains("haiku")); + } + + #[test] + fn terminal_options_precede_creation_and_attachment() { + let session = test_session(crate::ModelSelection::default()); + let options = super::terminal_options(); + let launch = super::launch_arguments(&session, "http://hook", &super::LaunchToken::generate(), None, None); + assert!(launch.starts_with(&options)); + assert_eq!(launch[options.len()], "new-session"); + let attach = super::attach_arguments(&session); + assert!(attach.starts_with(&options)); + assert_eq!( + &attach[options.len()..options.len() + 6], + ["set-option", "-t", &super::pane_target(&session), "mouse", "on", ";"] + ); + assert_eq!(attach[options.len() + 6], "attach-session"); + } + + #[test] + #[cfg(target_os = "linux")] + #[ignore = "requires Node.js, tmux and script; exercises scrollback in an isolated terminal server"] + fn scrollback_in_a_real_terminal() { + let session = test_session(crate::ModelSelection::default()); + let output = std::process::Command::new("node") + .arg(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/tmux_scrollback.mjs")) + .arg(serde_json::to_string(&super::terminal_options()).expect("options")) + .arg(serde_json::to_string(&super::attach_arguments(&session)).expect("attachment")) + .arg(super::session_name(&session)) + .output() + .expect("Node.js"); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + #[cfg(target_os = "linux")] + #[ignore = "requires Node.js, tmux and script; exercises Ctrl-Z in an isolated terminal server"] + fn suspend_is_refused_in_a_real_terminal() { + let session = test_session(crate::ModelSelection::default()); + let output = std::process::Command::new("node") + .arg(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/tmux_suspend.mjs")) + .arg(serde_json::to_string(&super::terminal_options()).expect("options")) + .arg(serde_json::to_string(&super::attach_arguments(&session)).expect("attachment")) + .arg(super::session_name(&session)) + .output() + .expect("Node.js"); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn attachment_uses_portable_utf8_terminal_environment() { + let session = test_session(crate::ModelSelection::default()); + + let request = super::attach_request(&session); + let spec = request.spec(); + + assert_eq!(spec.environment().get("LANG").map(String::as_str), Some("C.UTF-8")); + assert_eq!( + spec.environment().get("TERM").map(String::as_str), + Some("xterm-256color") + ); + assert_eq!(request.detach_keys(), Some("ctrl-b,d")); + } +} diff --git a/agentctl/src/sessions/sandboxes.rs b/agentctl/src/sessions/sandboxes.rs new file mode 100644 index 0000000..38ee81c --- /dev/null +++ b/agentctl/src/sessions/sandboxes.rs @@ -0,0 +1,52 @@ +//! Resolving a Session's owning Agent to its materialized Sandbox. +//! +//! The reconciler and the Session service both need "the Sandbox this +//! Session's Agent runs in"; this is the one place that lookup lives. + +use std::rc::Rc; + +use ::sandbox::SandboxHandle; + +use crate::{AgentId, Error, control_plane::AgentRecord, control_plane::AgentStore}; + +/// Agent records and their Sandboxes, as one lookup. +pub struct AgentSandboxes { + agents: Rc, + sandboxes: Rc, +} + +impl AgentSandboxes { + /// Pairs the Agent store with the Sandbox service. + #[must_use] + pub fn new(agents: Rc, sandboxes: Rc) -> Self { + Self { agents, sandboxes } + } + + /// Gets the active Agent record by name. + /// + /// # Errors + /// + /// Returns an error when no active Agent has that name. + pub async fn agent_by_name(&self, name: &str) -> Result { + self.agents.get_by_name(name).await + } + + /// Gets an Agent record by identity. + /// + /// # Errors + /// + /// Returns an error when the Agent does not exist. + pub async fn agent(&self, id: AgentId) -> Result { + self.agents.get(id).await + } + + /// Opens the materialized Sandbox of `record`. + /// + /// # Errors + /// + /// Returns an error when the Agent has no materialized Sandbox or its + /// Provider cannot open it. + pub async fn open(&self, record: &AgentRecord) -> Result { + self.sandboxes.open(record).await + } +} diff --git a/agentctl/src/sessions/service.rs b/agentctl/src/sessions/service.rs new file mode 100644 index 0000000..e0d6424 --- /dev/null +++ b/agentctl/src/sessions/service.rs @@ -0,0 +1,621 @@ +//! User-facing Session operations coordinated with the reconciler. + +use std::{cell::RefCell, collections::HashMap, rc::Rc, time::Duration}; + +use ::sandbox::SandboxHandle; +use tokio::sync::Notify; + +use crate::{Error, control_plane, control_plane::WaitPolicy}; + +use super::{ + AgentSandboxes, AttachTarget, LifecycleState, NewSession, Session, SessionId, SessionName, SessionRequest, + SessionRuntime, SharedStore, State, Turn, Wakeup, +}; + +/// Ceiling for completion waiting after prompt submission. +const PROMPT_TIMEOUT_MAX: Duration = Duration::from_mins(30); + +/// Polls durable activity while a caller waits for completion. +const ACTIVITY_POLL: Duration = Duration::from_millis(250); + +/// Maximum time to wait for a newly launched harness to accept input. +const INPUT_READY_TIMEOUT: Duration = Duration::from_secs(15); +const INPUT_READY_POLL: Duration = Duration::from_millis(100); +const UPGRADE_SESSION_PASS_TIMEOUT: Duration = Duration::from_mins(1); +const UPGRADE_SANDBOX_INSPECTION_TIMEOUT: Duration = Duration::from_secs(5); + +/// Sessions that block an upgrade and Sessions that will restart without resumption. +#[derive(Debug, Default, Eq, PartialEq)] +pub struct UpgradeReadiness { + /// Active work or attachments that make the transition unsafe. + pub blockers: Vec, + /// Quiescent Sessions without a harness-native conversation to resume. + pub warnings: Vec, +} + +/// Durable Session registry whose effects are owned by the daemon controller. +pub struct Service { + store: SharedStore, + sandboxes: Rc, + runtime: Rc, + convergence: control_plane::Convergence, + wakeup: Wakeup, + /// Sessions with a delivery in flight, each with the signal its waiters + /// sleep on. Two concurrent prompts would interleave their keystrokes in + /// the harness's single input line, so deliveries are serialized per Session. + deliveries: RefCell>>, +} + +/// Marks one Session busy delivering for as long as it lives; dropping it, +/// including on cancellation, releases the Session and wakes the next sender. +struct Delivering<'a> { + deliveries: &'a RefCell>>, + session: SessionId, +} + +impl<'a> Delivering<'a> { + async fn acquire(deliveries: &'a RefCell>>, session: SessionId) -> Self { + loop { + let busy = { + let mut map = deliveries.borrow_mut(); + if let Some(released) = map.get(&session) { + released.clone() + } else { + map.insert(session, Rc::new(Notify::new())); + break; + } + }; + busy.notified().await; + } + Self { deliveries, session } + } +} + +impl Drop for Delivering<'_> { + fn drop(&mut self) { + if let Some(released) = self.deliveries.borrow_mut().remove(&self.session) { + released.notify_waiters(); + } + } +} + +impl Service { + /// Creates a Session service over durable storage, Agent Sandboxes, + /// Agent convergence and the Session controller. + #[must_use] + pub fn new( + store: SharedStore, + sandboxes: Rc, + runtime: Rc, + convergence: control_plane::Convergence, + wakeup: Wakeup, + ) -> Self { + Self { + store, + sandboxes, + runtime, + convergence, + wakeup, + deliveries: RefCell::default(), + } + } + + /// Creates or gets one named Session and waits until its driver is ready. + /// + /// `request` applies only when this call creates the Session. Its harness, + /// model and effort resolve in that order of precedence: the explicit + /// request, then the selected installation's manifest defaults, then the + /// harness's own defaults; the resolved values are recorded with the + /// Session. The initial prompt is handed to the harness at its first + /// launch, so the harness starts working before this call returns. + /// + /// # Errors + /// + /// Returns an error when persistence fails, the Agent is invalid, or an + /// explicit selection conflicts with an existing Session; with + /// [`WaitPolicy::FirstPass`] also when the single Agent pass fails. + pub async fn ensure( + &self, + agent: &str, + name: &SessionName, + request: SessionRequest, + wait: WaitPolicy, + ) -> Result { + let (owner, session) = self.prepare(agent, name, request).await?; + let converged = self.convergence.converge(agent, wait).await?; + if converged.id != owner.id { + // The Agent was deleted and its name reused while this request waited. + return Err(Error::Conflict); + } + converged.reject_stopped()?; + // On a brand-new Agent this is the first moment the answer exists. + Self::reject_omitted_optional_harness(&converged, session.harness)?; + self.wakeup.reconcile(session.id).await?; + self.store.session_attach_target(session.id).await + } + + /// Delivers a prompt to a running Session's harness. + /// + /// With `wait`, snapshots the completed-turn counter before delivery and + /// waits for it to advance with identical waiting activity in two consecutive + /// polls, 250 ms apart. Work observed during settling requires another + /// completion. This is a timing heuristic, not identification of an answer + /// to this prompt. + /// Read the conversation separately with [`Self::turns`]. + /// + /// In both modes delivery waits for input readiness. The runtime may establish + /// readiness before the harness reports its first conversation. The completion + /// timeout starts after submission; queuing, readiness and delivery are excluded. + /// Activity is polled from the local database every 250 ms. + /// + /// # Errors + /// + /// Returns an error when the Session is not running, the harness has not + /// become ready for input within a short grace period, the input cannot be + /// delivered, the Session fails mid-turn, or the wait exceeds `timeout`. + pub async fn prompt( + &self, + agent: &str, + name: &SessionName, + prompt: &str, + wait: bool, + timeout: Option, + ) -> Result<(), Error> { + if timeout.is_some_and(|timeout| timeout > PROMPT_TIMEOUT_MAX) { + return Err(Error::Invalid(format!( + "completion timeout must not exceed {}m", + PROMPT_TIMEOUT_MAX.as_secs() / 60 + ))); + } + let (session, sandbox) = self.open_running(agent, name).await?; + let id = session.id; + let delivering = Delivering::acquire(&self.deliveries, id).await; + let session = self.ready_to_prompt(id, name, &sandbox).await?; + let completed_before = session.status.reported.activity.turns; + self.runtime.prompt(&session, &sandbox, prompt).await?; + drop(delivering); + if !wait { + return Ok(()); + } + tokio::time::timeout( + timeout.unwrap_or(PROMPT_TIMEOUT_MAX), + self.wait_for_completion(id, name, completed_before), + ).await.map_err(|_| Error::Session(format!( + "timed out waiting for Session \"{name}\" to complete; the prompt was submitted; inspect turns before retrying" + )))? + } + + async fn wait_for_completion( + &self, + id: SessionId, + name: &SessionName, + mut completed_before: u64, + ) -> Result<(), Error> { + let mut settling = None; + loop { + let current = self.store.get_session(id).await?; + let activity = ¤t.status.reported.activity; + let waiting = match current.status.state { + State::Failed => { + return Err(Error::Session(format!( + "Session \"{name}\" failed while waiting for turn completion: {}", + current.status.lifecycle.failure.as_deref().unwrap_or("unknown error") + ))); + } + State::Idle => { + return Err(Error::Session(format!( + "Session \"{name}\" was stopped while waiting for turn completion" + ))); + } + State::Archived => { + return Err(Error::Session(format!( + "Session \"{name}\" was archived while waiting for turn completion" + ))); + } + State::WaitingForInput => true, + // An archive that has not stopped the harness yet leaves the turn to its own report. + State::Archiving => activity.phase == super::Phase::WaitingForInput, + State::Starting | State::Working => false, + }; + if activity.turns > completed_before && waiting { + if settling.as_ref() == Some(activity) { + return Ok(()); + } + settling = Some(activity.clone()); + } else { + // A new turn can already be running when the previous completion + // is observed. Its permission waits must not satisfy this wait. + completed_before = completed_before.max(activity.turns); + settling = None; + } + tokio::time::sleep(ACTIVITY_POLL).await; + } + } + + /// Waits for a report or runtime-observed input readiness. A harness may + /// create its conversation only after input arrives, so the first prompt + /// cannot depend on that conversation's start report. + async fn ready_to_prompt( + &self, + id: SessionId, + name: &SessionName, + sandbox: &SandboxHandle, + ) -> Result { + tokio::time::timeout(INPUT_READY_TIMEOUT, async { + loop { + let session = self.store.get_session(id).await?; + match session.status.state { + State::Working | State::WaitingForInput => return Ok(session), + State::Idle | State::Archiving | State::Archived | State::Failed => { + return Err(session.not_running_error()); + } + State::Starting => { + if self.runtime.input_ready(&session, sandbox).await? { + return Ok(session); + } + } + } + tokio::time::sleep(INPUT_READY_POLL).await; + } + }) + .await + .map_err(|_| Error::Session(format!("timed out waiting for Session \"{name}\" to accept input")))? + } + + /// Reads the Session's conversation as ordered turns, optionally the last `last`. + /// + /// # Errors + /// + /// Returns an error when the Session or its Sandbox is unavailable or the + /// conversation cannot be read. + pub async fn turns(&self, agent: &str, name: &SessionName, last: Option) -> Result, Error> { + let session = self.visible(agent, name).await?; + let owner = self.sandboxes.agent(session.agent_id).await?; + owner.reject_stopped()?; + let sandbox = self.sandboxes.open(&owner).await?; + let session = self.store.get_session(session.id).await?; + self.runtime.turns(&session, &sandbox, last).await + } + + async fn open_running(&self, agent: &str, name: &SessionName) -> Result<(Session, SandboxHandle), Error> { + let session = self.visible(agent, name).await?; + if session.is_archived() { + return Err(session.archived_error()); + } + let owner = self.sandboxes.agent(session.agent_id).await?; + owner.reject_stopped()?; + if session.status.lifecycle.state != LifecycleState::Running { + return Err(session.not_running_error()); + } + let sandbox = self.sandboxes.open(&owner).await?; + Ok((session, sandbox)) + } + + /// Refuses a Session on an optional installation this Agent's Sandbox does not carry. + /// + /// Reports the reason to the caller; the Session reconciler enforces it. Before the Sandbox is + /// materialized nothing is known, so the decision is deferred to the next attach. + fn reject_omitted_optional_harness( + owner: &control_plane::AgentRecord, + harness: crate::Harness, + ) -> Result<(), Error> { + let Some(installation) = owner.agent.spec.harness(harness) else { + return Ok(()); + }; + let Some(crate::sandbox::Assignment::Materialized { harnesses, .. }) = &owner.agent.status.sandbox else { + return Ok(()); + }; + if !installation.optional || harnesses.contains(&harness) { + return Ok(()); + } + Err(Error::Invalid(format!( + "Agent {:?} declares harness {:?} as optional and it is not installed, because its \ + host login is absent; sign in on the host and the next Agent convergence installs it", + owner.agent.metadata.name, + installation.kind.as_str() + ))) + } + + async fn prepare( + &self, + agent: &str, + name: &SessionName, + request: SessionRequest, + ) -> Result<(control_plane::AgentRecord, Session), Error> { + let owner = self.sandboxes.agent_by_name(agent).await?; + if owner.agent.metadata.deletion_timestamp.is_some() { + return Err(Error::Conflict); + } + owner.reject_stopped()?; + if let Some(harness) = request.harness + && owner.agent.spec.harness(harness).is_none() + { + return Err(Error::Invalid(format!( + "Agent {agent:?} does not declare harness {:?}", + harness.as_str() + ))); + } + let existing = match self.visible(agent, name).await { + Ok(session) => { + if session.is_archived() { + return Err(Error::Invalid(format!( + "Session \"{name}\" is archived; unarchive it before attaching or prompting" + ))); + } + reject_conflicting_selections(name, &session, &request)?; + Some(session) + } + Err(Error::NotFound) => None, + Err(error) => return Err(error), + }; + let harness = match (&existing, request.harness) { + (Some(session), _) => session.harness, + (None, Some(harness)) => harness, + (None, None) => { + owner + .agent + .spec + .default_harness() + .ok_or_else(|| Error::Invalid(format!("Agent {agent:?} has no default harness")))? + .kind + } + }; + // Validated before the Session is persisted: a Session name is bound to its harness for the + // life of the Session, so a refused attempt must not leave the name claimed. + Self::reject_omitted_optional_harness(&owner, harness)?; + let session = if let Some(session) = existing { + session + } else { + let installation = owner.agent.spec.harness(harness).ok_or_else(|| { + Error::Invalid(format!( + "Agent {agent:?} does not declare harness {:?}", + harness.as_str() + )) + })?; + if let Some(initial_prompt) = &request.initial_prompt { + crate::harness::validate_initial_prompt(initial_prompt)?; + } + let new = NewSession { + initial_prompt: request.initial_prompt, + ..NewSession::resolved(installation.kind, request.model_selection, &installation.defaults) + }; + self.store.ensure_session(agent, name, new).await? + }; + if session.agent_id != owner.id { + return Err(Error::Conflict); + } + self.store.activate_session(session.id).await?; + Ok((owner, session)) + } + + /// Gets one durable Session from the active Agent incarnation. + /// + /// # Errors + /// + /// Returns an error when either resource is missing or persistent state cannot be read. + pub async fn get(&self, agent: &str, name: &SessionName) -> Result { + self.visible(agent, name).await + } + + /// Lists durable Sessions, optionally scoped to one active Agent incarnation. + /// + /// # Errors + /// + /// Returns an error when the scoped Agent is missing or persistent state cannot be read. + pub async fn list(&self, agent: Option<&str>) -> Result, Error> { + let Some(agent) = agent else { + return self.live_sessions().await; + }; + self.sandboxes.agent_by_name(agent).await?; + Ok(live(self.store.list_agent_sessions(agent).await?)) + } + + /// Releases one Session: its harness is stopped and the Session is removed. + /// + /// The request is recorded first, so a Session that cannot be released yet + /// stays marked and is retried by the Session controller instead of leaving + /// a harness running with nothing tracking it. The Session is no longer + /// listed or resolvable by name from the moment it is marked, and its name + /// becomes available again once the harness is gone. Repeating the request + /// while the release is still pending is safe. + /// + /// # Errors + /// + /// Returns an error when the Agent or Session is missing, the request + /// cannot be recorded, or the release pass fails; the marker survives a + /// failed pass. + pub async fn delete(&self, agent: &str, name: &SessionName) -> Result<(), Error> { + let session = self.store.mark_session_deleting(agent, name).await?; + self.wakeup.reconcile(session.id).await.map_err(|error| { + Error::Session(format!( + "Session \"{name}\" is marked for deletion and will be retried; stopping its harness failed: {error}" + )) + }) + } + + /// Archives or unarchives one Session and returns it as recorded. + /// + /// Archiving stops the harness and keeps it stopped, once any turn in + /// progress has ended; the Session keeps its name and conversation. + /// Unarchiving leaves it Idle, so the next attach resumes it. Repeating + /// either is safe. + /// + /// # Errors + /// + /// Returns an error when the Agent or Session is missing, the request + /// cannot be recorded, or the pass fails; the request survives a failed pass. + pub async fn set_archived(&self, agent: &str, name: &SessionName, archived: bool) -> Result { + let session = self.store.set_session_archived(agent, name, archived).await?; + self.wakeup.reconcile(session.id).await?; + self.store.get_session(session.id).await + } + + /// Every Session that is not being deleted. One already on its way out + /// is gone as far as listings and upgrades are concerned. + async fn live_sessions(&self) -> Result, Error> { + Ok(live(self.store.list_all_sessions().await?)) + } + + /// Resolves a Session a caller may still act on. A Session marked for + /// release is already gone as far as its name is concerned. + async fn visible(&self, agent: &str, name: &SessionName) -> Result { + let session = self.store.get_agent_session(agent, name).await?; + if session.is_deleting() { + return Err(Error::NotFound); + } + Ok(session) + } + + /// Lists active work and terminal attachments that must finish before an upgrade. + /// + /// # Errors + /// + /// Returns an error when the durable Session or Sandbox state cannot be inspected. + pub async fn upgrade_readiness(&self) -> Result { + tokio::time::timeout(UPGRADE_SESSION_PASS_TIMEOUT, self.inspect_upgrade_readiness()) + .await + .map_err(|_| Error::Session("timed out checking Sessions before upgrade".into()))? + } + + async fn inspect_upgrade_readiness(&self) -> Result { + let mut readiness = UpgradeReadiness::default(); + for session in self.live_sessions().await? { + let label = format!("session/{}/{}", session.agent, session.name); + // The harness's own state, so an archive waiting for a turn to end + // still holds the upgrade back until it has. + let state = session.status.harness_state(); + if state == State::Working { + readiness.blockers.push(format!("{label} (working)")); + continue; + } + if state == State::Starting && session.status.reported.harness_session_id.is_some() { + readiness.blockers.push(format!("{label} (starting)")); + continue; + } + let Some(sandbox) = self.upgrade_sandbox(&session).await? else { + if state == State::Starting { + readiness + .warnings + .push(format!("{label} will start a new conversation")); + } + continue; + }; + match self.runtime.observe(&session, &sandbox).await? { + super::runtime::Observation::Alive { attached: true, .. } => { + readiness.blockers.push(format!("{label} (terminal attached)")); + } + super::runtime::Observation::Alive { attached: false, .. } + if session.status.reported.harness_session_id.is_none() => + { + readiness + .warnings + .push(format!("{label} will start a new conversation")); + } + super::runtime::Observation::Missing if state == State::Starting => { + readiness + .warnings + .push(format!("{label} will start a new conversation")); + } + super::runtime::Observation::Missing | super::runtime::Observation::Alive { .. } => {} + } + } + Ok(readiness) + } + + /// Stops quiescent Session runtimes once after a software upgrade so normal + /// reconciliation relaunches them with the current harness hooks. + /// + /// # Errors + /// + /// Returns an error, without clearing the caller-owned marker, when a + /// running Sandbox cannot be inspected or a Session becomes attached. + pub async fn relaunch_after_upgrade(&self) -> Result<(), Error> { + tokio::time::timeout(UPGRADE_SESSION_PASS_TIMEOUT, self.relaunch_sessions()) + .await + .map_err(|_| Error::Session("timed out relaunching Sessions after upgrade".into()))? + } + + async fn relaunch_sessions(&self) -> Result<(), Error> { + // An archived Session stays stopped, even one still finishing its last turn. + for session in self + .live_sessions() + .await? + .into_iter() + .filter(|session| !session.is_archived()) + { + let Some(sandbox) = self.upgrade_sandbox(&session).await? else { + self.store.reset_session_launch_attempts(session.id).await?; + continue; + }; + match self.runtime.observe(&session, &sandbox).await? { + super::runtime::Observation::Missing => { + self.store.activate_session(session.id).await?; + } + super::runtime::Observation::Alive { attached: true, .. } => { + return Err(Error::Session(format!( + "Session \"{}/{}\" became attached during upgrade", + session.agent, session.name + ))); + } + super::runtime::Observation::Alive { attached: false, .. } => { + // Persist the relaunch request before removing an Idle runtime. + self.store.activate_session(session.id).await?; + self.runtime.stop(&session, &sandbox).await?; + } + } + self.store.reset_session_launch_attempts(session.id).await?; + } + Ok(()) + } + + async fn upgrade_sandbox(&self, session: &Session) -> Result, Error> { + let owner = self.sandboxes.agent(session.agent_id).await?; + if !matches!( + owner.agent.status.sandbox, + Some(crate::sandbox::Assignment::Materialized { .. }) + ) { + return Ok(None); + } + let opened = tokio::time::timeout(UPGRADE_SANDBOX_INSPECTION_TIMEOUT, self.sandboxes.open(&owner)) + .await + .map_err(|_| { + Error::Session(format!( + "timed out inspecting the Sandbox for Session \"{}\"", + session.name + )) + })?; + let sandbox = match opened { + Ok(sandbox) => sandbox, + Err(Error::Sandbox(error)) if error.is_not_found() => return Ok(None), + Err(error) => return Err(error), + }; + if sandbox.snapshot().state == ::sandbox::SandboxState::Stopped { + return Ok(None); + } + Ok(Some(sandbox)) + } +} + +/// Leaves out Sessions that are being deleted. +fn live(sessions: Vec) -> Vec { + sessions.into_iter().filter(|session| !session.is_deleting()).collect() +} + +/// An existing Session keeps its recorded harness, model and effort; only an +/// explicit, differing request is an error, so a manifest default that changed +/// after creation never conflicts with relaunching or attaching. +fn reject_conflicting_selections(name: &SessionName, session: &Session, request: &SessionRequest) -> Result<(), Error> { + if let Some(harness) = request.harness + && harness != session.harness + { + return Err(Error::Invalid(format!( + "Session \"{name}\" already uses harness {:?}, not {:?}", + session.harness.as_str(), + harness.as_str() + ))); + } + if let Some(conflict) = session.model_selection.conflict_with(&request.model_selection) { + return Err(Error::Invalid(format!("Session \"{name}\" {conflict}"))); + } + Ok(()) +} diff --git a/agentctl/src/sessions/transcript.rs b/agentctl/src/sessions/transcript.rs new file mode 100644 index 0000000..8c2ff67 --- /dev/null +++ b/agentctl/src/sessions/transcript.rs @@ -0,0 +1,50 @@ +//! Runtime-neutral conversation turns read from a Session. + +use serde::{Deserialize, Serialize}; + +/// One operator prompt and everything the harness produced in response. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Turn { + /// Messages that make up the turn, in order. + pub messages: Vec, +} + +/// One message inside a [`Turn`]. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Message { + /// Who authored the message. + pub role: Role, + /// Ordered content parts. + pub parts: Vec, +} + +/// Author of a [`Message`]. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Role { + /// The operator (or an upstream orchestrator). + User, + /// The harness model. + Assistant, +} + +/// One content part of a [`Message`], shaped after AHP chat parts. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Part { + /// Plain assistant or operator text. + Text { + /// The text content. + text: String, + }, + /// A tool invocation and, when known, whether it failed. + ToolCall { + /// Harness-native tool name. + name: String, + /// Whether the recorded result was an error. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + failed: bool, + }, +} diff --git a/agentctl/src/ssh/client_config.rs b/agentctl/src/ssh/client_config.rs new file mode 100644 index 0000000..fcdce6a --- /dev/null +++ b/agentctl/src/ssh/client_config.rs @@ -0,0 +1,635 @@ +//! OpenSSH client configuration files owned by the platform. +//! +//! `agentd` rewrites `/ssh/config` and `/ssh/known_hosts` as Agents +//! are applied and deleted; `agentctl ssh-config install` inserts one `Include` +//! of that config at the top of the user's own `~/.ssh/config` and touches +//! nothing else in it. + +use std::{ + fmt::Write as _, + path::{Path, PathBuf}, +}; + +use crate::Error; + +/// One `Host` block in the generated client configuration. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HostEntry { + /// `Host` alias, `agentctl-`. + pub alias: String, + /// Guest user. + pub user: String, + /// Complete `ProxyCommand` value. + pub proxy_command: String, + /// `HostKeyAlias`, the stable incarnation-keyed `known_hosts` name. + pub host_key_alias: String, + /// Absolute private key path. + pub identity_file: PathBuf, + /// Absolute `known_hosts` path. + pub known_hosts_file: PathBuf, +} + +/// Renders the platform-owned client configuration for every SSH-enabled Agent. +#[must_use] +pub fn render_config(entries: &[HostEntry], user_home: Option<&Path>) -> String { + let mut text = String::from( + "# Generated by agentd for `agentctl ssh`; every edit is overwritten.\n\ + # Include it from your own client configuration with `agentctl ssh-config install`.\n", + ); + for entry in entries { + let _infallible = writeln!( + text, + "\nHost {}\n User {}\n ProxyCommand {}\n HostKeyAlias {}\n IdentityFile {}\n UserKnownHostsFile {}\n IdentitiesOnly yes", + entry.alias, + entry.user, + entry.proxy_command, + entry.host_key_alias, + render_path(&entry.identity_file, user_home), + render_path(&entry.known_hosts_file, user_home), + ); + } + text +} + +/// Renders a host path for `IdentityFile`, `UserKnownHostsFile` or `Include`. +/// +/// A path below the user's home is written `~/...` with forward slashes, which +/// every OpenSSH port expands; anything else is written as the host spells it. +/// Anything outside a conservative character set is double-quoted with `"` and +/// `\` escaped, which is what OpenSSH's tokenizer understands, and `%` is +/// doubled because OpenSSH percent-expands all three directives. +#[must_use] +pub fn render_path(path: &Path, user_home: Option<&Path>) -> String { + let text = user_home + .and_then(|home| path.strip_prefix(home).ok()) + .filter(|relative| !relative.as_os_str().is_empty()) + .map_or_else( + || path.display().to_string(), + |relative| { + let mut text = String::from("~"); + for component in relative.components() { + text.push('/'); + text.push_str(&component.as_os_str().to_string_lossy()); + } + text + }, + ); + quote_config_value(&text).replace('%', "%%") +} + +/// How the host runs the string OpenSSH hands to `ProxyCommand`. +/// +/// Unix OpenSSH runs it through `/bin/sh -c`; Win32-OpenSSH hands it to +/// `CreateProcess`, which knows only double quotes. `agentd` generates the +/// configuration on the host that runs `ssh`, so it picks the host's shell. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CommandShell { + /// `/bin/sh`: single quotes make every character literal. + Posix, + /// `CreateProcess` command-line rules: double quotes, no expansion. + Windows, +} + +impl CommandShell { + /// Returns the shell of the host this binary runs on. + #[must_use] + pub const fn host() -> Self { + if cfg!(windows) { Self::Windows } else { Self::Posix } + } +} + +/// Renders the `ProxyCommand` value dialing one Agent through `agentctl`. +/// +/// The executable is always absolute: the shell running the command on Unix +/// would expand `~`, Win32-OpenSSH would not, and neither has `agentctl` on a +/// predictable `PATH`. Three parsers see the value: OpenSSH percent-expands it +/// (`%h`, `%p`, so a literal `%` becomes `%%`), then the host's shell splits +/// it. Quoting for that shell makes `$`, backticks, `;`, spaces and quotes in +/// a user's directory name literal, so such paths work rather than being +/// refused; only characters no configuration line can carry are rejected. +/// +/// # Errors +/// +/// Returns an error when the path is not UTF-8 or contains a line break or NUL. +pub fn render_proxy_command(agentctl: &Path, agent: &str, shell: CommandShell) -> Result { + let Some(executable) = agentctl.to_str() else { + return Err(Error::Invalid(format!( + "agentctl path {} is not valid UTF-8 and cannot be written to the SSH client configuration", + agentctl.display() + ))); + }; + if executable.contains(['\n', '\r', '\0']) { + return Err(Error::Invalid(format!( + "agentctl path {executable:?} contains a line break, which no SSH configuration line can carry" + ))); + } + let quoted = match shell { + CommandShell::Posix => posix_quote(executable), + CommandShell::Windows => windows_quote(executable), + }; + Ok(format!("{} {}", quoted.replace('%', "%%"), proxy_arguments(agent))) +} + +/// The arguments every Agent's `ProxyCommand` ends with, whatever the executable. +fn proxy_arguments(agent: &str) -> String { + format!("ssh-proxy agent/{agent}") +} + +/// Returns whether OpenSSH's resolved configuration for an Agent's alias, as +/// `ssh -G` prints it, dials the Agent through `agentctl`. +/// +/// This is how the user's own client configuration is known to reach the +/// generated one: OpenSSH applies its own `Include`, `Host` and `Match` +/// rules, so however the user included it, only the outcome is checked. The +/// executable is not compared, since any `agentctl` reaches the same daemon. +#[must_use] +pub fn resolves_through_agentctl(resolved: &str, agent: &str) -> bool { + let arguments = format!(" {}", proxy_arguments(agent)); + resolved.lines().any(|line| { + line.split_once(' ') + .is_some_and(|(keyword, value)| keyword == "proxycommand" && value.trim_end().ends_with(&arguments)) + }) +} + +/// Quotes one word for `/bin/sh`: single quotes, with an embedded `'` written as `'\''`. +fn posix_quote(text: &str) -> String { + if is_plain(text) { + return text.to_owned(); + } + format!("'{}'", text.replace('\'', r"'\''")) +} + +/// Quotes one word for `CreateProcess` command-line parsing: double quotes, in +/// which a backslash is literal unless it precedes a `"`; `"` is not valid in a +/// Windows path, so it is escaped defensively. +fn windows_quote(text: &str) -> String { + if is_plain(text) { + return text.to_owned(); + } + format!("\"{}\"", text.replace('"', "\\\"")) +} + +/// Whether a token needs no quoting under any of the parsers involved. +fn is_plain(text: &str) -> bool { + !text.is_empty() + && text + .chars() + .all(|character| character.is_ascii_alphanumeric() || "/~._-:+@".contains(character)) +} + +/// Quotes an OpenSSH configuration token unless it consists only of characters +/// that need no quoting. Inside quotes `\` and `"` are backslash-escaped. +fn quote_config_value(text: &str) -> String { + if is_plain(text) { + return text.to_owned(); + } + let mut quoted = String::with_capacity(text.len() + 2); + quoted.push('"'); + for character in text.chars() { + if character == '"' || character == '\\' { + quoted.push('\\'); + } + quoted.push(character); + } + quoted.push('"'); + quoted +} + +/// Replaces every `known_hosts` line for `alias` with `public_key`. +/// +/// # Errors +/// +/// Returns an error when the file cannot be read or rewritten. +pub fn upsert_known_host(path: &Path, alias: &str, public_key: &str) -> Result<(), Error> { + let mut lines = known_hosts_without(path, alias)?; + lines.push(format!("{alias} {public_key}")); + write_lines(path, &lines) +} + +/// Removes every `known_hosts` line for `alias`, leaving other entries untouched. +/// +/// # Errors +/// +/// Returns an error when the file cannot be read or rewritten. +pub fn remove_known_host(path: &Path, alias: &str) -> Result<(), Error> { + if !path.exists() { + return Ok(()); + } + let lines = known_hosts_without(path, alias)?; + write_lines(path, &lines) +} + +fn known_hosts_without(path: &Path, alias: &str) -> Result, Error> { + let existing = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(error.into()), + }; + Ok(existing + .lines() + .filter(|line| line.split_whitespace().next() != Some(alias)) + .map(str::to_owned) + .collect()) +} + +fn write_lines(path: &Path, lines: &[String]) -> Result<(), Error> { + let mut text = lines.join("\n"); + if !text.is_empty() { + text.push('\n'); + } + write_private_file(path, text.as_bytes()) +} + +/// Writes a user-only file atomically: the content lands under a temporary +/// name beside the target and is renamed over it. +/// +/// # Errors +/// +/// Returns an error when the parent directory is missing or the file cannot be written. +pub(super) fn write_private_file(path: &Path, contents: &[u8]) -> Result<(), Error> { + let directory = path + .parent() + .ok_or_else(|| Error::Invalid(format!("{} has no parent directory", path.display())))?; + let file_name = path + .file_name() + .ok_or_else(|| Error::Invalid(format!("{} has no file name", path.display())))? + .to_string_lossy(); + let temporary = directory.join(format!(".{file_name}.{}.tmp", std::process::id())); + std::fs::write(&temporary, contents)?; + crate::local::home::secure_file(&temporary)?; + if let Err(error) = std::fs::rename(&temporary, path) { + let _ignored = std::fs::remove_file(&temporary); + return Err(error.into()); + } + Ok(()) +} + +/// Result of installing the `Include` line into the user's client configuration. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum IncludeOutcome { + /// The line was inserted at the top of the file. + Installed, + /// The file already carried the line. + AlreadyInstalled, +} + +/// Renders the `Include` directive for the generated configuration. +#[must_use] +pub fn render_include(config: &Path, user_home: Option<&Path>) -> String { + format!("Include {}", render_path(config, user_home)) +} + +/// Idempotently inserts `include` as the first line of `user_config`. +/// +/// OpenSSH applies an `Include` inside a `Host` or `Match` block only to that +/// block, so only a copy that precedes the first block counts as installed; a +/// copy nested in a block is left alone and a global one is added above it. +/// Every existing line is kept verbatim. A symbolic link, as dotfile managers +/// create, is followed so the linked file is updated and the link survives, +/// also when its target does not exist yet. A missing file or directory is +/// created with user-only access. +/// +/// # Errors +/// +/// Returns an error when the file cannot be read or written. +pub fn install_include(user_config: &Path, include: &str) -> Result { + let target = link_target(user_config)?; + let existing = match std::fs::read_to_string(&target) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(error.into()), + }; + if global_lines(&existing).any(|line| line == include) { + return Ok(IncludeOutcome::AlreadyInstalled); + } + if let Some(directory) = target.parent() + && !directory.exists() + { + std::fs::create_dir_all(directory)?; + crate::local::home::secure_directory(directory)?; + } + let mut text = format!("{include}\n"); + if !existing.is_empty() { + text.push('\n'); + text.push_str(&existing); + } + write_private_file(&target, text.as_bytes())?; + Ok(IncludeOutcome::Installed) +} + +/// Follows a chain of symbolic links to the file they name, whether or not it +/// exists yet, so a write lands in the linked file and the link survives. +fn link_target(path: &Path) -> Result { + let mut current = path.to_path_buf(); + for _ in 0..40 { + match std::fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => { + let next = std::fs::read_link(¤t)?; + current = if next.is_absolute() { + next + } else { + current + .parent() + .map_or_else(|| next.clone(), |parent| parent.join(&next)) + }; + } + Ok(_) => return Ok(current), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(current), + Err(error) => return Err(error.into()), + } + } + Err(Error::Invalid(format!( + "{} is a chain of too many symbolic links", + path.display() + ))) +} + +/// Yields the trimmed lines of an OpenSSH client configuration that apply +/// unconditionally: everything before the first `Host` or `Match` keyword. +fn global_lines(text: &str) -> impl Iterator { + text.lines().map(str::trim).take_while(|line| { + let keyword = line + .split(|character: char| character.is_whitespace() || character == '=') + .next() + .unwrap_or_default(); + !keyword.eq_ignore_ascii_case("host") && !keyword.eq_ignore_ascii_case("match") + }) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use std::path::Path; + + use super::{ + CommandShell, HostEntry, IncludeOutcome, install_include, remove_known_host, render_config, render_include, + render_path, render_proxy_command, resolves_through_agentctl, upsert_known_host, + }; + + fn entry(name: &str, id: &str, root: &Path) -> HostEntry { + HostEntry { + alias: format!("agentctl-{name}"), + user: "agent".into(), + proxy_command: render_proxy_command(Path::new("/usr/local/bin/agentctl"), name, CommandShell::Posix) + .expect("proxy command"), + host_key_alias: format!("agent-{id}"), + identity_file: root.join(id).join("id_ed25519"), + known_hosts_file: root.join("known_hosts"), + } + } + + #[test] + fn config_dials_through_agentctl_and_pins_the_incarnation_key() { + let home = Path::new("/home/me"); + let root = home.join(".agent").join("ssh"); + let text = render_config(&[entry("worker", "1111", &root)], Some(home)); + let expected = "\ +# Generated by agentd for `agentctl ssh`; every edit is overwritten. +# Include it from your own client configuration with `agentctl ssh-config install`. + +Host agentctl-worker + User agent + ProxyCommand /usr/local/bin/agentctl ssh-proxy agent/worker + HostKeyAlias agent-1111 + IdentityFile ~/.agent/ssh/1111/id_ed25519 + UserKnownHostsFile ~/.agent/ssh/known_hosts + IdentitiesOnly yes +"; + assert_eq!(text, expected); + assert_eq!(render_config(&[], Some(home)).lines().count(), 2); + } + + #[test] + fn paths_outside_home_and_with_spaces_are_absolute_and_quoted() { + assert_eq!( + render_path( + Path::new("/srv/agent home/ssh/known_hosts"), + Some(Path::new("/home/me")) + ), + "\"/srv/agent home/ssh/known_hosts\"" + ); + assert_eq!( + render_path(Path::new("/home/me"), Some(Path::new("/home/me"))), + "/home/me" + ); + assert_eq!( + render_proxy_command(Path::new("/opt/agent tools/agentctl"), "worker", CommandShell::Posix).expect("posix"), + "'/opt/agent tools/agentctl' ssh-proxy agent/worker" + ); + assert_eq!( + render_path(Path::new("/srv/say \"hi\"/known_hosts"), None), + r#""/srv/say \"hi\"/known_hosts""# + ); + assert_eq!( + render_path(Path::new("/srv/100%/known_hosts"), None), + r#""/srv/100%%/known_hosts""#, + "IdentityFile and UserKnownHostsFile are percent-expanded" + ); + assert_eq!( + render_include(Path::new("/srv/100%/config"), None), + r#"Include "/srv/100%%/config""#, + "Include is percent-expanded like IdentityFile" + ); + } + + #[test] + fn proxy_command_quotes_for_the_host_shell_and_doubles_percent() { + // Windows: CreateProcess rules, backslashes literal, `%` doubled for OpenSSH. + assert_eq!( + render_proxy_command( + Path::new(r"C:\Users\100%$user\.local\bin\agentctl.exe"), + "worker", + CommandShell::Windows + ) + .expect("Windows"), + r#""C:\Users\100%%$user\.local\bin\agentctl.exe" ssh-proxy agent/worker"# + ); + assert_eq!( + render_proxy_command(Path::new(r"C:\agent\agentctl.exe"), "worker", CommandShell::Windows) + .expect("Windows plain"), + r#""C:\agent\agentctl.exe" ssh-proxy agent/worker"# + ); + // Unix: single quotes make shell syntax in the user's directory name literal. + assert_eq!( + render_proxy_command(Path::new("/home/we$ird;u'ser/agentctl"), "worker", CommandShell::Posix) + .expect("posix"), + r"'/home/we$ird;u'\''ser/agentctl' ssh-proxy agent/worker" + ); + assert_eq!( + render_proxy_command(Path::new("/home/100%/agentctl"), "worker", CommandShell::Posix).expect("posix"), + "'/home/100%%/agentctl' ssh-proxy agent/worker" + ); + for impossible in ["/opt/line\nbreak/agentctl", "/opt/nul\0/agentctl"] { + assert!( + render_proxy_command(Path::new(impossible), "worker", CommandShell::Posix).is_err(), + "{impossible:?} cannot be written on one configuration line" + ); + } + } + + /// The Unix form survives the real shell: what `sh` sees after OpenSSH has + /// undone `%%` is exactly the installed path. + #[cfg(unix)] + #[test] + fn posix_proxy_command_round_trips_through_sh() { + for path in [ + "/opt/agent tools/agentctl", + "/home/we$ird;u'ser/`id`/agentctl", + "/home/100%/agent\"quote/agentctl", + ] { + let rendered = render_proxy_command(Path::new(path), "worker", CommandShell::Posix).expect("posix"); + let command = rendered.replace("%%", "%"); + let word = command.strip_suffix(" ssh-proxy agent/worker").expect("suffix"); + let output = std::process::Command::new("sh") + .arg("-c") + .arg(format!("printf '%s' {word}")) + .output() + .expect("sh"); + assert_eq!(String::from_utf8(output.stdout).expect("UTF-8"), path); + } + } + + #[test] + fn known_hosts_entries_are_keyed_by_alias() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("known_hosts"); + std::fs::write(&path, "github.com ssh-ed25519 AAAAgithub\n").expect("seed"); + upsert_known_host(&path, "agent-1", "ssh-ed25519 AAAAone").expect("insert"); + upsert_known_host(&path, "agent-2", "ssh-ed25519 AAAAtwo").expect("insert second"); + upsert_known_host(&path, "agent-1", "ssh-ed25519 AAAAone-rotated").expect("replace"); + assert_eq!( + std::fs::read_to_string(&path).expect("read"), + "github.com ssh-ed25519 AAAAgithub\nagent-2 ssh-ed25519 AAAAtwo\nagent-1 ssh-ed25519 AAAAone-rotated\n" + ); + remove_known_host(&path, "agent-1").expect("remove"); + remove_known_host(&path, "agent-missing").expect("remove absent alias"); + assert_eq!( + std::fs::read_to_string(&path).expect("read"), + "github.com ssh-ed25519 AAAAgithub\nagent-2 ssh-ed25519 AAAAtwo\n" + ); + remove_known_host(&directory.path().join("absent"), "agent-1").expect("absent file is fine"); + } + + #[test] + fn include_goes_to_the_top_once_and_keeps_every_other_line() { + let directory = tempfile::tempdir().expect("temporary directory"); + let config = directory.path().join(".ssh").join("config"); + let include = "Include ~/.agent/ssh/config"; + + assert_eq!( + install_include(&config, include).expect("fresh install"), + IncludeOutcome::Installed + ); + assert_eq!( + std::fs::read_to_string(&config).expect("read"), + "Include ~/.agent/ssh/config\n" + ); + assert_eq!( + install_include(&config, include).expect("repeat"), + IncludeOutcome::AlreadyInstalled + ); + + let existing = "# mine\nHost github.com\n User git\n"; + std::fs::write(&config, existing).expect("user config"); + assert_eq!( + install_include(&config, include).expect("install"), + IncludeOutcome::Installed + ); + assert_eq!( + std::fs::read_to_string(&config).expect("read"), + format!("Include ~/.agent/ssh/config\n\n{existing}") + ); + assert_eq!( + install_include(&config, include).expect("repeat"), + IncludeOutcome::AlreadyInstalled + ); + + // A copy scoped to a Host block does not apply to the Agent aliases, so the + // global line is still added; the scoped copy is kept verbatim. + let scoped = "Host x\n Include ~/.agent/ssh/config\n"; + std::fs::write(&config, scoped).expect("scoped include"); + assert_eq!( + install_include(&config, include).expect("scoped copy"), + IncludeOutcome::Installed + ); + assert_eq!( + std::fs::read_to_string(&config).expect("read"), + format!("Include ~/.agent/ssh/config\n\n{scoped}") + ); + std::fs::write(&config, " include ~/.agent/ssh/config\nMatch all\n").expect("indented global"); + assert_eq!( + install_include(&config, "include ~/.agent/ssh/config").expect("indented global line counts"), + IncludeOutcome::AlreadyInstalled + ); + } + + #[cfg(unix)] + #[test] + fn include_install_follows_a_symlinked_user_config() { + let directory = tempfile::tempdir().expect("temporary directory"); + let dotfiles = directory.path().join("dotfiles").join("ssh_config"); + std::fs::create_dir_all(dotfiles.parent().expect("parent")).expect("dotfiles"); + std::fs::write(&dotfiles, "Host github.com\n User git\n").expect("managed config"); + let ssh = directory.path().join(".ssh"); + std::fs::create_dir_all(&ssh).expect(".ssh"); + let config = ssh.join("config"); + std::os::unix::fs::symlink(&dotfiles, &config).expect("symlink"); + + assert_eq!( + install_include(&config, "Include ~/.agent/ssh/config").expect("install"), + IncludeOutcome::Installed + ); + assert!( + std::fs::symlink_metadata(&config) + .expect("metadata") + .file_type() + .is_symlink() + ); + assert_eq!( + std::fs::read_to_string(&dotfiles).expect("managed config"), + "Include ~/.agent/ssh/config\n\nHost github.com\n User git\n" + ); + + // A link whose target does not exist yet: the target is created, the link kept. + let dangling_target = directory.path().join("dotfiles").join("later").join("ssh_config"); + let dangling = ssh.join("config-dangling"); + std::os::unix::fs::symlink(&dangling_target, &dangling).expect("dangling symlink"); + assert_eq!( + install_include(&dangling, "Include ~/.agent/ssh/config").expect("install through dangling link"), + IncludeOutcome::Installed + ); + assert!( + std::fs::symlink_metadata(&dangling) + .expect("metadata") + .file_type() + .is_symlink() + ); + assert_eq!( + std::fs::read_to_string(&dangling_target).expect("created target"), + "Include ~/.agent/ssh/config\n" + ); + } + + #[test] + fn a_resolved_alias_reaches_the_agent_only_through_its_own_proxy_command() { + let proxy = render_proxy_command(Path::new("/opt/my tools/100%/agentctl"), "worker", CommandShell::Posix) + .expect("proxy command"); + let resolved = format!("user agent\nproxycommand {proxy}\nhostkeyalias agent-1\n"); + + assert!(resolves_through_agentctl(&resolved, "worker")); + assert!(!resolves_through_agentctl(&resolved, "coworker")); + assert!(!resolves_through_agentctl( + &resolved.replace("/worker", "/coworker"), + "worker" + )); + assert!( + !resolves_through_agentctl("user me\nhostname agentctl-worker\n", "worker"), + "an alias OpenSSH does not know resolves to no proxy command" + ); + assert!( + !resolves_through_agentctl("proxycommand ssh -W %h:%p jump\n", "worker"), + "an earlier match of the user's own wins over the generated one" + ); + } +} diff --git a/agentctl/src/ssh/keys.rs b/agentctl/src/ssh/keys.rs new file mode 100644 index 0000000..77308ce --- /dev/null +++ b/agentctl/src/ssh/keys.rs @@ -0,0 +1,74 @@ +//! Ed25519 key pairs in OpenSSH encoding. + +use ssh_key::{Algorithm, LineEnding, PrivateKey, rand_core::OsRng}; +use zeroize::Zeroizing; + +use crate::Error; + +/// One freshly generated or decoded key pair in OpenSSH encoding. +pub struct KeyPair { + /// The private key in OpenSSH private key format, ending in a newline. + pub private: Zeroizing, + /// The public key as one `authorized_keys`/`known_hosts` entry without a trailing newline. + pub public: String, +} + +impl KeyPair { + /// Generates an Ed25519 key pair with the host's operating system randomness. + /// + /// # Errors + /// + /// Returns an error when the key cannot be generated or encoded. + pub fn generate_ed25519(comment: &str) -> Result { + let mut key = PrivateKey::random(&mut OsRng, Algorithm::Ed25519).map_err(|error| key_error(&error))?; + key.set_comment(comment); + Self::from_private(&key) + } + + /// Decodes an OpenSSH private key and derives its public entry. + /// + /// # Errors + /// + /// Returns an error when the bytes are not an unencrypted OpenSSH private key. + pub fn from_openssh(private: &[u8]) -> Result { + let key = PrivateKey::from_openssh(private).map_err(|error| key_error(&error))?; + Self::from_private(&key) + } + + fn from_private(key: &PrivateKey) -> Result { + let private = key.to_openssh(LineEnding::LF).map_err(|error| key_error(&error))?; + let public = key.public_key().to_openssh().map_err(|error| key_error(&error))?; + Ok(Self { + private: Zeroizing::new(private.to_string()), + public, + }) + } +} + +fn key_error(error: &ssh_key::Error) -> Error { + Error::Daemon(format!("SSH key operation failed: {error}")) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::KeyPair; + + #[test] + fn generated_keys_are_openssh_ed25519() { + let pair = KeyPair::generate_ed25519("agent-test").expect("key pair"); + assert!(pair.private.starts_with("-----BEGIN OPENSSH PRIVATE KEY-----\n")); + assert!(pair.private.ends_with("-----END OPENSSH PRIVATE KEY-----\n")); + assert!(pair.public.starts_with("ssh-ed25519 AAAA")); + assert!(pair.public.ends_with(" agent-test")); + assert!(!pair.public.contains('\n')); + + let decoded = KeyPair::from_openssh(pair.private.as_bytes()).expect("decode"); + assert_eq!(decoded.public, pair.public); + assert_ne!( + KeyPair::generate_ed25519("other").expect("second pair").public, + pair.public + ); + } +} diff --git a/agentctl/src/ssh/memory.rs b/agentctl/src/ssh/memory.rs new file mode 100644 index 0000000..40c06d5 --- /dev/null +++ b/agentctl/src/ssh/memory.rs @@ -0,0 +1,50 @@ +//! In-memory SSH host key storage for tests and single-process use. + +use std::{cell::RefCell, collections::BTreeMap}; + +use ::sandbox::LocalFuture; +use zeroize::Zeroizing; + +use crate::{AgentId, Error}; + +use super::HostKeyStore; + +/// Keeps host keys in process memory. +#[derive(Default)] +pub struct InMemoryHostKeyStore { + keys: RefCell>>>, +} + +impl InMemoryHostKeyStore { + /// Creates an empty store. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Returns whether a host key is stored for the incarnation. + #[must_use] + pub fn contains(&self, id: AgentId) -> bool { + self.keys.borrow().contains_key(&id) + } +} + +impl HostKeyStore for InMemoryHostKeyStore { + fn load_host_key(&self, id: AgentId) -> LocalFuture<'_, Result>>, Error>> { + Box::pin(async move { Ok(self.keys.borrow().get(&id).cloned()) }) + } + + fn store_host_key(&self, id: AgentId, key: Zeroizing>) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.keys.borrow_mut().insert(id, key); + Ok(()) + }) + } + + fn delete_host_key(&self, id: AgentId) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.keys.borrow_mut().remove(&id); + Ok(()) + }) + } +} diff --git a/agentctl/src/ssh/mod.rs b/agentctl/src/ssh/mod.rs new file mode 100644 index 0000000..8b629b7 --- /dev/null +++ b/agentctl/src/ssh/mod.rs @@ -0,0 +1,516 @@ +//! SSH access to Agents. +//! +//! An Agent declaring `spec.access: [{type: ssh}]` gets an OpenSSH server +//! inside its Sandbox, listening on the guest loopback only, and an OpenSSH +//! client configuration on the host that reaches it through +//! `agentctl ssh-proxy`. The platform owns every moving part: the guest user +//! is `agent`, the host key is generated per Agent incarnation and kept in +//! the owner-protected database, the client key pair lives under the +//! control-plane home and its private half never enters the guest. +//! +//! The image provides OpenSSH, systemd and a usable `agent` account; `agentd` +//! owns the server policy, unit and per-Agent state written at setup. Nothing +//! here is a security boundary beyond the Sandbox itself: the guest user has +//! passwordless `sudo`, so the server hardening is hygiene, and Sessions and +//! SSH logins share one trust boundary. + +mod client_config; +mod keys; +pub mod memory; + +use std::{ + path::{Path, PathBuf}, + rc::Rc, +}; + +use ::sandbox::{LocalFuture, SandboxHandle}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::{ + AgentId, Error, + control_plane::{AgentRecord, AgentStore}, + local::home::ControlPlaneHome, + sandbox::platform, +}; + +pub use client_config::{ + CommandShell, HostEntry, IncludeOutcome, install_include, remove_known_host, render_config, render_include, + render_path, render_proxy_command, resolves_through_agentctl, upsert_known_host, +}; +pub use keys::KeyPair; + +/// Guest user every SSH login becomes: the platform-owned Sandbox user the Linux adapter defines. +pub const GUEST_USER: &str = platform::USER; +/// Guest loopback port the Agent's server listens on. +pub const GUEST_PORT: u16 = 2222; +/// The `type` value of an SSH access descriptor. +pub const ACCESS_TYPE: &str = "ssh"; + +/// Returns the OpenSSH `Host` alias for an Agent name. +#[must_use] +pub fn alias(agent: &str) -> String { + format!("agentctl-{agent}") +} + +/// Returns the `HostKeyAlias` keying `known_hosts` by the stable incarnation. +#[must_use] +pub fn host_key_alias(id: AgentId) -> String { + format!("agent-{id}") +} + +/// Host-side SSH paths below one control-plane home. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SshHome(PathBuf); + +impl SshHome { + /// Locates the SSH directory of a control-plane home. + #[must_use] + pub fn new(home: &ControlPlaneHome) -> Self { + Self(home.ssh_directory()) + } + + /// Returns the directory itself. + #[must_use] + pub fn root(&self) -> &Path { + &self.0 + } + + /// Returns the generated OpenSSH client configuration. + #[must_use] + pub fn config_path(&self) -> PathBuf { + self.0.join("config") + } + + /// Returns the `known_hosts` file pre-seeded with every Agent's host key. + #[must_use] + pub fn known_hosts_path(&self) -> PathBuf { + self.0.join("known_hosts") + } + + /// Returns the directory holding one Agent incarnation's client key pair. + #[must_use] + pub fn agent_directory(&self, id: AgentId) -> PathBuf { + self.0.join(id.to_string()) + } + + /// Returns one Agent incarnation's private client key. + #[must_use] + pub fn identity_path(&self, id: AgentId) -> PathBuf { + self.agent_directory(id).join("id_ed25519") + } + + /// Returns one Agent incarnation's public client key. + #[must_use] + pub fn public_identity_path(&self, id: AgentId) -> PathBuf { + self.agent_directory(id).join("id_ed25519.pub") + } +} + +/// The `Include` that makes the generated configuration apply to plain `ssh`, +/// `sftp` and editors, and the user's own OpenSSH configuration it belongs in. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserInclude { + /// The user's `~/.ssh/config`. + pub user_config: PathBuf, + /// The complete `Include` line. + pub line: String, +} + +impl UserInclude { + /// Locates the include for the generated configuration of `home`. + /// + /// # Errors + /// + /// Returns an error when the user's home directory is not set. + pub fn for_home(home: &ControlPlaneHome) -> Result { + let user_home = crate::local::home::user_home_directory() + .ok_or_else(|| Error::Invalid("the user home directory is not set (HOME or USERPROFILE)".into()))?; + Ok(Self { + user_config: user_home.join(".ssh").join("config"), + line: render_include(&SshHome::new(home).config_path(), Some(&user_home)), + }) + } + + /// Adds the line at the top of the user's configuration unless it is there. + /// + /// # Errors + /// + /// Returns an error when the configuration cannot be read or written. + pub fn install(&self) -> Result { + install_include(&self.user_config, &self.line) + } +} + +/// Stable machine-readable description of one Agent's SSH access. +/// +/// This is the seam an IDE integration consumes: everything needed to open a +/// connection without parsing the generated configuration. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AccessInfo { + /// Access kind, always `ssh`. + #[serde(rename = "type")] + pub kind: String, + /// Agent name. + pub agent: String, + /// Agent incarnation identity the key material belongs to. + pub agent_id: AgentId, + /// OpenSSH `Host` alias in the generated configuration. + pub alias: String, + /// Guest user. + pub user: String, + /// Absolute private client key path. + pub identity_file: PathBuf, + /// Absolute `known_hosts` path holding the Agent's host key. + pub known_hosts_file: PathBuf, + /// Absolute path of the generated OpenSSH client configuration. + pub config_file: PathBuf, + /// Complete `ProxyCommand` value dialing the Agent through `agentctl`. + pub proxy_command: String, + /// Guest directory Sessions open in, which editors open as their folder. + pub working_directory: String, +} + +/// Persists each Agent incarnation's SSH host key. +pub trait HostKeyStore { + /// Loads the stored OpenSSH private host key, if one exists. + fn load_host_key(&self, id: AgentId) -> LocalFuture<'_, Result>>, Error>>; + + /// Stores the OpenSSH private host key. + fn store_host_key(&self, id: AgentId, key: Zeroizing>) -> LocalFuture<'_, Result<(), Error>>; + + /// Deletes the stored host key; a missing key is not an error. + fn delete_host_key(&self, id: AgentId) -> LocalFuture<'_, Result<(), Error>>; +} + +/// Files the guest server needs, produced on the host for one pass. +pub struct GuestMaterial { + /// OpenSSH private host key. + pub host_private_key: Zeroizing>, + /// Public host key entry, without a trailing newline. + pub host_public_key: String, + /// Complete `authorized_keys` content. + pub authorized_keys: String, +} + +/// Builds the message given when the Agent's image cannot serve SSH access. +/// +/// The image is immutable for the incarnation, so the message names what is +/// missing and the only fixes: an image that provides it, or withdrawing access. +#[must_use] +pub fn image_contract_missing(what: &str) -> String { + format!( + "the Agent's image cannot provide SSH access: {what}; re-apply the Agent with an image that provides \ + OpenSSH, runs systemd and has a usable `agent` account, or remove `ssh` from spec.access" + ) +} + +/// Reconciles SSH access for Agents: host key material, client configuration +/// and the in-guest server state. +pub struct Access { + home: SshHome, + agentctl: PathBuf, + user_home: Option, + keys: Rc, + agents: Rc, +} + +impl Access { + /// Creates the SSH access reconciler. + /// + /// `agentctl` is the absolute executable the generated `ProxyCommand` runs. + #[must_use] + pub fn new( + home: &ControlPlaneHome, + agentctl: PathBuf, + keys: Rc, + agents: Rc, + ) -> Self { + Self { + home: SshHome::new(home), + agentctl, + user_home: crate::local::home::user_home_directory(), + keys, + agents, + } + } + + /// Overrides the user home used to shorten paths to `~/...`; tests pin it. + #[must_use] + pub fn with_user_home(mut self, user_home: Option) -> Self { + self.user_home = user_home; + self + } + + /// Returns the host-side SSH paths. + #[must_use] + pub const fn home(&self) -> &SshHome { + &self.home + } + + /// Describes the SSH access of a named Agent. + /// + /// # Errors + /// + /// Returns `Error::NotFound` for an unknown Agent, `Error::Conflict` while + /// it is being deleted, and `Error::Invalid` when it declares no SSH access. + pub async fn describe(&self, name: &str) -> Result { + let record = self.agents.get_by_name(name).await?; + if record.agent.metadata.deletion_timestamp.is_some() { + return Err(Error::Conflict); + } + if !record.agent.spec.ssh_access() { + return Err(Error::Invalid(format!( + "Agent {name:?} does not declare SSH access; add `access: [{{type: ssh}}]` to its spec and re-apply" + ))); + } + self.info(&record) + } + + /// Builds the descriptor for a record without consulting the store. + /// + /// # Errors + /// + /// Returns an error when the `agentctl` path cannot be rendered into a `ProxyCommand`. + pub fn info(&self, record: &AgentRecord) -> Result { + let name = &record.agent.metadata.name; + Ok(AccessInfo { + kind: ACCESS_TYPE.into(), + agent: name.clone(), + agent_id: record.id, + alias: alias(name), + user: GUEST_USER.into(), + identity_file: self.home.identity_path(record.id), + known_hosts_file: self.home.known_hosts_path(), + config_file: self.home.config_path(), + proxy_command: render_proxy_command(&self.agentctl, name, CommandShell::host())?, + working_directory: platform::WORKING_DIRECTORY.into(), + }) + } + + /// Converges SSH access for one Agent against its running Sandbox. + /// + /// Returns whether the Agent has SSH access after the pass. An Agent + /// without declared access has any earlier server state removed. + /// + /// # Errors + /// + /// Returns `Error::Invalid` when the image lacks an OpenSSH server or the + /// Sandbox operating system is unsupported, and transient errors when key + /// material or guest setup cannot be written. + pub async fn reconcile(&self, record: &AgentRecord, sandbox: &SandboxHandle) -> Result { + let os = sandbox.snapshot().image.platform.os.clone(); + if !record.agent.spec.ssh_access() { + // Guest first: while a stop can still fail, the host key and known_hosts must + // keep matching the server that may still be running. + remove_guest_state(&os, sandbox).await?; + self.remove_host_material(record).await?; + return Ok(false); + } + verify_guest_server(&os, sandbox).await?; + let material = self.ensure_host_material(record).await?; + install_guest_state(&os, sandbox, &material).await?; + Ok(true) + } + + /// Removes every host-side trace of a deleted Agent incarnation. + /// + /// # Errors + /// + /// Returns an error when key files or configuration cannot be rewritten. + pub async fn remove(&self, record: &AgentRecord) -> Result<(), Error> { + self.remove_host_material(record).await + } + + async fn ensure_host_material(&self, record: &AgentRecord) -> Result { + let id = record.id; + let name = record.agent.metadata.name.as_str(); + let host_key = if let Some(stored) = self.keys.load_host_key(id).await? { + KeyPair::from_openssh(&stored)? + } else { + let generated = KeyPair::generate_ed25519(&host_key_alias(id))?; + self.keys + .store_host_key(id, Zeroizing::new(generated.private.as_bytes().to_vec())) + .await?; + generated + }; + let home = self.home.clone(); + let client_key = tokio::task::spawn_blocking({ + let name = name.to_owned(); + move || ensure_client_key(&home, id, &name) + }) + .await + .map_err(|error| Error::Daemon(format!("SSH client key task failed: {error}")))??; + // `HostKeyAlias` keys the entry by the incarnation for OpenSSH; a second entry under the + // `Host` alias serves clients that parse the configuration but ignore `HostKeyAlias`, + // such as JetBrains IDEs, and is replaced when a re-applied name gets a new host key. + let known_hosts = self.home.known_hosts_path(); + upsert_known_host(&known_hosts, &host_key_alias(id), &host_key.public)?; + upsert_known_host(&known_hosts, &alias(name), &host_key.public)?; + self.rewrite_config().await?; + Ok(GuestMaterial { + host_private_key: Zeroizing::new(host_key.private.as_bytes().to_vec()), + host_public_key: host_key.public, + authorized_keys: format!("{client_key}\n"), + }) + } + + async fn remove_host_material(&self, record: &AgentRecord) -> Result<(), Error> { + let id = record.id; + self.keys.delete_host_key(id).await?; + let directory = self.home.agent_directory(id); + if directory.exists() { + std::fs::remove_dir_all(&directory)?; + } + let known_hosts = self.home.known_hosts_path(); + remove_known_host(&known_hosts, &host_key_alias(id))?; + // The name alias belongs to whichever incarnation currently owns the name. + let owned_by_another = matches!( + self.agents.get_by_name(&record.agent.metadata.name).await, + Ok(current) if current.id != id && current.agent.spec.ssh_access() + ); + if !owned_by_another { + remove_known_host(&known_hosts, &alias(&record.agent.metadata.name))?; + } + self.rewrite_config().await + } + + /// Rewrites the generated client configuration from every active Agent with SSH access. + async fn rewrite_config(&self) -> Result<(), Error> { + let mut entries = self + .agents + .list() + .await? + .into_iter() + .filter(|record| record.agent.metadata.deletion_timestamp.is_none() && record.agent.spec.ssh_access()) + .map(|record| { + Ok(HostEntry { + alias: alias(&record.agent.metadata.name), + user: GUEST_USER.into(), + proxy_command: render_proxy_command( + &self.agentctl, + &record.agent.metadata.name, + CommandShell::host(), + )?, + host_key_alias: host_key_alias(record.id), + identity_file: self.home.identity_path(record.id), + known_hosts_file: self.home.known_hosts_path(), + }) + }) + .collect::, Error>>()?; + entries.sort_by(|left, right| left.alias.cmp(&right.alias)); + prepare_directory(self.home.root())?; + let text = render_config(&entries, self.user_home.as_deref()); + client_config::write_private_file(&self.home.config_path(), text.as_bytes()) + } +} + +/// Chooses the `agentctl` path the generated `ProxyCommand` runs. +/// +/// `sibling` is `agentctl` beside the running `agentd`, which on an installed +/// release is a versioned directory that a later `agentctl self update` +/// replaces. When a `PATH` entry resolves to the same executable, that stable +/// spelling, such as `~/.local/bin/agentctl`, is preferred so the configuration +/// survives upgrades between reconciliation passes. +#[must_use] +pub fn stable_agentctl_path(sibling: &Path, path_variable: Option<&std::ffi::OsStr>) -> PathBuf { + let Ok(target) = std::fs::canonicalize(sibling) else { + return sibling.to_path_buf(); + }; + let file_name = sibling.file_name().map(std::ffi::OsStr::to_owned); + path_variable + .into_iter() + .flat_map(std::env::split_paths) + .filter(|directory| directory.is_absolute()) + .filter_map(|directory| Some(directory.join(file_name.as_ref()?))) + .find(|candidate| std::fs::canonicalize(candidate).is_ok_and(|resolved| resolved == target)) + .unwrap_or_else(|| sibling.to_path_buf()) +} + +/// Creates the client key pair for an incarnation when missing and returns its public entry. +fn ensure_client_key(home: &SshHome, id: AgentId, agent: &str) -> Result { + prepare_directory(home.root())?; + prepare_directory(&home.agent_directory(id))?; + let identity = home.identity_path(id); + let pair = match std::fs::read(&identity) { + Ok(private) => KeyPair::from_openssh(&private)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let generated = KeyPair::generate_ed25519(&format!("{}@{agent}", host_key_alias(id)))?; + client_config::write_private_file(&identity, generated.private.as_bytes())?; + generated + } + Err(error) => return Err(error.into()), + }; + let public_path = home.public_identity_path(id); + let public_line = format!("{}\n", pair.public); + if std::fs::read_to_string(&public_path).ok().as_deref() != Some(public_line.as_str()) { + client_config::write_private_file(&public_path, public_line.as_bytes())?; + } + Ok(pair.public) +} + +fn prepare_directory(path: &Path) -> Result<(), Error> { + if !path.is_dir() { + std::fs::create_dir_all(path)?; + } + crate::local::home::secure_directory(path) +} + +async fn verify_guest_server(os: &str, sandbox: &SandboxHandle) -> Result<(), Error> { + match os { + "linux" => platform::linux_ssh::verify_server(sandbox).await, + os => Err(Error::Invalid(format!( + "SSH access is not supported on Sandbox operating system {os:?}" + ))), + } +} + +async fn install_guest_state(os: &str, sandbox: &SandboxHandle, material: &GuestMaterial) -> Result<(), Error> { + match os { + "linux" => platform::linux_ssh::install_server_state(sandbox, material).await, + os => Err(Error::Invalid(format!( + "SSH access is not supported on Sandbox operating system {os:?}" + ))), + } +} + +async fn remove_guest_state(os: &str, sandbox: &SandboxHandle) -> Result<(), Error> { + match os { + "linux" => platform::linux_ssh::remove_server_state(sandbox).await, + // Nothing was ever installed on an unsupported operating system. + _ => Ok(()), + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::stable_agentctl_path; + + #[test] + fn proxy_command_prefers_a_path_entry_resolving_to_the_same_agentctl() { + let directory = tempfile::tempdir().expect("temporary directory"); + let releases = directory.path().join("releases").join("v1"); + let bin = directory.path().join("bin"); + std::fs::create_dir_all(&releases).expect("release directory"); + std::fs::create_dir_all(&bin).expect("bin directory"); + let sibling = releases.join("agentctl"); + std::fs::write(&sibling, "#!/bin/sh\n").expect("release agentctl"); + std::fs::write(bin.join("agentctl"), "#!/bin/sh\n").expect("unrelated agentctl"); + + let unrelated = std::env::join_paths([&bin]).expect("PATH"); + assert_eq!(stable_agentctl_path(&sibling, Some(&unrelated)), sibling); + assert_eq!(stable_agentctl_path(&sibling, None), sibling); + + #[cfg(unix)] + { + let stable = directory.path().join("stable"); + std::fs::create_dir_all(&stable).expect("stable directory"); + std::os::unix::fs::symlink(&sibling, stable.join("agentctl")).expect("symlink"); + let path = std::env::join_paths([&bin, &stable]).expect("PATH"); + assert_eq!(stable_agentctl_path(&sibling, Some(&path)), stable.join("agentctl")); + } + } +} diff --git a/agentctl/src/upgrade.rs b/agentctl/src/upgrade.rs new file mode 100644 index 0000000..f36acb6 --- /dev/null +++ b/agentctl/src/upgrade.rs @@ -0,0 +1,1128 @@ +//! Durable installation and state shared by an updater and the first daemon startup. + +use std::{ + collections::BTreeSet, + env, + fmt::Write as _, + fs::{self, File, OpenOptions}, + io::{Read as _, Write as _}, + path::{Component, Path, PathBuf}, + process::Command, + sync::OnceLock, + time::Duration, +}; + +use flate2::read::GzDecoder; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; + +use crate::{Error, local::home::ControlPlaneHome, sessions}; + +const INSTALL_FORMAT: u32 = 1; +const JOURNAL_FORMAT: u32 = 1; +const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_mins(1); +const INSTALL_LOCK_TIMEOUT: Duration = Duration::from_secs(5); +const BINARY_STEMS: [&str; 2] = ["agentctl", "agentd"]; + +/// Filesystem locations for one managed Agent installation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InstallPaths { + root: PathBuf, + bin: PathBuf, +} + +impl InstallPaths { + /// Resolves environment overrides and platform defaults. + /// + /// # Errors + /// + /// Returns an error when the platform's user directory cannot be resolved. + pub fn resolve() -> Result { + let root = match env::var_os("AGENT_INSTALL_ROOT").filter(|value| !value.is_empty()) { + Some(path) => absolute(Path::new(&path))?, + None => match inferred_install_root()? { + Some(path) => path, + None => default_install_root()?, + }, + }; + let bin = match env::var_os("AGENT_INSTALL_DIR").filter(|value| !value.is_empty()) { + Some(path) => absolute(Path::new(&path))?, + None if root.join("install.json").is_file() => InstallMetadata::read_from_root(&root)?.bin_directory, + #[cfg(unix)] + None => default_bin_directory()?, + #[cfg(windows)] + None => root.join("bin"), + }; + Ok(Self { root, bin }) + } + + /// Constructs explicit locations for installer handoff and tests. + /// + /// # Errors + /// + /// Returns an error unless both paths are absolute. + pub fn new(root: PathBuf, bin: PathBuf) -> Result { + if !root.is_absolute() || !bin.is_absolute() { + return Err(Error::Invalid("installation paths must be absolute".into())); + } + Ok(Self { root, bin }) + } + + #[must_use] + pub fn root(&self) -> &Path { + &self.root + } + + #[must_use] + pub fn bin(&self) -> &Path { + &self.bin + } + + #[must_use] + pub fn releases(&self) -> PathBuf { + self.root.join("releases") + } + + #[must_use] + pub fn current(&self) -> PathBuf { + self.root.join("current") + } + + #[must_use] + pub fn journal(&self) -> PathBuf { + self.root.join("update.json") + } + + #[must_use] + pub fn metadata(&self) -> PathBuf { + self.root.join("install.json") + } + + /// Serializes installers and updaters. + /// + /// # Errors + /// + /// Returns an error when the lock cannot be created or acquired. + pub async fn lock(&self) -> Result { + fs::create_dir_all(&self.root)?; + crate::local::home::secure_directory(&self.root)?; + let path = self.root.join("install.lock"); + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&path)?; + crate::local::home::secure_file(&path)?; + let deadline = tokio::time::Instant::now() + INSTALL_LOCK_TIMEOUT; + loop { + match file.try_lock() { + Ok(()) => break, + Err(std::fs::TryLockError::WouldBlock) if tokio::time::Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(50)).await; + } + Err(std::fs::TryLockError::WouldBlock) => { + return Err(Error::Daemon("another Agent update is already running".into())); + } + Err(std::fs::TryLockError::Error(error)) => return Err(Error::Io(error)), + } + } + Ok(InstallLock { _file: file }) + } +} + +#[derive(Debug)] +pub struct InstallLock { + _file: File, +} + +/// Non-secret facts about a managed installation. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct InstallMetadata { + format_version: u32, + repository: String, + target: String, + bin_directory: PathBuf, +} + +impl InstallMetadata { + #[must_use] + pub fn new(repository: String, bin_directory: PathBuf) -> Self { + Self { + format_version: INSTALL_FORMAT, + repository, + target: package_target().into(), + bin_directory, + } + } + + #[must_use] + pub fn repository(&self) -> &str { + &self.repository + } + + /// Writes the metadata atomically. + /// + /// # Errors + /// + /// Returns an error when serialization or durable replacement fails. + pub fn write(&self, paths: &InstallPaths) -> Result<(), Error> { + atomic_json(&paths.metadata(), self) + } + + /// Reads and validates the managed installation metadata. + /// + /// # Errors + /// + /// Returns an error for invalid, incompatible, or unreadable metadata. + pub fn read(paths: &InstallPaths) -> Result { + Self::read_from_root(paths.root()) + } + + fn read_from_root(root: &Path) -> Result { + let metadata: Self = serde_json::from_slice(&fs::read(root.join("install.json"))?)?; + if metadata.format_version != INSTALL_FORMAT + || metadata.target != package_target() + || !metadata.bin_directory.is_absolute() + { + return Err(Error::Invalid( + "managed Agent installation has an incompatible format or target".into(), + )); + } + Ok(metadata) + } +} + +/// One resolved release package. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Release { + pub version: String, + pub repository: String, + pub local_archive: Option, + pub local_checksum: Option, +} + +impl Release { + /// Resolves an explicit release, a local package, or the latest GitHub release. + /// + /// # Errors + /// + /// Returns an error when release resolution or version validation fails. + pub async fn resolve(version: Option<&str>, repository: String) -> Result { + let local_archive = env::var_os("AGENT_LOCAL_ARCHIVE") + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + let version = match version { + Some(version) => normalize_version(version)?, + None if local_archive.is_some() => normalize_version( + &env::var("AGENT_VERSION") + .map_err(|_| Error::Invalid("AGENT_VERSION is required with AGENT_LOCAL_ARCHIVE".into()))?, + )?, + None => latest_release(&repository).await?, + }; + let local_checksum = local_archive.as_ref().map(|archive| { + env::var_os("AGENT_LOCAL_ARCHIVE_SHA256") + .map_or_else(|| PathBuf::from(format!("{}.sha256", archive.display())), PathBuf::from) + }); + Ok(Self { + version, + repository, + local_archive, + local_checksum, + }) + } + + #[must_use] + pub fn directory_name(&self) -> String { + format!("{}-{}", self.version, package_target()) + } + + fn archive_name() -> String { + format!("agent-{}.tar.gz", package_target()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StagedRelease { + pub release: Release, + pub path: PathBuf, +} + +/// Downloads, verifies, extracts, and validates a release without changing `current`. +/// +/// # Errors +/// +/// Returns an error when download, checksum, archive, or binary validation fails. +pub async fn stage_release(paths: &InstallPaths, release: Release) -> Result { + let final_path = paths.releases().join(release.directory_name()); + { + let _lock = paths.lock().await?; + if final_path.exists() { + validate_release_directory(&final_path, &release.version)?; + return Ok(StagedRelease { + release, + path: final_path, + }); + } + } + let temporary = tempfile::Builder::new().prefix("agent-release-").tempdir()?; + let archive = temporary.path().join(Release::archive_name()); + let checksum = temporary.path().join(format!("{}.sha256", Release::archive_name())); + if let Some(local) = &release.local_archive { + fs::copy(local, &archive)?; + fs::copy( + release + .local_checksum + .as_ref() + .ok_or_else(|| Error::Invalid("local package checksum is missing".into()))?, + &checksum, + )?; + } else { + let base = format!( + "https://github.com/{}/releases/download/agentctl/{}", + release.repository, release.version + ); + download(&format!("{base}/{}", Release::archive_name()), &archive).await?; + download(&format!("{base}/{}.sha256", Release::archive_name()), &checksum).await?; + } + verify_checksum(&archive, &checksum)?; + let extracted = temporary.path().join("release"); + fs::create_dir(&extracted)?; + extract_archive(&archive, &extracted)?; + let final_path = publish_release(paths, &extracted, &release.version).await?; + Ok(StagedRelease { + release, + path: final_path, + }) +} + +/// Validates and publishes an extracted package while serializing release ownership. +/// +/// # Errors +/// +/// Returns an error when the package is invalid or another update holds the install lock. +pub async fn publish_release(paths: &InstallPaths, source: &Path, version: &str) -> Result { + validate_release_directory(source, version)?; + let _lock = paths.lock().await?; + fs::create_dir_all(paths.releases())?; + crate::local::home::secure_directory(&paths.releases())?; + let final_path = paths.releases().join(format!("{version}-{}", package_target())); + if final_path.exists() { + validate_release_directory(&final_path, version)?; + return Ok(final_path); + } + let staging = tempfile::Builder::new() + .prefix(".staging-") + .tempdir_in(paths.releases())?; + for binary in binary_names() { + fs::copy(source.join(&binary), staging.path().join(binary))?; + } + validate_release_directory(staging.path(), version)?; + fs::rename(staging.path(), &final_path)?; + #[cfg(unix)] + sync_directory(&paths.releases())?; + Ok(final_path) +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum UpdatePhase { + Prepared, + Migrated, + Activated, + Complete, +} + +/// Target-owned durable update journal. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct UpdateJournal { + format_version: u32, + pub previous_release: Option, + pub target_release: PathBuf, + pub target_version: String, + pub phase: UpdatePhase, +} + +impl UpdateJournal { + #[must_use] + pub const fn new(previous_release: Option, target_release: PathBuf, target_version: String) -> Self { + Self { + format_version: JOURNAL_FORMAT, + previous_release, + target_release, + target_version, + phase: UpdatePhase::Prepared, + } + } + + /// Reads the journal when one exists. + /// + /// # Errors + /// + /// Returns an error for invalid, unsupported, or unsafe journal data. + pub fn read(paths: &InstallPaths) -> Result, Error> { + let bytes = match fs::read(paths.journal()) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + let journal: Self = serde_json::from_slice(&bytes)?; + if journal.format_version != JOURNAL_FORMAT { + return Err(Error::Invalid("unsupported Agent update journal format".into())); + } + journal.validate(paths)?; + Ok(Some(journal)) + } + + /// Durably advances the state machine. + /// + /// # Errors + /// + /// Returns an error for backward movement or a failed write. + pub fn advance(&mut self, paths: &InstallPaths, phase: UpdatePhase) -> Result<(), Error> { + if phase < self.phase { + return Err(Error::Invalid("Agent update journal cannot move backwards".into())); + } + self.phase = phase; + atomic_json(&paths.journal(), self) + } + + /// Removes a journal before migration has committed. + /// + /// # Errors + /// + /// Returns an error after migration or when the journal cannot be removed durably. + pub fn discard_before_migration(&self, paths: &InstallPaths) -> Result<(), Error> { + if self.phase != UpdatePhase::Prepared { + return Err(Error::Invalid( + "an Agent update journal can only be discarded before migration".into(), + )); + } + fs::remove_file(paths.journal())?; + #[cfg(unix)] + sync_directory(paths.root())?; + Ok(()) + } + + fn validate(&self, paths: &InstallPaths) -> Result<(), Error> { + let releases = canonical_or_absolute(&paths.releases())?; + for path in self + .previous_release + .iter() + .chain(std::iter::once(&self.target_release)) + { + if !path.is_absolute() { + return Err(Error::Invalid( + "Agent update journal names a release outside the install root".into(), + )); + } + let resolved = fs::canonicalize(path) + .map_err(|_| Error::Invalid("Agent update journal names a release that no longer exists".into()))?; + if resolved.parent() != Some(releases.as_path()) { + return Err(Error::Invalid( + "Agent update journal names a release outside the install root".into(), + )); + } + } + Ok(()) + } +} + +/// Reads the active release pointer. +/// +/// # Errors +/// +/// Returns an error when the pointer cannot be read. +pub fn current_release(paths: &InstallPaths) -> Result, Error> { + read_current(paths) +} + +#[cfg(unix)] +fn read_current(paths: &InstallPaths) -> Result, Error> { + match fs::read_link(paths.current()) { + Ok(target) => Ok(Some(if target.is_absolute() { + target + } else { + paths.root.join(target) + })), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } +} + +#[cfg(windows)] +fn read_current(paths: &InstallPaths) -> Result, Error> { + match fs::read_to_string(paths.current()) { + Ok(target) => Ok(Some(PathBuf::from(target.trim()))), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } +} + +/// Atomically selects a release and refreshes the user-visible launch paths. +/// +/// # Errors +/// +/// Returns an error for an unsafe target or a failed filesystem operation. +pub fn activate_release(paths: &InstallPaths, target: &Path) -> Result<(), Error> { + let releases = canonical_or_absolute(&paths.releases())?; + let target = fs::canonicalize(target)?; + if !target.is_absolute() || target.parent() != Some(releases.as_path()) { + return Err(Error::Invalid( + "target release is outside the managed releases directory".into(), + )); + } + activate_links(paths, &target)?; + #[cfg(unix)] + sync_directory(paths.root())?; + Ok(()) +} + +#[cfg(unix)] +fn activate_links(paths: &InstallPaths, target: &Path) -> Result<(), Error> { + replace_directory_link(&paths.current(), target)?; + fs::create_dir_all(paths.bin())?; + for binary in binary_names() { + replace_file_link(&paths.bin.join(&binary), &paths.current().join(binary))?; + } + Ok(()) +} + +#[cfg(windows)] +fn activate_links(paths: &InstallPaths, target: &Path) -> Result<(), Error> { + replace_windows_pointer(&paths.current(), target)?; + fs::create_dir_all(paths.bin())?; + for binary in BINARY_STEMS { + let legacy = paths.bin().join(format!("{binary}.exe")); + if legacy.exists() { + fs::remove_file(legacy)?; + } + let root = windows_command_path(paths.root()).replace('%', "%%"); + let script = format!( + "@echo off\r\nsetlocal\r\nset /p AGENT_CURRENT=<\"{root}\\current\"\r\n\"%AGENT_CURRENT%\\{binary}.exe\" %*\r\n" + ); + let launcher = paths.bin().join(format!("{binary}.cmd")); + // cmd.exe reads batch files lazily, so replacing the active launcher can corrupt its next command. + if !launcher.try_exists()? { + replace_windows_file(&launcher, script.as_bytes())?; + } + } + Ok(()) +} + +/// Durably requests one target-daemon Session relaunch pass. +/// +/// # Errors +/// +/// Returns an error when the marker cannot be written securely. +pub fn create_session_relaunch_marker(home: &ControlPlaneHome, build_version: &str) -> Result<(), Error> { + home.prepare()?; + atomic_json( + &home.pending_session_relaunch_path(), + &SessionRelaunchMarker { + build_version: build_version.into(), + }, + ) +} + +/// Keeps current and previous releases plus anything needed by an unfinished update. +/// +/// # Errors +/// +/// Returns an error when releases cannot be enumerated or removed. +pub fn prune_releases(paths: &InstallPaths, previous: Option<&Path>) -> Result<(), Error> { + let mut keep = BTreeSet::new(); + keep.extend(current_release(paths)?); + keep.extend(previous.map(Path::to_path_buf)); + if let Some(journal) = UpdateJournal::read(paths)?.filter(|journal| journal.phase != UpdatePhase::Complete) { + keep.insert(journal.target_release); + keep.extend(journal.previous_release); + } + let keep = keep + .into_iter() + .map(fs::canonicalize) + .collect::, _>>()?; + for entry in fs::read_dir(paths.releases())? { + let entry = entry?; + let path = entry.path(); + if entry.file_type()?.is_dir() && !keep.contains(&fs::canonicalize(&path)?) { + fs::remove_dir_all(path)?; + } + } + Ok(()) +} + +#[must_use] +pub const fn package_target() -> &'static str { + if cfg!(all(target_os = "linux", target_arch = "x86_64")) { + "linux-x86_64" + } else if cfg!(all(target_os = "linux", target_arch = "aarch64")) { + "linux-aarch64" + } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + "macos-aarch64" + } else if cfg!(all(target_os = "windows", target_arch = "x86_64")) { + "windows-x86_64" + } else if cfg!(all(target_os = "windows", target_arch = "aarch64")) { + "windows-aarch64" + } else { + "unsupported" + } +} + +/// Runs and acknowledges a pending post-upgrade Session relaunch pass. +/// +/// # Errors +/// +/// Returns an error while retaining the marker when the pass cannot complete safely. +pub async fn consume_pending_session_relaunch( + home: &ControlPlaneHome, + service: &sessions::Service, +) -> Result<(), Error> { + let path = home.pending_session_relaunch_path(); + let bytes = match tokio::fs::read(&path).await { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + crate::local::home::secure_file(&path)?; + let marker: SessionRelaunchMarker = serde_json::from_slice(&bytes)?; + if marker.build_version != crate::build_version() { + return Err(Error::Daemon(format!( + "Session relaunch marker targets build {:?}, but this agentd is {:?}", + marker.build_version, + crate::build_version() + ))); + } + service.relaunch_after_upgrade().await?; + tokio::fs::remove_file(path).await?; + #[cfg(unix)] + sync_directory(home.path())?; + Ok(()) +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct SessionRelaunchMarker { + build_version: String, +} + +async fn latest_release(repository: &str) -> Result { + #[derive(Deserialize)] + struct GithubRelease { + tag_name: String, + } + let client = http_client()?; + for page in 1_u32.. { + let releases = client + .get(format!( + "https://api.github.com/repos/{repository}/releases?per_page=100&page={page}" + )) + .header(reqwest::header::USER_AGENT, "agentctl-updater") + .send() + .await + .map_err(|error| Error::Daemon(format!("resolve Agent release: {error}")))? + .error_for_status() + .map_err(|error| Error::Daemon(format!("resolve Agent release: {error}")))? + .json::>() + .await + .map_err(|error| Error::Daemon(format!("decode Agent releases: {error}")))?; + if let Some(version) = releases + .iter() + .find_map(|release| release.tag_name.strip_prefix("agentctl/")) + { + return normalize_version(version); + } + if releases.len() < 100 { + break; + } + } + Err(Error::Invalid("GitHub has no agentctl release".into())) +} + +async fn download(url: &str, path: &Path) -> Result<(), Error> { + let bytes = http_client()? + .get(url) + .header(reqwest::header::USER_AGENT, "agentctl-updater") + .send() + .await + .map_err(|error| Error::Daemon(format!("download Agent package: {error}")))? + .error_for_status() + .map_err(|error| Error::Daemon(format!("download Agent package: {error}")))? + .bytes() + .await + .map_err(|error| Error::Daemon(format!("read Agent package: {error}")))?; + fs::write(path, bytes)?; + Ok(()) +} + +fn http_client() -> Result<&'static reqwest::Client, Error> { + static CLIENT: OnceLock> = OnceLock::new(); + CLIENT + .get_or_init(|| { + reqwest::Client::builder() + .connect_timeout(HTTP_CONNECT_TIMEOUT) + .timeout(HTTP_REQUEST_TIMEOUT) + .build() + .map_err(|error| error.to_string()) + }) + .as_ref() + .map_err(|error| Error::Daemon(format!("create Agent update HTTP client: {error}"))) +} + +fn normalize_version(version: &str) -> Result { + let trimmed = version.trim(); + let bare = trimmed.strip_prefix('v').unwrap_or(trimmed); + semver::Version::parse(bare).map_err(|error| Error::Invalid(format!("invalid Agent release version: {error}")))?; + Ok(format!("v{bare}")) +} + +fn verify_checksum(archive: &Path, checksum: &Path) -> Result<(), Error> { + let expected = fs::read_to_string(checksum)? + .split_whitespace() + .next() + .ok_or_else(|| Error::Invalid("Agent checksum file is empty".into()))? + .to_ascii_lowercase(); + if expected.len() != 64 || !expected.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(Error::Invalid("Agent checksum is not SHA-256".into())); + } + let mut input = File::open(archive)?; + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 8 * 1024]; + loop { + let read = input.read(&mut buffer)?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + let actual = digest + .finalize() + .iter() + .fold(String::with_capacity(64), |mut output, byte| { + let _ = write!(output, "{byte:02x}"); + output + }); + if actual != expected { + return Err(Error::Invalid("Agent archive checksum mismatch".into())); + } + Ok(()) +} + +fn extract_archive(archive: &Path, destination: &Path) -> Result<(), Error> { + let mut package = tar::Archive::new(GzDecoder::new(File::open(archive)?)); + let expected = binary_names().into_iter().collect::>(); + let mut found = BTreeSet::new(); + for entry in package + .entries() + .map_err(|error| Error::Invalid(format!("invalid Agent archive: {error}")))? + { + let mut entry = entry.map_err(|error| Error::Invalid(format!("invalid Agent archive: {error}")))?; + let path = entry + .path() + .map_err(|error| Error::Invalid(format!("invalid Agent archive: {error}")))?; + let mut components = path.components(); + let Some(Component::Normal(name)) = components.next() else { + return Err(Error::Invalid("Agent archive contains an invalid path".into())); + }; + if components.next().is_some() { + return Err(Error::Invalid( + "Agent archive must contain only top-level binaries".into(), + )); + } + let name = name + .to_str() + .ok_or_else(|| Error::Invalid("Agent archive contains a non-UTF-8 path".into()))?; + if !expected.contains(name) || !found.insert(name.to_owned()) || !entry.header().entry_type().is_file() { + return Err(Error::Invalid(format!("unexpected Agent archive entry {name:?}"))); + } + entry + .unpack(destination.join(name)) + .map_err(|error| Error::Invalid(format!("invalid Agent archive: {error}")))?; + } + if found != expected { + return Err(Error::Invalid( + "Agent archive does not contain one matched agentctl and agentd".into(), + )); + } + Ok(()) +} + +/// Validates the exact package contents and both embedded build versions. +/// +/// # Errors +/// +/// Returns an error when the directory is not one matched release. +pub fn validate_release_directory(path: &Path, version: &str) -> Result<(), Error> { + let entries = fs::read_dir(path)? + .collect::, _>>()? + .into_iter() + .map(|entry| entry.file_name()) + .collect::>(); + let expected = binary_names().into_iter().map(Into::into).collect::>(); + if entries != expected { + return Err(Error::Invalid( + "Agent release directory must contain only agentctl and agentd".into(), + )); + } + for binary in binary_names() { + let executable = path.join(&binary); + require_executable(&executable)?; + let output = Command::new(&executable).arg("--version").output()?; + let actual = String::from_utf8_lossy(&output.stdout); + let name = binary.trim_end_matches(std::env::consts::EXE_SUFFIX); + if !output.status.success() || actual.trim() != format!("{name} {version}") { + return Err(Error::Invalid(format!( + "{} reports {:?}; expected {name} {version:?}", + executable.display(), + actual.trim() + ))); + } + } + Ok(()) +} + +/// Reads and validates the version encoded by one managed release directory. +/// +/// # Errors +/// +/// Returns an error when the directory name or package contents are invalid. +pub fn managed_release_version(path: &Path) -> Result { + let name = path + .file_name() + .and_then(std::ffi::OsStr::to_str) + .ok_or_else(|| Error::Invalid("managed Agent release has an invalid directory name".into()))?; + let version = name + .strip_suffix(&format!("-{}", package_target())) + .ok_or_else(|| Error::Invalid("managed Agent release does not match this platform".into()))?; + let version = normalize_version(version)?; + validate_release_directory(path, &version)?; + Ok(version) +} + +#[cfg(unix)] +fn require_executable(path: &Path) -> Result<(), Error> { + use std::os::unix::fs::PermissionsExt as _; + if fs::metadata(path)?.permissions().mode() & 0o111 == 0 { + return Err(Error::Invalid(format!("{} is not executable", path.display()))); + } + Ok(()) +} + +#[cfg(windows)] +fn require_executable(path: &Path) -> Result<(), Error> { + if !path.is_file() { + return Err(Error::Invalid(format!("{} is not a file", path.display()))); + } + Ok(()) +} + +fn binary_names() -> [String; 2] { + BINARY_STEMS.map(|name| format!("{name}{}", std::env::consts::EXE_SUFFIX)) +} + +fn atomic_json(path: &Path, value: &impl Serialize) -> Result<(), Error> { + let parent = path + .parent() + .ok_or_else(|| Error::Invalid("state path has no parent directory".into()))?; + fs::create_dir_all(parent)?; + crate::local::home::secure_directory(parent)?; + let temporary = path.with_extension(format!("tmp-{}", std::process::id())); + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&temporary)?; + crate::local::home::secure_file(&temporary)?; + file.write_all(&serde_json::to_vec_pretty(value)?)?; + file.write_all(b"\n")?; + file.sync_all()?; + drop(file); + fs::rename(&temporary, path)?; + #[cfg(unix)] + sync_directory(parent)?; + Ok(()) +} + +fn canonical_or_absolute(path: &Path) -> Result { + if path.exists() { + Ok(fs::canonicalize(path)?) + } else if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Err(Error::Invalid("managed installation path must be absolute".into())) + } +} + +fn absolute(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Ok(env::current_dir()?.join(path)) + } +} + +fn inferred_install_root() -> Result, Error> { + let executable = fs::canonicalize(env::current_exe()?)?; + Ok(install_root_for_executable(&executable) + .filter(|root| root.join("install.json").is_file() || root.join("update.json").is_file())) +} + +fn install_root_for_executable(executable: &Path) -> Option { + let releases = executable.parent()?.parent()?; + if releases.file_name()? != "releases" { + return None; + } + releases.parent().map(Path::to_path_buf) +} + +#[cfg(unix)] +fn default_install_root() -> Result { + if let Some(path) = env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) { + return Ok(PathBuf::from(path).join("agent")); + } + env::var_os("HOME") + .map(PathBuf::from) + .map(|path| path.join(".local/share/agent")) + .ok_or_else(|| Error::Invalid("HOME is not set".into())) +} + +#[cfg(unix)] +fn default_bin_directory() -> Result { + env::var_os("HOME") + .map(PathBuf::from) + .map(|path| path.join(".local/bin")) + .ok_or_else(|| Error::Invalid("HOME is not set".into())) +} + +#[cfg(windows)] +fn default_install_root() -> Result { + env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .map(|path| path.join("Agent")) + .ok_or_else(|| Error::Invalid("LOCALAPPDATA is not set".into())) +} + +#[cfg(unix)] +fn replace_directory_link(link: &Path, target: &Path) -> Result<(), Error> { + use std::os::unix::fs::symlink; + replace_symlink(link, |temporary| symlink(target, temporary)) +} + +#[cfg(unix)] +fn replace_file_link(link: &Path, target: &Path) -> Result<(), Error> { + use std::os::unix::fs::symlink; + replace_symlink(link, |temporary| symlink(target, temporary)) +} + +#[cfg(unix)] +fn replace_symlink(link: &Path, create: impl FnOnce(&Path) -> std::io::Result<()>) -> Result<(), Error> { + let temporary = link.with_extension(format!("next-{}", std::process::id())); + let _ = fs::remove_file(&temporary); + create(&temporary)?; + fs::rename(temporary, link)?; + Ok(()) +} + +#[cfg(windows)] +fn replace_windows_pointer(path: &Path, target: &Path) -> Result<(), Error> { + let target = windows_command_path(target); + replace_windows_file(path, format!("{target}\r\n").as_bytes()) +} + +#[cfg(windows)] +fn windows_command_path(path: &Path) -> String { + let path = path.to_string_lossy(); + if let Some(path) = path.strip_prefix(r"\\?\UNC\") { + return format!(r"\\{path}"); + } + if let Some(path) = path.strip_prefix(r"\\?\") { + return path.to_owned(); + } + path.into_owned() +} + +#[cfg(windows)] +fn replace_windows_file(path: &Path, contents: &[u8]) -> Result<(), Error> { + let temporary = path.with_extension(format!("next-{}", std::process::id())); + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&temporary)?; + file.write_all(contents)?; + file.sync_all()?; + drop(file); + fs::rename(temporary, path)?; + Ok(()) +} + +#[cfg(unix)] +fn sync_directory(path: &Path) -> Result<(), Error> { + File::open(path)?.sync_all()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(windows)] + #[test] + fn command_paths_do_not_use_the_windows_verbatim_prefix() { + assert_eq!( + windows_command_path(Path::new(r"\\?\C:\Agent\releases\v2")), + r"C:\Agent\releases\v2" + ); + assert_eq!( + windows_command_path(Path::new(r"\\?\UNC\server\share\Agent")), + r"\\server\share\Agent" + ); + } + + #[test] + fn journal_rejects_release_outside_install_root() { + let temporary = tempfile::TempDir::new().expect("temporary directory"); + let root = temporary.path().join("root"); + let paths = InstallPaths::new(root, temporary.path().join("bin")).expect("paths"); + fs::create_dir_all(paths.releases()).expect("releases"); + let journal = UpdateJournal::new(None, temporary.path().join("elsewhere"), "v2.0.0".into()); + assert!(journal.validate(&paths).is_err()); + } + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn install_lock_reports_a_concurrent_update() { + let temporary = tempfile::TempDir::new().expect("temporary directory"); + let paths = InstallPaths::new(temporary.path().join("install"), temporary.path().join("bin")).expect("paths"); + let _first = paths.lock().await.expect("first lock"); + + let error = paths.lock().await.expect_err("second lock"); + + assert!(error.to_string().contains("another Agent update is already running")); + } + + #[test] + fn managed_executable_identifies_its_install_root() { + let root = Path::new("managed/agent"); + let executable = root.join("releases/v2.0.0-linux-x86_64/agentctl"); + + assert_eq!(install_root_for_executable(&executable), Some(root.to_path_buf())); + assert_eq!(install_root_for_executable(Path::new("agentctl")), None); + } + + #[test] + fn install_metadata_preserves_the_visible_bin_directory() { + let temporary = tempfile::TempDir::new().expect("temporary directory"); + let paths = + InstallPaths::new(temporary.path().join("install"), temporary.path().join("custom-bin")).expect("paths"); + InstallMetadata::new("example/repository".into(), paths.bin().to_path_buf()) + .write(&paths) + .expect("metadata"); + + let metadata = InstallMetadata::read(&paths).expect("read metadata"); + + assert_eq!(metadata.repository(), "example/repository"); + assert_eq!(metadata.bin_directory, paths.bin()); + } + + #[test] + fn relaunch_marker_is_owner_only() { + let temporary = tempfile::TempDir::new().expect("temporary directory"); + let home = ControlPlaneHome::resolve(Some(temporary.path())).expect("home"); + create_session_relaunch_marker(&home, "v2").expect("marker"); + let marker: SessionRelaunchMarker = + serde_json::from_slice(&fs::read(home.pending_session_relaunch_path()).expect("read marker")) + .expect("decode marker"); + assert_eq!(marker.build_version, "v2"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!( + fs::metadata(home.pending_session_relaunch_path()) + .expect("metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + } + + #[tokio::test(flavor = "local")] + async fn checksum_failure_does_not_create_a_release_or_current_pointer() { + let temporary = tempfile::TempDir::new().expect("temporary directory"); + let archive = temporary.path().join("broken.tar.gz"); + let checksum = temporary.path().join("broken.tar.gz.sha256"); + fs::write(&archive, b"not an archive").expect("archive"); + fs::write(&checksum, format!("{} broken.tar.gz\n", "0".repeat(64))).expect("checksum"); + let paths = InstallPaths::new(temporary.path().join("install"), temporary.path().join("bin")).expect("paths"); + let release = Release { + version: "v1.0.0".into(), + repository: "example/repository".into(), + local_archive: Some(archive), + local_checksum: Some(checksum), + }; + + let error = stage_release(&paths, release).await.expect_err("checksum mismatch"); + + assert!(error.to_string().contains("checksum mismatch")); + assert!(!paths.current().exists()); + assert!(!paths.releases().exists()); + } + + #[cfg(unix)] + #[test] + fn activation_and_pruning_use_canonical_release_paths() { + let temporary = tempfile::TempDir::new().expect("temporary directory"); + let paths = InstallPaths::new(temporary.path().join("install"), temporary.path().join("bin")).expect("paths"); + let release = paths.releases().join("v1.0.0-linux-x86_64"); + fs::create_dir_all(&release).expect("release"); + for binary in binary_names() { + fs::write(release.join(binary), []).expect("binary"); + } + + activate_release(&paths, &release).expect("activation"); + + assert_eq!( + current_release(&paths).expect("current"), + Some(fs::canonicalize(&release).expect("canonical release")) + ); + for binary in binary_names() { + assert_eq!( + fs::read_link(paths.bin().join(&binary)).expect("visible link"), + paths.current().join(binary) + ); + } + let stale = paths.releases().join("v0.9.0-linux-x86_64"); + fs::create_dir(&stale).expect("stale release"); + prune_releases(&paths, None).expect("prune releases"); + assert!(release.exists()); + assert!(!stale.exists()); + } + + #[cfg(unix)] + #[test] + fn managed_release_version_comes_from_the_active_package() { + let temporary = tempfile::TempDir::new().expect("temporary directory"); + let release = temporary.path().join(format!("v2.3.4-preview.1-{}", package_target())); + fs::create_dir(&release).expect("release"); + // A child process writes the executables. A descriptor this process + // opened for writing leaks into any child another test is forking + // until that child execs, and executing the file meanwhile fails with + // ETXTBSY ("Text file busy"). + let written = Command::new("/bin/sh") + .arg("-c") + .arg(r#"for binary in agentctl agentd; do printf '#!/bin/sh\necho "%s v2.3.4-preview.1"\n' "$binary" > "$binary" && chmod 755 "$binary" || exit; done"#) + .current_dir(&release) + .status() + .expect("write binaries"); + assert!(written.success()); + + assert_eq!( + managed_release_version(&release).expect("managed version"), + "v2.3.4-preview.1" + ); + } +} diff --git a/agentctl/src/vnc/mod.rs b/agentctl/src/vnc/mod.rs new file mode 100644 index 0000000..ed70a79 --- /dev/null +++ b/agentctl/src/vnc/mod.rs @@ -0,0 +1,190 @@ +//! VNC access to Agents. +//! +//! The image runs the desktop and ships the units that bridge it to guest ports, disabled, and +//! lists them in `/etc/agent-access.d/vnc.conf`. When an Agent declares `access: [{type: vnc}]` +//! this module enables those units, and disables them when it stops. Nothing here names a socket, +//! viewer or URL, so an image with a different viewer needs no change here. +//! +//! This decides what the platform offers; it is not an isolation boundary. The Agent has `sudo` +//! and could enable the units itself. The Sandbox boundary, and forwarding that only the host can +//! start, protect the desktop, as they do every guest loopback port. + +use std::{cell::RefCell, collections::BTreeMap, path::PathBuf, rc::Rc}; + +use ::sandbox::SandboxHandle; +use serde::{Deserialize, Serialize}; + +use crate::{ + AgentId, Error, + control_plane::{AgentRecord, AgentStore}, + sandbox::platform, +}; + +/// Guest loopback port carrying the RFB stream. The image declares the same ports, and a mismatch +/// is refused when reconciling. +pub const GUEST_PORT: u16 = 5900; +/// Guest loopback port serving the image's browser-based viewer over HTTP. +pub const WEB_GUEST_PORT: u16 = 6080; +/// The `type` value of a VNC access descriptor. +pub const ACCESS_TYPE: &str = "vnc"; + +/// Machine-readable description of one Agent's VNC access. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AccessInfo { + /// Access kind, always `vnc`. + #[serde(rename = "type")] + pub kind: String, + /// Agent name. + pub agent: String, + /// Agent incarnation identity. + pub agent_id: AgentId, + /// Guest loopback port carrying the RFB stream. + pub guest_port: u16, + /// Guest loopback port serving the browser viewer, whose root the caller opens. Absent when + /// the image serves none, and until a pass has seen what the image declares. + pub web_guest_port: Option, + /// Complete command forwarding the RFB port to the caller's machine. + pub forward_command: String, +} + +/// Builds the message given when the Agent's image cannot serve VNC access. The image cannot +/// change for the incarnation, so it names the only fixes. +#[must_use] +pub fn image_contract_missing(what: &str) -> String { + format!( + "the Agent's image cannot provide VNC access: {what}; re-apply the Agent with an image that declares its \ + access units in /etc/agent-access.d/vnc.conf, such as the published desktop Agent image, or remove `vnc` \ + from spec.access" + ) +} + +/// Reconciles VNC access for Agents by enabling or disabling the units their image declares. +pub struct Access { + agentctl: PathBuf, + agents: Rc, + /// What the last successful pass left in each Agent's guest, so that describing an Agent needs + /// no call into its Sandbox and a resync can skip a withdrawal that already succeeded. Not + /// persisted: after a restart it is unknown until the next pass. + applied: RefCell>, +} + +/// What one successful reconciliation pass left in an Agent's guest. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Applied { + /// The image's units are enabled, with the browser viewer port it declared. + Granted { web_port: Option }, + /// The units are disabled, or the image declares none. + Withdrawn, +} + +impl Access { + /// Creates the VNC access reconciler; `agentctl` is the executable the forwarding command names. + #[must_use] + pub fn new(agentctl: PathBuf, agents: Rc) -> Self { + Self { + agentctl, + agents, + applied: RefCell::new(BTreeMap::new()), + } + } + + /// Describes the VNC access of a named Agent. + /// + /// # Errors + /// + /// Returns `Error::NotFound` for an unknown Agent, `Error::Conflict` while + /// it is being deleted, and `Error::Invalid` when it declares no VNC access. + pub async fn describe(&self, name: &str) -> Result { + let record = self.agents.get_by_name(name).await?; + if record.agent.metadata.deletion_timestamp.is_some() { + return Err(Error::Conflict); + } + if !record.agent.spec.vnc_access() { + return Err(Error::Invalid(format!( + "Agent {name:?} does not declare VNC access; add `access: [{{type: vnc}}]` to its spec and re-apply" + ))); + } + Ok(self.info(&record)) + } + + /// Builds the descriptor for a record without consulting the store. + #[must_use] + pub fn info(&self, record: &AgentRecord) -> AccessInfo { + let name = &record.agent.metadata.name; + AccessInfo { + kind: ACCESS_TYPE.into(), + agent: name.clone(), + agent_id: record.id, + guest_port: GUEST_PORT, + web_guest_port: match self.applied.borrow().get(&record.id) { + Some(Applied::Granted { web_port }) => *web_port, + Some(Applied::Withdrawn) | None => None, + }, + // `:PORT` binds a free local port, as `agentctl vnc` does: 5900 is often taken locally. + forward_command: format!("{} port-forward agent/{name} :{GUEST_PORT}", self.agentctl.display()), + } + } + + /// Converges VNC access for one Agent and returns whether it has access afterwards. Without + /// declared access the units are disabled, leaving the desktop running with nothing + /// listening; a withdrawal that succeeded is not repeated for the same incarnation. + /// + /// # Errors + /// + /// Returns `Error::Invalid` when the image runs no desktop or the Sandbox + /// operating system is unsupported, and transient errors when the guest + /// setup cannot be applied. + pub async fn reconcile(&self, record: &AgentRecord, sandbox: &SandboxHandle) -> Result { + if !record.agent.spec.vnc_access() && self.applied.borrow().get(&record.id) == Some(&Applied::Withdrawn) { + return Ok(false); + } + match self.apply(record, sandbox).await { + Ok(applied) => { + self.applied.borrow_mut().insert(record.id, applied); + Ok(applied != Applied::Withdrawn) + } + Err(error) => { + // A failed pass can leave the guest between states, so nothing is known until a + // pass succeeds again. + self.applied.borrow_mut().remove(&record.id); + Err(error) + } + } + } + + async fn apply(&self, record: &AgentRecord, sandbox: &SandboxHandle) -> Result { + let os = sandbox.snapshot().image.platform.os.clone(); + if record.agent.spec.vnc_access() { + let capability = grant_guest_access(&os, sandbox).await?; + Ok(Applied::Granted { + web_port: capability.web_port, + }) + } else { + remove_guest_state(&os, sandbox).await?; + Ok(Applied::Withdrawn) + } + } + + /// Forgets what was applied to a deleted Agent incarnation. + pub fn forget(&self, id: AgentId) { + self.applied.borrow_mut().remove(&id); + } +} + +async fn grant_guest_access(os: &str, sandbox: &SandboxHandle) -> Result { + match os { + "linux" => platform::linux_vnc::grant(sandbox).await, + os => Err(Error::Invalid(format!( + "VNC access is not supported on Sandbox operating system {os:?}" + ))), + } +} + +async fn remove_guest_state(os: &str, sandbox: &SandboxHandle) -> Result<(), Error> { + match os { + "linux" => platform::linux_vnc::withdraw(sandbox).await, + // Nothing was ever granted on an unsupported operating system. + _ => Ok(()), + } +} diff --git a/agentctl/tests/architecture.rs b/agentctl/tests/architecture.rs new file mode 100644 index 0000000..0666fd1 --- /dev/null +++ b/agentctl/tests/architecture.rs @@ -0,0 +1,169 @@ +#![allow(clippy::expect_used)] + +use std::path::{Path, PathBuf}; + +#[test] +fn harness_internals_are_contained_by_the_harness_adapter() { + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let harness = source.join("harness"); + let binaries = source.join("bin"); + let mut files = Vec::new(); + rust_files(&source, &mut files); + + for path in files { + // The harness adapter owns harness internals. The `bin/` composition + // roots legitimately name a harness to dispatch on the closed enum + // (like `agentd` naming a Sandbox Provider), but must still not carry + // harness implementation details — asserted separately below. + if path.starts_with(&harness) || path.starts_with(&binaries) { + continue; + } + let contents = std::fs::read_to_string(&path).expect("Agent source should be readable"); + let lowercase = contents.to_ascii_lowercase(); + for implementation_name in ["claude", "anthropic", "sk-ant", "codex", "openai"] { + assert!( + !lowercase.contains(implementation_name), + "harness-specific name {implementation_name:?} leaked into {}", + path.display() + ); + } + } + + let mut binary_files = Vec::new(); + rust_files(&binaries, &mut binary_files); + for path in binary_files { + let contents = std::fs::read_to_string(&path).expect("binary source should be readable"); + let lowercase = contents.to_ascii_lowercase(); + for implementation_detail in [ + "anthropic", + "sk-ant", + "setup-token", + "oauth", + ".credentials.json", + "api.openai", + "auth.json", + "sk-agent-mediated", + ] { + assert!( + !lowercase.contains(implementation_detail), + "harness implementation detail {implementation_detail:?} leaked into {}", + path.display() + ); + } + } + + let dispatch = std::fs::read_to_string(harness.join("mod.rs")).expect("harness dispatch should be readable"); + let lowercase = dispatch.to_ascii_lowercase(); + for implementation_detail in [ + "api.anthropic", + "sk-ant", + "/home/agent/.claude", + "oauth", + "access-token", + "refresh-token", + "api.openai", + "auth.json", + "sk-agent-mediated", + ] { + assert!( + !lowercase.contains(implementation_detail), + "harness implementation detail {implementation_detail:?} leaked into the generic dispatch" + ); + } +} + +#[test] +fn microsandbox_internals_are_contained_by_the_microsandbox_adapter() { + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let adapter = source.join("sandbox").join("microsandbox"); + let mut files = Vec::new(); + rust_files(&source, &mut files); + + for path in files { + if path.starts_with(&adapter) { + continue; + } + let contents = std::fs::read_to_string(&path).expect("Agent source should be readable"); + assert!( + !contents.contains("sandbox_microsandbox"), + "Microsandbox implementation leaked into {}", + path.display() + ); + } +} + +#[test] +fn sandbox_operating_system_details_are_contained_by_platform_and_harness_adapters() { + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = Vec::new(); + rust_files(&source, &mut files); + + for path in files { + if path.starts_with(source.join("sandbox").join("platform")) + || path.starts_with(source.join("harness")) + || path.starts_with(source.join("sandbox").join("microsandbox")) + || path == source.join("sessions").join("runtime").join("tmux.rs") + { + continue; + } + let contents = std::fs::read_to_string(&path).expect("Agent source should be readable"); + for platform_detail in ["/home/agent", "/usr/bin/"] { + assert!( + !contents.contains(platform_detail), + "Sandbox-platform detail {platform_detail:?} leaked into {}", + path.display() + ); + } + } +} + +#[test] +fn tmux_implementation_details_are_contained_by_its_session_runtime() { + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let runtime = source.join("sessions").join("runtime").join("tmux.rs"); + let mut files = Vec::new(); + rust_files(&source, &mut files); + + for path in files { + if path == runtime { + continue; + } + let contents = std::fs::read_to_string(&path).expect("Agent source should be readable"); + for implementation_detail in ["/usr/bin/tmux", "has-session", "new-session", "attach-session"] { + assert!( + !contents.contains(implementation_detail), + "tmux implementation detail {implementation_detail:?} leaked into {}", + path.display() + ); + } + } +} + +#[test] +fn control_plane_core_does_not_reference_concrete_sandbox_or_harness_implementations() { + let control_plane = Path::new(env!("CARGO_MANIFEST_DIR")).join("src").join("control_plane"); + let mut files = Vec::new(); + rust_files(&control_plane, &mut files); + + for path in files { + let contents = std::fs::read_to_string(&path).expect("control-plane source should be readable"); + for concrete in ["microsandbox", "claude", "Linux", "AgentPreparation", "AgentBootstrap"] { + assert!( + !contents.contains(concrete), + "concrete runtime detail {concrete:?} leaked into {}", + path.display() + ); + } + } +} + +fn rust_files(directory: &Path, files: &mut Vec) { + for entry in std::fs::read_dir(directory).expect("Agent source directory should be readable") { + let path = entry.expect("Agent source entry should be readable").path(); + if path.is_dir() { + rust_files(&path, files); + } else if path.extension().is_some_and(|extension| extension == "rs") { + files.push(path); + } + } +} diff --git a/agentctl/tests/control_api.rs b/agentctl/tests/control_api.rs new file mode 100644 index 0000000..5cef5e4 --- /dev/null +++ b/agentctl/tests/control_api.rs @@ -0,0 +1,1221 @@ +#![allow(clippy::expect_used, clippy::panic)] + +mod support; + +use std::{ + cell::{Cell, RefCell}, + rc::Rc, + time::Duration, +}; + +use agent::{ + Error, + control_api::{ + AuthenticationApi, Client, Connection, Connector, ConvergenceApi, ExecutionApi, Server, SessionApi, + SshAccessApi, VncAccessApi, + }, + control_plane::WaitPolicy, + control_plane::{ApplyRequest, ControlPlane, memory::InMemoryAgentStore}, + harness::ImportedAuthentication, + resources::Changes, +}; +use sandbox::LocalFuture; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + sync::Notify, +}; + +use support::{IgnoreNotifications, agent}; + +struct FakeAuthentication; +/// Converges an Agent at once, except `stuck`, which never converges. +struct FakeConvergence; +struct FakeSshAccess; +struct FakeVncAccess; + +impl SshAccessApi for FakeSshAccess { + fn describe<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result> { + Box::pin(async move { + if name != "worker" { + return Err(Error::NotFound); + } + Ok(agent::ssh::AccessInfo { + kind: "ssh".into(), + agent: name.into(), + agent_id: "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"), + alias: "agentctl-worker".into(), + user: "agent".into(), + identity_file: "/home/me/.agent/ssh/38f41de4-6ff7-4679-ae46-678bc61e4dcb/id_ed25519".into(), + known_hosts_file: "/home/me/.agent/ssh/known_hosts".into(), + config_file: "/home/me/.agent/ssh/config".into(), + proxy_command: "/usr/local/bin/agentctl ssh-proxy agent/worker".into(), + working_directory: "/home/agent/code".into(), + }) + }) + } +} + +impl VncAccessApi for FakeVncAccess { + fn describe<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result> { + Box::pin(async move { + if name != "worker" { + return Err(Error::NotFound); + } + Ok(agent::vnc::AccessInfo { + kind: "vnc".into(), + agent: name.into(), + agent_id: "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"), + guest_port: 5900, + web_guest_port: Some(6080), + forward_command: "/usr/local/bin/agentctl port-forward agent/worker :5900".into(), + }) + }) + } +} +/// Executions of Agent `worker`; an ensure for `stuck` waits until dropped. +#[derive(Default)] +struct FakeExecutions { + waiting: Rc>, +} + +/// Counts one waiting ensure for as long as it lives. +struct Waiting(Rc>); + +impl Drop for Waiting { + fn drop(&mut self) { + self.0.set(self.0.get() - 1); + } +} +/// One `sessions.v1.prompt` as the fake saw it: prompt, wait flag, timeout. +type SentMessage = (String, bool, Option); + +#[derive(Default)] +struct UpgradeGates { + prompt: RefCell>>, + prompt_started: Notify, + readiness: RefCell>>, + readiness_started: Notify, +} + +struct FakeSessions { + ensured: Rc>>, + sent: Rc>>, + deleted: Rc>>, + archived: Rc>>, + upgrade_blockers: Rc>>, + upgrade_warnings: Rc>>, + upgrade_gates: Rc, +} + +fn answered_turn(prompt: &str, answer: &str) -> agent::sessions::Turn { + agent::sessions::Turn { + messages: vec![ + agent::sessions::Message { + role: agent::sessions::Role::User, + parts: vec![agent::sessions::Part::Text { text: prompt.into() }], + }, + agent::sessions::Message { + role: agent::sessions::Role::Assistant, + parts: vec![ + agent::sessions::Part::ToolCall { + name: "Bash".into(), + failed: true, + }, + agent::sessions::Part::Text { text: answer.into() }, + ], + }, + ], + } +} + +impl AuthenticationApi for FakeAuthentication { + fn login<'a>( + &'a self, + _harness: agent::Harness, + _token: &'a str, + _imported: bool, + ) -> LocalFuture<'a, Result> { + Box::pin(async { + Ok(ImportedAuthentication { + provider: "claude".into(), + ready: true, + }) + }) + } +} + +impl SessionApi for FakeSessions { + fn ensure<'a>( + &'a self, + _agent: &'a str, + _name: &'a agent::sessions::SessionName, + request: agent::sessions::SessionRequest, + _wait: WaitPolicy, + ) -> LocalFuture<'a, Result> { + self.ensured.borrow_mut().push(request); + Box::pin(async { Err(Error::NotFound) }) + } + + fn get<'a>( + &'a self, + _agent: &'a str, + _name: &'a agent::sessions::SessionName, + ) -> LocalFuture<'a, Result> { + Box::pin(async { Err(Error::NotFound) }) + } + + fn list<'a>(&'a self, _agent: Option<&'a str>) -> LocalFuture<'a, Result, Error>> { + Box::pin(async { Ok(Vec::new()) }) + } + + fn prompt<'a>( + &'a self, + agent: &'a str, + _name: &'a agent::sessions::SessionName, + prompt: &'a str, + wait: bool, + timeout: Option, + ) -> LocalFuture<'a, Result<(), Error>> { + self.sent.borrow_mut().push((prompt.to_owned(), wait, timeout)); + let gate = self.upgrade_gates.prompt.borrow().clone(); + let upgrade_gates = self.upgrade_gates.clone(); + let blockers = self.upgrade_blockers.clone(); + Box::pin(async move { + if agent != "worker" { + return Err(Error::NotFound); + } + if let Some(gate) = gate { + upgrade_gates.prompt_started.notify_one(); + gate.notified().await; + blockers.borrow_mut().push("session/worker/s1 (working)".into()); + } + Ok(()) + }) + } + + fn turns<'a>( + &'a self, + agent: &'a str, + _name: &'a agent::sessions::SessionName, + last: Option, + ) -> LocalFuture<'a, Result, Error>> { + Box::pin(async move { + if agent != "worker" { + return Err(Error::NotFound); + } + let mut turns = vec![answered_turn("one", "1"), answered_turn("two", "2")]; + if let Some(last) = last { + turns.drain(0..turns.len().saturating_sub(last)); + } + Ok(turns) + }) + } + + fn set_archived<'a>( + &'a self, + agent: &'a str, + name: &'a agent::sessions::SessionName, + archived: bool, + ) -> LocalFuture<'a, Result> { + self.archived + .borrow_mut() + .push((agent.to_owned(), name.clone(), archived)); + Box::pin(async move { + if agent != "worker" { + return Err(Error::NotFound); + } + let session = serde_json::json!({ + "id": "00000000-0000-4000-8000-000000000001", + "agentId": "00000000-0000-4000-8000-000000000002", + "agent": agent, + "name": name, + "harness": "claudeCode", + "createdAt": "2026-09-25T00:00:00Z", + "archivedAt": archived.then_some("2026-09-25T00:00:01Z"), + }); + Ok(serde_json::from_value(session).expect("archived Session")) + }) + } + + fn delete<'a>( + &'a self, + agent: &'a str, + name: &'a agent::sessions::SessionName, + ) -> LocalFuture<'a, Result<(), Error>> { + self.deleted.borrow_mut().push((agent.to_owned(), name.clone())); + Box::pin(async move { + if agent == "worker" { + Ok(()) + } else { + Err(Error::NotFound) + } + }) + } + + fn upgrade_readiness(&self) -> LocalFuture<'_, Result> { + let blockers = self.upgrade_blockers.borrow().clone(); + let warnings = self.upgrade_warnings.borrow().clone(); + let gate = self.upgrade_gates.readiness.borrow().clone(); + let upgrade_gates = self.upgrade_gates.clone(); + Box::pin(async move { + if let Some(gate) = gate { + upgrade_gates.readiness_started.notify_one(); + gate.notified().await; + } + Ok(agent::sessions::UpgradeReadiness { blockers, warnings }) + }) + } +} + +impl ConvergenceApi for FakeConvergence { + fn converge<'a>(&'a self, name: &'a str) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + if name == "stuck" { + std::future::pending::<()>().await; + } + Ok(()) + }) + } +} + +impl ExecutionApi for FakeExecutions { + fn ensure<'a>( + &'a self, + name: &'a str, + _wait: WaitPolicy, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + if name == "stuck" { + self.waiting.set(self.waiting.get() + 1); + let _waiting = Waiting(self.waiting.clone()); + std::future::pending::<()>().await; + } + if name == "stopped" { + return Err(Error::Stopped(name.into())); + } + if name != "worker" { + return Err(Error::NotFound); + } + Ok(agent::sandbox::ExecutionTarget { + sandbox: agent::sandbox::Assignment::Materialized { + provider: agent::sandbox::ProviderId::new("memory")?, + id: "ca4e2f21-91d9-43f1-97c6-13f0f350fbe7" + .parse() + .map_err(|error| Error::Invalid(format!("invalid test Sandbox ID: {error}")))?, + harnesses: Vec::new(), + }, + operating_system: "linux".into(), + }) + }) + } +} + +struct InProcessConnector { + server: Rc, +} + +struct ScriptedConnector { + frames: &'static str, +} + +struct ApiFixture { + server: Rc, + client: Client, + /// Execution ensures still waiting in the server. + waiting: Rc>, + ensured: Rc>>, + sent: Rc>>, + changes: Changes, + deleted: Rc>>, + archived: Rc>>, + upgrade_blockers: Rc>>, + upgrade_warnings: Rc>>, + upgrade_gates: Rc, +} + +impl Connector for InProcessConnector { + fn connect(&self) -> LocalFuture<'_, Result, Error>> { + Box::pin(async move { + let (client, server) = tokio::io::duplex(64 * 1024); + let api = self.server.clone(); + tokio::task::spawn_local(async move { + let _ignored = api.serve_connection(server).await; + }); + Ok(Box::new(client) as Box) + }) + } +} + +impl Connector for ScriptedConnector { + fn connect(&self) -> LocalFuture<'_, Result, Error>> { + Box::pin(async move { + let (client, server) = tokio::io::duplex(16 * 1024); + let frames = self.frames; + tokio::task::spawn_local(async move { + let mut server = BufReader::new(server); + let mut request = String::new(); + server.read_line(&mut request).await.expect("request"); + server.get_mut().write_all(frames.as_bytes()).await.expect("responses"); + }); + Ok(Box::new(client) as Box) + }) + } +} + +fn api() -> ApiFixture { + let control_plane = Rc::new(ControlPlane::new( + Rc::new(InMemoryAgentStore::new()), + Rc::new(IgnoreNotifications), + )); + let ensured = Rc::new(RefCell::new(Vec::new())); + let sent = Rc::new(RefCell::new(Vec::new())); + let deleted = Rc::new(RefCell::new(Vec::new())); + let archived = Rc::new(RefCell::new(Vec::new())); + let observed_errors = Rc::new(RefCell::new(Vec::new())); + let changes = Changes::new(); + let upgrade_blockers = Rc::new(RefCell::new(Vec::new())); + let upgrade_warnings = Rc::new(RefCell::new(Vec::new())); + let upgrade_gates = Rc::new(UpgradeGates::default()); + let executions = Rc::new(FakeExecutions::default()); + let waiting = executions.waiting.clone(); + let server = Rc::new(Server::new( + control_plane, + Rc::new(FakeAuthentication), + Rc::new(FakeConvergence), + executions, + Rc::new(FakeSessions { + ensured: ensured.clone(), + sent: sent.clone(), + deleted: deleted.clone(), + archived: archived.clone(), + upgrade_blockers: upgrade_blockers.clone(), + upgrade_warnings: upgrade_warnings.clone(), + upgrade_gates: upgrade_gates.clone(), + }), + Rc::new(FakeSshAccess), + Rc::new(FakeVncAccess), + changes.clone(), + Rc::new(move |error| observed_errors.borrow_mut().push(error.to_string())), + )); + let client = Client::new(Rc::new(InProcessConnector { server: server.clone() })); + ApiFixture { + server, + client, + waiting, + ensured, + sent, + changes, + deleted, + archived, + upgrade_blockers, + upgrade_warnings, + upgrade_gates, + } +} + +struct DelayedConnector { + inner: InProcessConnector, +} + +impl Connector for DelayedConnector { + fn connect(&self) -> LocalFuture<'_, Result, Error>> { + Box::pin(async move { + tokio::time::sleep(Duration::from_millis(100)).await; + self.inner.connect().await + }) + } +} + +#[tokio::test(flavor = "local")] +async fn prompt_completion_timeout_is_unchanged_by_transit() { + let fixture = api(); + let client = Client::new(Rc::new(DelayedConnector { + inner: InProcessConnector { + server: fixture.server.clone(), + }, + })); + client + .prompt_session( + "worker", + agent::sessions::SessionName::new("s1").expect("name"), + "go".into(), + true, + Some(Duration::from_millis(20)), + ) + .await + .expect("delivered"); + assert_eq!( + fixture.sent.borrow().as_slice(), + [("go".into(), true, Some(Duration::from_millis(20)))] + ); +} + +#[tokio::test(flavor = "local")] +async fn session_send_and_turns_round_trip_with_their_parameters() { + let fixture = api(); + let name = agent::sessions::SessionName::new("s1").expect("name"); + + fixture + .client + .prompt_session( + "worker", + name.clone(), + "do it".into(), + true, + Some(std::time::Duration::from_secs(90)), + ) + .await + .expect("send with wait"); + fixture + .client + .prompt_session("worker", name.clone(), "fire and forget".into(), false, None) + .await + .expect("send without wait"); + { + let sent = fixture.sent.borrow(); + assert_eq!(sent.len(), 2); + assert_eq!((&sent[0].0, sent[0].1), (&"do it".to_owned(), true)); + assert_eq!(sent[0].2, Some(Duration::from_secs(90))); + assert_eq!(sent[1], ("fire and forget".to_owned(), false, None)); + } + + let last = fixture + .client + .session_turns("worker", name.clone(), Some(1)) + .await + .expect("turns"); + assert_eq!(last.len(), 1); + assert_eq!(last[0], answered_turn("two", "2")); + assert_eq!( + fixture + .client + .session_turns("worker", name.clone(), None) + .await + .expect("turns") + .len(), + 2 + ); + let missing = fixture + .client + .prompt_session("ghost", name, "hello".into(), false, None) + .await + .expect_err("unknown Agent"); + match missing { + Error::Rpc(error) => assert_eq!(error.code, -32004), + other => panic!("unexpected error: {other}"), + } +} + +#[tokio::test(flavor = "local")] +async fn session_archive_and_unarchive_round_trip_and_report_a_missing_session() { + let fixture = api(); + let name = agent::sessions::SessionName::new("s1").expect("name"); + + let archived = fixture + .client + .set_session_archived("worker", name.clone(), true) + .await + .expect("archive Session"); + assert!(archived.is_archived()); + let unarchived = fixture + .client + .set_session_archived("worker", name.clone(), false) + .await + .expect("unarchive Session"); + assert!(!unarchived.is_archived()); + assert_eq!( + fixture.archived.borrow().as_slice(), + [ + ("worker".to_owned(), name.clone(), true), + ("worker".to_owned(), name.clone(), false) + ] + ); + + let missing = fixture + .client + .set_session_archived("ghost", name, true) + .await + .expect_err("unknown Agent"); + match missing { + Error::Rpc(error) => assert_eq!(error.code, -32004), + other => panic!("unexpected error: {other}"), + } +} + +#[tokio::test(flavor = "local")] +async fn session_deletion_round_trips_and_reports_a_missing_session() { + let fixture = api(); + let name = agent::sessions::SessionName::new("s1").expect("name"); + + fixture + .client + .delete_session("worker", name.clone()) + .await + .expect("delete Session"); + assert_eq!( + fixture.deleted.borrow().as_slice(), + [("worker".to_owned(), name.clone())] + ); + + let missing = fixture + .client + .delete_session("ghost", name) + .await + .expect_err("unknown Agent"); + match missing { + Error::Rpc(error) => assert_eq!(error.code, -32004), + other => panic!("unexpected error: {other}"), + } +} + +#[tokio::test(flavor = "local")] +async fn a_wait_ends_when_its_client_goes_away() { + let fixture = api(); + let interrupted = tokio::time::timeout( + Duration::from_millis(100), + fixture.client.ensure_execution("stuck", WaitPolicy::UntilConverged), + ) + .await; + assert!(interrupted.is_err(), "the wait never ends on its own"); + + tokio::time::timeout(Duration::from_secs(1), async { + while fixture.waiting.get() > 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("the server stops waiting once its client has gone"); +} + +#[tokio::test(flavor = "local")] +async fn an_interrupted_prompt_is_still_delivered() { + let fixture = api(); + let gate = Rc::new(Notify::new()); + fixture.upgrade_gates.prompt.replace(Some(gate.clone())); + let interrupted = tokio::time::timeout(Duration::from_millis(100), async { + let prompting = fixture.client.prompt_session( + "worker", + agent::sessions::SessionName::new("s1").expect("name"), + "go".into(), + false, + None, + ); + tokio::select! { + result = prompting => result, + () = async { + fixture.upgrade_gates.prompt_started.notified().await; + std::future::pending::<()>().await; + } => unreachable!(), + } + }) + .await; + assert!(interrupted.is_err(), "the delivery is still in progress"); + + gate.notify_one(); + tokio::time::timeout(Duration::from_secs(1), async { + while fixture.upgrade_blockers.borrow().is_empty() { + tokio::task::yield_now().await; + } + }) + .await + .expect("the delivery runs to completion without its client"); +} + +#[tokio::test(flavor = "local")] +async fn login_returns_only_non_secret_readiness() { + let fixture = api(); + let imported = fixture + .client + .auth_login(agent::Harness::ClaudeCode, "sk-ant-oat01-canary".into(), false) + .await + .expect("login"); + assert_eq!(imported.provider, "claude"); + assert!(imported.ready); +} + +#[tokio::test(flavor = "local")] +async fn health_reports_a_compatible_daemon() { + let fixture = api(); + let daemon = fixture.client.require_compatible_daemon().await.expect("health check"); + assert_eq!(daemon.protocol_version.as_deref(), Some("v4")); + assert_eq!(daemon.build_version.as_deref(), Some(agent::build_version())); +} + +#[test] +fn daemon_identity_rejects_preview_1_and_mixed_builds() { + let extended: agent::control_api::DaemonInfo = serde_json::from_value(serde_json::json!({ + "protocolVersion": "v4", + "buildVersion": agent::build_version(), + "futureCapability": true + })) + .expect("extended health response"); + extended.require_compatible().expect("compatible extended response"); + + for daemon in [ + agent::control_api::DaemonInfo { + protocol_version: Some("v1".into()), + build_version: None, + }, + agent::control_api::DaemonInfo { + protocol_version: Some("v4".into()), + build_version: Some("another-build".into()), + }, + ] { + let error = daemon.require_compatible().expect_err("incompatible daemon"); + assert!(error.to_string().contains("running agentd is incompatible")); + } +} + +#[tokio::test(flavor = "local")] +async fn shutdown_reports_blocking_sessions_without_draining() { + let fixture = api(); + fixture + .upgrade_blockers + .borrow_mut() + .push("session/worker/busy (working)".into()); + let error = fixture + .client + .shutdown_for_upgrade() + .await + .expect_err("working Session blocks shutdown"); + assert!(matches!(error, Error::Rpc(error) if error.is_invalid_params())); + fixture.client.health().await.expect("daemon remains available"); +} + +#[tokio::test(flavor = "local")] +async fn shutdown_returns_nonblocking_session_warnings() { + let fixture = api(); + fixture + .upgrade_warnings + .borrow_mut() + .push("session/worker/fresh will start a new conversation".into()); + + assert_eq!( + fixture.client.shutdown_for_upgrade().await.expect("shutdown"), + ["session/worker/fresh will start a new conversation"] + ); +} + +#[tokio::test(flavor = "local")] +async fn shutdown_waits_for_admitted_mutations_before_checking_sessions() { + let fixture = api(); + let gate = Rc::new(Notify::new()); + *fixture.upgrade_gates.prompt.borrow_mut() = Some(gate.clone()); + let prompt_client = Client::new(Rc::new(InProcessConnector { + server: fixture.server.clone(), + })); + let shutdown_client = Client::new(Rc::new(InProcessConnector { + server: fixture.server.clone(), + })); + let started = fixture.upgrade_gates.prompt_started.notified(); + let prompt = tokio::task::spawn_local(async move { + prompt_client + .prompt_session( + "worker", + agent::sessions::SessionName::new("s1").expect("name"), + "start work".into(), + false, + None, + ) + .await + }); + started.await; + + let shutdown = tokio::task::spawn_local(async move { shutdown_client.shutdown_for_upgrade().await }); + tokio::task::yield_now().await; + assert!(!shutdown.is_finished(), "shutdown passed the pending prompt"); + + gate.notify_one(); + prompt.await.expect("prompt task").expect("prompt response"); + let error = shutdown + .await + .expect("shutdown task") + .expect_err("new work blocks shutdown"); + assert!(matches!(error, Error::Rpc(error) if error.is_invalid_params())); + fixture + .client + .health() + .await + .expect("rejected shutdown restores admission"); +} + +#[tokio::test(flavor = "local")] +async fn shutdown_rejects_reported_work_before_waiting_for_admitted_mutations() { + let fixture = api(); + let gate = Rc::new(Notify::new()); + *fixture.upgrade_gates.prompt.borrow_mut() = Some(gate.clone()); + let prompt_client = Client::new(Rc::new(InProcessConnector { + server: fixture.server.clone(), + })); + let shutdown_client = Client::new(Rc::new(InProcessConnector { + server: fixture.server.clone(), + })); + let started = fixture.upgrade_gates.prompt_started.notified(); + let prompt = tokio::task::spawn_local(async move { + prompt_client + .prompt_session( + "worker", + agent::sessions::SessionName::new("s1").expect("name"), + "continue work".into(), + true, + Some(Duration::from_secs(10)), + ) + .await + }); + started.await; + fixture + .upgrade_blockers + .borrow_mut() + .push("session/worker/s1 (working)".into()); + + let error = tokio::time::timeout(Duration::from_secs(1), shutdown_client.shutdown_for_upgrade()) + .await + .expect("shutdown should inspect reported work without draining the prompt") + .expect_err("reported work blocks shutdown"); + assert!(matches!(error, Error::Rpc(error) if error.is_invalid_params())); + assert!( + !prompt.is_finished(), + "rejected shutdown must not wait for the active prompt" + ); + fixture.client.health().await.expect("daemon remains available"); + + gate.notify_one(); + prompt.await.expect("prompt task").expect("prompt response"); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn shutdown_preparation_has_one_deadline_and_restores_admission() { + let fixture = api(); + let prompt_gate = Rc::new(Notify::new()); + let readiness_gate = Rc::new(Notify::new()); + *fixture.upgrade_gates.prompt.borrow_mut() = Some(prompt_gate.clone()); + *fixture.upgrade_gates.readiness.borrow_mut() = Some(readiness_gate); + let prompt_client = Client::new(Rc::new(InProcessConnector { + server: fixture.server.clone(), + })); + let shutdown_client = Client::new(Rc::new(InProcessConnector { + server: fixture.server.clone(), + })); + let prompt_started = fixture.upgrade_gates.prompt_started.notified(); + let prompt = tokio::task::spawn_local(async move { + prompt_client + .prompt_session( + "worker", + agent::sessions::SessionName::new("s1").expect("name"), + "start work".into(), + false, + None, + ) + .await + }); + prompt_started.await; + + let shutdown = tokio::task::spawn_local(async move { shutdown_client.shutdown_for_upgrade().await }); + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_secs(59)).await; + prompt_gate.notify_one(); + prompt.await.expect("prompt task").expect("prompt response"); + fixture.upgrade_gates.readiness_started.notified().await; + tokio::time::advance(Duration::from_secs(2)).await; + + let error = shutdown + .await + .expect("shutdown task") + .expect_err("preparation exceeds its shared deadline"); + assert!(error.to_string().contains("did not finish preparing")); + *fixture.upgrade_gates.prompt.borrow_mut() = None; + fixture + .client + .prompt_session( + "worker", + agent::sessions::SessionName::new("s2").expect("name"), + "still admitted".into(), + false, + None, + ) + .await + .expect("timed out shutdown restores admission"); +} + +fn request(name: &str) -> ApplyRequest { + ApplyRequest { + source_directory: std::env::temp_dir().join("agent-platform-source"), + manifest_path: None, + env_file: None, + create_only: false, + agent: agent(name), + } +} + +#[tokio::test(flavor = "local")] +async fn client_and_server_exchange_versioned_agent_operations() { + let fixture = api(); + let client = &fixture.client; + let applied = client.apply(request("worker")).await.expect("apply"); + let fetched = client.get("worker").await.expect("get"); + assert_eq!(applied, fetched); + assert_eq!(client.list_agents().await.expect("list"), vec![applied.clone()]); + assert_eq!( + client + .resolve_agent(request("worker").source_directory.join("nested")) + .await + .expect("resolve source"), + applied + ); + let execution = client + .ensure_execution("worker", WaitPolicy::FirstPass) + .await + .expect("execution target"); + assert_eq!(execution.operating_system, "linux"); + assert_eq!(execution.sandbox.provider().as_str(), "memory"); + assert!(client.list_sessions(None).await.expect("list all Sessions").is_empty()); + let request = agent::sessions::SessionRequest { + harness: Some(agent::Harness::ClaudeCode), + model_selection: agent::ModelSelection { + model: Some(agent::Model::new("claude-fable-5").expect("model")), + effort: Some(agent::Effort::new("xhigh").expect("effort")), + }, + initial_prompt: None, + }; + let ensure_error = client + .ensure_session( + "worker", + agent::sessions::SessionName::new("s1").expect("Session name"), + request.clone(), + WaitPolicy::FirstPass, + ) + .await + .expect_err("fake Session ensure should fail after decoding parameters"); + assert!(matches!(ensure_error, Error::Rpc(error) if error.code == -32004)); + let omitted = client + .ensure_session( + "worker", + agent::sessions::SessionName::new("s2").expect("Session name"), + agent::sessions::SessionRequest::default(), + WaitPolicy::FirstPass, + ) + .await + .expect_err("fake Session ensure should fail after decoding parameters"); + assert!(matches!(omitted, Error::Rpc(error) if error.code == -32004)); + assert_eq!( + fixture.ensured.borrow().as_slice(), + &[request, agent::sessions::SessionRequest::default()], + "model and effort travel as opaque values and stay absent when omitted" + ); + let session_error = client + .get_session("worker", agent::sessions::SessionName::new("s1").expect("Session name")) + .await + .expect_err("missing Session"); + assert!(matches!(session_error, Error::Rpc(error) if error.code == -32004)); + + client.delete("worker").await.expect("delete request"); + let deleting = client.get("worker").await.expect("marked resource"); + assert!(deleting.metadata.deletion_timestamp.is_some()); +} + +#[tokio::test(flavor = "local")] +async fn resource_watch_returns_current_state_then_waits_for_the_next_change() { + let fixture = api(); + let initial = fixture.client.watch_resources(None).await.expect("initial state"); + assert!(initial.agents.is_empty()); + assert!(initial.sessions.is_empty()); + + let watcher = Client::new(Rc::new(InProcessConnector { + server: fixture.server.clone(), + })); + let revision = initial.revision; + let watch = tokio::task::spawn_local(async move { watcher.watch_resources(Some(revision)).await }); + tokio::task::yield_now().await; + assert!(!watch.is_finished(), "a current revision waits for a change"); + + let applied = fixture.client.apply(request("worker")).await.expect("apply"); + fixture.changes.bump(); + let changed = watch.await.expect("watch task").expect("changed state"); + assert_ne!(changed.revision, revision); + assert_eq!(changed.agents, vec![applied]); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn resource_watch_replies_unchanged_after_the_keepalive() { + let fixture = api(); + let current = fixture.client.watch_resources(None).await.expect("initial state"); + let started = tokio::time::Instant::now(); + let unchanged = fixture + .client + .watch_resources(Some(current.revision)) + .await + .expect("keepalive state"); + assert_eq!(unchanged, current); + assert_eq!(started.elapsed(), Duration::from_secs(30)); +} + +#[tokio::test(flavor = "local")] +async fn resource_watch_neither_holds_nor_outlives_an_upgrade_drain() { + let fixture = api(); + let current = fixture.client.watch_resources(None).await.expect("initial state"); + let watcher = Client::new(Rc::new(InProcessConnector { + server: fixture.server.clone(), + })); + let watch = tokio::task::spawn_local(async move { watcher.watch_resources(Some(current.revision)).await }); + tokio::task::yield_now().await; + + fixture + .client + .shutdown_for_upgrade() + .await + .expect("a pending watch is not an admitted mutation"); + let released = watch.await.expect("watch task").expect("state on drain"); + assert_eq!(released.revision, current.revision); +} + +#[tokio::test(flavor = "local")] +async fn a_converge_wait_ends_with_an_upgrade_drain() { + let fixture = api(); + let waiter = Client::new(Rc::new(InProcessConnector { + server: fixture.server.clone(), + })); + let wait = tokio::task::spawn_local(async move { waiter.converge("stuck").await }); + tokio::task::yield_now().await; + + fixture + .client + .shutdown_for_upgrade() + .await + .expect("a pending wait is not an admitted mutation"); + let error = wait.await.expect("wait task").expect_err("the drain ends the wait"); + assert!( + matches!(&error, Error::Rpc(error) if error.message.contains("run the command again")), + "{error:?}" + ); +} + +#[tokio::test(flavor = "local")] +async fn agent_progress_returns_the_status_then_waits_for_the_next_change() { + let fixture = api(); + fixture.client.apply(request("worker")).await.expect("apply"); + let current = fixture + .client + .agent_progress("worker", None, None) + .await + .expect("current progress"); + assert!(current.provisioning.is_none(), "no pass has run"); + assert!(current.status.conditions.is_empty()); + + let follower = Client::new(Rc::new(InProcessConnector { + server: fixture.server.clone(), + })); + let revision = current.revision; + let follow = tokio::task::spawn_local(async move { follower.agent_progress("worker", Some(revision), None).await }); + tokio::task::yield_now().await; + assert!(!follow.is_finished(), "a current revision waits for a change"); + fixture.changes.bump(); + let changed = follow.await.expect("follow task").expect("changed progress"); + assert_ne!(changed.revision, revision); + + let missing = fixture + .client + .agent_progress("missing", None, None) + .await + .expect_err("unknown Agent"); + assert!(matches!(missing, Error::Rpc(error) if error.is_not_found())); +} + +#[tokio::test(flavor = "local")] +async fn a_frame_that_is_not_the_response_fails_the_call() { + let notification = ScriptedConnector { + frames: concat!(r#"{"jsonrpc":"2.0","method":"progress.v1.event","params":{}}"#, "\n"), + }; + let client = Client::new(Rc::new(notification)); + let error = client + .ensure_execution("worker", WaitPolicy::UntilConverged) + .await + .expect_err("a notification is not a response"); + assert!(matches!(error, Error::Json(_)), "unexpected error: {error}"); +} + +#[tokio::test(flavor = "local")] +async fn stop_and_start_record_the_run_state_as_a_new_generation() { + let fixture = api(); + let client = &fixture.client; + let applied = client.apply(request("worker")).await.expect("apply"); + + let stopped = client + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("stop"); + assert_eq!(stopped.spec.run_state, Some(agent::RunState::Stopped)); + assert_eq!(stopped.metadata.generation, applied.metadata.generation + 1); + assert_eq!(client.get("worker").await.expect("get"), stopped); + let again = client + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("repeated stop"); + assert_eq!(again.metadata.generation, stopped.metadata.generation); + assert_eq!(client.converge("worker").await.expect("converge"), stopped); + + let started = client + .set_run_state("worker", agent::RunState::Running) + .await + .expect("start"); + assert_eq!(started.spec.run_state, None); + assert_eq!(started.metadata.generation, stopped.metadata.generation + 1); + + let error = client + .set_run_state("missing", agent::RunState::Stopped) + .await + .expect_err("missing Agent"); + assert!(matches!(error, Error::Rpc(error) if error.is_not_found())); +} + +#[tokio::test(flavor = "local")] +async fn work_in_a_stopped_agent_is_refused_with_how_to_start_it() { + let fixture = api(); + let error = fixture + .client + .ensure_execution("stopped", WaitPolicy::UntilConverged) + .await + .expect_err("a stopped Agent runs nothing"); + match error { + Error::Rpc(error) => { + assert!(error.is_invalid_params(), "agentctl prints it as the whole story"); + assert_eq!( + error.message, + "Agent \"stopped\" is stopped; run `agentctl start agent/stopped`" + ); + } + other => panic!("unexpected error: {other}"), + } +} + +#[tokio::test(flavor = "local")] +async fn application_errors_keep_stable_protocol_codes() { + let fixture = api(); + let error = fixture + .client + .get("missing") + .await + .expect_err("missing Agent should fail"); + + match error { + Error::Rpc(error) => assert_eq!(error.code, -32004), + other => panic!("unexpected error: {other}"), + } +} + +#[tokio::test(flavor = "local")] +async fn malformed_and_idle_connections_do_not_block_other_clients() { + let fixture = api(); + let server = fixture.server; + let client = fixture.client; + client.apply(request("worker")).await.expect("apply"); + + let (mut malformed_client, malformed_server) = tokio::io::duplex(1024); + let malformed_api = server.clone(); + tokio::task::spawn_local(async move { + let _ignored = malformed_api.serve_connection(malformed_server).await; + }); + malformed_client + .write_all(b"{not-json}\n") + .await + .expect("write malformed request"); + let mut response = String::new(); + BufReader::new(&mut malformed_client) + .read_line(&mut response) + .await + .expect("read parse error"); + assert!(response.contains("-32700")); + + let (_idle_client, idle_server) = tokio::io::duplex(1024); + let idle_api = server; + tokio::task::spawn_local(async move { + let _ignored = idle_api.serve_connection(idle_server).await; + }); + let fetched = tokio::time::timeout(Duration::from_secs(1), client.get("worker")) + .await + .expect("active client should not wait for idle connection") + .expect("get"); + assert_eq!(fetched.metadata.name, "worker"); +} + +#[tokio::test(flavor = "local")] +async fn session_ensure_rejects_invalid_selections_before_reaching_the_service() { + let fixture = api(); + let (mut raw_client, raw_server) = tokio::io::duplex(4096); + let api = fixture.server.clone(); + tokio::task::spawn_local(async move { + let _ignored = api.serve_connection(raw_server).await; + }); + let mut reader = BufReader::new(&mut raw_client); + for (id, params) in [ + (1, r#"{"agent":"worker","name":"s1","model_selection":{"model":""}}"#), + ( + 2, + r#"{"agent":"worker","name":"s1","model_selection":{"effort":"very high"}}"#, + ), + ] { + let request = format!(r#"{{"jsonrpc":"2.0","id":{id},"method":"sessions.v1.ensure","params":{params}}}"#); + reader + .get_mut() + .write_all(format!("{request}\n").as_bytes()) + .await + .expect("write request"); + let mut response = String::new(); + reader.read_line(&mut response).await.expect("read response"); + let response: serde_json::Value = serde_json::from_str(&response).expect("JSON-RPC response"); + assert_eq!(response["error"]["code"], -32602, "{response}"); + let message = response["error"]["message"].as_str().expect("message"); + assert!( + message.contains("must be 1-128 ASCII letters"), + "the validation failure names the rule: {message}" + ); + } + assert!(fixture.ensured.borrow().is_empty()); +} + +#[cfg(unix)] +#[tokio::test(flavor = "local")] +async fn unix_socket_transport_is_private_and_usable() { + use std::os::unix::fs::PermissionsExt; + + let temporary = tempfile::Builder::new() + .prefix("agent-api-") + .tempdir() + .expect("temporary API directory"); + let socket_path = temporary.path().join("p").join("agentd.sock"); + let fixture = api(); + let server = fixture.server; + let served_path = socket_path.clone(); + let mut server_task = tokio::task::spawn_local(async move { server.serve_path(&served_path).await }); + let wait_for_socket = tokio::time::timeout(Duration::from_secs(1), async { + while !socket_path.exists() { + tokio::task::yield_now().await; + } + }); + tokio::select! { + result = &mut server_task => panic!("server stopped before creating its socket: {result:?}"), + result = wait_for_socket => result.expect("socket should be created"), + } + + let client = Client::for_path(socket_path.clone()); + let applied = client.apply(request("worker")).await.expect("apply over Unix socket"); + assert_eq!(applied.metadata.name, "worker"); + + let directory_mode = std::fs::metadata(socket_path.parent().expect("socket parent")) + .expect("directory metadata") + .permissions() + .mode() + & 0o777; + let socket_mode = std::fs::metadata(&socket_path) + .expect("socket metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(directory_mode, 0o700); + assert_eq!(socket_mode, 0o600); + client.shutdown_for_upgrade().await.expect("graceful shutdown"); + tokio::time::timeout(Duration::from_secs(1), &mut server_task) + .await + .expect("server should stop") + .expect("server task") + .expect("server result"); +} diff --git a/agentctl/tests/control_plane.rs b/agentctl/tests/control_plane.rs new file mode 100644 index 0000000..0cce899 --- /dev/null +++ b/agentctl/tests/control_plane.rs @@ -0,0 +1,2871 @@ +#![allow(clippy::expect_used)] + +mod support; + +use std::{ + cell::{Cell, RefCell}, + collections::VecDeque, + path::PathBuf, + rc::Rc, + time::Duration, +}; + +use agent::{ + AgentId, ConditionStatus, EnvironmentSpec, Error, FailureKind, MountSpec, SecretSpec, Status, + control_plane::{ + AgentRecord, AgentStore, ControlPlane, Controller, Convergence, Notifier, Reconciler, WaitPolicy, memory, + }, + progress::{OutputPosition, ProvisioningState, SandboxObserver}, + resources::Changes, + sandbox::{ + ExecutionService, PlatformAdapter, Provider, ProviderEnsureOutcome, ProviderId, Service, UNRESPONSIVE_AFTER, + }, +}; +use sandbox::{ + EnsureSandboxRequest, GuestHeartbeat, LocalFuture, Platform, RetentionPolicy, RootFilesystem, SandboxHandle, + SandboxName, SandboxPath, SandboxResources, SandboxService, + backend::SandboxBackend as _, + init::InitSystem, + memory as sandbox_memory, + network::{NetworkEndpointSelection, PacketMedium}, +}; +use tokio::sync::Notify; + +use support::{TempDirectory, agent}; + +#[derive(Default)] +struct NotificationCounter(Cell); + +impl Notifier for NotificationCounter { + fn notify(&self, _id: AgentId) { + self.0.set(self.0.get() + 1); + } +} + +#[derive(Default)] +struct SessionNotificationCounter(Cell); + +impl agent::control_plane::SessionNotifier for SessionNotificationCounter { + fn notify(&self, _id: AgentId) { + self.0.set(self.0.get() + 1); + } + + fn settle(&self, id: AgentId) -> LocalFuture<'_, ()> { + self.notify(id); + Box::pin(async {}) + } +} + +struct NoopPlatform; + +impl PlatformAdapter for NoopPlatform { + fn supports(&self, platform: &Platform) -> bool { + platform.os == "linux" + } + + fn setup<'a>( + &'a self, + _record: &'a AgentRecord, + _sandbox: &'a SandboxHandle, + _harnesses: &'a [agent::Harness], + _steps: &'a sandbox::SandboxProgress, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async { Ok(()) }) + } +} + +struct Blocking { + agent: AgentId, + calls: Rc>, + started: Rc, + release: Rc, +} + +struct MemoryProvider { + id: ProviderId, + service: SandboxService, + default_architecture: String, + blocking: Option, + report_runtime_restart: Rc>, + /// Stops that fail before stopping anything, as a runtime that cannot be reached does. + failing_stops: Rc>, +} + +impl MemoryProvider { + fn new(backend: Rc) -> Self { + Self { + id: ProviderId::new("memory").expect("Provider ID"), + service: SandboxService::new(backend).with_network_backend(Rc::new( + sandbox_memory::NetworkBackend::for_endpoint( + "memory", + NetworkEndpointSelection::Packet(PacketMedium::Ethernet), + ), + )), + default_architecture: Platform::native("linux").architecture, + blocking: None, + report_runtime_restart: Rc::new(Cell::new(false)), + failing_stops: Rc::new(Cell::new(0)), + } + } + + fn with_blocking(mut self, blocking: Blocking) -> Self { + self.blocking = Some(blocking); + self + } +} + +impl Provider for MemoryProvider { + fn id(&self) -> &ProviderId { + &self.id + } + + fn supports<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result> { + Box::pin(async move { Ok(record.agent.spec.sandbox.platform.os == "linux") }) + } + + fn ensure<'a>( + &'a self, + record: &'a AgentRecord, + environment: std::collections::BTreeMap, + _progress: sandbox::ProgressReporter, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + if let Some(blocking) = &self.blocking + && record.id == blocking.agent + { + let call = blocking.calls.get() + 1; + blocking.calls.set(call); + if call == 1 { + blocking.started.notify_one(); + blocking.release.notified().await; + } + } + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &self.default_architecture); + // Like Microsandbox, starting a stopped Sandbox restarts its runtime. + let was_stopped = self + .service + .inspect(&record.sandbox_name()?) + .await + .is_ok_and(|sandbox| sandbox.state == sandbox::SandboxState::Stopped); + let sandbox = self + .service + .ensure( + &EnsureSandboxRequest::new(record.sandbox_name()?, spec) + .with_hostname(record.sandbox_hostname()?) + .with_mounts(record.agent.spec.sandbox.resolved_mounts()) + .with_environment(environment), + ) + .await + .map_err(Error::from)?; + Ok(ProviderEnsureOutcome { + sandbox, + runtime_restarted: self.report_runtime_restart.replace(false) || was_stopped, + harnesses: record + .agent + .spec + .harnesses + .iter() + .map(|installation| installation.kind) + .collect(), + }) + }) + } + + fn open<'a>( + &'a self, + record: &'a AgentRecord, + id: &'a sandbox::SandboxId, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + self.service + .open(id, record.agent.spec.sandbox.resolved_retention_policy()) + .await + .map_err(Error::from) + }) + } + + fn stop<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + if let Some(remaining) = self.failing_stops.get().checked_sub(1) { + self.failing_stops.set(remaining); + return Err(Error::Sandbox(sandbox::Error::Backend("runtime unreachable".into()))); + } + self.service.stop(&record.sandbox_name()?).await.map_err(Error::from) + }) + } + + fn release<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + self.service + .release( + &record.sandbox_name()?, + record.agent.spec.sandbox.resolved_retention_policy(), + ) + .await + .map_err(Error::from) + }) + } +} + +struct UnsupportedProvider { + id: ProviderId, +} + +/// A planned Sandbox ensure failure. +/// +/// A permanent failure fails every pass, as agentd's Providers do until the +/// manifest or `.env` changes. A transient failure fails only the pass that +/// takes it. +#[derive(Clone)] +enum PlannedFailure { + Invalid(String), + /// The Sandbox Provider rejects the request itself (an SDK `InvalidRequest`). + Rejected, + /// Floods telemetry past the lossy channel's capacity, then fails as invalid. + InvalidAfterFlood(String), + Transient(String), + /// Fails transiently on every pass until `ended` is set. + Outage { + message: String, + ended: Rc>, + }, +} + +const TELEMETRY_FLOOD: usize = 4_096; + +struct PlannedProvider { + inner: MemoryProvider, + failures: RefCell>, +} + +impl PlannedProvider { + fn new(backend: Rc, failures: impl IntoIterator) -> Self { + Self { + inner: MemoryProvider::new(backend), + failures: RefCell::new(failures.into_iter().collect()), + } + } +} + +impl Provider for PlannedProvider { + fn id(&self) -> &ProviderId { + self.inner.id() + } + + fn supports<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result> { + self.inner.supports(record) + } + + fn ensure<'a>( + &'a self, + record: &'a AgentRecord, + environment: std::collections::BTreeMap, + progress: sandbox::ProgressReporter, + ) -> LocalFuture<'a, Result> { + let planned = { + let mut failures = self.failures.borrow_mut(); + if matches!(failures.front(), Some(PlannedFailure::Outage { ended, .. }) if ended.get()) { + failures.pop_front(); + } + if matches!(failures.front(), Some(PlannedFailure::Transient(_))) { + failures.pop_front() + } else { + failures.front().cloned() + } + }; + match planned { + Some(PlannedFailure::Invalid(message)) => Box::pin(async move { Err(Error::Invalid(message)) }), + Some(PlannedFailure::Rejected) => Box::pin(async move { + Err(Error::Sandbox(sandbox::Error::Invalid { + field: "spec.resources.cpu", + reason: "fractional CPUs are not supported", + })) + }), + Some(PlannedFailure::InvalidAfterFlood(message)) => Box::pin(async move { + let _phase = progress.start_phase(sandbox::SandboxPhase::ImagePrepare).await; + let step = progress + .steps() + .start_measured_step("Pull layer", sandbox::ProgressUnit::Bytes, None) + .await; + for completed in 0..TELEMETRY_FLOOD { + step.report(completed as u64, None).await; + } + Err(Error::Invalid(message)) + }), + Some(PlannedFailure::Transient(message) | PlannedFailure::Outage { message, .. }) => Box::pin(async move { + let _phase = progress.start_phase(sandbox::SandboxPhase::SandboxStart).await; + Err(Error::Sandbox(sandbox::Error::Backend(message))) + }), + None => self.inner.ensure(record, environment, progress), + } + } + + fn open<'a>( + &'a self, + record: &'a AgentRecord, + id: &'a sandbox::SandboxId, + ) -> LocalFuture<'a, Result> { + self.inner.open(record, id) + } + + fn stop<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>> { + self.inner.stop(record) + } + + fn release<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>> { + self.inner.release(record) + } +} + +impl UnsupportedProvider { + fn new() -> Self { + Self { + id: ProviderId::new("unsupported").expect("Provider ID"), + } + } +} + +impl Provider for UnsupportedProvider { + fn id(&self) -> &ProviderId { + &self.id + } + + fn supports<'a>(&'a self, _record: &'a AgentRecord) -> LocalFuture<'a, Result> { + Box::pin(async { Ok(false) }) + } + + fn ensure<'a>( + &'a self, + _record: &'a AgentRecord, + _environment: std::collections::BTreeMap, + _progress: sandbox::ProgressReporter, + ) -> LocalFuture<'a, Result> { + Box::pin(async { Err(Error::Invalid("unsupported Provider was selected".into())) }) + } + + fn open<'a>( + &'a self, + _record: &'a AgentRecord, + _id: &'a sandbox::SandboxId, + ) -> LocalFuture<'a, Result> { + Box::pin(async { Err(Error::Invalid("unsupported Provider was selected".into())) }) + } + + fn stop<'a>(&'a self, _record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async { Err(Error::Invalid("unsupported Provider was selected".into())) }) + } + + fn release<'a>(&'a self, _record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async { Err(Error::Invalid("unsupported Provider was selected".into())) }) + } +} + +fn sandbox_service(provider: Rc) -> Rc { + Rc::new(Service::new([provider], [Rc::new(NoopPlatform) as Rc]).expect("Sandbox service")) +} + +fn reconciler(store: Rc, provider: Rc) -> Reconciler { + Reconciler::new(store, sandbox_service(provider), ProvisioningState::default()) +} + +struct Fixture { + store: Rc, + backend: Rc, + control_plane: ControlPlane, + reconciler: Reconciler, +} + +fn fixture() -> Fixture { + let store = Rc::new(memory::InMemoryAgentStore::new()); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider: Rc = Rc::new(MemoryProvider::new(backend.clone())); + Fixture { + control_plane: ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())), + reconciler: reconciler(store.clone(), provider), + store, + backend, + } +} + +fn apply_request(name: &str) -> agent::control_plane::ApplyRequest { + apply_request_in(name, std::env::temp_dir().join("agent-platform-source")) +} + +fn apply_request_in(name: &str, source_directory: PathBuf) -> agent::control_plane::ApplyRequest { + agent::control_plane::ApplyRequest { + manifest_path: Some(source_directory.join("agent.yaml")), + env_file: None, + source_directory, + create_only: false, + agent: agent(name), + } +} + +fn sandbox_name(record: &AgentRecord) -> SandboxName { + SandboxName::new(format!("agent-{}", record.id)).expect("Agent ID should form a valid Sandbox name") +} + +async fn stored(fixture: &Fixture, name: &str) -> AgentRecord { + fixture.store.get_by_name(name).await.expect("stored Agent") +} + +async fn reconcile(fixture: &Fixture, name: &str) { + let id = stored(fixture, name).await.id; + fixture.reconciler.reconcile(id).await.expect("reconcile"); +} + +#[tokio::test(flavor = "local")] +async fn apply_stores_desired_state_without_running_inline() { + let fixture = fixture(); + let applied = fixture + .control_plane + .apply(apply_request("worker")) + .await + .expect("apply"); + + assert_eq!(applied.metadata.generation, 1); + assert_eq!( + applied.spec.sandbox.platform.architecture, + Some(Platform::native("linux").architecture) + ); + assert_eq!(fixture.backend.count(), 0); + assert!(applied.status.conditions.is_empty()); +} + +#[tokio::test(flavor = "local")] +async fn lists_agents_and_resolves_the_nearest_unique_source_directory() { + let fixture = fixture(); + let root = std::env::temp_dir().join("agent-platform-sources"); + let outer = apply_request_in("outer", root.clone()); + fixture.control_plane.apply(outer).await.expect("outer Agent"); + let inner = apply_request_in("inner", root.join("nested")); + fixture.control_plane.apply(inner).await.expect("inner Agent"); + + let listed = fixture.control_plane.list().await.expect("list Agents"); + assert_eq!( + listed + .iter() + .map(|agent| agent.metadata.name.as_str()) + .collect::>(), + vec!["inner", "outer"] + ); + let resolved = fixture + .control_plane + .resolve_directory(&root.join("nested/worktree")) + .await + .expect("nearest Agent source"); + assert_eq!(resolved.metadata.name, "inner"); +} + +#[tokio::test(flavor = "local")] +async fn directory_resolution_selects_leaf_variants_and_prefers_the_default_manifest() { + let fixture = fixture(); + let root = std::env::temp_dir().join("agent-platform-variant-sources"); + let default = apply_request_in("default", root.clone()); + fixture.control_plane.apply(default).await.expect("default Agent"); + + let mut nested = apply_request_in("nested", root.clone()); + nested.manifest_path = Some(root.join("agent.nested.yaml")); + fixture.control_plane.apply(nested).await.expect("nested Agent"); + + assert_eq!( + fixture + .control_plane + .resolve_directory(&root) + .await + .expect("default preference") + .metadata + .name, + "default" + ); + assert_eq!( + fixture + .control_plane + .resolve_directory_variant(&root, Some(&agent::AgentVariantName::new("nested").expect("variant"))) + .await + .expect("variant selection") + .metadata + .name, + "nested" + ); + + let mut local = apply_request_in("local", root.clone()); + local.manifest_path = Some(root.join("agent.mine.yaml")); + fixture.control_plane.apply(local).await.expect("local Agent"); + assert_eq!( + fixture + .control_plane + .resolve_directory_variant(&root, Some(&agent::AgentVariantName::new("mine").expect("variant"))) + .await + .expect("multi-level local variant selection") + .metadata + .name, + "local" + ); + assert!(matches!( + fixture + .control_plane + .resolve_directory_variant(&root, Some(&agent::AgentVariantName::new("missing").expect("variant"))) + .await, + Err(Error::Invalid(message)) if message.contains("agent.missing.yaml") + )); +} + +#[tokio::test(flavor = "local")] +async fn directory_resolution_remains_ambiguous_without_one_default_manifest() { + let fixture = fixture(); + let root = std::env::temp_dir().join("agent-platform-ambiguous-variant-sources"); + for (name, variant) in [("nested", "nested"), ("worktree", "worktree")] { + let mut request = apply_request_in(name, root.clone()); + request.manifest_path = Some(root.join(format!("agent.{variant}.yaml"))); + fixture.control_plane.apply(request).await.expect("variant Agent"); + } + let error = fixture + .control_plane + .resolve_directory(&root) + .await + .expect_err("ambiguous variants"); + assert!(matches!(error, Error::Invalid(message) if message.contains("--agent or --variant"))); +} + +#[tokio::test(flavor = "local")] +async fn bind_mounts_resolve_from_the_manifest_and_drive_directory_inference_and_materialization() { + let fixture = fixture(); + let temporary = TempDirectory::new("bind-mount"); + let physical_root = temporary.path().join("physical"); + std::fs::create_dir_all(&physical_root).expect("physical workspace directory"); + #[cfg(unix)] + let root = { + let alias = temporary.path().join("alias"); + std::os::unix::fs::symlink(&physical_root, &alias).expect("workspace alias"); + alias + }; + #[cfg(not(unix))] + let root = physical_root.clone(); + let manifest = root.join("agents/worktree"); + let nested = root.join("src/feature"); + std::fs::create_dir_all(&manifest).expect("manifest directory"); + std::fs::create_dir_all(&nested).expect("nested workspace directory"); + let mut request = apply_request_in("worker", manifest); + request.agent.spec.sandbox.mounts.push(MountSpec::Bind { + source: PathBuf::from("../.."), + target: SandboxPath::new("/home/agent/code/altinn-studio"), + read_only: false, + }); + + let applied = fixture.control_plane.apply(request).await.expect("apply"); + let MountSpec::Bind { source, .. } = &applied.spec.sandbox.mounts[0] else { + panic!("expected bind Mount"); + }; + assert_eq!(source, &std::fs::canonicalize(&root).expect("canonical workspace")); + assert_eq!( + fixture + .control_plane + .resolve_directory(&nested) + .await + .expect("infer Agent") + .metadata + .name, + "worker" + ); + + reconcile(&fixture, "worker").await; + let record = stored(&fixture, "worker").await; + let materialized = fixture + .backend + .find(&sandbox_name(&record)) + .await + .expect("materialized Sandbox"); + assert_eq!(materialized.mounts, record.agent.spec.sandbox.resolved_mounts()); +} + +#[tokio::test(flavor = "local")] +async fn api_responses_carry_provenance_without_persisting_it() { + let fixture = fixture(); + let request = apply_request("worker"); + let expected = agent::Provenance { + source_directory: request.source_directory.clone(), + manifest_path: request.manifest_path.clone(), + env_file: None, + }; + + let applied = fixture.control_plane.apply(request.clone()).await.expect("apply"); + assert_eq!(applied.status.provenance.as_ref(), Some(&expected)); + + let unchanged = fixture.control_plane.apply(request).await.expect("unchanged apply"); + assert_eq!(unchanged.status.provenance.as_ref(), Some(&expected)); + + let fetched = fixture.control_plane.get("worker").await.expect("get"); + assert_eq!(fetched.status.provenance.as_ref(), Some(&expected)); + + let listed = fixture.control_plane.list().await.expect("list"); + assert_eq!(listed[0].status.provenance.as_ref(), Some(&expected)); + + let resolved = fixture + .control_plane + .resolve_directory(&expected.source_directory) + .await + .expect("resolve directory"); + assert_eq!(resolved.status.provenance.as_ref(), Some(&expected)); + + let record = stored(&fixture, "worker").await; + assert_eq!(record.agent.status.provenance, None); + assert_eq!(record.manifest_path, expected.manifest_path); + + reconcile(&fixture, "worker").await; + let reconciled = fixture.control_plane.get("worker").await.expect("get after reconcile"); + assert_eq!(reconciled.status.provenance.as_ref(), Some(&expected)); + assert!(!reconciled.status.conditions.is_empty()); + let record = stored(&fixture, "worker").await; + assert_eq!(record.agent.status.provenance, None); +} + +#[tokio::test(flavor = "local")] +async fn create_only_applies_reject_existing_names() { + let fixture = fixture(); + let mut request = apply_request("worker"); + request.create_only = true; + fixture + .control_plane + .apply(request.clone()) + .await + .expect("initial create"); + + let error = fixture + .control_plane + .apply(request.clone()) + .await + .expect_err("repeated create must fail"); + assert!(matches!(error, Error::Invalid(message) if message.contains("already exists"))); + + request.create_only = false; + fixture.control_plane.apply(request).await.expect("upsert still works"); +} + +#[tokio::test(flavor = "local")] +async fn applies_keep_the_recorded_manifest_path_unless_a_new_one_is_reported() { + let fixture = fixture(); + let request = apply_request("worker"); + let recorded = request.manifest_path.clone(); + fixture.control_plane.apply(request.clone()).await.expect("apply"); + + let mut pathless = request.clone(); + pathless.manifest_path = None; + fixture.control_plane.apply(pathless).await.expect("pathless apply"); + assert_eq!(stored(&fixture, "worker").await.manifest_path, recorded); + + let mut renamed = request.clone(); + renamed.manifest_path = Some(request.source_directory.join("worker.yml")); + let applied = fixture + .control_plane + .apply(renamed.clone()) + .await + .expect("renamed apply"); + assert_eq!(stored(&fixture, "worker").await.manifest_path, renamed.manifest_path); + assert_eq!( + applied + .status + .provenance + .and_then(|provenance| provenance.manifest_path), + renamed.manifest_path + ); + + let mut foreign = request; + foreign.manifest_path = Some(PathBuf::from("/elsewhere/agent.yaml")); + let error = fixture + .control_plane + .apply(foreign) + .await + .expect_err("manifest outside sourceDirectory must fail"); + assert!(matches!(error, Error::Invalid(message) if message.contains("manifestPath"))); +} + +#[tokio::test(flavor = "local")] +async fn changing_the_source_directory_is_rejected_by_name() { + let fixture = fixture(); + fixture + .control_plane + .apply(apply_request("worker")) + .await + .expect("apply"); + + let mut moved = apply_request("worker"); + moved.source_directory = std::env::temp_dir().join("agent-platform-elsewhere"); + moved.manifest_path = Some(moved.source_directory.join("agent.yaml")); + let error = fixture + .control_plane + .apply(moved) + .await + .expect_err("directory change must fail"); + assert!(matches!(error, Error::Immutable("sourceDirectory"))); +} + +#[tokio::test(flavor = "local")] +async fn changing_a_mount_is_rejected_for_an_existing_agent() { + let fixture = fixture(); + let root = TempDirectory::new("immutable-mount"); + let mut request = apply_request_in("worker", root.path().to_path_buf()); + request.agent.spec.sandbox.mounts.push(MountSpec::Bind { + source: PathBuf::from("."), + target: SandboxPath::new("/home/agent/code/first"), + read_only: false, + }); + fixture + .control_plane + .apply(request.clone()) + .await + .expect("initial apply"); + request.agent.spec.sandbox.mounts[0] = MountSpec::Bind { + source: PathBuf::from("."), + target: SandboxPath::new("/home/agent/code/second"), + read_only: false, + }; + + let error = fixture + .control_plane + .apply(request) + .await + .expect_err("Mounts are immutable"); + + assert!(matches!(error, Error::Immutable("spec.sandbox.mounts"))); +} + +#[tokio::test(flavor = "local")] +async fn directory_resolution_rejects_shared_sources_instead_of_guessing() { + let fixture = fixture(); + fixture + .control_plane + .apply(apply_request("first")) + .await + .expect("first Agent"); + fixture + .control_plane + .apply(apply_request("second")) + .await + .expect("second Agent"); + + let error = fixture + .control_plane + .resolve_directory(&std::env::temp_dir().join("agent-platform-source/worktree")) + .await + .expect_err("shared source must be ambiguous"); + assert!(matches!(error, Error::Invalid(message) if message.contains("multiple Agents"))); +} + +#[tokio::test(flavor = "local")] +async fn reconcile_resolves_an_omitted_architecture_without_changing_desired_state() { + let fixture = fixture(); + let mut request = apply_request("worker"); + request.agent.spec.sandbox.platform.architecture = None; + fixture.control_plane.apply(request).await.expect("apply"); + + reconcile(&fixture, "worker").await; + + let desired = fixture.control_plane.get("worker").await.expect("get"); + assert_eq!(desired.spec.sandbox.platform.architecture, None); + let sandbox = fixture + .backend + .find(&sandbox_name(&stored(&fixture, "worker").await)) + .await + .expect("sandbox"); + assert_eq!(sandbox.image.platform, Platform::native("linux")); +} + +#[tokio::test(flavor = "local")] +async fn reconcile_resolves_sources_and_reports_sandbox_ready() { + let fixture = fixture(); + fixture + .control_plane + .apply(apply_request("worker")) + .await + .expect("apply"); + reconcile(&fixture, "worker").await; + + let observed = fixture.control_plane.get("worker").await.expect("get"); + let materialized_name = sandbox_name(&stored(&fixture, "worker").await); + let sandbox_id = observed + .status + .sandbox + .as_ref() + .and_then(agent::sandbox::Assignment::id) + .expect("sandbox id"); + assert_eq!(observed.status.observed_generation, 1); + assert_eq!( + observed + .status + .conditions + .iter() + .map(|condition| (condition.kind.as_str(), condition.status)) + .collect::>(), + [ + (agent::Condition::SANDBOX_READY, ConditionStatus::True), + // The memory backend reports no guest heartbeat. + (agent::Condition::SANDBOX_RESPONSIVE, ConditionStatus::Unknown), + (agent::Condition::READY, ConditionStatus::True), + ] + ); + let sandbox = fixture.backend.find(&materialized_name).await.expect("sandbox"); + assert_eq!(&sandbox.id, sandbox_id); + assert_eq!( + sandbox.image.source, + sandbox::image::ImageSource::Build { + context: std::env::temp_dir().join("agent-platform-source").join("image"), + dockerfile: PathBuf::from("Dockerfile"), + target: None, + } + ); +} + +#[tokio::test(flavor = "local")] +async fn reconciliation_resolves_provider_capabilities_and_persists_the_assignment() { + let store = Rc::new(memory::InMemoryAgentStore::new()); + let backend = Rc::new(sandbox_memory::Provider::new()); + let providers: [Rc; 2] = [ + Rc::new(UnsupportedProvider::new()), + Rc::new(MemoryProvider::new(backend.clone())), + ]; + let sandboxes = + Rc::new(Service::new(providers, [Rc::new(NoopPlatform) as Rc]).expect("Sandbox service")); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())); + let reconciler = Reconciler::new(store.clone(), sandboxes, ProvisioningState::default()); + control_plane.apply(apply_request("worker")).await.expect("apply"); + + reconciler + .reconcile(store.get_by_name("worker").await.expect("record").id) + .await + .expect("reconcile"); + + let assignment = store + .get_by_name("worker") + .await + .expect("record") + .agent + .status + .sandbox + .expect("assignment"); + assert_eq!(assignment.provider().as_str(), "memory"); + assert!(assignment.id().is_some()); + assert_eq!(backend.count(), 1); +} + +#[tokio::test(flavor = "local")] +async fn repeated_reconciliation_reuses_the_same_sandbox() { + let fixture = fixture(); + fixture + .control_plane + .apply(apply_request("worker")) + .await + .expect("apply"); + + reconcile(&fixture, "worker").await; + let first = fixture.control_plane.get("worker").await.expect("first status"); + reconcile(&fixture, "worker").await; + let second = fixture.control_plane.get("worker").await.expect("second status"); + + assert_eq!(fixture.backend.count(), 1); + assert_eq!(first.status.sandbox, second.status.sandbox); +} + +#[tokio::test(flavor = "local")] +async fn agent_transitions_notify_sessions_without_repeated_ready_noise() { + let store = Rc::new(memory::InMemoryAgentStore::new()); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider: Rc = Rc::new(MemoryProvider::new(backend)); + let notifications = Rc::new(SessionNotificationCounter::default()); + let reconciler = reconciler(store.clone(), provider).with_session_notifier(notifications.clone()); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())); + control_plane.apply(apply_request("worker")).await.expect("apply"); + let id = store.get_by_name("worker").await.expect("Agent").id; + + reconciler.reconcile(id).await.expect("materialize"); + assert_eq!(notifications.0.get(), 1); + reconciler.reconcile(id).await.expect("steady ready pass"); + assert_eq!(notifications.0.get(), 1); + control_plane.delete("worker").await.expect("delete"); + reconciler.reconcile(id).await.expect("release"); + assert_eq!(notifications.0.get(), 2); +} + +#[tokio::test(flavor = "local")] +async fn sandbox_runtime_restart_notifies_sessions_without_an_identity_change() { + let store = Rc::new(memory::InMemoryAgentStore::new()); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider = MemoryProvider::new(backend); + let restart = provider.report_runtime_restart.clone(); + let provider: Rc = Rc::new(provider); + let notifications = Rc::new(SessionNotificationCounter::default()); + let reconciler = reconciler(store.clone(), provider).with_session_notifier(notifications.clone()); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())); + control_plane.apply(apply_request("worker")).await.expect("apply"); + let id = store.get_by_name("worker").await.expect("Agent").id; + + reconciler.reconcile(id).await.expect("materialize"); + assert_eq!(notifications.0.get(), 1); + restart.set(true); + reconciler.reconcile(id).await.expect("restart-backed reconcile"); + + assert_eq!(notifications.0.get(), 2); +} + +#[tokio::test(flavor = "local")] +async fn repeated_apply_is_idempotent_and_immutable_fields_are_rejected() { + let fixture = fixture(); + let request = apply_request("worker"); + let first = fixture.control_plane.apply(request.clone()).await.expect("first apply"); + let second = fixture + .control_plane + .apply(request.clone()) + .await + .expect("second apply"); + assert_eq!(first.metadata.generation, second.metadata.generation); + + let mut mutable_change = request.clone(); + mutable_change.agent.spec.sandbox.retention_policy = Some(RetentionPolicy::Delete); + mutable_change.agent.spec.harnesses[0].version = Some("2.1.240".into()); + mutable_change.agent.spec.harnesses[0].default = true; + let updated_request = mutable_change.clone(); + let updated = fixture + .control_plane + .apply(mutable_change) + .await + .expect("mutable update"); + assert_eq!(updated.metadata.generation, 2); + assert_eq!(updated.spec.harnesses[0].version.as_deref(), Some("2.1.240")); + assert!(updated.spec.harnesses[0].default); + + let mut kind_set_change = updated_request.clone(); + kind_set_change.agent.spec.harnesses[0].default = true; + kind_set_change.agent.spec.harnesses.push(agent::HarnessSpec { + kind: agent::Harness::Codex, + version: Some("0.149.1".into()), + auth: agent::HarnessAuthMode::Mediated, + optional: false, + default: false, + defaults: agent::ModelSelection::default(), + }); + let error = fixture + .control_plane + .apply(kind_set_change) + .await + .expect_err("harness kind set should be immutable"); + assert!(matches!(error, Error::Immutable("spec.harnesses.type"))); + + let mut immutable_change = updated_request.clone(); + immutable_change.agent.spec.sandbox.platform.architecture = Some( + if Platform::native("linux").architecture == "amd64" { + "arm64" + } else { + "amd64" + } + .into(), + ); + let error = fixture + .control_plane + .apply(immutable_change) + .await + .expect_err("Sandbox Platform should be immutable"); + assert!(matches!(error, Error::Immutable("spec.sandbox.platform"))); + + let mut init_system_change = updated_request.clone(); + init_system_change.agent.spec.sandbox.init_system = InitSystem::Image; + let error = fixture + .control_plane + .apply(init_system_change) + .await + .expect_err("Sandbox init system should be immutable"); + assert!(matches!(error, Error::Immutable("spec.sandbox.initSystem"))); + + let mut root_mode_change = updated_request; + let resources = root_mode_change.agent.spec.sandbox.resources; + root_mode_change.agent.spec.sandbox.resources = SandboxResources::new( + resources.cpu(), + resources.memory(), + RootFilesystem::direct(resources.root_filesystem().capacity()), + ); + let error = fixture + .control_plane + .apply(root_mode_change) + .await + .expect_err("Sandbox root filesystem mode should be immutable"); + assert!(matches!( + error, + Error::Immutable("spec.sandbox.resources.rootFilesystem.mode") + )); +} + +#[tokio::test(flavor = "local")] +async fn secret_binding_definitions_are_mutable_desired_state() { + let fixture = fixture(); + let request = apply_request("worker"); + fixture + .control_plane + .apply(request.clone()) + .await + .expect("initial apply"); + + let mut changed = request; + changed.agent.spec.secrets.push(SecretSpec { + environment: "GITHUB_TOKEN".into(), + optional: false, + placeholder: None, + allowed_hosts: vec!["github.com".into()], + source: Some("GH_PAT".into()), + }); + let applied = fixture + .control_plane + .apply(changed) + .await + .expect("secret binding update"); + + assert_eq!(applied.metadata.generation, 2); + assert_eq!(applied.spec.secrets.len(), 1); +} + +#[cfg(unix)] +#[tokio::test(flavor = "local")] +async fn directory_resolution_survives_a_symlinked_parent_of_a_missing_source() { + // macOS and Windows temp directories canonicalize to a different spelling; a source + // directory that does not exist on disk must still resolve by its literal path. + let fixture = fixture(); + let real = tempfile::tempdir().expect("real directory"); + let link = tempfile::tempdir().expect("link holder"); + let alias = link.path().join("alias"); + std::os::unix::fs::symlink(real.path(), &alias).expect("symlink"); + let source_directory = alias.join("missing-source"); + let request = apply_request_in("worker", source_directory.clone()); + let applied = fixture.control_plane.apply(request).await.expect("apply"); + + let resolved = fixture + .control_plane + .resolve_directory(&source_directory.join("nested")) + .await + .expect("a subdirectory of the literal source path resolves"); + + assert_eq!(resolved.metadata.name, applied.metadata.name); +} + +#[tokio::test(flavor = "local")] +async fn selected_secret_file_inside_a_bind_mount_is_rejected() { + let fixture = fixture(); + let root = tempfile::tempdir().expect("temporary checkout"); + let source_directory = root.path().join("examples/worktree"); + std::fs::create_dir_all(&source_directory).expect("source directory"); + let mut request = apply_request_in("worker", source_directory.clone()); + request.agent.spec.secrets.push(SecretSpec { + environment: "GITHUB_TOKEN".into(), + optional: false, + placeholder: None, + allowed_hosts: vec!["github.com".into()], + source: None, + }); + request.agent.spec.sandbox.mounts.push(agent::MountSpec::Bind { + source: root.path().to_path_buf(), + target: sandbox::SandboxPath::new("/home/agent/code/checkout"), + read_only: false, + }); + + let initial = fixture + .control_plane + .apply(request.clone()) + .await + .expect("an absent default .env does not make the mount unsafe"); + + let outside = tempfile::tempdir().expect("secret directory outside the checkout"); + request.env_file = Some(outside.path().join("worker.env")); + let applied = fixture + .control_plane + .apply(request.clone()) + .await + .expect("a secret file outside every mount is accepted"); + assert_eq!( + applied.status.provenance.expect("provenance").env_file, + request.env_file + ); + assert_eq!( + stored(&fixture, "worker").await.env_file_path(), + outside.path().join("worker.env") + ); + assert!(applied.metadata.generation > initial.metadata.generation); + + std::fs::write(root.path().join(".env"), "GITHUB_TOKEN=checkout-token\n").expect("environment file"); + let error = fixture + .control_plane + .apply(request.clone()) + .await + .expect_err("a .env anywhere in the mount is rejected despite the external override"); + assert!( + matches!(&error, Error::Invalid(message) if message.contains("contains .env")), + "{error}" + ); + std::fs::remove_file(root.path().join(".env")).expect("remove environment file"); + + let mut unchanged = request.clone(); + unchanged.env_file = None; + let reapplied = fixture + .control_plane + .apply(unchanged) + .await + .expect("omitting envFile keeps the recorded path"); + assert_eq!(reapplied.metadata.generation, applied.metadata.generation); + + let mut inside = request; + inside.env_file = Some(root.path().join("secrets.env")); + let error = fixture + .control_plane + .apply(inside) + .await + .expect_err("an explicit secret file inside the mount is still rejected"); + assert!(matches!(error, Error::Invalid(_))); +} + +#[tokio::test(flavor = "local")] +async fn git_ignored_nested_dot_env_is_rejected_case_insensitively_without_declared_secrets() { + let fixture = fixture(); + let checkout = tempfile::tempdir().expect("checkout"); + let relative_env = PathBuf::from("ignored").join("nested").join(".EnV"); + let ignored = checkout + .path() + .join(relative_env.parent().expect("environment file parent")); + std::fs::create_dir_all(&ignored).expect("ignored directory"); + std::fs::write(checkout.path().join(".gitignore"), "ignored/\n").expect("ignore file"); + std::fs::write(checkout.path().join(&relative_env), "PRIVATE=value\n").expect("nested environment file"); + let source = tempfile::tempdir().expect("manifest directory"); + let mut request = apply_request_in("worker", source.path().to_path_buf()); + request.agent.spec.sandbox.mounts.push(agent::MountSpec::Bind { + source: checkout.path().to_path_buf(), + target: sandbox::SandboxPath::new("/home/agent/code/checkout"), + read_only: false, + }); + + let error = fixture + .control_plane + .apply(request) + .await + .expect_err("ignored directories are still inspected case-insensitively for .env files"); + let expected_path = relative_env.display().to_string(); + assert!( + matches!(&error, Error::Invalid(message) + if message.contains("spec.sandbox.mounts[0]") && message.contains(&expected_path)), + "{error}" + ); +} + +#[tokio::test(flavor = "local")] +async fn a_directory_named_dot_env_is_allowed() { + let fixture = fixture(); + let checkout = tempfile::tempdir().expect("checkout"); + std::fs::create_dir(checkout.path().join(".ENV")).expect("directory named .ENV"); + let source = tempfile::tempdir().expect("manifest directory"); + let mut request = apply_request_in("worker", source.path().to_path_buf()); + request.agent.spec.sandbox.mounts.push(agent::MountSpec::Bind { + source: checkout.path().to_path_buf(), + target: sandbox::SandboxPath::new("/home/agent/code/checkout"), + read_only: false, + }); + + fixture + .control_plane + .apply(request) + .await + .expect("a directory named .env is not an environment file"); +} + +#[cfg(unix)] +#[tokio::test(flavor = "local")] +async fn a_dot_env_symlink_is_rejected() { + let fixture = fixture(); + let checkout = tempfile::tempdir().expect("checkout"); + std::fs::write(checkout.path().join("credentials"), "PRIVATE=value\n").expect("target file"); + std::os::unix::fs::symlink("credentials", checkout.path().join(".ENV")).expect("environment symlink"); + let source = tempfile::tempdir().expect("manifest directory"); + let mut request = apply_request_in("worker", source.path().to_path_buf()); + request.agent.spec.sandbox.mounts.push(agent::MountSpec::Bind { + source: checkout.path().to_path_buf(), + target: sandbox::SandboxPath::new("/home/agent/code/checkout"), + read_only: false, + }); + + fixture + .control_plane + .apply(request) + .await + .expect_err("a case-variant .env symlink still exposes a file"); +} + +#[tokio::test(flavor = "local")] +async fn existing_default_env_outside_bind_mount_is_allowed() { + let fixture = fixture(); + let source = tempfile::tempdir().expect("manifest directory"); + let checkout = tempfile::tempdir().expect("mounted checkout"); + let external = tempfile::tempdir().expect("external environment directory"); + std::fs::write(source.path().join(".env"), "GITHUB_TOKEN=unmounted-token\n") + .expect("unmounted default environment file"); + let mut request = apply_request_in("worker", source.path().to_path_buf()); + request.env_file = Some(external.path().join("worker.env")); + request.agent.spec.secrets.push(SecretSpec { + environment: "GITHUB_TOKEN".into(), + optional: false, + placeholder: None, + allowed_hosts: vec!["github.com".into()], + source: None, + }); + request.agent.spec.sandbox.mounts.push(agent::MountSpec::Bind { + source: checkout.path().to_path_buf(), + target: sandbox::SandboxPath::new("/home/agent/code/checkout"), + read_only: false, + }); + + fixture + .control_plane + .apply(request) + .await + .expect("an unmounted default .env is not exposed"); +} + +#[cfg(unix)] +#[tokio::test(flavor = "local")] +async fn secret_file_reached_through_a_symlinked_ancestor_is_still_rejected() { + let fixture = fixture(); + let checkout = tempfile::tempdir().expect("checkout"); + let link_holder = tempfile::tempdir().expect("link holder"); + let alias = link_holder.path().join("alias"); + std::os::unix::fs::symlink(checkout.path(), &alias).expect("symlink"); + let source_directory = checkout.path().join("examples/worktree"); + std::fs::create_dir_all(&source_directory).expect("source directory"); + let mut request = apply_request_in("worker", source_directory); + request.agent.spec.secrets.push(SecretSpec { + environment: "GITHUB_TOKEN".into(), + optional: false, + placeholder: None, + allowed_hosts: vec!["github.com".into()], + source: None, + }); + request.agent.spec.sandbox.mounts.push(agent::MountSpec::Bind { + source: checkout.path().to_path_buf(), + target: sandbox::SandboxPath::new("/home/agent/code/checkout"), + read_only: false, + }); + // Neither the file nor its two parent directories exist yet, and the path enters the + // mounted checkout through a symlink. + request.env_file = Some(alias.join("secrets/not-yet/worker.env")); + + let error = fixture + .control_plane + .apply(request) + .await + .expect_err("the secret file would land inside the mounted checkout"); + assert!(matches!(error, Error::Invalid(_)), "{error}"); +} + +#[tokio::test(flavor = "local")] +async fn bind_mount_exposing_another_agents_secret_file_is_rejected() { + let fixture = fixture(); + let root = tempfile::tempdir().expect("temporary checkout"); + let with_secrets = root.path().join("agents/full"); + std::fs::create_dir_all(&with_secrets).expect("secret Agent source directory"); + let mut secret_agent = apply_request_in("full", with_secrets); + let selected_secret_file = root.path().join("credentials.txt"); + std::fs::write(&selected_secret_file, "GITHUB_TOKEN=private\n").expect("selected environment file"); + secret_agent.env_file = Some(selected_secret_file); + secret_agent.agent.spec.secrets.push(SecretSpec { + environment: "GITHUB_TOKEN".into(), + optional: false, + placeholder: None, + allowed_hosts: vec!["github.com".into()], + source: None, + }); + fixture.control_plane.apply(secret_agent).await.expect("secret Agent"); + + let mounted = root.path().join("agents/worktree"); + std::fs::create_dir_all(&mounted).expect("mounted Agent source directory"); + let mut worktree = apply_request_in("worktree", mounted); + worktree.agent.spec.sandbox.mounts.push(agent::MountSpec::Bind { + source: root.path().to_path_buf(), + target: sandbox::SandboxPath::new("/home/agent/code/checkout"), + read_only: false, + }); + + let error = fixture + .control_plane + .apply(worktree) + .await + .expect_err("the mount would expose the other Agent's selected environment file"); + assert!( + matches!(&error, Error::Invalid(message) if message.contains("Agent \"full\"")), + "{error}" + ); +} + +#[tokio::test(flavor = "local")] +async fn unchanged_apply_still_requests_immediate_reconciliation() { + let store = Rc::new(memory::InMemoryAgentStore::new()); + let notifications = Rc::new(NotificationCounter::default()); + let control_plane = ControlPlane::new(store, notifications.clone()); + let request = apply_request("worker"); + + control_plane.apply(request.clone()).await.expect("first apply"); + control_plane.apply(request).await.expect("unchanged apply"); + + assert_eq!(notifications.0.get(), 2); +} + +#[tokio::test(flavor = "local")] +async fn selected_environment_converges_from_the_env_file_without_exporting_other_values() { + let fixture = fixture(); + let source = tempfile::tempdir().expect("Agent source"); + let mut request = apply_request_in("worker", source.path().to_path_buf()); + request.agent.spec.environment = vec![ + EnvironmentSpec { + name: "GIT_USER_NAME".into(), + source: Some("HOST_GIT_NAME".into()), + }, + EnvironmentSpec { + name: "GIT_USER_EMAIL".into(), + source: None, + }, + ]; + std::fs::write( + source.path().join(".env"), + "HOST_GIT_NAME=First User\nGIT_USER_EMAIL=first@example.com\nUNSELECTED=private\n", + ) + .expect("first environment file"); + fixture.control_plane.apply(request.clone()).await.expect("apply"); + reconcile(&fixture, "worker").await; + let record = stored(&fixture, "worker").await; + let first = fixture + .backend + .find(&sandbox_name(&record)) + .await + .expect("Sandbox after first apply"); + assert_eq!( + first.environment.get("GIT_USER_NAME").map(String::as_str), + Some("First User") + ); + assert_eq!( + first.environment.get("GIT_USER_EMAIL").map(String::as_str), + Some("first@example.com") + ); + assert!(!first.environment.contains_key("UNSELECTED")); + + std::fs::write( + source.path().join(".env"), + "HOST_GIT_NAME=Second User\nGIT_USER_EMAIL=second@example.com\nUNSELECTED=still-private\n", + ) + .expect("updated environment file"); + let reapplied = fixture.control_plane.apply(request).await.expect("unchanged reapply"); + assert_eq!(reapplied.metadata.generation, 1); + reconcile(&fixture, "worker").await; + let second = fixture + .backend + .find(&sandbox_name(&record)) + .await + .expect("Sandbox after environment update"); + assert_eq!( + second.environment.get("GIT_USER_NAME").map(String::as_str), + Some("Second User") + ); + assert_eq!( + second.environment.get("GIT_USER_EMAIL").map(String::as_str), + Some("second@example.com") + ); + assert!(!second.environment.contains_key("UNSELECTED")); +} + +#[tokio::test(flavor = "local")] +async fn selected_environment_requires_present_non_empty_values() { + let fixture = fixture(); + let source = tempfile::tempdir().expect("Agent source"); + let mut request = apply_request_in("worker", source.path().to_path_buf()); + request.agent.spec.environment = vec![ + EnvironmentSpec { + name: "GIT_USER_NAME".into(), + source: None, + }, + EnvironmentSpec { + name: "GIT_USER_EMAIL".into(), + source: None, + }, + ]; + std::fs::write(source.path().join(".env"), "GIT_USER_NAME=\n").expect("incomplete environment file"); + fixture.control_plane.apply(request).await.expect("apply"); + let id = stored(&fixture, "worker").await.id; + + let error = fixture + .reconciler + .reconcile(id) + .await + .expect_err("empty selected value must fail"); + assert!(matches!(error, Error::Invalid(message) if message.contains("GIT_USER_NAME") && message.contains("empty"))); + + std::fs::write(source.path().join(".env"), "GIT_USER_NAME=Ready\n").expect("missing environment value"); + let error = fixture + .reconciler + .reconcile(id) + .await + .expect_err("missing selected value must fail"); + assert!( + matches!(error, Error::Invalid(message) if message.contains("GIT_USER_EMAIL") && message.contains("does not define")) + ); +} + +#[tokio::test(flavor = "local")] +async fn apply_requires_an_absolute_source_directory() { + let fixture = fixture(); + let mut request = apply_request("worker"); + request.source_directory = PathBuf::from("relative"); + + let error = fixture + .control_plane + .apply(request) + .await + .expect_err("relative source should fail"); + assert!(matches!(error, Error::Invalid(_))); +} + +#[tokio::test(flavor = "local")] +async fn retained_sandbox_is_not_inherited_by_a_reused_agent_name() { + let fixture = fixture(); + let request = apply_request("worker"); + fixture.control_plane.apply(request.clone()).await.expect("apply"); + let first_record = stored(&fixture, "worker").await; + reconcile(&fixture, "worker").await; + let first_sandbox_name = sandbox_name(&first_record); + let original_id = fixture.backend.find(&first_sandbox_name).await.expect("sandbox").id; + + fixture.control_plane.delete("worker").await.expect("delete request"); + fixture.reconciler.reconcile(first_record.id).await.expect("release"); + assert!(matches!( + fixture.control_plane.get("worker").await, + Err(Error::NotFound) + )); + + fixture.control_plane.apply(request.clone()).await.expect("re-apply"); + let second_record = stored(&fixture, "worker").await; + assert_ne!(first_record.id, second_record.id); + reconcile(&fixture, "worker").await; + let second_id = fixture + .backend + .find(&sandbox_name(&second_record)) + .await + .expect("new sandbox") + .id; + assert_ne!(second_id, original_id); + assert_eq!(fixture.backend.count(), 2); + + let mut delete_request = request; + delete_request.agent.spec.sandbox.retention_policy = Some(RetentionPolicy::Delete); + fixture + .control_plane + .apply(delete_request) + .await + .expect("update retention"); + fixture.control_plane.delete("worker").await.expect("delete request"); + reconcile(&fixture, "worker").await; + assert_eq!(fixture.backend.count(), 1); + assert_eq!( + fixture + .backend + .find(&first_sandbox_name) + .await + .expect("retained sandbox") + .id, + original_id + ); +} + +#[tokio::test(flavor = "local")] +async fn omitted_retention_deletes_the_sandbox() { + let fixture = fixture(); + let mut request = apply_request("worker"); + request.agent.spec.sandbox.retention_policy = None; + let applied = fixture.control_plane.apply(request).await.expect("apply"); + assert_eq!(applied.spec.sandbox.retention_policy, None); + reconcile(&fixture, "worker").await; + let id = stored(&fixture, "worker").await.id; + fixture.control_plane.delete("worker").await.expect("delete request"); + fixture.reconciler.reconcile(id).await.expect("delete sandbox"); + assert_eq!(fixture.backend.count(), 0); +} + +#[tokio::test(flavor = "local")] +async fn controller_reconciles_after_a_wakeup() { + let store = Rc::new(memory::InMemoryAgentStore::new()); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider: Rc = Rc::new(MemoryProvider::new(backend.clone())); + let reconciler = Rc::new(reconciler(store.clone(), provider)); + let (controller, wakeup) = Controller::new(store.clone(), reconciler, Duration::from_mins(1), Rc::new(|_, _| {})); + let control_plane = ControlPlane::new(store, Rc::new(wakeup)); + let task = tokio::task::spawn_local(controller.run()); + + control_plane.apply(apply_request("worker")).await.expect("apply"); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if backend.count() == 1 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("controller should reconcile"); + task.abort(); +} + +/// An Agent `worker` whose Sandbox ensures fail as planned, reconciled by a +/// running controller, with a waiter over the same change history. +struct Waiting { + store: Rc, + backend: Rc, + provisioning: ProvisioningState, + execution: Rc, + wakeup: agent::control_plane::Wakeup, + task: tokio::task::JoinHandle<()>, +} + +/// A controller interval short enough for a test to wait through background retries. +const BACKGROUND_RETRIES: Duration = Duration::from_millis(20); +/// A controller interval long enough that only a test's own wakeups reconcile. +const NO_BACKGROUND_PASSES: Duration = Duration::from_mins(1); + +async fn waiting(failures: impl IntoIterator, interval: Duration) -> Waiting { + let changes = Changes::new(); + let store = Rc::new(memory::InMemoryAgentStore::with_changes(changes.clone())); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider: Rc = Rc::new(PlannedProvider::new(backend.clone(), failures)); + let provisioning = ProvisioningState::new(changes.clone()); + let reconciler = Rc::new(Reconciler::new( + store.clone(), + sandbox_service(provider), + provisioning.clone(), + )); + let (controller, wakeup) = Controller::new(store.clone(), reconciler, interval, Rc::new(|_, _| {})); + let execution = Rc::new(ExecutionService::new( + store.clone(), + Convergence::new(wakeup.clone(), store.clone(), changes), + )); + let task = tokio::task::spawn_local(controller.run()); + tokio::task::yield_now().await; + control_plane.apply(apply_request("worker")).await.expect("apply"); + Waiting { + store, + backend, + provisioning, + execution, + wakeup, + task, + } +} + +impl Waiting { + async fn id(&self) -> AgentId { + self.store.get_by_name("worker").await.expect("stored Agent").id + } +} + +#[tokio::test(flavor = "local")] +async fn execution_target_waits_for_agent_convergence() { + let fixture = waiting([], NO_BACKGROUND_PASSES).await; + let target = tokio::time::timeout( + Duration::from_secs(1), + fixture.execution.ensure("worker", WaitPolicy::FirstPass), + ) + .await + .expect("execution target should not wait for the periodic scan") + .expect("ready execution target"); + + assert_eq!(target.operating_system, "linux"); + assert_eq!(target.sandbox.provider().as_str(), "memory"); + assert!(target.sandbox.id().is_some()); + assert_eq!(fixture.backend.count(), 1); + fixture.task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn an_invalid_failure_fails_the_wait_immediately() { + let fixture = waiting( + [PlannedFailure::Invalid( + ".env does not define required variable \"GITHUB_TOKEN\"".into(), + )], + NO_BACKGROUND_PASSES, + ) + .await; + let error = fixture + .execution + .ensure("worker", WaitPolicy::UntilConverged) + .await + .expect_err("invalid preparation must fail fast"); + + assert!(matches!(error, Error::Invalid(message) if message.contains("GITHUB_TOKEN"))); + let stored = fixture.store.get(fixture.id().await).await.expect("stored Agent"); + assert_eq!(stored.agent.status.failure, Some(FailureKind::Invalid)); + let provisioning = fixture.provisioning.get(stored.id).expect("failed pass"); + assert!(matches!( + provisioning.progress.status(), + sandbox::progress::OperationStatus::Failed { .. } + )); + fixture.task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn a_provider_rejection_is_permanent_and_fails_the_wait_immediately() { + let fixture = waiting([PlannedFailure::Rejected], NO_BACKGROUND_PASSES).await; + let error = tokio::time::timeout( + Duration::from_secs(1), + fixture.execution.ensure("worker", WaitPolicy::UntilConverged), + ) + .await + .expect("a permanent rejection must not be waited through") + .expect_err("rejected request fails"); + + assert!(matches!(error, Error::Invalid(message) if message.contains("fractional CPUs"))); + fixture.task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn a_flood_of_progress_does_not_stall_the_wait() { + let fixture = waiting( + [PlannedFailure::InvalidAfterFlood( + ".env does not define required variable \"GITHUB_TOKEN\"".into(), + )], + NO_BACKGROUND_PASSES, + ) + .await; + let error = tokio::time::timeout( + Duration::from_secs(1), + fixture.execution.ensure("worker", WaitPolicy::UntilConverged), + ) + .await + .expect("progress volume must not stall the request") + .expect_err("invalid preparation must fail"); + + assert!(matches!(error, Error::Invalid(message) if message.contains("GITHUB_TOKEN"))); + fixture.task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn waiting_follows_background_retries_after_transient_failures() { + let fixture = waiting( + [ + PlannedFailure::Transient("temporary runtime failure".into()), + PlannedFailure::Transient("temporary runtime failure".into()), + ], + BACKGROUND_RETRIES, + ) + .await; + let target = tokio::time::timeout( + Duration::from_secs(1), + fixture.execution.ensure("worker", WaitPolicy::UntilConverged), + ) + .await + .expect("background retry should complete") + .expect("eventual execution target"); + + assert!(target.sandbox.id().is_some()); + let provisioning = fixture.provisioning.get(fixture.id().await).expect("latest pass"); + assert_eq!( + provisioning.progress.status(), + &sandbox::progress::OperationStatus::Succeeded, + "the latest pass is the retry that succeeded" + ); + fixture.task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn provisioning_is_projected_but_not_stored_and_omitted_after_success() { + let changes = Changes::new(); + let store = Rc::new(memory::InMemoryAgentStore::with_changes(changes.clone())); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider: Rc = Rc::new(PlannedProvider::new( + backend, + [PlannedFailure::Transient("temporary runtime failure".into())], + )); + let provisioning = ProvisioningState::new(changes.clone()); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())) + .with_provisioning(provisioning.clone()); + let reconciler = Reconciler::new(store.clone(), sandbox_service(provider), provisioning.clone()); + control_plane.apply(apply_request("worker")).await.expect("apply"); + let id = store.get_by_name("worker").await.expect("stored Agent").id; + + reconciler.reconcile(id).await.expect_err("planned transient failure"); + let failed = control_plane.get("worker").await.expect("failed Agent"); + assert_eq!(failed.status.failure, Some(FailureKind::Transient)); + let summary = failed.status.progress.expect("failed provisioning"); + assert!(matches!( + summary.progress.status(), + sandbox::progress::OperationStatus::Failed { detail } if detail.contains("temporary runtime failure") + )); + assert_eq!( + store.get(id).await.expect("stored Agent").agent.status.progress, + None, + "provisioning is projected, never stored" + ); + + reconciler.reconcile(id).await.expect("retry succeeds"); + let ready = control_plane.get("worker").await.expect("ready Agent"); + assert!(ready.status.is_ready()); + assert_eq!(ready.status.failure, None); + assert_eq!(ready.status.progress, None, "a succeeded pass is not listed"); + let finished = provisioning.get(id).expect("finished pass"); + assert_ne!(finished.pass, summary.pass, "each retry is a new pass"); + assert_eq!( + finished.progress.status(), + &sandbox::progress::OperationStatus::Succeeded + ); + assert!( + finished + .progress + .finished() + .iter() + .any(|phase| phase.phase == agent::progress::SETUP && phase.outcome == sandbox::Outcome::Completed), + "Agent setup is reported as a phase of the pass" + ); + + let revision = changes.revision(); + reconciler.reconcile(id).await.expect("resync of a Ready Agent"); + assert_eq!( + provisioning.get(id), + Some(finished), + "a resync that succeeds leaves the pass that provisioned the Agent" + ); + let resynced = control_plane.get("worker").await.expect("resynced Agent"); + assert_eq!(resynced.status.progress, None); + assert!(resynced.status.is_ready()); + assert_eq!( + changes.revision(), + revision, + "a resync that changes nothing wakes no watcher" + ); +} + +#[tokio::test(flavor = "local")] +async fn progress_trims_only_the_output_of_the_pass_the_follower_has_seen() { + let store = Rc::new(memory::InMemoryAgentStore::new()); + let provisioning = ProvisioningState::default(); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())) + .with_provisioning(provisioning.clone()); + control_plane.apply(apply_request("worker")).await.expect("apply"); + let id = store.get_by_name("worker").await.expect("stored Agent").id; + let texts = |provisioning: &agent::progress::Provisioning| { + provisioning + .progress + .output() + .lines() + .map(|line| line.text.clone()) + .collect::>() + }; + + let first = SandboxObserver::new(id, provisioning.clone()); + let phase = first.reporter().start_phase(agent::progress::SETUP).await; + let step = first.reporter().steps().start_step("Sync home").await; + step.output(sandbox::OutputStream::Stdout, "one\ntwo\n").await; + let (_, full) = control_plane.progress("worker", None).await.expect("progress"); + let full = full.expect("first pass"); + assert_eq!(texts(&full), ["one", "two"]); + + let seen = OutputPosition { + pass: full.pass, + sequence: 1, + }; + let (_, trimmed) = control_plane.progress("worker", Some(seen)).await.expect("progress"); + assert_eq!( + texts(&trimmed.expect("first pass")), + ["two"], + "output already seen is left out" + ); + drop((step, phase, first)); + + let second = SandboxObserver::new(id, provisioning.clone()); + let _phase = second.reporter().start_phase(agent::progress::SETUP).await; + let step = second.reporter().steps().start_step("Sync home").await; + step.output(sandbox::OutputStream::Stdout, "three\n").await; + let (_, next) = control_plane.progress("worker", Some(seen)).await.expect("progress"); + let next = next.expect("second pass"); + assert_ne!(next.pass, full.pass); + assert_eq!(texts(&next), ["three"], "a position in another pass trims nothing"); +} + +#[tokio::test(flavor = "local")] +async fn a_first_pass_wait_returns_its_failure_and_until_ready_waits_through_retries() { + let fixture = waiting( + [ + PlannedFailure::Transient("temporary runtime failure".into()), + PlannedFailure::Transient("temporary runtime failure".into()), + ], + BACKGROUND_RETRIES, + ) + .await; + let error = fixture + .execution + .ensure("worker", WaitPolicy::FirstPass) + .await + .expect_err("first pass fails"); + assert!(matches!(error, Error::Daemon(message) if message.contains("temporary runtime failure"))); + + let target = tokio::time::timeout( + Duration::from_secs(1), + fixture.execution.ensure("worker", WaitPolicy::UntilConverged), + ) + .await + .expect("background retry should complete") + .expect("eventual execution target"); + assert!(target.sandbox.id().is_some()); + fixture.task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn dropping_a_wait_does_not_stop_background_reconciliation() { + // Every pass fails until the wait is dropped, so the failure stays visible + // and only a pass that starts after the drop can succeed. + let ended = Rc::new(Cell::new(false)); + let fixture = waiting( + [PlannedFailure::Outage { + message: "temporary runtime failure".into(), + ended: ended.clone(), + }], + BACKGROUND_RETRIES, + ) + .await; + let id = fixture.id().await; + let waiting = fixture.execution.clone(); + let wait = tokio::task::spawn_local(async move { waiting.ensure("worker", WaitPolicy::UntilConverged).await }); + tokio::time::timeout(Duration::from_secs(1), async { + while !fixture.provisioning.get(id).is_some_and(|pass| { + matches!( + pass.progress.status(), + sandbox::progress::OperationStatus::Failed { .. } + ) + }) { + tokio::time::sleep(Duration::from_millis(1)).await; + } + }) + .await + .expect("transient failure recorded"); + wait.abort(); + ended.set(true); + + tokio::time::timeout(Duration::from_secs(1), async { + while fixture.backend.count() == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("background controller should keep reconciling"); + let _ = fixture.wakeup; + fixture.task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn controller_runs_agents_concurrently_and_serializes_reruns_per_id() { + let store = Rc::new(memory::InMemoryAgentStore::new()); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())); + control_plane.apply(apply_request("slow")).await.expect("slow Agent"); + control_plane.apply(apply_request("fast")).await.expect("fast Agent"); + let slow = store.get_by_name("slow").await.expect("slow record").id; + + let backend = Rc::new(sandbox_memory::Provider::new()); + let started = Rc::new(Notify::new()); + let release = Rc::new(Notify::new()); + let slow_calls = Rc::new(Cell::new(0)); + let provider: Rc = Rc::new(MemoryProvider::new(backend.clone()).with_blocking(Blocking { + agent: slow, + calls: slow_calls.clone(), + started: started.clone(), + release: release.clone(), + })); + let reconciler = Rc::new(reconciler(store.clone(), provider)); + let (controller, wakeup) = Controller::new(store.clone(), reconciler, Duration::from_mins(1), Rc::new(|_, _| {})); + let task = tokio::task::spawn_local(controller.run()); + + tokio::time::timeout(Duration::from_secs(1), started.notified()) + .await + .expect("slow reconciliation should start"); + let selected = store.get(slow).await.expect("selected slow Agent"); + assert!(matches!( + selected.agent.status.sandbox, + Some(agent::sandbox::Assignment::Selected { .. }) + )); + wakeup.notify(slow); + wakeup.notify(slow); + wakeup.notify(slow); + tokio::time::timeout(Duration::from_secs(1), async { + while backend.count() != 1 { + tokio::task::yield_now().await; + } + }) + .await + .expect("fast Agent should finish while the slow Agent is blocked"); + + release.notify_one(); + tokio::time::timeout(Duration::from_secs(1), async { + while backend.count() != 2 || slow_calls.get() != 2 { + tokio::task::yield_now().await; + } + }) + .await + .expect("queued notifications should coalesce into one serialized rerun"); + task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn stale_status_write_is_rejected() { + let fixture = fixture(); + fixture + .control_plane + .apply(apply_request("worker")) + .await + .expect("apply"); + let mut changed = apply_request("worker"); + changed.agent.spec.sandbox.retention_policy = Some(RetentionPolicy::Delete); + fixture.control_plane.apply(changed).await.expect("second generation"); + + let error = fixture + .store + .update_status(stored(&fixture, "worker").await.id, 1, Status::default()) + .await + .expect_err("stale status should fail"); + assert!(matches!(error, Error::Conflict)); +} + +fn is_ssh_server_check(spec: &sandbox::execution::ExecutionSpec) -> bool { + matches!( + spec.program(), + sandbox::execution::Program::Command { executable, args } + if executable.as_str() == "/usr/bin/test" && args == &["-x", "/usr/sbin/sshd"] + ) +} + +fn is_ssh_policy_check(spec: &sandbox::execution::ExecutionSpec) -> bool { + matches!( + spec.program(), + sandbox::execution::Program::Command { executable, args } + if executable.as_str() == "/usr/bin/sudo" + && args == &[ + "-n", + "/usr/sbin/sshd", + "-T", + "-f", + "/var/lib/agent/ssh/sshd_config", + "-C", + "user=agent,host=localhost,addr=127.0.0.1,laddr=127.0.0.1,lport=2222", + ] + ) +} + +fn exited(code: i32) -> Vec { + vec![ + sandbox::execution::ExecutionEvent::Started { process_id: None }, + sandbox::execution::ExecutionEvent::Exited(sandbox::execution::ExitStatus { code }), + ] +} + +#[tokio::test(flavor = "local")] +async fn ssh_access_is_reported_underneath_ready_and_cleaned_up_on_deletion() { + let temporary = TempDirectory::new("ssh-access"); + let home = agent::local::home::ControlPlaneHome::resolve(Some(&temporary.path().join("home"))).expect("home"); + home.prepare().expect("prepare home"); + let store = Rc::new(memory::InMemoryAgentStore::new()); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider: Rc = Rc::new(MemoryProvider::new(backend.clone())); + let keys = Rc::new(agent::ssh::memory::InMemoryHostKeyStore::new()); + let ssh = Rc::new( + agent::ssh::Access::new( + &home, + PathBuf::from("/usr/local/bin/agentctl"), + keys.clone(), + store.clone(), + ) + .with_user_home(None), + ); + let reconciler = + Reconciler::new(store.clone(), sandbox_service(provider), ProvisioningState::default()).with_ssh_access(ssh); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())); + let mut request = apply_request("worker"); + request.agent.spec.access = vec![agent::AccessSpec::Ssh {}]; + control_plane.apply(request).await.expect("apply"); + let id = store.get_by_name("worker").await.expect("stored").id; + + // The image lacks a server: the Agent is not Ready and the failure is permanent. + backend.queue_execution_events_matching(is_ssh_server_check, exited(1)); + let error = reconciler + .reconcile(id) + .await + .expect_err("missing server fails the pass"); + assert_eq!(agent::ReconcileFailure::classify(&error).kind, FailureKind::Invalid); + let status = store.get(id).await.expect("record").agent.status; + let ready = status.ready_condition().expect("Ready condition"); + assert_eq!(ready.status, ConditionStatus::False); + assert_eq!(ready.reason, "SshAccessFailed"); + assert!(ready.message.contains("cannot provide SSH access")); + let ssh_ready = status + .conditions + .iter() + .find(|condition| condition.kind == agent::Condition::SSH_READY) + .expect("SshReady condition"); + assert_eq!(ssh_ready.status, ConditionStatus::False); + assert!( + status + .sandbox + .as_ref() + .and_then(agent::sandbox::Assignment::id) + .is_some() + ); + assert!(!keys.contains(id)); + + // A server is present: the Agent is Ready and SshReady is reported alongside SandboxReady. + backend.queue_execution_events_matching(is_ssh_server_check, exited(0)); + backend.queue_execution_events_matching( + is_ssh_policy_check, + vec![ + sandbox::execution::ExecutionEvent::Started { process_id: None }, + sandbox::execution::ExecutionEvent::Stdout(b"permituserenvironment yes\nusepam no\n".as_slice().into()), + sandbox::execution::ExecutionEvent::Exited(sandbox::execution::ExitStatus { code: 0 }), + ], + ); + reconciler.reconcile(id).await.expect("reconcile with a server"); + let status = store.get(id).await.expect("record").agent.status; + assert!(status.is_ready()); + assert_eq!( + status + .conditions + .iter() + .map(|condition| (condition.kind.as_str(), condition.status)) + .collect::>(), + [ + (agent::Condition::SANDBOX_READY, ConditionStatus::True), + (agent::Condition::SANDBOX_RESPONSIVE, ConditionStatus::Unknown), + (agent::Condition::SSH_READY, ConditionStatus::True), + (agent::Condition::READY, ConditionStatus::True), + ] + ); + assert!(keys.contains(id)); + let ssh_home = agent::ssh::SshHome::new(&home); + assert!(ssh_home.identity_path(id).is_file()); + let known_hosts = std::fs::read_to_string(ssh_home.known_hosts_path()).expect("known_hosts"); + assert!(known_hosts.starts_with(&format!("agent-{id} ssh-ed25519 "))); + assert!(known_hosts.contains("\nagentctl-worker ssh-ed25519 ")); + assert!( + std::fs::read_to_string(ssh_home.config_path()) + .expect("config") + .contains("Host agentctl-worker\n") + ); + + control_plane.delete("worker").await.expect("delete request"); + reconciler.reconcile(id).await.expect("delete"); + assert!(!keys.contains(id)); + assert!(!ssh_home.agent_directory(id).exists()); + assert_eq!( + std::fs::read_to_string(ssh_home.known_hosts_path()).expect("known_hosts"), + "" + ); + assert!( + !std::fs::read_to_string(ssh_home.config_path()) + .expect("config") + .contains("Host ") + ); +} + +/// A Linux platform whose setup can be made to wait forever, as setup does +/// when its guest stops answering Executions. +#[derive(Default)] +struct StallingPlatform { + stall: Cell, + /// Setup fails at once, as a command timing out inside the guest does. + fail: Cell, + setups: Cell, + started: Notify, +} + +impl PlatformAdapter for StallingPlatform { + fn supports(&self, platform: &Platform) -> bool { + platform.os == "linux" + } + + fn setup<'a>( + &'a self, + _record: &'a AgentRecord, + _sandbox: &'a SandboxHandle, + _harnesses: &'a [agent::Harness], + _steps: &'a sandbox::SandboxProgress, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + self.setups.set(self.setups.get() + 1); + if self.fail.get() { + return Err(Error::SandboxSetup("`codex --version` did not finish within 5s".into())); + } + if self.stall.get() { + self.started.notify_one(); + std::future::pending::<()>().await; + } + Ok(()) + }) + } +} + +/// An Agent `worker` whose guest heartbeat and setup the test controls. +struct Stalling { + store: Rc, + backend: Rc, + platform: Rc, + reconciler: Rc, + id: AgentId, +} + +async fn stalling(changes: Changes) -> Stalling { + let store = Rc::new(memory::InMemoryAgentStore::with_changes(changes)); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider: Rc = Rc::new(MemoryProvider::new(backend.clone())); + let platform = Rc::new(StallingPlatform::default()); + let sandboxes = + Rc::new(Service::new([provider], [platform.clone() as Rc]).expect("Sandbox service")); + let reconciler = Rc::new(Reconciler::new(store.clone(), sandboxes, ProvisioningState::default())); + ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())) + .apply(apply_request("worker")) + .await + .expect("apply"); + let id = store.get_by_name("worker").await.expect("stored Agent").id; + Stalling { + store, + backend, + platform, + reconciler, + id, + } +} + +impl Stalling { + async fn record(&self) -> AgentRecord { + self.store.get(self.id).await.expect("stored Agent") + } + + /// Reports `sequence` as the guest's heartbeat. + async fn beat(&self, sequence: u64) { + let record = self.record().await; + let sandbox = record + .agent + .status + .sandbox + .as_ref() + .and_then(agent::sandbox::Assignment::id) + .expect("materialized Sandbox"); + self.backend + .set_guest_heartbeat(sandbox, Some(GuestHeartbeat::new(sequence))) + .expect("heartbeat should be set"); + } +} + +fn condition<'a>(status: &'a Status, kind: &str) -> &'a agent::Condition { + status + .conditions + .iter() + .find(|condition| condition.kind == kind) + .expect("the condition should be recorded") +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn a_stalled_guest_ends_setup_and_its_agent_reports_it_unresponsive() { + let fixture = stalling(Changes::new()).await; + fixture.reconciler.reconcile(fixture.id).await.expect("first pass"); + let status = fixture.record().await.agent.status; + assert!(status.is_ready()); + assert_eq!( + condition(&status, agent::Condition::SANDBOX_RESPONSIVE).reason, + "HeartbeatNotObserved" + ); + fixture.beat(1).await; + tokio::time::advance(Duration::from_secs(1)).await; + fixture.beat(2).await; + + fixture.platform.stall.set(true); + let pass = tokio::task::spawn_local({ + let reconciler = fixture.reconciler.clone(); + let id = fixture.id; + async move { reconciler.reconcile(id).await } + }); + fixture.platform.started.notified().await; + + // The heartbeat stays at 2, so the pass's own inspections find the stall. + let result = pass.await.expect("the pass should not panic"); + assert!( + matches!(result, Err(Error::SandboxUnresponsive(_))), + "the stalled pass should end as unresponsive, got {result:?}" + ); + let status = fixture.record().await.agent.status; + let ready = condition(&status, agent::Condition::READY); + assert_eq!( + (ready.status, ready.reason.as_str()), + (ConditionStatus::False, "SandboxUnresponsive") + ); + let responsive = condition(&status, agent::Condition::SANDBOX_RESPONSIVE); + assert_eq!( + (responsive.status, responsive.reason.as_str()), + (ConditionStatus::False, "HeartbeatStale") + ); + assert_eq!( + condition(&status, agent::Condition::SANDBOX_READY).status, + ConditionStatus::True, + "the Sandbox lifecycle is unchanged" + ); + assert_eq!(status.failure, Some(FailureKind::Transient)); + + // While the guest stays stalled, a pass does not reach into it. + let setups = fixture.platform.setups.get(); + let result = fixture.reconciler.reconcile(fixture.id).await; + assert!(matches!(result, Err(Error::SandboxUnresponsive(_))), "{result:?}"); + assert_eq!(fixture.platform.setups.get(), setups); + + // A heartbeat that advances again makes the Agent Ready. + fixture.platform.stall.set(false); + fixture.beat(3).await; + fixture.reconciler.reconcile(fixture.id).await.expect("recovered pass"); + let status = fixture.record().await.agent.status; + assert!(status.is_ready()); + let responsive = condition(&status, agent::Condition::SANDBOX_RESPONSIVE); + assert_eq!( + (responsive.status, responsive.reason.as_str()), + (ConditionStatus::True, "HeartbeatAdvancing") + ); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn a_failure_from_a_guest_that_stopped_beating_is_reported_as_the_stall() { + let fixture = stalling(Changes::new()).await; + fixture.reconciler.reconcile(fixture.id).await.expect("first pass"); + fixture.beat(1).await; + fixture.platform.fail.set(true); + + let result = fixture.reconciler.reconcile(fixture.id).await; + + assert!(matches!(result, Err(Error::SandboxUnresponsive(_))), "{result:?}"); + let status = fixture.record().await.agent.status; + assert_eq!( + condition(&status, agent::Condition::READY).reason, + "SandboxUnresponsive" + ); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn a_failure_from_a_guest_that_still_beats_stands() { + let fixture = stalling(Changes::new()).await; + fixture.reconciler.reconcile(fixture.id).await.expect("first pass"); + fixture.beat(1).await; + fixture.platform.fail.set(true); + let beating = tokio::task::spawn_local({ + let fixture = Rc::new(fixture); + let pass = fixture.clone(); + let beats = async move { + let mut sequence = 1; + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + sequence += 1; + fixture.beat(sequence).await; + } + }; + async move { + tokio::select! { + () = beats => unreachable!(), + result = pass.reconciler.reconcile(pass.id) => result, + } + } + }); + + let result = beating.await.expect("the pass should not panic"); + + assert!( + matches!(&result, Err(Error::SandboxSetup(message)) if message.contains("codex --version")), + "{result:?}" + ); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn an_agent_with_a_stalled_guest_can_still_be_deleted() { + let fixture = stalling(Changes::new()).await; + fixture.reconciler.reconcile(fixture.id).await.expect("first pass"); + fixture.beat(1).await; + fixture.platform.stall.set(true); + let result = fixture.reconciler.reconcile(fixture.id).await; + assert!(matches!(result, Err(Error::SandboxUnresponsive(_))), "{result:?}"); + + ControlPlane::new(fixture.store.clone(), Rc::new(NotificationCounter::default())) + .delete("worker") + .await + .expect("delete request"); + let sandbox = sandbox_name(&fixture.record().await); + fixture.reconciler.reconcile(fixture.id).await.expect("release"); + + assert!(matches!(fixture.store.get(fixture.id).await, Err(Error::NotFound))); + let retained = fixture.backend.find(&sandbox).await.expect("retained Sandbox"); + assert_eq!(retained.state, sandbox::SandboxState::Stopped); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn waiting_until_ready_ends_once_the_guest_is_recorded_unresponsive() { + let changes = Changes::new(); + let fixture = stalling(changes.clone()).await; + let (controller, wakeup) = Controller::new( + fixture.store.clone(), + fixture.reconciler.clone(), + NO_BACKGROUND_PASSES, + Rc::new(|_, _| {}), + ); + let task = tokio::task::spawn_local(controller.run()); + let convergence = Convergence::new(wakeup, fixture.store.clone(), changes); + convergence + .converge("worker", WaitPolicy::UntilConverged) + .await + .expect("the healthy Agent should become Ready"); + fixture.beat(1).await; + fixture.platform.stall.set(true); + + let started = tokio::time::Instant::now(); + let result = convergence.converge("worker", WaitPolicy::UntilConverged).await; + + assert!(matches!(result, Err(Error::SandboxUnresponsive(_))), "{result:?}"); + let waited = started.elapsed(); + assert!( + waited >= UNRESPONSIVE_AFTER && waited < UNRESPONSIVE_AFTER + Duration::from_secs(3), + "the wait ends with the pass that finds the stall, took {waited:?}" + ); + task.abort(); +} + +async fn assert_stopped(store: &memory::InMemoryAgentStore, backend: &sandbox_memory::Provider, id: AgentId) { + let record = store.get(id).await.expect("stored Agent"); + let status = &record.agent.status; + assert!(status.is_stopped(), "{status:?}"); + assert_eq!(status.observed_generation, record.agent.metadata.generation); + assert_eq!(status.failure, None, "a stop is not a failure"); + let ready = condition(status, agent::Condition::READY); + assert_eq!(ready.status, ConditionStatus::False); + assert_eq!( + condition(status, agent::Condition::SANDBOX_READY).reason, + agent::Condition::REASON_STOPPED + ); + assert!( + status + .conditions + .iter() + .all(|condition| condition.kind != agent::Condition::SANDBOX_RESPONSIVE), + "a stopped Sandbox has no guest to be responsive or not: {status:?}" + ); + let sandbox = backend.find(&sandbox_name(&record)).await.expect("kept Sandbox"); + assert_eq!(sandbox.state, sandbox::SandboxState::Stopped); +} + +#[tokio::test(flavor = "local")] +async fn a_stopped_agent_keeps_its_sandbox_stopped_across_passes_and_reapplies() { + let fixture = fixture(); + fixture + .control_plane + .apply(apply_request("worker")) + .await + .expect("apply"); + reconcile(&fixture, "worker").await; + let ready = stored(&fixture, "worker").await; + let sandbox = ready.agent.status.sandbox.clone().expect("materialized Sandbox"); + + let stopping = fixture + .control_plane + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("stop"); + assert_eq!(stopping.metadata.generation, ready.agent.metadata.generation + 1); + assert_eq!(stopping.spec.run_state, Some(agent::RunState::Stopped)); + reconcile(&fixture, "worker").await; + assert_stopped(&fixture.store, &fixture.backend, ready.id).await; + let stopped = stored(&fixture, "worker").await; + assert_eq!( + stopped.agent.status.sandbox, + Some(sandbox), + "the Sandbox keeps its identity" + ); + + // A periodic pass and a repeated stop change nothing. + reconcile(&fixture, "worker").await; + let again = fixture + .control_plane + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("repeated stop"); + assert_eq!(again.metadata.generation, stopped.agent.metadata.generation); + assert_stopped(&fixture.store, &fixture.backend, ready.id).await; + + // Applying a manifest that does not set a run state keeps the Agent stopped. + let reapplied = fixture + .control_plane + .apply(apply_request("worker")) + .await + .expect("re-apply"); + assert_eq!(reapplied.metadata.generation, stopped.agent.metadata.generation); + assert!(reapplied.spec.is_stopped()); + let mut changed = apply_request("worker"); + changed.agent.spec.harnesses[0].version = Some("2.1.240".into()); + let changed = fixture.control_plane.apply(changed).await.expect("changed apply"); + assert!(changed.spec.is_stopped(), "a changed manifest keeps the run state too"); + reconcile(&fixture, "worker").await; + assert_stopped(&fixture.store, &fixture.backend, ready.id).await; + assert_eq!(fixture.backend.count(), 1); + + // A manifest that sets the run state changes it, like `agentctl start` does. + let mut running = apply_request("worker"); + running.agent.spec.harnesses[0].version = Some("2.1.240".into()); + running.agent.spec.run_state = Some(agent::RunState::Running); + let started = fixture.control_plane.apply(running).await.expect("apply Running"); + assert_eq!(started.spec.run_state, None, "Running is stored as omitted"); + reconcile(&fixture, "worker").await; + assert!(stored(&fixture, "worker").await.agent.status.is_ready()); +} + +#[tokio::test(flavor = "local")] +async fn a_start_boots_the_same_sandbox_and_wakes_sessions() { + let store = Rc::new(memory::InMemoryAgentStore::new()); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider: Rc = Rc::new(MemoryProvider::new(backend.clone())); + let notifications = Rc::new(SessionNotificationCounter::default()); + let reconciler = reconciler(store.clone(), provider).with_session_notifier(notifications.clone()); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())); + control_plane.apply(apply_request("worker")).await.expect("apply"); + let id = store.get_by_name("worker").await.expect("Agent").id; + reconciler.reconcile(id).await.expect("materialize"); + let sandbox = store.get(id).await.expect("Agent").agent.status.sandbox; + + control_plane + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("stop"); + reconciler.reconcile(id).await.expect("stop pass"); + assert_stopped(&store, &backend, id).await; + let after_stop = notifications.0.get(); + assert!(after_stop > 1, "Sessions are told their Agent is no longer Ready"); + + control_plane + .set_run_state("worker", agent::RunState::Running) + .await + .expect("start"); + reconciler.reconcile(id).await.expect("start pass"); + let record = store.get(id).await.expect("Agent"); + assert!(record.agent.status.is_ready()); + assert_eq!(record.agent.status.sandbox, sandbox, "the same Sandbox starts again"); + let running = backend.find(&sandbox_name(&record)).await.expect("Sandbox"); + assert_eq!(running.state, sandbox::SandboxState::Running); + assert!(notifications.0.get() > after_stop, "Sessions wake"); + assert_eq!(backend.count(), 1); +} + +#[tokio::test(flavor = "local")] +async fn a_new_agent_applied_stopped_materializes_only_once_started() { + let fixture = fixture(); + let mut request = apply_request("worker"); + request.agent.spec.run_state = Some(agent::RunState::Stopped); + fixture.control_plane.apply(request).await.expect("apply"); + reconcile(&fixture, "worker").await; + let record = stored(&fixture, "worker").await; + assert!(record.agent.status.is_stopped()); + assert_eq!(record.agent.status.sandbox, None); + assert_eq!(fixture.backend.count(), 0); + + fixture + .control_plane + .set_run_state("worker", agent::RunState::Running) + .await + .expect("start"); + reconcile(&fixture, "worker").await; + assert!(stored(&fixture, "worker").await.agent.status.is_ready()); + assert_eq!(fixture.backend.count(), 1); +} + +#[tokio::test(flavor = "local")] +async fn a_restarted_daemon_keeps_a_stopped_agent_stopped_and_starts_a_running_one() { + let fixture = fixture(); + for name in ["stopped", "running"] { + fixture.control_plane.apply(apply_request(name)).await.expect("apply"); + reconcile(&fixture, name).await; + } + fixture + .control_plane + .set_run_state("stopped", agent::RunState::Stopped) + .await + .expect("stop"); + reconcile(&fixture, "stopped").await; + // The host went down: every VM is gone, and a new daemon reconciles the stored Agents. + let running = stored(&fixture, "running").await; + fixture + .backend + .stop( + running + .agent + .status + .sandbox + .as_ref() + .and_then(agent::sandbox::Assignment::id) + .expect("Sandbox"), + ) + .await + .expect("VM gone"); + let restarted = reconciler( + fixture.store.clone(), + Rc::new(MemoryProvider::new(fixture.backend.clone())), + ); + for name in ["stopped", "running"] { + restarted + .reconcile(stored(&fixture, name).await.id) + .await + .expect("pass"); + } + + assert_stopped(&fixture.store, &fixture.backend, stored(&fixture, "stopped").await.id).await; + let sandbox = fixture.backend.find(&sandbox_name(&running)).await.expect("Sandbox"); + assert_eq!(sandbox.state, sandbox::SandboxState::Running); +} + +#[tokio::test(flavor = "local")] +async fn a_stopped_agent_can_be_deleted() { + let fixture = fixture(); + fixture + .control_plane + .apply(apply_request("worker")) + .await + .expect("apply"); + reconcile(&fixture, "worker").await; + fixture + .control_plane + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("stop"); + reconcile(&fixture, "worker").await; + let record = stored(&fixture, "worker").await; + + fixture.control_plane.delete("worker").await.expect("delete"); + fixture.reconciler.reconcile(record.id).await.expect("release"); + + assert!(matches!(fixture.store.get(record.id).await, Err(Error::NotFound))); + // The test manifest retains its Sandbox on release. + let retained = fixture + .backend + .find(&sandbox_name(&record)) + .await + .expect("retained Sandbox"); + assert_eq!(retained.state, sandbox::SandboxState::Stopped); + let error = fixture + .control_plane + .set_run_state("worker", agent::RunState::Running) + .await + .expect_err("a deleted Agent cannot start"); + assert!(matches!(error, Error::NotFound)); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn an_agent_with_a_stalled_guest_can_be_stopped_and_started() { + let fixture = stalling(Changes::new()).await; + fixture.reconciler.reconcile(fixture.id).await.expect("first pass"); + fixture.beat(1).await; + fixture.platform.stall.set(true); + let result = fixture.reconciler.reconcile(fixture.id).await; + assert!(matches!(result, Err(Error::SandboxUnresponsive(_))), "{result:?}"); + + let control_plane = ControlPlane::new(fixture.store.clone(), Rc::new(NotificationCounter::default())); + control_plane + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("stop"); + let setups = fixture.platform.setups.get(); + fixture + .reconciler + .reconcile(fixture.id) + .await + .expect("a stop needs no guest"); + assert_eq!( + fixture.platform.setups.get(), + setups, + "the stop does not reach into the guest" + ); + assert_stopped(&fixture.store, &fixture.backend, fixture.id).await; + + // The started guest beats from a new sequence, and the stall before the stop is forgotten. + fixture.platform.stall.set(false); + control_plane + .set_run_state("worker", agent::RunState::Running) + .await + .expect("start"); + fixture.reconciler.reconcile(fixture.id).await.expect("start pass"); + let status = fixture.record().await.agent.status; + assert!(status.is_ready(), "{status:?}"); + assert!(status.unresponsive().is_none()); +} + +#[tokio::test(flavor = "local")] +async fn commands_on_a_stopped_agent_fail_at_once_and_a_wait_ends_when_it_stops() { + let ended = Rc::new(Cell::new(false)); + let fixture = waiting( + [PlannedFailure::Outage { + message: "runtime is down".into(), + ended: ended.clone(), + }], + BACKGROUND_RETRIES, + ) + .await; + let control_plane = ControlPlane::new(fixture.store.clone(), Rc::new(fixture.wakeup.clone())); + + // A wait through an outage ends once the Agent is stopped. + let waited = tokio::task::spawn_local({ + let execution = fixture.execution.clone(); + async move { execution.ensure("worker", WaitPolicy::UntilConverged).await } + }); + tokio::time::sleep(BACKGROUND_RETRIES * 3).await; + control_plane + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("stop"); + let error = tokio::time::timeout(Duration::from_secs(1), waited) + .await + .expect("the wait ends with the stop") + .expect("the wait does not panic") + .expect_err("a stopped Agent never becomes Ready"); + assert!(matches!(&error, Error::Stopped(name) if name == "worker"), "{error:?}"); + assert_eq!( + error.to_string(), + "Agent \"worker\" is stopped; run `agentctl start agent/worker`" + ); + + // Later commands are refused before anything is woken or waited for. + ended.set(true); + for wait in [WaitPolicy::FirstPass, WaitPolicy::UntilConverged] { + let error = tokio::time::timeout(Duration::from_millis(100), fixture.execution.ensure("worker", wait)) + .await + .expect("refused without waiting") + .expect_err("a stopped Agent runs nothing"); + assert!(matches!(error, Error::Stopped(_)), "{error:?}"); + } + fixture.task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn a_stop_recorded_while_an_execution_waits_refuses_it() { + let changes = Changes::new(); + let store = Rc::new(memory::InMemoryAgentStore::with_changes(changes.clone())); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())); + control_plane.apply(apply_request("worker")).await.expect("apply"); + let id = store.get_by_name("worker").await.expect("Agent").id; + let started = Rc::new(Notify::new()); + let release = Rc::new(Notify::new()); + let provider: Rc = Rc::new( + MemoryProvider::new(Rc::new(sandbox_memory::Provider::new())).with_blocking(Blocking { + agent: id, + calls: Rc::new(Cell::new(0)), + started: started.clone(), + release: release.clone(), + }), + ); + let (controller, wakeup) = Controller::new( + store.clone(), + Rc::new(reconciler(store.clone(), provider)), + NO_BACKGROUND_PASSES, + Rc::new(|_, _| {}), + ); + let task = tokio::task::spawn_local(controller.run()); + started.notified().await; + + // The wait is admitted while the Agent runs, and its pass is the one after the stop. + let execution = Rc::new(ExecutionService::new( + store.clone(), + Convergence::new(wakeup, store.clone(), changes), + )); + let waited = tokio::task::spawn_local(async move { execution.ensure("worker", WaitPolicy::FirstPass).await }); + tokio::task::yield_now().await; + control_plane + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("stop"); + release.notify_one(); + + let result = tokio::time::timeout(Duration::from_secs(1), waited) + .await + .expect("the wait ends with the stop pass") + .expect("the wait does not panic"); + assert!(matches!(result, Err(Error::Stopped(_))), "{result:?}"); + task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn converging_the_run_state_waits_for_a_stop_and_for_a_start() { + let fixture = waiting([], NO_BACKGROUND_PASSES).await; + let convergence = Convergence::new(fixture.wakeup.clone(), fixture.store.clone(), Changes::new()); + let control_plane = ControlPlane::new(fixture.store.clone(), Rc::new(fixture.wakeup.clone())); + let converged = tokio::time::timeout( + Duration::from_secs(1), + convergence.converge("worker", WaitPolicy::UntilConverged), + ) + .await + .expect("a new Agent converges") + .expect("Ready"); + assert!(converged.agent.status.is_ready()); + + control_plane + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("stop"); + let converged = tokio::time::timeout( + Duration::from_secs(1), + convergence.converge("worker", WaitPolicy::UntilConverged), + ) + .await + .expect("a stop converges") + .expect("stopped"); + assert!(converged.agent.spec.is_stopped() && converged.agent.status.is_stopped()); + assert_eq!( + converged.agent.status.observed_generation, + converged.agent.metadata.generation + ); + + control_plane + .set_run_state("worker", agent::RunState::Running) + .await + .expect("start"); + let converged = tokio::time::timeout( + Duration::from_secs(1), + convergence.converge("worker", WaitPolicy::UntilConverged), + ) + .await + .expect("a start converges") + .expect("Ready"); + assert!(converged.agent.status.is_ready() && !converged.agent.spec.is_stopped()); + + let error = convergence + .converge("missing", WaitPolicy::UntilConverged) + .await + .expect_err("missing Agent"); + assert!(matches!(error, Error::NotFound), "{error:?}"); + fixture.task.abort(); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn converging_a_stopped_agent_is_not_ended_by_the_stall_it_was_stopped_for() { + let changes = Changes::new(); + let fixture = stalling(changes.clone()).await; + let (controller, wakeup) = Controller::new( + fixture.store.clone(), + fixture.reconciler.clone(), + NO_BACKGROUND_PASSES, + Rc::new(|_, _| {}), + ); + let task = tokio::task::spawn_local(controller.run()); + let convergence = Convergence::new(wakeup, fixture.store.clone(), changes); + convergence + .converge("worker", WaitPolicy::UntilConverged) + .await + .expect("Ready"); + fixture.beat(1).await; + fixture.platform.stall.set(true); + let error = convergence + .converge("worker", WaitPolicy::UntilConverged) + .await + .expect_err("a running Agent with a stalled guest cannot converge"); + assert!(matches!(error, Error::SandboxUnresponsive(_)), "{error:?}"); + + ControlPlane::new(fixture.store.clone(), Rc::new(NotificationCounter::default())) + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("stop"); + let stopped = convergence + .converge("worker", WaitPolicy::UntilConverged) + .await + .expect("the stop converges"); + assert!(stopped.agent.status.is_stopped()); + task.abort(); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn stopping_an_agent_with_a_stalled_guest_tells_its_sessions() { + let store = Rc::new(memory::InMemoryAgentStore::new()); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider: Rc = Rc::new(MemoryProvider::new(backend.clone())); + let platform = Rc::new(StallingPlatform::default()); + let sandboxes = + Rc::new(Service::new([provider], [platform.clone() as Rc]).expect("Sandbox service")); + let notifications = Rc::new(SessionNotificationCounter::default()); + let reconciler = Reconciler::new(store.clone(), sandboxes, ProvisioningState::default()) + .with_session_notifier(notifications.clone()); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())); + control_plane.apply(apply_request("worker")).await.expect("apply"); + let id = store.get_by_name("worker").await.expect("Agent").id; + reconciler.reconcile(id).await.expect("first pass"); + let sandbox = store + .get(id) + .await + .expect("Agent") + .agent + .status + .sandbox + .and_then(|assignment| assignment.id().cloned()) + .expect("materialized Sandbox"); + backend + .set_guest_heartbeat(&sandbox, Some(GuestHeartbeat::new(1))) + .expect("heartbeat should be set"); + platform.stall.set(true); + let result = reconciler.reconcile(id).await; + assert!(matches!(result, Err(Error::SandboxUnresponsive(_))), "{result:?}"); + // Not Ready already, so only the stop itself can tell the held Sessions. + let before = notifications.0.get(); + + control_plane + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("stop"); + reconciler.reconcile(id).await.expect("stop pass"); + assert!( + notifications.0.get() > before, + "Sessions held by the stalled guest must learn the Agent stopped, so they go Idle" + ); +} + +#[tokio::test(flavor = "local")] +async fn a_failed_stop_reads_as_stopping_and_converges_once_a_retry_stops_it() { + let changes = Changes::new(); + let store = Rc::new(memory::InMemoryAgentStore::with_changes(changes.clone())); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider = MemoryProvider::new(backend.clone()); + let failing_stops = provider.failing_stops.clone(); + let reconciler = Rc::new(reconciler(store.clone(), Rc::new(provider))); + let control_plane = ControlPlane::new(store.clone(), Rc::new(NotificationCounter::default())); + control_plane.apply(apply_request("worker")).await.expect("apply"); + let id = store.get_by_name("worker").await.expect("Agent").id; + reconciler.reconcile(id).await.expect("Ready"); + + failing_stops.set(1); + control_plane + .set_run_state("worker", agent::RunState::Stopped) + .await + .expect("stop"); + reconciler.reconcile(id).await.expect_err("the stop fails"); + let record = store.get(id).await.expect("Agent"); + let ready = condition(&record.agent.status, agent::Condition::READY); + assert_eq!(ready.reason, agent::Condition::REASON_STOPPING); + assert!(ready.message.contains("runtime unreachable"), "{ready:?}"); + assert_eq!( + record.agent.status.failure, + Some(FailureKind::Transient), + "a failed stop is retried" + ); + let sandbox = backend.find(&sandbox_name(&record)).await.expect("Sandbox"); + assert_eq!(sandbox.state, sandbox::SandboxState::Running, "nothing stopped yet"); + + // The background controller retries, and a wait for the stop ends with it. + let (controller, wakeup) = Controller::new(store.clone(), reconciler, BACKGROUND_RETRIES, Rc::new(|_, _| {})); + let task = tokio::task::spawn_local(controller.run()); + failing_stops.set(1); + let converged = tokio::time::timeout( + Duration::from_secs(1), + Convergence::new(wakeup, store.clone(), changes).converge("worker", WaitPolicy::UntilConverged), + ) + .await + .expect("a retried stop converges") + .expect("stopped"); + assert!(converged.agent.status.is_stopped()); + assert_eq!(failing_stops.get(), 0, "the wait went through a failed retry"); + assert_stopped(&store, &backend, id).await; + task.abort(); +} diff --git a/agentctl/tests/database.rs b/agentctl/tests/database.rs new file mode 100644 index 0000000..8974fbc --- /dev/null +++ b/agentctl/tests/database.rs @@ -0,0 +1,1494 @@ +#![allow(clippy::expect_used)] + +mod support; + +use std::path::{Path, PathBuf}; + +use agent::{ + AgentId, Condition, ConditionStatus, Error, Status, + control_plane::{AgentRecord, AgentStore as _}, + persistence, + sandbox::{Assignment, ProviderId}, + sessions::{Lifecycle, NewSession, SessionName, SessionReports as _, SessionStore as _}, +}; +use sandbox::secret_store::SecretStore as _; +use tempfile::TempDir; +use tokio::runtime::LocalRuntime; + +fn test_agent_id() -> AgentId { + "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID") +} + +fn record_with_id(name: &str, generation: u64, id: AgentId) -> AgentRecord { + let mut agent = support::agent(name); + agent.metadata.generation = generation; + AgentRecord { + id, + source_directory: PathBuf::from("/source"), + manifest_path: None, + env_file: None, + agent, + } +} + +fn record(name: &str, generation: u64) -> AgentRecord { + record_with_id(name, generation, test_agent_id()) +} + +fn ready_record(name: &str, id: AgentId) -> AgentRecord { + let mut ready = record_with_id(name, 1, id); + ready.agent.status = Status::observed( + 1, + Some(Assignment::Materialized { + provider: ProviderId::new("memory").expect("Provider ID"), + id: "3f978c33-4d43-4ea4-b58d-10b90ef166af".parse().expect("Sandbox ID"), + harnesses: ready + .agent + .spec + .harnesses + .iter() + .map(|installation| installation.kind) + .collect(), + }), + vec![Condition { + kind: "Ready".into(), + status: ConditionStatus::True, + reason: "SandboxReady".into(), + message: String::new(), + last_transition_time: None, + }], + ); + ready +} + +const PREVIEW_1_SCHEMA: &str = " + CREATE TABLE agents ( + id TEXT PRIMARY KEY NOT NULL, + active_name TEXT UNIQUE, + source_directory TEXT NOT NULL, + desired_json TEXT NOT NULL, + deletion_timestamp INTEGER, + status_json TEXT NOT NULL DEFAULT '{}' + ); + CREATE TABLE secrets ( + name TEXT PRIMARY KEY NOT NULL, + value BLOB NOT NULL + ); + CREATE TABLE provider_accounts ( + provider TEXT PRIMARY KEY NOT NULL, + metadata_json TEXT NOT NULL + ); + CREATE TABLE sessions ( + id TEXT PRIMARY KEY NOT NULL, + agent_id TEXT NOT NULL REFERENCES agents(id), + name TEXT NOT NULL, + harness TEXT NOT NULL, + created_at INTEGER NOT NULL, + activation_generation INTEGER NOT NULL DEFAULT 0, + lifecycle_json TEXT NOT NULL DEFAULT '{}', + harness_native_id TEXT, + launch_token TEXT UNIQUE, + launch_sandbox TEXT, + launched_at INTEGER, + launch_attempts INTEGER NOT NULL DEFAULT 0, + UNIQUE (agent_id, name) + ); + PRAGMA user_version = 1; +"; + +// The schema produced by the session-management build on main before migrations were introduced. +const EXPANDED_VERSION_1_SCHEMA: &str = " + CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY NOT NULL, + active_name TEXT UNIQUE, + source_directory TEXT NOT NULL, + desired_json TEXT NOT NULL, + deletion_timestamp INTEGER, + status_json TEXT NOT NULL DEFAULT '{}' + ); + CREATE TABLE IF NOT EXISTS secrets ( + name TEXT PRIMARY KEY NOT NULL, + value BLOB NOT NULL + ); + CREATE TABLE IF NOT EXISTS provider_accounts ( + provider TEXT PRIMARY KEY NOT NULL, + metadata_json TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY NOT NULL, + agent_id TEXT NOT NULL REFERENCES agents(id), + name TEXT NOT NULL, + harness TEXT NOT NULL, + created_at INTEGER NOT NULL, + activation_generation INTEGER NOT NULL DEFAULT 0, + lifecycle_json TEXT NOT NULL DEFAULT '{}', + initial_prompt TEXT, + harness_native_id TEXT, + harness_transcript_path TEXT, + activity_json TEXT NOT NULL DEFAULT '{}', + launch_token TEXT UNIQUE, + launch_sandbox TEXT, + launched_at INTEGER, + launch_attempts INTEGER NOT NULL DEFAULT 0, + UNIQUE (agent_id, name) + ); + CREATE TABLE IF NOT EXISTS session_activity_reports ( + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + launch_token TEXT NOT NULL, + event_id TEXT NOT NULL, + PRIMARY KEY (session_id, launch_token, event_id) + ); + PRAGMA user_version = 1; +"; + +const PREVIEW_AGENT_ID: &str = "11111111-1111-4111-8111-111111111111"; +const PREVIEW_DELETED_AGENT_ID: &str = "22222222-2222-4222-8222-222222222222"; +const EXPANDED_AGENT_ID: &str = "33333333-3333-4333-8333-333333333333"; +const PREVIEW_SECRET: &[u8] = b"\0preview-one-secret\xff"; + +fn preview_desired(name: &str) -> String { + let mut desired = serde_json::to_value(support::agent(name)).expect("serialize fixture Agent"); + let spec = desired["spec"].as_object_mut().expect("fixture spec"); + let mut instructions = spec.remove("instructions").expect("fixture instructions"); + let instruction = instructions.as_array_mut().expect("current instructions").remove(0); + spec.insert("instructions".into(), instruction); + spec.remove("skills"); + serde_json::to_string(&desired).expect("encode preview desired state") +} + +fn preview_desired_with_null_instructions(name: &str) -> String { + let mut desired = serde_json::to_value(support::agent(name)).expect("serialize fixture Agent"); + let spec = desired["spec"].as_object_mut().expect("fixture spec"); + spec.insert("instructions".into(), serde_json::Value::Null); + spec.remove("skills"); + serde_json::to_string(&desired).expect("encode preview desired state") +} + +fn create_preview_1_database(path: &Path) { + let connection = rusqlite::Connection::open(path).expect("create preview 1 database"); + connection.execute_batch(PREVIEW_1_SCHEMA).expect("preview 1 schema"); + connection + .execute( + "INSERT INTO agents \ + (id, active_name, source_directory, desired_json, deletion_timestamp, status_json) \ + VALUES (?1, 'worker', ?2, ?3, NULL, '{}'), (?4, NULL, ?5, ?6, 1700000000, '{}')", + rusqlite::params![ + PREVIEW_AGENT_ID, + serde_json::to_string(Path::new("/preview/source")).expect("source"), + preview_desired("worker"), + PREVIEW_DELETED_AGENT_ID, + serde_json::to_string(Path::new("/preview/deleted")).expect("deleted source"), + preview_desired_with_null_instructions("deleted") + ], + ) + .expect("preview Agents"); + for (index, state) in (0_i64..).zip(["starting", "running", "idle", "failed"]) { + let id = format!("00000000-0000-4000-8000-{index:012}"); + let lifecycle = serde_json::json!({ + "state": state, + "failure": (state == "failed").then_some("preview failure"), + "observedActivationGeneration": index, + }); + connection + .execute( + "INSERT INTO sessions \ + (id, agent_id, name, harness, created_at, activation_generation, lifecycle_json, \ + harness_native_id, launch_token, launch_sandbox, launched_at, launch_attempts) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'preview-sandbox', ?10, ?11)", + rusqlite::params![ + id, + PREVIEW_AGENT_ID, + format!("session-{state}"), + if index % 2 == 0 { "claudeCode" } else { "codex" }, + 1_700_000_000_i64 + index, + index, + serde_json::to_string(&lifecycle).expect("lifecycle"), + format!("native-{index}"), + format!("00000000-0000-4000-9000-{index:012}"), + 1_700_000_100_i64 + index, + index + 1, + ], + ) + .expect("preview Session"); + } + connection + .execute( + "INSERT INTO secrets (name, value) VALUES ('claude-access-token', ?1)", + [PREVIEW_SECRET], + ) + .expect("preview secret"); + connection + .execute( + "INSERT INTO provider_accounts (provider, metadata_json) VALUES ('claudeCode', ?1)", + [r#"{"account":"preview-user"}"#], + ) + .expect("preview provider account"); +} + +#[test] +fn stores_scrub_projected_provenance_and_keep_recorded_manifest_paths() { + let directory = TempDir::new().expect("temporary directory"); + let store = persistence::Database::open(&directory.path().join("control-plane.db")).expect("open database"); + LocalRuntime::new().expect("local runtime").block_on(async { + let mut record = ready_record("worker", test_agent_id()); + record.manifest_path = Some(PathBuf::from("/source/worker.yml")); + record.agent.status.provenance = Some(agent::Provenance { + source_directory: PathBuf::from("/leaked"), + manifest_path: None, + env_file: None, + }); + store.put(record.clone(), 0).await.expect("Agent stored"); + + let stored = store.get(record.id).await.expect("Agent loaded"); + assert_eq!(stored.agent.status.provenance, None); + assert_eq!(stored.manifest_path.as_deref(), Some(Path::new("/source/worker.yml"))); + assert_eq!(stored.source_directory, record.source_directory); + + let mut status = stored.agent.status.clone(); + status.provenance = Some(agent::Provenance { + source_directory: PathBuf::from("/leaked"), + manifest_path: None, + env_file: None, + }); + store + .update_status(record.id, stored.agent.metadata.generation, status) + .await + .expect("status updated"); + let reloaded = store.get(record.id).await.expect("Agent reloaded"); + assert_eq!(reloaded.agent.status.provenance, None); + assert_eq!(reloaded.agent.status.conditions, stored.agent.status.conditions); + assert_eq!(reloaded.manifest_path.as_deref(), Some(Path::new("/source/worker.yml"))); + assert_eq!(reloaded.source_directory, record.source_directory); + }); +} + +fn ready_false(reason: &str, message: &str, at: Option) -> Condition { + Condition { + kind: Condition::READY.into(), + status: ConditionStatus::False, + reason: reason.into(), + message: message.into(), + last_transition_time: at, + } +} + +#[test] +fn status_updates_stamp_condition_transitions_and_keep_the_failure_class() { + let directory = TempDir::new().expect("temporary directory"); + let store = persistence::Database::open(&directory.path().join("control-plane.db")).expect("open database"); + LocalRuntime::new().expect("local runtime").block_on(async { + let entered = time::OffsetDateTime::from_unix_timestamp(1_600_000_000).expect("timestamp"); + let mut record = record("worker", 1); + record.agent.status = Status::observed( + 1, + None, + vec![ready_false("ProviderSelected", "provisioning", Some(entered))], + ); + let changes = store.changes(); + store.put(record.clone(), 0).await.expect("Agent stored"); + let written = changes.revision(); + store.get(record.id).await.expect("Agent read"); + assert_eq!( + changes.revision(), + written, + "reads do not advance the resource revision" + ); + + let mut retry = Status::observed(1, None, vec![ready_false("ProviderSelected", "another detail", None)]); + retry.failure = Some(agent::FailureKind::Transient); + retry.progress = Some(agent::progress::Provisioning { + pass: changes.revision(), + progress: sandbox::progress::Progress::new(), + }); + let stored = store.update_status(record.id, 1, retry).await.expect("status updated"); + assert_ne!( + changes.revision(), + written, + "status writes advance the resource revision" + ); + assert_eq!(stored.progress, None, "progress is projected, never stored"); + assert_eq!( + stored.conditions[0].last_transition_time, + Some(entered), + "a message-only change is not a transition" + ); + assert_eq!(stored.failure, Some(agent::FailureKind::Transient)); + let reloaded = store.get(record.id).await.expect("Agent reloaded"); + assert_eq!(reloaded.agent.status, stored, "the returned status is what was stored"); + + let failed = Status::observed(1, None, vec![ready_false("SandboxReconcileFailed", "boom", None)]); + let stored = store.update_status(record.id, 1, failed).await.expect("status updated"); + assert!( + stored.conditions[0].last_transition_time.is_some_and(|at| at > entered), + "a reason change is stamped" + ); + assert_eq!(stored.failure, None); + }); +} + +#[test] +fn sessions_are_idempotent_and_survive_database_reopen() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + let first = persistence::Database::open(&path).expect("open first database owner"); + LocalRuntime::new().expect("local runtime").block_on(async { + let ready = ready_record("worker", test_agent_id()); + first.put(ready, 0).await.expect("ready Agent"); + let name = SessionName::new("s1").expect("session name"); + let created = first + .ensure_session( + "worker", + &name, + NewSession { + initial_prompt: Some("first prompt".into()), + ..NewSession::resolved( + agent::Harness::ClaudeCode, + agent::ModelSelection { + model: Some(agent::Model::new("fable").expect("model")), + effort: Some(agent::Effort::new("xhigh").expect("effort")), + }, + &agent::ModelSelection::default(), + ) + }, + ) + .await + .expect("create session"); + let existing = first + .ensure_session( + "worker", + &name, + NewSession { + initial_prompt: Some("ignored: not created here".into()), + ..NewSession::resolved( + agent::Harness::ClaudeCode, + agent::ModelSelection { + model: Some(agent::Model::new("fable").expect("model")), + effort: Some(agent::Effort::new("xhigh").expect("effort")), + }, + &agent::ModelSelection::default(), + ) + }, + ) + .await + .expect("get session"); + assert_eq!(created.agent_id, test_agent_id()); + assert_eq!(created.harness, agent::Harness::ClaudeCode); + assert_eq!(created.model_selection.model_str(), Some("fable")); + assert_eq!(created.model_selection.effort_str(), Some("xhigh")); + assert_eq!(created, existing, "creation-time selections are recorded once"); + let unselected = first + .ensure_session( + "worker", + &SessionName::new("plain").expect("session name"), + NewSession::for_harness(agent::Harness::Codex), + ) + .await + .expect("create session without selections"); + assert!(unselected.model_selection.is_empty()); + first + .update_session_lifecycle(created.id, Lifecycle::running(), 0) + .await + .expect("persist observed state"); + }); + drop(first); + + let second = persistence::Database::open(&path).expect("reopen database owner"); + LocalRuntime::new().expect("local runtime").block_on(async { + let sessions = second.list_agent_sessions("worker").await.expect("persistent sessions"); + assert_eq!(sessions.len(), 2); + assert_eq!(sessions[1].name.as_str(), "s1"); + assert_eq!(sessions[1].harness, agent::Harness::ClaudeCode); + assert_eq!(sessions[1].model_selection.model_str(), Some("fable")); + assert_eq!(sessions[1].model_selection.effort_str(), Some("xhigh")); + assert_eq!(sessions[0].name.as_str(), "plain"); + assert!(sessions[0].model_selection.is_empty()); + assert_eq!( + sessions[1].status.lifecycle.state, + agent::sessions::LifecycleState::Running + ); + assert_eq!( + rusqlite::Connection::open(&path) + .expect("read database") + .query_row( + "SELECT initial_prompt FROM sessions WHERE id = ?1", + [sessions[1].id.to_string()], + |row| row.get::<_, Option>(0) + ) + .expect("initial prompt") + .as_deref(), + Some("first prompt"), + "the first prompt is recorded once, at creation" + ); + assert_eq!( + second + .get_agent_session("worker", &SessionName::new("s1").expect("Session name")) + .await + .expect("named Session"), + sessions[1] + ); + }); +} + +#[test] +fn concurrent_creation_only_conflicts_on_explicit_selections() { + let directory = TempDir::new().expect("temporary directory"); + let store = persistence::Database::open(&directory.path().join("control-plane.db")).expect("open database"); + LocalRuntime::new().expect("local runtime").block_on(async { + store + .put(ready_record("worker", test_agent_id()), 0) + .await + .expect("ready Agent"); + let name = SessionName::new("raced").expect("session name"); + let selection = |model: Option<&str>, effort: Option<&str>| agent::ModelSelection { + model: model.map(|model| agent::Model::new(model).expect("model")), + effort: effort.map(|effort| agent::Effort::new(effort).expect("effort")), + }; + let defaults = selection(Some("fable"), Some("xhigh")); + let first = store + .ensure_session( + "worker", + &name, + NewSession::resolved(agent::Harness::ClaudeCode, selection(Some("opus"), None), &defaults), + ) + .await + .expect("first creation"); + assert_eq!(first.model_selection, selection(Some("opus"), Some("xhigh"))); + + // A loser that chose nothing resolved to other values, but it did not ask for them. + let omitted = store + .ensure_session( + "worker", + &name, + NewSession::resolved(agent::Harness::ClaudeCode, agent::ModelSelection::default(), &defaults), + ) + .await + .expect("omitted selections take the Session as recorded"); + assert_eq!(omitted.id, first.id); + assert_eq!(omitted.model_selection, first.model_selection); + + let same = store + .ensure_session( + "worker", + &name, + NewSession::resolved(agent::Harness::ClaudeCode, selection(Some("opus"), None), &defaults), + ) + .await + .expect("the same explicit choice finds the Session"); + assert_eq!(same.id, first.id); + + let conflict = store + .ensure_session( + "worker", + &name, + NewSession::resolved(agent::Harness::ClaudeCode, selection(Some("sonnet"), None), &defaults), + ) + .await + .expect_err("a loser that explicitly chose differently is told"); + assert_eq!( + conflict.to_string(), + "invalid Agent: Session \"raced\" already uses model \"opus\", not \"sonnet\"" + ); + let unselected = store + .ensure_session( + "worker", + &name, + NewSession::resolved( + agent::Harness::ClaudeCode, + selection(None, Some("low")), + &agent::ModelSelection::default(), + ), + ) + .await + .expect_err("an explicit effort conflicts with the recorded one"); + assert!( + unselected + .to_string() + .contains("already uses effort \"xhigh\", not \"low\""), + "{unselected}" + ); + }); +} + +#[test] +fn attach_error_identifies_the_session_and_its_lifecycle_failure() { + let directory = TempDir::new().expect("temporary directory"); + let store = persistence::Database::open(&directory.path().join("control-plane.db")).expect("open database"); + LocalRuntime::new().expect("local runtime").block_on(async { + store + .put(ready_record("worker", test_agent_id()), 0) + .await + .expect("ready Agent"); + let session = store + .ensure_session("worker", &SessionName::new("recovering").expect("Session name"), NewSession::for_harness(agent::Harness::Codex)) + .await + .expect("Session"); + store + .update_session_lifecycle( + session.id, + Lifecycle::starting("harness exited; relaunching after up to 10s of backoff"), + 1, + ) + .await + .expect("lifecycle"); + + assert_eq!( + store + .session_attach_target(session.id) + .await + .expect_err("Session is not running") + .to_string(), + "invalid Agent: Session \"recovering\" is not running: harness exited; relaunching after up to 10s of backoff" + ); + }); +} + +#[test] +fn finalized_agents_and_their_sessions_remain_as_tombstones_when_a_name_is_reused() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + let store = persistence::Database::open(&path).expect("open database owner"); + let old_id = "f9fc2dac-ae2d-4534-a9c1-dd13dd9b5160".parse().expect("old Agent ID"); + let new_id = "f50fbec8-03a9-43ea-b65d-c15a86e9eb65".parse().expect("new Agent ID"); + LocalRuntime::new().expect("local runtime").block_on(async { + store.put(ready_record("worker", old_id), 0).await.expect("old Agent"); + let old_session = SessionName::new("old-session").expect("session name"); + store + .ensure_session( + "worker", + &old_session, + NewSession::for_harness(agent::Harness::ClaudeCode), + ) + .await + .expect("old session"); + store.mark_deleting("worker").await.expect("mark deleting"); + store.finalize_deletion(old_id, 1).await.expect("finalize deletion"); + assert!(matches!(store.get(old_id).await, Err(Error::NotFound))); + + store + .put(ready_record("worker", new_id), 0) + .await + .expect("new Agent incarnation"); + let sessions = store.list_agent_sessions("worker").await.expect("new Agent sessions"); + assert!(sessions.is_empty()); + }); + drop(store); + + let connection = rusqlite::Connection::open(path).expect("inspect database"); + assert_eq!( + connection + .query_row("SELECT COUNT(*) FROM agents", [], |row| row.get::<_, i64>(0)) + .expect("Agent count"), + 2 + ); + assert_eq!( + connection + .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get::<_, i64>(0)) + .expect("session count"), + 1 + ); +} + +#[test] +fn released_preview_1_database_migrates_without_losing_state() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + create_preview_1_database(&path); + + let database = persistence::Database::open(&path).expect("migrate preview 1 database"); + LocalRuntime::new().expect("local runtime").block_on(async { + let agent = database + .get(PREVIEW_AGENT_ID.parse().expect("preview Agent ID")) + .await + .expect("migrated Agent"); + assert_eq!(agent.source_directory, Path::new("/preview/source")); + assert_eq!(agent.agent.spec.instructions.len(), 1); + assert!(agent.agent.spec.skills.is_empty()); + let sessions = database.list_agent_sessions("worker").await.expect("migrated Sessions"); + assert_eq!(sessions.len(), 4); + assert_eq!( + sessions[0].status.lifecycle.state, + agent::sessions::LifecycleState::Failed + ); + assert_eq!( + sessions[1].status.lifecycle.state, + agent::sessions::LifecycleState::Idle + ); + assert_eq!( + sessions[2].status.lifecycle.state, + agent::sessions::LifecycleState::Running + ); + assert_eq!( + sessions[3].status.lifecycle.state, + agent::sessions::LifecycleState::Starting + ); + }); + drop(database); + + let connection = rusqlite::Connection::open(&path).expect("inspect migrated database"); + assert_eq!( + connection + .query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0)) + .expect("schema version"), + 5 + ); + assert_migrated_session_selections(&connection, 2, 2); + assert_eq!( + connection + .query_row( + "SELECT source_directory FROM agents WHERE id = ?1", + [PREVIEW_DELETED_AGENT_ID], + |row| row.get::<_, String>(0), + ) + .expect("deleted Agent source"), + serde_json::to_string(Path::new("/preview/deleted")).expect("source") + ); + let deleted: agent::Agent = serde_json::from_str( + &connection + .query_row( + "SELECT desired_json FROM agents WHERE id = ?1", + [PREVIEW_DELETED_AGENT_ID], + |row| row.get::<_, String>(0), + ) + .expect("deleted Agent desired state"), + ) + .expect("migrated deleted Agent"); + assert!(deleted.spec.instructions.is_empty()); + assert_eq!( + connection + .query_row( + "SELECT value FROM secrets WHERE name = 'claude-access-token'", + [], + |row| { row.get::<_, Vec>(0) } + ) + .expect("migrated secret"), + PREVIEW_SECRET + ); + assert_eq!( + connection + .query_row( + "SELECT metadata_json FROM provider_accounts WHERE provider = 'claudeCode'", + [], + |row| row.get::<_, String>(0), + ) + .expect("provider metadata"), + r#"{"account":"preview-user"}"# + ); + assert_eq!( + connection + .query_row( + "SELECT COUNT(*) FROM sessions \ + WHERE initial_prompt IS NULL AND harness_transcript_path IS NULL AND activity_json = '{}'", + [], + |row| row.get::<_, u32>(0), + ) + .expect("migrated Session defaults"), + 4 + ); + drop(connection); + drop(persistence::Database::open(&path).expect("reopen migrated database")); +} + +#[test] +fn preview_1_home_opened_by_the_expanded_version_1_build_migrates() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + create_preview_1_database(&path); + let connection = rusqlite::Connection::open(&path).expect("open intermediate database"); + connection + .execute_batch( + "CREATE TABLE session_activity_reports ( + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + launch_token TEXT NOT NULL, + event_id TEXT NOT NULL, + PRIMARY KEY (session_id, launch_token, event_id) + );", + ) + .expect("intermediate reports table"); + let expanded_desired = serde_json::to_string(&support::agent("new-worker")).expect("expanded desired state"); + connection + .execute( + "INSERT INTO agents \ + (id, active_name, source_directory, desired_json, deletion_timestamp, status_json) \ + VALUES (?1, 'new-worker', ?2, ?3, NULL, '{}')", + rusqlite::params![ + EXPANDED_AGENT_ID, + serde_json::to_string(Path::new("/expanded/source")).expect("source"), + expanded_desired, + ], + ) + .expect("expanded Agent"); + drop(connection); + + let database = persistence::Database::open(&path).expect("migrate intermediate database"); + LocalRuntime::new().expect("local runtime").block_on(async { + let agent = database + .get(PREVIEW_AGENT_ID.parse().expect("preview Agent ID")) + .await + .expect("preserved Agent"); + assert_eq!(agent.agent.spec.instructions.len(), 1); + let expanded = database + .get(EXPANDED_AGENT_ID.parse().expect("expanded Agent ID")) + .await + .expect("preserved expanded Agent"); + assert_eq!(expanded.agent.spec.instructions.len(), 1); + }); + drop(database); + + assert_eq!(schema_snapshot(&path).0, 5); + assert_eq!( + connection_value(&path, EXPANDED_AGENT_ID, "desired_json"), + expanded_desired, + "array-valued instructions should not rewrite current desired state" + ); +} + +#[test] +fn version_2_home_records_the_model_existing_claude_code_sessions_launched_with() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + let connection = rusqlite::Connection::open(&path).expect("create version 2 database"); + connection + .execute_batch(EXPANDED_VERSION_1_SCHEMA) + .expect("version 2 tables"); + connection.pragma_update(None, "user_version", 2).expect("version 2"); + let desired = serde_json::to_string(&support::agent("worker")).expect("desired state"); + connection + .execute( + "INSERT INTO agents \ + (id, active_name, source_directory, desired_json, deletion_timestamp, status_json) \ + VALUES (?1, 'worker', ?2, ?3, NULL, '{}')", + rusqlite::params![ + PREVIEW_AGENT_ID, + serde_json::to_string(Path::new("/source")).expect("source"), + desired, + ], + ) + .expect("Agent"); + for (index, harness) in ["claudeCode", "codex"].into_iter().enumerate() { + connection + .execute( + "INSERT INTO sessions (id, agent_id, name, harness, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + format!("00000000-0000-4000-8000-{index:012}"), + PREVIEW_AGENT_ID, + format!("session-{harness}"), + harness, + 1_700_000_000_i64, + ], + ) + .expect("version 2 Session"); + } + drop(connection); + + let database = persistence::Database::open(&path).expect("migrate version 2 database"); + LocalRuntime::new().expect("local runtime").block_on(async { + let sessions = database.list_agent_sessions("worker").await.expect("Sessions"); + assert_eq!(sessions.len(), 2); + assert_eq!(sessions[0].harness, agent::Harness::ClaudeCode); + assert_eq!(sessions[0].model_selection.model_str(), Some("fable")); + assert_eq!(sessions[0].model_selection.effort_str(), None); + assert_eq!(sessions[1].harness, agent::Harness::Codex); + assert!(sessions[1].model_selection.is_empty()); + }); + drop(database); + assert_eq!(schema_snapshot(&path).0, 5); + assert!( + directory.path().join("backups").is_dir(), + "a pending migration is backed up first" + ); +} + +#[test] +fn expanded_version_1_schema_is_adopted_without_losing_state() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + let connection = rusqlite::Connection::open(&path).expect("create expanded version 1 database"); + connection + .execute_batch(EXPANDED_VERSION_1_SCHEMA) + .expect("expanded version 1 schema"); + let desired = serde_json::to_string(&support::agent("worker")).expect("expanded desired state"); + connection + .execute( + "INSERT INTO agents \ + (id, active_name, source_directory, desired_json, deletion_timestamp, status_json) \ + VALUES (?1, 'worker', ?2, ?3, NULL, '{}')", + rusqlite::params![ + EXPANDED_AGENT_ID, + serde_json::to_string(Path::new("/expanded/source")).expect("source"), + desired, + ], + ) + .expect("expanded Agent"); + drop(connection); + let before = schema_snapshot(&path).1; + + let database = persistence::Database::open(&path).expect("adopt expanded version 1 database"); + LocalRuntime::new().expect("local runtime").block_on(async { + database + .get(EXPANDED_AGENT_ID.parse().expect("expanded Agent ID")) + .await + .expect("preserved Agent"); + }); + drop(database); + + let after = schema_snapshot(&path); + assert_eq!(after.0, 5); + let unchanged = |snapshot: &[(String, String)]| { + snapshot + .iter() + .filter(|(name, _)| name != "table:sessions") + .cloned() + .collect::>() + }; + assert_eq!(unchanged(&after.1), unchanged(&before)); + let sessions_sql = |snapshot: &[(String, String)]| { + snapshot + .iter() + .find(|(name, _)| name == "table:sessions") + .map(|(_, sql)| sql.clone()) + .expect("sessions table") + }; + let (before_sessions, after_sessions) = (sessions_sql(&before), sessions_sql(&after.1)); + assert!(!before_sessions.contains("model TEXT")); + assert!( + after_sessions.contains("model TEXT") && after_sessions.contains("effort TEXT"), + "only the Session selection columns are added: {after_sessions}" + ); +} + +#[test] +fn partial_version_1_schema_is_rejected_without_mutation() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + let connection = rusqlite::Connection::open(&path).expect("partial database"); + connection + .execute_batch("CREATE TABLE agents (id TEXT PRIMARY KEY NOT NULL); PRAGMA user_version = 1;") + .expect("partial version 1 schema"); + let before = schema_snapshot(&path); + + let Err(error) = persistence::Database::open(&path) else { + panic!("partial schema should be rejected"); + }; + assert!(error.to_string().contains("not a recognized released schema")); + assert_eq!(schema_snapshot(&path), before, "rejection must not mutate the schema"); +} + +#[test] +fn future_schema_is_rejected_without_mutation() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + let connection = rusqlite::Connection::open(&path).expect("future database"); + connection + .pragma_update(None, "user_version", 99) + .expect("future version"); + drop(connection); + let before = schema_snapshot(&path); + let Err(error) = persistence::Database::open(&path) else { + panic!("future schema should be rejected"); + }; + assert!(error.to_string().contains("newer than the supported schema")); + assert_eq!( + rusqlite::Connection::open(&path) + .expect("inspect future database") + .query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0)) + .expect("future version"), + 99 + ); + assert_eq!(schema_snapshot(&path), before); +} + +#[test] +fn failed_preview_1_row_migration_rolls_back_schema_and_rows() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + create_preview_1_database(&path); + let connection = rusqlite::Connection::open(&path).expect("corrupt preview fixture"); + connection + .execute( + "UPDATE agents SET desired_json = 'not-json' WHERE id = ?1", + [PREVIEW_DELETED_AGENT_ID], + ) + .expect("corrupt later Agent row"); + drop(connection); + let before = schema_snapshot(&path); + let valid_desired = connection_value(&path, PREVIEW_AGENT_ID, "desired_json"); + + let Err(error) = persistence::Database::open(&path) else { + panic!("malformed desired state should fail migration"); + }; + assert!(error.to_string().contains("invalid desired state during migration")); + assert_eq!(schema_snapshot(&path), before); + assert_eq!(connection_value(&path, PREVIEW_AGENT_ID, "desired_json"), valid_desired); + let connection = rusqlite::Connection::open(path).expect("inspect rollback"); + assert_eq!( + connection + .query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0)) + .expect("rolled-back version"), + 1 + ); +} + +/// Preview Sessions never chose a model, but every Claude Code Session launched on +/// the `fable` alias the adapter hardcoded; the migration records that so they stay +/// on the same model, while Codex Sessions keep their harness default. +fn assert_migrated_session_selections(connection: &rusqlite::Connection, claude_code: u32, codex: u32) { + let count = |filter: &str| { + connection + .query_row(&format!("SELECT COUNT(*) FROM sessions WHERE {filter}"), [], |row| { + row.get::<_, u32>(0) + }) + .expect("migrated Sessions") + }; + assert_eq!( + count("harness = 'claudeCode' AND model = 'fable' AND effort IS NULL"), + claude_code + ); + assert_eq!(count("harness = 'codex' AND model IS NULL AND effort IS NULL"), codex); +} + +fn schema_snapshot(path: &Path) -> (u32, Vec<(String, String)>) { + let connection = rusqlite::Connection::open(path).expect("snapshot database"); + let version = connection + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .expect("snapshot version"); + let mut statement = connection + .prepare( + "SELECT type || ':' || name, coalesce(sql, '') FROM sqlite_schema \ + WHERE name NOT LIKE 'sqlite_%' ORDER BY 1", + ) + .expect("snapshot query"); + let schema = statement + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .expect("snapshot rows") + .collect::>() + .expect("snapshot values"); + (version, schema) +} + +fn connection_value(path: &Path, id: &str, column: &str) -> String { + let connection = rusqlite::Connection::open(path).expect("read fixture value"); + connection + .query_row(&format!("SELECT {column} FROM agents WHERE id = ?1"), [id], |row| { + row.get(0) + }) + .expect("fixture value") +} + +#[test] +fn secret_material_is_persistent_and_replaced_by_name() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + let first = persistence::Database::open(&path).expect("open first database owner"); + LocalRuntime::new().expect("local runtime").block_on(async { + first.set("claude-access", b"first").await.expect("store secret"); + }); + drop(first); + + let second = persistence::Database::open(&path).expect("reopen database owner"); + LocalRuntime::new().expect("local runtime").block_on(async { + let reference = second.set("claude-access", b"second").await.expect("replace secret"); + let material = second.resolve(&reference).await.expect("resolve secret"); + assert_eq!(material.expose(), b"second"); + }); +} + +#[test] +fn finalizing_an_agent_removes_only_its_scoped_secret_material() { + let directory = TempDir::new().expect("temporary directory"); + let store = persistence::Database::open(&directory.path().join("control-plane.db")).expect("database"); + LocalRuntime::new().expect("local runtime").block_on(async { + let id = test_agent_id(); + store.put(record("worker", 1), 0).await.expect("Agent"); + let agent_secret = store + .set(&format!("agent/{id}/github-token"), b"github-secret") + .await + .expect("Agent secret"); + let provider_secret = store + .set("claude-access-token", b"claude-secret") + .await + .expect("provider secret"); + + store.mark_deleting("worker").await.expect("mark deleting"); + store.finalize_deletion(id, 1).await.expect("finalize deletion"); + + assert!(store.resolve(&agent_secret).await.is_err()); + assert_eq!( + store + .resolve(&provider_secret) + .await + .expect("provider secret remains") + .expose(), + b"claude-secret" + ); + }); +} + +#[test] +fn agent_records_survive_reopen_and_preserve_compare_and_swap() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + let first = persistence::Database::open(&path).expect("open first database owner"); + LocalRuntime::new().expect("local runtime").block_on(async { + first + .put(record("worker", 1), 0) + .await + .expect("insert first generation"); + let error = first + .put(record("worker", 2), 0) + .await + .expect_err("duplicate create should conflict"); + assert!(matches!(error, Error::Conflict)); + }); + drop(first); + + let second = persistence::Database::open(&path).expect("reopen database owner"); + LocalRuntime::new().expect("local runtime").block_on(async { + assert_eq!( + second.get(test_agent_id()).await.expect("persistent record"), + record("worker", 1) + ); + second.put(record("worker", 2), 1).await.expect("compare and swap"); + let error = second + .update_status(test_agent_id(), 1, Status::default()) + .await + .expect_err("stale status should conflict"); + assert!(matches!(error, Error::Conflict)); + }); +} + +#[test] +fn desired_state_cannot_change_after_deletion_starts() { + let directory = TempDir::new().expect("temporary directory"); + let store = persistence::Database::open(&directory.path().join("control-plane.db")).expect("database"); + LocalRuntime::new().expect("local runtime").block_on(async { + store.put(record("worker", 1), 0).await.expect("Agent"); + let mut stale = store.get_by_name("worker").await.expect("stale desired state"); + store.mark_deleting("worker").await.expect("mark deleting"); + + stale.agent.metadata.generation = 2; + let error = store + .put(stale, 1) + .await + .expect_err("deleting Agent must reject desired-state updates"); + assert!(matches!(error, Error::Conflict)); + assert!( + store + .get_by_name("worker") + .await + .expect("deleting Agent") + .agent + .metadata + .deletion_timestamp + .is_some() + ); + }); +} + +#[tokio::test(flavor = "local")] +async fn initial_prompt_consumption_and_launch_record_commit_together() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("agent.db"); + let database = persistence::Database::open(&path).expect("database"); + database + .put(ready_record("worker", test_agent_id()), 0) + .await + .expect("Agent"); + let session = database + .ensure_session( + "worker", + &SessionName::new("s1").expect("name"), + NewSession { + initial_prompt: Some("once".into()), + ..NewSession::for_harness(agent::Harness::ClaudeCode) + }, + ) + .await + .expect("Session"); + let token: agent::sessions::LaunchToken = "cccccccc-cccc-4ccc-8ccc-cccccccccccc".parse().expect("token"); + let launch = agent::sessions::LaunchRecord { + token: token.clone(), + sandbox: "sandbox-1".into(), + launched_at: 0, + attempts: 1, + }; + let inspect = rusqlite::Connection::open(&path).expect("inspect"); + inspect.execute_batch("CREATE TRIGGER reject_consumption AFTER UPDATE OF initial_prompt ON sessions WHEN OLD.initial_prompt IS NOT NULL AND NEW.initial_prompt IS NULL BEGIN SELECT RAISE(ABORT, 'injected consumption failure'); END;").expect("inject failure"); + database + .record_session_launch(session.id, launch.clone()) + .await + .expect_err("consumption failure"); + assert_eq!( + database.session_launch_state(session.id).await.expect("state"), + None, + "failed consumption rolls back launch bookkeeping" + ); + inspect + .execute_batch("DROP TRIGGER reject_consumption;") + .expect("remove failure"); + assert_eq!( + database + .record_session_launch(session.id, launch.clone()) + .await + .expect("consume"), + Some("once".into()), + "failed transaction did not consume the prompt" + ); + drop(database); + let reopened = persistence::Database::open(&path).expect("reopen"); + assert_eq!( + reopened + .record_session_launch(session.id, launch) + .await + .expect("relaunch"), + None, + "recovery after a crash never replays the prompt" + ); +} + +#[tokio::test(flavor = "local")] +async fn a_session_records_when_it_entered_its_state() { + use agent::sessions::{ActivityEvent, LaunchRecord, State}; + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + database + .put(ready_record("worker", test_agent_id()), 0) + .await + .expect("Agent"); + let session = database + .ensure_session( + "worker", + &SessionName::new("s1").expect("name"), + NewSession::for_harness(agent::Harness::ClaudeCode), + ) + .await + .expect("Session"); + let failed = Lifecycle::failed("harness exited"); + database + .update_session_lifecycle(session.id, failed.clone(), 0) + .await + .expect("failed"); + let entered = database + .get_session(session.id) + .await + .expect("Session") + .status + .state_since; + assert!(entered.is_some(), "a lifecycle change is stamped"); + database + .update_session_lifecycle(session.id, failed, 0) + .await + .expect("still failed"); + assert_eq!( + database + .get_session(session.id) + .await + .expect("Session") + .status + .state_since, + entered, + "the same lifecycle state keeps its time" + ); + + database + .update_session_lifecycle(session.id, Lifecycle::running(), 0) + .await + .expect("running"); + let token: agent::sessions::LaunchToken = "cccccccc-cccc-4ccc-8ccc-cccccccccccc".parse().expect("token"); + database + .record_session_launch( + session.id, + LaunchRecord { + token: token.clone(), + sandbox: "sandbox-1".into(), + launched_at: 0, + attempts: 1, + }, + ) + .await + .expect("launch"); + let at = |seconds| time::OffsetDateTime::from_unix_timestamp(seconds).expect("timestamp"); + database + .record_session_start_for_launch(session.id, &token, uuid::Uuid::new_v4(), "native", None, at(100)) + .await + .expect("start"); + database + .apply_session_activity_for_launch( + session.id, + &token, + uuid::Uuid::new_v4(), + ActivityEvent::TurnCompleted, + at(110), + ) + .await + .expect("turn"); + database + .apply_session_activity_for_launch( + session.id, + &token, + uuid::Uuid::new_v4(), + ActivityEvent::WaitingForInput, + at(170), + ) + .await + .expect("idle notification"); + let status = database.get_session(session.id).await.expect("Session").status; + assert_eq!(status.state, State::WaitingForInput); + assert_eq!( + status.state_since, + Some(at(110)), + "waiting since the turn ended, not since the notification" + ); +} + +#[tokio::test(flavor = "local")] +async fn activity_deduplication_is_durable_and_rolls_back_with_the_fold() { + use agent::sessions::{ActivityEvent, LaunchRecord}; + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("agent.db"); + let database = persistence::Database::open(&path).expect("database"); + database + .put(ready_record("worker", test_agent_id()), 0) + .await + .expect("Agent"); + let session = database + .ensure_session( + "worker", + &SessionName::new("s1").expect("name"), + NewSession::for_harness(agent::Harness::ClaudeCode), + ) + .await + .expect("Session"); + let token: agent::sessions::LaunchToken = "cccccccc-cccc-4ccc-8ccc-cccccccccccc".parse().expect("token"); + database + .record_session_launch( + session.id, + LaunchRecord { + token: token.clone(), + sandbox: "sandbox-1".into(), + launched_at: 0, + attempts: 1, + }, + ) + .await + .expect("launch"); + let event_id = uuid::Uuid::new_v4(); + let at = time::OffsetDateTime::now_utc(); + let inspect = rusqlite::Connection::open(&path).expect("inspect"); + inspect.execute_batch("CREATE TRIGGER reject_activity BEFORE UPDATE OF activity_json ON sessions BEGIN SELECT RAISE(ABORT, 'injected fold failure'); END;").expect("inject failure"); + database + .apply_session_activity_for_launch(session.id, &token, event_id, ActivityEvent::TurnCompleted, at) + .await + .expect_err("fold failure"); + inspect + .execute_batch("DROP TRIGGER reject_activity;") + .expect("remove failure"); + let activity = database + .apply_session_activity_for_launch(session.id, &token, event_id, ActivityEvent::TurnCompleted, at) + .await + .expect("retry") + .expect("applied"); + assert_eq!(activity.turns, 1, "rolled-back receipt must not suppress the retry"); + drop(database); + let reopened = persistence::Database::open(&path).expect("reopen"); + assert_eq!( + reopened + .apply_session_activity_for_launch(session.id, &token, event_id, ActivityEvent::TurnCompleted, at) + .await + .expect("lost response retry"), + None + ); + assert_eq!( + reopened + .get_session(session.id) + .await + .expect("Session") + .status + .reported + .activity, + activity, + "duplicate does not change count, phase, or timestamp" + ); +} + +#[test] +fn a_deleted_session_is_marked_before_it_is_removed_and_frees_its_name() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + let store = persistence::Database::open(&path).expect("open database owner"); + LocalRuntime::new().expect("local runtime").block_on(async { + store + .put(ready_record("worker", test_agent_id()), 0) + .await + .expect("Agent"); + let name = SessionName::new("s1").expect("session name"); + let created = store + .ensure_session("worker", &name, NewSession::for_harness(agent::Harness::ClaudeCode)) + .await + .expect("create Session"); + + let marked = store + .mark_session_deleting("worker", &name) + .await + .expect("mark deleting"); + assert_eq!(marked.id, created.id); + let timestamp = marked.deletion_timestamp.expect("deletion timestamp"); + assert_eq!( + store + .mark_session_deleting("worker", &name) + .await + .expect("repeated deletion is safe") + .deletion_timestamp, + Some(timestamp), + "the first request owns the deletion timestamp" + ); + + // Reconciliation still sees the Session it has to release, so a daemon + // restart between the request and the release cannot strand a harness. + assert!( + store + .get_session(created.id) + .await + .expect("marked Session") + .is_deleting() + ); + assert!( + store + .list_all_sessions() + .await + .expect("sessions") + .iter() + .any(|session| session.id == created.id) + ); + // The name stays taken until the harness is gone. + let reused = store + .ensure_session("worker", &name, NewSession::for_harness(agent::Harness::ClaudeCode)) + .await + .expect_err("the name is still taken"); + assert!(reused.to_string().contains("is being deleted"), "{reused}"); + + store + .finalize_session_deletion(created.id) + .await + .expect("finalize deletion"); + assert!(matches!(store.get_session(created.id).await, Err(Error::NotFound))); + assert!(matches!( + store.finalize_session_deletion(created.id).await, + Err(Error::Conflict) + )); + assert!(matches!( + store.mark_session_deleting("worker", &name).await, + Err(Error::NotFound) + )); + + let replacement = store + .ensure_session("worker", &name, NewSession::for_harness(agent::Harness::ClaudeCode)) + .await + .expect("the freed name is available again"); + assert_ne!(replacement.id, created.id); + }); + drop(store); + + let connection = rusqlite::Connection::open(path).expect("inspect database"); + assert_eq!( + connection + .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get::<_, i64>(0)) + .expect("session count"), + 1, + "the released Session leaves no row behind" + ); +} + +#[test] +fn ssh_host_keys_are_stored_per_incarnation_and_removed_with_it() { + use agent::ssh::HostKeyStore as _; + + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + let store = persistence::Database::open(&path).expect("database"); + LocalRuntime::new().expect("local runtime").block_on(async { + let id = test_agent_id(); + store.put(record("worker", 1), 0).await.expect("Agent"); + assert!(store.load_host_key(id).await.expect("load").is_none()); + store + .store_host_key(id, zeroize::Zeroizing::new(b"host-key".to_vec())) + .await + .expect("store"); + assert_eq!( + store.load_host_key(id).await.expect("load").expect("stored").as_slice(), + b"host-key" + ); + // Manifest secrets of the same Agent live under another prefix. + let agent_secret = store + .set(&format!("agent/{id}/github-token"), b"github-secret") + .await + .expect("Agent secret"); + store.mark_deleting("worker").await.expect("mark deleting"); + store.finalize_deletion(id, 1).await.expect("finalize deletion"); + assert!(store.load_host_key(id).await.expect("load").is_none()); + assert!(store.resolve(&agent_secret).await.is_err()); + + store + .store_host_key(id, zeroize::Zeroizing::new(b"again".to_vec())) + .await + .expect("store"); + store.delete_host_key(id).await.expect("delete"); + store.delete_host_key(id).await.expect("deleting twice is fine"); + assert!(store.load_host_key(id).await.expect("load").is_none()); + }); +} + +#[test] +fn archiving_a_session_is_recorded_once_and_survives_reopening() { + let directory = TempDir::new().expect("temporary directory"); + let path = directory.path().join("control-plane.db"); + let store = persistence::Database::open(&path).expect("open database owner"); + let name = SessionName::new("s1").expect("session name"); + let (id, archived_at) = LocalRuntime::new().expect("local runtime").block_on(async { + store + .put(ready_record("worker", test_agent_id()), 0) + .await + .expect("Agent"); + let created = store + .ensure_session("worker", &name, NewSession::for_harness(agent::Harness::ClaudeCode)) + .await + .expect("create Session"); + assert!(!created.is_archived()); + + let archived = store + .set_session_archived("worker", &name, true) + .await + .expect("archive"); + let archived_at = archived.archived_at.expect("archive time"); + assert_eq!( + store + .set_session_archived("worker", &name, true) + .await + .expect("archiving again is safe") + .archived_at, + Some(archived_at), + "the first request owns the archive time" + ); + (created.id, archived_at) + }); + drop(store); + + let store = persistence::Database::open(&path).expect("reopen database owner"); + LocalRuntime::new().expect("local runtime").block_on(async { + let session = store.get_session(id).await.expect("archived Session"); + assert_eq!(session.archived_at, Some(archived_at)); + let unarchived = store + .set_session_archived("worker", &name, false) + .await + .expect("unarchive"); + assert!(!unarchived.is_archived()); + + store + .mark_session_deleting("worker", &name) + .await + .expect("mark deleting"); + assert!(matches!( + store.set_session_archived("worker", &name, true).await, + Err(Error::NotFound) + )); + }); +} diff --git a/agentctl/tests/fixtures/claude-code-transcript.jsonl b/agentctl/tests/fixtures/claude-code-transcript.jsonl new file mode 100644 index 0000000..7778991 --- /dev/null +++ b/agentctl/tests/fixtures/claude-code-transcript.jsonl @@ -0,0 +1,23 @@ +{"type":"queue-operation","operation":"enqueue","timestamp":"2026-09-09T08:00:00.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001"} +{"type":"user","isSidechain":false,"uuid":"u1","parentUuid":null,"timestamp":"2026-09-09T08:00:01.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","cwd":"/home/agent/code","version":"2.1.266","userType":"external","message":{"role":"user","content":"Rename the helper and run the tests."}} +{"type":"assistant","isSidechain":false,"uuid":"a1","parentUuid":"u1","timestamp":"2026-09-09T08:00:03.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","requestId":"req_1","apiBlockIndex":0,"message":{"id":"msg_01","role":"assistant","model":"fable","content":[{"type":"text","text":"Renaming the helper first."}]}} +{"type":"assistant","isSidechain":false,"uuid":"a2","parentUuid":"a1","timestamp":"2026-09-09T08:00:03.100Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","requestId":"req_1","apiBlockIndex":1,"message":{"id":"msg_01","role":"assistant","model":"fable","content":[{"type":"tool_use","id":"toolu_01","name":"Edit","input":{"file_path":"src/lib.rs"}}]}} +{"type":"user","isSidechain":false,"uuid":"u2","parentUuid":"a2","timestamp":"2026-09-09T08:00:04.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","toolUseResult":{},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","is_error":false,"content":"edited"}]}} +{"type":"assistant","isSidechain":false,"uuid":"a3","parentUuid":"u2","timestamp":"2026-09-09T08:00:05.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","requestId":"req_2","apiBlockIndex":0,"message":{"id":"msg_02","role":"assistant","model":"fable","content":[{"type":"tool_use","id":"toolu_02","name":"Bash","input":{"command":"cargo test"}}]}} +{"type":"user","isSidechain":false,"uuid":"u3","parentUuid":"a3","timestamp":"2026-09-09T08:00:09.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","toolUseResult":{},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_02","is_error":true,"content":"error[E0425]: cannot find function `helper`"}]}} +{"type":"assistant","isSidechain":true,"uuid":"s1","parentUuid":null,"timestamp":"2026-09-09T08:00:09.500Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","message":{"id":"msg_side","role":"assistant","content":[{"type":"text","text":"subagent chatter"}]}} +{"type":"assistant","isSidechain":false,"uuid":"a4","parentUuid":"u3","timestamp":"2026-09-09T08:00:10.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","requestId":"req_3","apiBlockIndex":0,"message":{"id":"msg_03","role":"assistant","model":"fable","content":[{"type":"tool_use","id":"toolu_03","name":"Bash","input":{"command":"cargo test"}}]}} +{"type":"user","isSidechain":false,"uuid":"u4","parentUuid":"a4","timestamp":"2026-09-09T08:00:14.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","toolUseResult":{},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_03","is_error":false,"content":"test result: ok"}]}} +{"type":"assistant","isSidechain":false,"uuid":"a5","parentUuid":"u4","timestamp":"2026-09-09T08:00:15.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","requestId":"req_4","apiBlockIndex":0,"message":{"id":"msg_04","role":"assistant","model":"fable","content":[{"type":"text","text":"Renamed and the tests pass."}]}} +{"type":"user","isSidechain":false,"uuid":"u5","parentUuid":"a5","timestamp":"2026-09-09T08:00:16.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","message":{"role":"user","content":"\nb554bjc3w\ncompleted\n"}} +{"type":"user","isSidechain":false,"isMeta":true,"uuid":"u6","parentUuid":"u5","timestamp":"2026-09-09T08:00:17.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","message":{"role":"user","content":"Caveat: The messages below were generated by the user while running local commands."}} +{"type":"user","isSidechain":false,"uuid":"u7","parentUuid":"u6","timestamp":"2026-09-09T08:00:17.500Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","message":{"role":"user","content":"/cost\ncost\n"}} +{"type":"user","isSidechain":false,"uuid":"u8","parentUuid":"u7","timestamp":"2026-09-09T08:00:18.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","message":{"role":"user","content":"Total cost: $0.42"}} +{"type":"user","isSidechain":false,"uuid":"u9","parentUuid":"u8","timestamp":"2026-09-09T08:00:30.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","message":{"role":"user","content":"Now commit it."}} +{"type":"assistant","isSidechain":false,"uuid":"a6","parentUuid":"u9","timestamp":"2026-09-09T08:00:31.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","requestId":"req_5","apiBlockIndex":0,"message":{"id":"msg_05","role":"assistant","model":"fable","content":[{"type":"text","text":"Committing."}]}} +{"type":"assistant","isSidechain":false,"uuid":"a7","parentUuid":"a6","timestamp":"2026-09-09T08:00:31.100Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","requestId":"req_5","apiBlockIndex":1,"message":{"id":"msg_05","role":"assistant","model":"fable","content":[{"type":"tool_use","id":"toolu_04","name":"Bash","input":{"command":"git commit -am rename"}}]}} +{"type":"queue-operation","operation":"enqueue","timestamp":"2026-09-09T08:00:32.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","content":"Use a conventional commit message."} +{"type":"queue-operation","operation":"remove","timestamp":"2026-09-09T08:00:33.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","content":"Use a conventional commit message.","reason":"absorbed_mid_turn"} +{"type":"attachment","isSidechain":false,"uuid":"at1","parentUuid":"a7","timestamp":"2026-09-09T08:00:33.000Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","attachment":{"type":"queued_command","prompt":"Use a conventional commit message.","source_uuid":"q1","commandMode":"prompt","origin":{"kind":"human"},"timestamp":"2026-09-09T08:00:32.000Z"},"rendered":[{"content":"\nThe user sent a new message while you were working:\nUse a conventional commit message.\n"}]} +{"type":"attachment","isSidechain":false,"uuid":"at2","parentUuid":"at1","timestamp":"2026-09-09T08:00:33.100Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","attachment":{"type":"queued_command","prompt":"\nx\n","source_uuid":"q2","commandMode":"prompt","origin":{"kind":"system"},"timestamp":"2026-09-09T08:00:33.000Z"},"rendered":[]} +{"type":"attachment","isSidechain":false,"uuid":"at3","parentUuid":"at2","timestamp":"2026-09-09T08:00:33.200Z","sessionId":"7c1e2a3b-0000-4000-8000-000000000001","attachment":{"type":"total_tokens_reminder","text":"..."},"rendered":[]} diff --git a/agentctl/tests/fixtures/codex-rollout.jsonl b/agentctl/tests/fixtures/codex-rollout.jsonl new file mode 100644 index 0000000..3b66f2d --- /dev/null +++ b/agentctl/tests/fixtures/codex-rollout.jsonl @@ -0,0 +1,22 @@ +{"timestamp":"2026-09-09T08:00:00.000Z","type":"session_meta","payload":{"id":"019fb826-b440-7432-8531-ac6be65198ec","timestamp":"2026-09-09T08:00:00.000Z","cwd":"/home/agent/code","originator":"codex_cli_rs","cli_version":"0.153.4","source":"cli"}} +{"timestamp":"2026-09-09T08:00:01.000Z","ordinal":1,"type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1","model_context_window":258400,"collaboration_mode_kind":"default"}} +{"timestamp":"2026-09-09T08:00:01.001Z","type":"response_item","payload":{"type":"message","role":"developer","content":[{"type":"input_text","text":"\nFilesystem sandboxing is disabled.\n"}]}} +{"timestamp":"2026-09-09T08:00:01.002Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"# AGENTS.md instructions\n\n\n# AGENTS.md\n\nDefault workflow for coding.\n"}]}} +{"timestamp":"2026-09-09T08:00:01.003Z","type":"turn_context","payload":{"turn_id":"turn-1","cwd":"/home/agent/code","approval_policy":"never","model":"gpt-5.5-codex","effort":"medium","summary":"auto"}} +{"timestamp":"2026-09-09T08:00:01.004Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Measure the desk and draw it."}]}} +{"timestamp":"2026-09-09T08:00:03.000Z","type":"response_item","payload":{"type":"reasoning","summary":[{"type":"summary_text","text":"**Inspecting the workspace**"}],"content":null,"encrypted_content":"gAAAA"}} +{"timestamp":"2026-09-09T08:00:03.500Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"I'll inspect the photo and the existing files first."}],"phase":"commentary"}} +{"timestamp":"2026-09-09T08:00:04.000Z","type":"response_item","payload":{"type":"custom_tool_call","status":"completed","call_id":"call_1","name":"exec_command","input":"ls"}} +{"timestamp":"2026-09-09T08:00:04.500Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_1","output":"Chunk ID: 123abc\nWall time: 0.1000 seconds\nProcess exited with code 0\nOutput:\nindex.html"}} +{"timestamp":"2026-09-09T08:00:05.000Z","type":"event_msg","payload":{"type":"token_count","info":null,"rate_limits":null}} +{"timestamp":"2026-09-09T08:00:06.000Z","type":"response_item","payload":{"type":"function_call","name":"shell","arguments":"{\"command\":[\"python3\",\"measure.py\"]}","call_id":"call_2"}} +{"timestamp":"2026-09-09T08:00:07.000Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call_2","output":"Exit code: 2\nWall time: 0.1 seconds\nOutput:\npython3: can't open file 'measure.py'"}} +{"timestamp":"2026-09-09T08:00:09.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Drawn to scale."}],"phase":"final_answer"}} +{"timestamp":"2026-09-09T08:00:09.500Z","type":"event_msg","payload":{"type":"token_count","info":null,"rate_limits":null}} +{"timestamp":"2026-09-09T08:00:10.000Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","last_agent_message":"Drawn to scale."}} +{"timestamp":"2026-09-09T08:05:00.000Z","ordinal":2,"type":"event_msg","payload":{"type":"task_started","turn_id":"turn-2","model_context_window":258400,"collaboration_mode_kind":"default"}} +{"timestamp":"2026-09-09T08:05:00.001Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"\n /home/agent/code\n"}]}} +{"timestamp":"2026-09-09T08:05:00.002Z","type":"turn_context","payload":{"turn_id":"turn-2","cwd":"/home/agent/code","approval_policy":"never","model":"gpt-5.5-codex","effort":"medium","summary":"auto"}} +{"timestamp":"2026-09-09T08:05:00.003Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Make the top bar slimmer."}]}} +{"timestamp":"2026-09-09T08:05:02.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Adjusting the bar height."}],"phase":"commentary"}} +{"timestamp":"2026-09-09T08:05:03.000Z","type":"response_item","payload":{"type":"custom_tool_call","status":"in_progress","call_id":"call_3","name":"exec_command","input":"sed -i s/40px/24px/ index.html"}} diff --git a/agentctl/tests/home.rs b/agentctl/tests/home.rs new file mode 100644 index 0000000..db5e130 --- /dev/null +++ b/agentctl/tests/home.rs @@ -0,0 +1,55 @@ +#![allow(clippy::expect_used)] + +mod support; + +use std::path::Path; + +use agent::local::home::ControlPlaneHome; + +use support::TempDirectory; + +#[test] +fn configured_home_is_absolute_and_uses_fixed_socket_path() { + let temporary = TempDirectory::new("home-path"); + let relative = temporary.path().file_name().expect("temporary name"); + let home = ControlPlaneHome::resolve(Some(Path::new(relative))).expect("home should resolve"); + + assert!(home.path().is_absolute()); + assert_eq!(home.socket_path().file_name(), Some("agentd.sock".as_ref())); +} + +#[test] +fn only_one_daemon_can_lock_a_home() { + let temporary = TempDirectory::new("home-lock"); + let home = ControlPlaneHome::resolve(Some(temporary.path())).expect("home should resolve"); + let first = home.acquire_lock().expect("first lock should succeed"); + + let second = home.acquire_lock().expect_err("second lock should fail"); + assert!(second.to_string().contains("already locked")); + + drop(first); + home.acquire_lock().expect("lock should be released when dropped"); +} + +#[cfg(unix)] +#[test] +fn home_and_lock_file_are_private() { + use std::os::unix::fs::PermissionsExt; + + let temporary = TempDirectory::new("home-mode"); + let home = ControlPlaneHome::resolve(Some(temporary.path())).expect("home should resolve"); + let _lock = home.acquire_lock().expect("lock should succeed"); + + let directory_mode = std::fs::metadata(home.path()) + .expect("home metadata") + .permissions() + .mode() + & 0o777; + let file_mode = std::fs::metadata(home.path().join("agentd.lock")) + .expect("lock metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(directory_mode, 0o700); + assert_eq!(file_mode, 0o600); +} diff --git a/agentctl/tests/manifest.rs b/agentctl/tests/manifest.rs new file mode 100644 index 0000000..ca41a09 --- /dev/null +++ b/agentctl/tests/manifest.rs @@ -0,0 +1,656 @@ +#![allow(clippy::expect_used)] + +mod support; + +use std::path::PathBuf; + +use agent::{API_VERSION, EnvironmentSpec, Harness, KIND, SecretSpec, manifest}; +use sandbox::RootFilesystemMode; + +#[test] +fn decodes_sandbox_mount_primitives() { + let bytes = br#" +apiVersion: agents.platform/v1alpha1 +kind: Agent +metadata: + name: worker +spec: + sandbox: + image: + type: reference + reference: example.invalid/agent:latest + platform: + os: linux + resources: + cpu: "2" + memory: "1Gi" + rootFilesystem: + capacity: "4Gi" + mode: layered + mounts: + - type: bind + source: ../.. + target: /home/agent/code/altinn-studio + readOnly: false + - type: tmpfs + target: /tmp + capacity: "1Gi" + home: + source: home + harnesses: + - type: claudeCode + version: "2.1.266" + auth: mediated + network: + mode: mediated + allow: all +"#; + + let agent = manifest::decode(bytes).expect("manifest with Sandbox Mounts should decode"); + let value = serde_json::to_value(agent).expect("Agent JSON"); + + assert_eq!(value["spec"]["sandbox"]["platform"]["os"], "linux"); + assert_eq!(value["spec"]["sandbox"]["mounts"][0]["source"], "../.."); + assert_eq!(value["spec"]["sandbox"]["mounts"][1]["capacity"], "1Gi"); +} + +#[test] +fn decodes_a_harness_without_a_declared_version() { + let bytes = br#" +apiVersion: agents.platform/v1alpha1 +kind: Agent +metadata: + name: worker +spec: + sandbox: + image: + type: reference + reference: example.invalid/agent:latest + platform: + os: linux + resources: + cpu: "2" + memory: "1Gi" + rootFilesystem: + capacity: "4Gi" + mode: layered + home: + source: home + harnesses: + - type: claudeCode + auth: mediated + network: + mode: mediated + allow: all +"#; + + let agent = manifest::decode(bytes).expect("manifest without a harness version should decode"); + assert_eq!(agent.spec.harnesses[0].version, None); + + let value = serde_json::to_value(agent).expect("Agent JSON"); + assert!(value["spec"]["harnesses"][0].get("version").is_none()); +} + +#[test] +fn decodes_the_minimal_manifest() { + let bytes = include_bytes!("../examples/minimal/agent.yaml"); + let agent = manifest::decode(bytes).expect("minimal manifest should decode"); + + assert_eq!(agent.api_version, API_VERSION); + assert_eq!(agent.kind, KIND); + assert_eq!(agent.metadata.name, "minimal"); + assert_eq!(agent.spec.sandbox.platform.os, "linux"); + assert_eq!(agent.spec.sandbox.platform.architecture, None); + assert_eq!(agent.spec.sandbox.retention_policy, None); + assert_eq!(agent.spec.harnesses.len(), 1); + assert!(!agent.spec.harnesses[0].default); + assert_eq!( + agent.spec.default_harness().map(|harness| harness.kind), + Some(Harness::ClaudeCode) + ); + assert_eq!( + agent.spec.sandbox.resources.root_filesystem().mode(), + RootFilesystemMode::Layered + ); +} + +#[test] +fn a_manifest_may_set_the_run_state_and_omits_it_by_default() { + let minimal = include_str!("../examples/minimal/agent.yaml"); + let agent = manifest::decode(minimal.as_bytes()).expect("minimal manifest should decode"); + assert_eq!(agent.spec.run_state, None); + assert!(!agent.spec.is_stopped()); + + let stopped = minimal.replacen("spec:\n", "spec:\n runState: Stopped\n", 1); + let agent = manifest::decode(stopped.as_bytes()).expect("a Stopped run state should decode"); + assert_eq!(agent.spec.run_state, Some(agent::RunState::Stopped)); + let encoded = serde_yaml_ng::to_string(&agent).expect("encode"); + assert!(encoded.contains("runState: Stopped"), "{encoded}"); + + let paused = minimal.replacen("spec:\n", "spec:\n runState: Paused\n", 1); + manifest::decode(paused.as_bytes()).expect_err("only Running and Stopped exist"); +} + +#[test] +fn decodes_explicit_non_secret_environment_with_an_optional_source() { + let mut agent = support::agent("worker"); + agent.spec.environment = vec![ + EnvironmentSpec { + name: "GIT_USER_NAME".into(), + source: Some("HOST_GIT_NAME".into()), + }, + EnvironmentSpec { + name: "GIT_USER_EMAIL".into(), + source: None, + }, + ]; + + let encoded = serde_yaml_ng::to_string(&agent).expect("encoded manifest"); + let decoded = manifest::decode(encoded.as_bytes()).expect("manifest environment"); + + assert_eq!(decoded.spec.environment[0].name, "GIT_USER_NAME"); + assert_eq!(decoded.spec.environment[0].source(), "HOST_GIT_NAME"); + assert_eq!(decoded.spec.environment[1].source(), "GIT_USER_EMAIL"); +} + +#[test] +fn optional_secret_round_trips_without_changing_the_required_default() { + let mut agent = support::agent("worker"); + agent.spec.secrets.push(SecretSpec { + environment: "OPTIONAL_TOKEN".into(), + optional: true, + placeholder: None, + allowed_hosts: vec!["example.com".into()], + source: None, + }); + + let encoded = serde_yaml_ng::to_string(&agent).expect("encoded manifest"); + let decoded = manifest::decode(encoded.as_bytes()).expect("manifest with optional secret"); + assert!(decoded.spec.secrets[0].optional); + assert!(encoded.contains("optional: true")); + + agent.spec.secrets[0].optional = false; + let required = serde_yaml_ng::to_string(&agent).expect("encoded required secret"); + assert!(!required.contains("optional:")); +} + +#[test] +fn rejects_invalid_duplicate_and_unpaired_environment_names() { + let mut invalid = support::agent("worker"); + invalid.spec.environment.push(EnvironmentSpec { + name: "NOT-PORTABLE".into(), + source: None, + }); + assert!(matches!( + invalid.validate(), + Err(agent::Error::Invalid(message)) if message.contains("spec.environment[0]") + )); + + let mut duplicate = support::agent("worker"); + duplicate.spec.environment = vec![ + EnvironmentSpec { + name: "EDITOR".into(), + source: None, + }, + EnvironmentSpec { + name: "EDITOR".into(), + source: Some("HOST_EDITOR".into()), + }, + ]; + assert!(matches!( + duplicate.validate(), + Err(agent::Error::Invalid(message)) if message.contains("spec.environment[1]") + )); + + let mut unpaired = support::agent("worker"); + unpaired.spec.environment.push(EnvironmentSpec { + name: "GIT_USER_NAME".into(), + source: None, + }); + assert!(matches!( + unpaired.validate(), + Err(agent::Error::Invalid(message)) if message.contains("GIT_USER_NAME and GIT_USER_EMAIL") + )); +} + +#[test] +fn rejects_environment_collisions_with_secrets_and_harness_owned_values() { + let mut secret_collision = support::agent("worker"); + secret_collision.spec.environment.push(EnvironmentSpec { + name: "PLAIN_VALUE".into(), + source: Some("SHARED_VALUE".into()), + }); + secret_collision.spec.secrets.push(SecretSpec { + environment: "API_TOKEN".into(), + optional: false, + placeholder: None, + allowed_hosts: vec!["example.com".into()], + source: Some("SHARED_VALUE".into()), + }); + assert!(matches!( + secret_collision.validate(), + Err(agent::Error::Invalid(message)) if message.contains("spec.secrets[0]") + )); + + let mut harness_collision = support::agent("worker"); + harness_collision.spec.environment.push(EnvironmentSpec { + name: "CLAUDE_CONFIG_DIR".into(), + source: None, + }); + assert!(matches!( + harness_collision.validate(), + Err(agent::Error::Invalid(message)) if message.contains("spec.environment[0]") + )); +} + +#[test] +fn rejects_removed_repository_bootstrap_configuration() { + let bytes = br" +apiVersion: agents.platform/v1alpha1 +kind: Agent +metadata: + name: worker +spec: + repositories: [] +"; + let error = manifest::decode(bytes).expect_err("repository bootstrap should not be part of the manifest"); + + assert!(error.to_string().contains("repositories")); +} + +#[test] +fn rejects_an_agent_name_that_cannot_identify_its_sandbox() { + let agent = support::agent("Worker_Name"); + let error = agent.validate().expect_err("non-portable name should be rejected"); + + assert!(matches!(error, agent::Error::Invalid(message) if message.starts_with("metadata.name:"))); +} + +#[test] +fn rejects_a_custom_placeholder_that_collides_with_a_generated_one() { + let mut agent = support::agent("worker"); + agent.spec.secrets = vec![ + SecretSpec { + environment: "FIRST_TOKEN".into(), + optional: false, + placeholder: None, + allowed_hosts: vec!["example.com".into()], + source: None, + }, + SecretSpec { + environment: "SECOND_TOKEN".into(), + optional: false, + placeholder: Some("$AGENT_SECRET_FIRST_TOKEN".into()), + allowed_hosts: vec!["example.com".into()], + source: None, + }, + ]; + + let error = agent + .validate() + .expect_err("effective placeholders must remain unambiguous"); + + assert!(matches!(error, agent::Error::Invalid(message) if message.contains("spec.secrets[1]"))); +} + +#[test] +fn decodes_an_optional_harness_installation_and_omits_the_flag_by_default() { + let bytes = br#" +apiVersion: agents.platform/v1alpha1 +kind: Agent +metadata: + name: worker +spec: + sandbox: + image: + type: reference + reference: ghcr.io/altinn/altinn-studio/agent-minimal:latest + platform: + os: linux + resources: + cpu: "2" + memory: "4Gi" + rootFilesystem: + capacity: "32Gi" + mode: layered + home: + source: home + harnesses: + - type: claudeCode + auth: mediated + default: true + - type: codex + auth: mediated + optional: true + network: + mode: mediated + allow: all +"#; + + let agent = manifest::decode(bytes).expect("manifest with an optional harness should decode"); + let claude = agent + .spec + .harness(Harness::ClaudeCode) + .expect("Claude Code installation"); + let codex = agent.spec.harness(Harness::Codex).expect("Codex installation"); + assert!(!claude.optional); + assert!(codex.optional); + + // The flag is absent from a required installation's serialized form, so manifests that never + // opt in are unchanged by this field existing. + let value = serde_json::to_value(&agent).expect("Agent JSON"); + assert!(value["spec"]["harnesses"][0].get("optional").is_none()); + assert_eq!(value["spec"]["harnesses"][1]["optional"], true); +} + +#[test] +fn validates_harness_installation_cardinality_and_defaults() { + let mut empty = support::agent("worker"); + empty.spec.harnesses.clear(); + assert!(matches!( + empty.validate(), + Err(agent::Error::Invalid(message)) if message.contains("spec.harnesses must not be empty") + )); + + let installation = support::agent("worker").spec.harnesses.remove(0); + let mut duplicate = support::agent("worker"); + let mut explicit_default = installation.clone(); + explicit_default.default = true; + duplicate.spec.harnesses = vec![explicit_default, installation.clone()]; + assert!(matches!( + duplicate.validate(), + Err(agent::Error::Invalid(message)) if message.contains("duplicate harness kind") + )); + + let mut codex = installation.clone(); + codex.kind = Harness::Codex; + codex.version = Some("0.149.1".into()); + + let mut no_default = support::agent("worker"); + no_default.spec.harnesses = vec![installation.clone(), codex.clone()]; + assert!(matches!( + no_default.validate(), + Err(agent::Error::Invalid(message)) if message.contains("exactly one default") + )); + + let mut multiple_defaults = support::agent("worker"); + let mut first = installation; + first.default = true; + let mut second = codex; + second.default = true; + multiple_defaults.spec.harnesses = vec![first, second]; + assert!(matches!( + multiple_defaults.validate(), + Err(agent::Error::Invalid(message)) if message.contains("exactly one default") + )); +} + +#[test] +fn rejects_manifest_secrets_owned_by_a_declared_harness() { + let mut agent = support::agent("worker"); + let mut codex = agent.spec.harnesses[0].clone(); + codex.kind = Harness::Codex; + codex.version = Some("0.149.1".into()); + codex.default = false; + agent.spec.harnesses[0].default = true; + agent.spec.harnesses.push(codex); + agent.spec.secrets.push(SecretSpec { + environment: "AGENT_CODEX_ACCESS_TOKEN".into(), + optional: false, + placeholder: None, + allowed_hosts: vec!["chatgpt.com".into()], + source: None, + }); + + assert!(matches!( + agent.validate(), + Err(agent::Error::Invalid(message)) if message.contains("spec.secrets[0]") + )); +} + +#[test] +fn status_tolerates_unknown_fields_inside_provenance_and_conditions() { + let status: agent::Status = serde_json::from_value(serde_json::json!({ + "observedGeneration": 1, + "futureField": true, + "provenance": { + "sourceDirectory": "/source", + "manifestPath": "/source/worker.yml", + "futureField": "ignored" + }, + "conditions": [{ "type": "Ready", "status": "True", "futureField": "ignored" }] + })) + .expect("newer status should decode"); + assert!(status.is_ready()); + let provenance = status.provenance.expect("provenance"); + assert_eq!(provenance.source_directory, std::path::Path::new("/source")); + assert_eq!( + provenance.manifest_path.as_deref(), + Some(std::path::Path::new("/source/worker.yml")) + ); +} + +#[test] +fn rejects_skills_without_a_directory_name_or_with_duplicate_names() { + let mut agent = support::agent("worker"); + agent.spec.skills = vec![agent::SkillSpec { + source: PathBuf::from("skills/.."), + name: None, + }]; + let error = agent.validate().expect_err("a source ending in .. has no skill name"); + assert!(matches!(error, agent::Error::Invalid(message) if message.starts_with("spec.skills[0]"))); + + agent.spec.skills = vec![ + agent::SkillSpec { + source: PathBuf::from("skills/evidence"), + name: None, + }, + agent::SkillSpec { + source: PathBuf::from("../shared/evidence/"), + name: None, + }, + ]; + let error = agent + .validate() + .expect_err("two skills with the same directory name collide"); + assert!( + matches!(error, agent::Error::Invalid(message) if message == "spec.skills[1] duplicates skill \"evidence\"") + ); + + agent.spec.skills.pop(); + agent.validate().expect("one named skill is valid"); + assert_eq!(agent.spec.skills[0].name(), Some("evidence")); + + agent.spec.skills[0].name = Some("installed-evidence".into()); + agent.validate().expect("an explicit skill name is valid"); + assert_eq!(agent.spec.skills[0].name(), Some("installed-evidence")); +} + +#[test] +fn harness_installations_declare_optional_model_and_effort_defaults() { + let bytes = br#" +apiVersion: agents.platform/v1alpha1 +kind: Agent +metadata: + name: worker +spec: + sandbox: + image: + type: build + context: . + dockerfile: Dockerfile + platform: + os: linux + resources: + cpu: "1" + memory: "1Gi" + rootFilesystem: + capacity: "8Gi" + mode: layered + home: + source: home + harnesses: + - type: claudeCode + auth: mediated + default: true + defaults: + model: fable + effort: xhigh + - type: codex + auth: mediated + defaults: + model: gpt-5.4-codex + network: + mode: mediated + allow: all +"#; + + let agent = manifest::decode(bytes).expect("manifest with harness defaults should decode"); + let claude = &agent.spec.harnesses[0].defaults; + assert_eq!(claude.model_str(), Some("fable")); + assert_eq!(claude.effort_str(), Some("xhigh")); + let codex = &agent.spec.harnesses[1].defaults; + assert_eq!(codex.model_str(), Some("gpt-5.4-codex")); + assert_eq!(codex.effort_str(), None); + + let value = serde_json::to_value(&agent).expect("Agent JSON"); + assert_eq!(value["spec"]["harnesses"][0]["defaults"]["model"], "fable"); + assert_eq!(value["spec"]["harnesses"][0]["defaults"]["effort"], "xhigh"); + assert!(value["spec"]["harnesses"][1]["defaults"].get("effort").is_none()); + let plain = manifest::decode(include_bytes!("../examples/minimal/agent.yaml")).expect("minimal manifest"); + let plain = serde_json::to_value(&plain).expect("Agent JSON"); + assert_eq!(plain["spec"]["harnesses"][0]["defaults"]["model"], "fable"); + assert!(plain["spec"]["harnesses"][0]["defaults"].get("effort").is_none()); + + for (field, valid, invalid) in [ + ("model", "fable", "\"\""), + ("effort", "xhigh", "\"\""), + ("model", "fable", "\"gpt 5\""), + ("effort", "xhigh", "\"hi'gh\""), + ] { + let yaml = String::from_utf8_lossy(bytes).replace( + &format!(" {field}: {valid}\n"), + &format!(" {field}: {invalid}\n"), + ); + let error = manifest::decode(yaml.as_bytes()).expect_err("invalid selections are rejected"); + assert!( + error + .to_string() + .contains(&format!("{field} must be 1-128 ASCII letters")), + "{field} = {invalid}: {error}" + ); + } +} + +const ACCESS_MANIFEST_HEAD: &str = r#" +apiVersion: agents.platform/v1alpha1 +kind: Agent +metadata: + name: worker +spec: + sandbox: + image: + type: reference + reference: example.invalid/agent:latest + platform: + os: linux + resources: + cpu: "2" + memory: "1Gi" + rootFilesystem: + capacity: "4Gi" + mode: layered + home: + source: home + harnesses: + - type: claudeCode + auth: mediated +"#; + +const ACCESS_MANIFEST_TAIL: &str = r" + network: + mode: mediated + allow: all +"; + +fn manifest_with_access(access: &str) -> Vec { + format!("{ACCESS_MANIFEST_HEAD}{access}{ACCESS_MANIFEST_TAIL}").into_bytes() +} + +#[test] +fn decodes_ssh_access_as_a_tagged_agent_capability() { + let agent = manifest::decode(&manifest_with_access(" access:\n - type: ssh\n")).expect("SSH access decodes"); + assert_eq!(agent.spec.access, vec![agent::AccessSpec::Ssh {}]); + assert!(agent.spec.ssh_access()); + let value = serde_json::to_value(&agent).expect("Agent JSON"); + assert_eq!(value["spec"]["access"], serde_json::json!([{"type": "ssh"}])); + + let without = manifest::decode(&manifest_with_access("")).expect("omitted access decodes"); + assert!(without.spec.access.is_empty()); + assert!(!without.spec.ssh_access()); + let value = serde_json::to_value(&without).expect("Agent JSON"); + assert!(value["spec"].get("access").is_none(), "an empty list is not serialized"); +} + +#[test] +fn decodes_vnc_access_beside_ssh_as_a_tagged_agent_capability() { + let agent = manifest::decode(&manifest_with_access(" access:\n - type: ssh\n - type: vnc\n")) + .expect("SSH and VNC access decode"); + assert_eq!( + agent.spec.access, + vec![agent::AccessSpec::Ssh {}, agent::AccessSpec::Vnc {}] + ); + assert!(agent.spec.ssh_access()); + assert!(agent.spec.vnc_access()); + let value = serde_json::to_value(&agent).expect("Agent JSON"); + assert_eq!( + value["spec"]["access"], + serde_json::json!([{"type": "ssh"}, {"type": "vnc"}]) + ); + + let ssh_only = manifest::decode(&manifest_with_access(" access:\n - type: ssh\n")).expect("SSH only"); + assert!(!ssh_only.spec.vnc_access(), "one capability does not imply the other"); +} + +#[test] +fn rejects_unknown_duplicate_and_configured_access_capabilities() { + assert!(matches!( + manifest::decode(&manifest_with_access(" access:\n - type: ssh\n - type: ssh\n")), + Err(agent::Error::Invalid(message)) if message.contains("spec.access[1]") + )); + assert!(matches!( + manifest::decode(&manifest_with_access(" access:\n - type: vnc\n - type: vnc\n")), + Err(agent::Error::Invalid(message)) if message.contains("spec.access[1]") + )); + assert!(matches!( + manifest::decode(&manifest_with_access(" access:\n - type: rdp\n")), + Err(agent::Error::Yaml(_)) + )); + assert!( + matches!( + manifest::decode(&manifest_with_access(" access:\n - type: vnc\n port: 5901\n")), + Err(agent::Error::Yaml(_)) + ), + "VNC access exposes no tunables" + ); + assert!( + matches!( + manifest::decode(&manifest_with_access(" access:\n - type: ssh\n port: 22\n")), + Err(agent::Error::Yaml(_)) + ), + "SSH access exposes no tunables" + ); + // `access` belongs to the Agent, not the Sandbox: nest it in the existing sandbox block. + let nested = format!("{ACCESS_MANIFEST_HEAD}{ACCESS_MANIFEST_TAIL}").replace( + " mode: layered\n", + " mode: layered\n access:\n - type: ssh\n", + ); + assert!( + nested.contains(" access:"), + "fixture places access under spec.sandbox" + ); + assert!(matches!( + manifest::decode(nested.as_bytes()), + Err(agent::Error::Yaml(_)) + )); +} diff --git a/agentctl/tests/microsandbox_attach.rs b/agentctl/tests/microsandbox_attach.rs new file mode 100644 index 0000000..ed0a889 --- /dev/null +++ b/agentctl/tests/microsandbox_attach.rs @@ -0,0 +1,18 @@ +#![allow(clippy::expect_used)] + +use sandbox_microsandbox::MicrosandboxProvider; +use tempfile::TempDir; + +#[tokio::test(flavor = "local")] +async fn daemon_and_direct_attach_clients_can_open_the_same_microsandbox_home() { + let directory = TempDir::new().expect("temporary directory"); + let daemon = MicrosandboxProvider::open(directory.path()) + .await + .expect("daemon provider"); + let attach = MicrosandboxProvider::open(directory.path()) + .await + .expect("direct attach provider"); + + drop(attach); + drop(daemon); +} diff --git a/agentctl/tests/platform_api.rs b/agentctl/tests/platform_api.rs new file mode 100644 index 0000000..39af048 --- /dev/null +++ b/agentctl/tests/platform_api.rs @@ -0,0 +1,373 @@ +#![allow(clippy::expect_used)] + +mod support; + +use std::{path::PathBuf, rc::Rc, time::Duration}; + +use agent::{ + AgentId, Condition, ConditionStatus, Status, + control_plane::{AgentRecord, AgentStore as _}, + persistence, + sandbox::{Assignment as SandboxAssignment, ProviderId}, + sessions::{LaunchRecord, SessionName, SessionStore as _}, +}; +use tempfile::TempDir; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + +fn ready_record(name: &str, id: AgentId) -> AgentRecord { + let mut resource = support::agent(name); + resource.metadata.generation = 1; + resource.status = Status::observed( + 1, + Some(SandboxAssignment::Materialized { + provider: ProviderId::new("memory").expect("Provider ID"), + id: "3f978c33-4d43-4ea4-b58d-10b90ef166af".parse().expect("Sandbox ID"), + harnesses: resource + .spec + .harnesses + .iter() + .map(|installation| installation.kind) + .collect(), + }), + vec![Condition { + kind: "Ready".into(), + status: ConditionStatus::True, + reason: "SandboxReady".into(), + message: String::new(), + last_transition_time: None, + }], + ); + AgentRecord { + id, + source_directory: PathBuf::from("/source"), + manifest_path: None, + env_file: None, + agent: resource, + } +} + +async fn request_to(port: u16, path: &str, token: &str, body: &str) -> u16 { + let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .expect("connect to Platform API endpoint"); + let request = format!( + "POST {path} HTTP/1.1\r\nhost: h\r\nauthorization: Bearer {token}\r\n\ + content-type: application/json\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ); + stream.write_all(request.as_bytes()).await.expect("send request"); + let mut response = String::new(); + stream.read_to_string(&mut response).await.expect("read response"); + response + .split(' ') + .nth(1) + .and_then(|status| status.parse().ok()) + .expect("response status") +} + +async fn request(port: u16, token: &str, body: &str) -> u16 { + request_to(port, "/v1/session/hooks", token, body).await +} + +#[tokio::test(flavor = "local")] +#[allow(clippy::too_many_lines)] +async fn session_reports_require_the_current_launch_token() { + const TOKEN_1: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const TOKEN_2: &str = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let agent_id = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + database.put(ready_record("worker", agent_id), 0).await.expect("Agent"); + let session = database + .ensure_session( + "worker", + &SessionName::new("s1").expect("name"), + agent::sessions::NewSession::for_harness(agent::Harness::ClaudeCode), + ) + .await + .expect("session"); + database + .record_session_launch( + session.id, + LaunchRecord { + token: TOKEN_1.parse().expect("launch token"), + sandbox: "sandbox-1".into(), + launched_at: 0, + attempts: 1, + }, + ) + .await + .expect("record launch"); + + let listener = agent::platform_api::bind_persistent(&directory.path().join("platform-api-port")) + .await + .expect("bind Platform API listener"); + let port = listener.local_addr().expect("local address").port(); + let reported_errors = Rc::new(std::cell::Cell::new(0)); + let error_count = reported_errors.clone(); + let server = Rc::new(agent::platform_api::Server::new( + Rc::new(database.clone()), + Rc::new(move |error| { + assert!( + error.to_string().contains("injected fold failure"), + "unexpected Platform API error: {error}" + ); + error_count.set(error_count.get() + 1); + }), + )); + let server_task = tokio::task::spawn_local(server.serve(listener)); + + let native = "0f0e0d0c-0b0a-4908-8706-050403020100"; + let transcript = "/home/agent/.claude/projects/-home-agent-code/0f0e0d0c-0b0a-4908-8706-050403020100.jsonl"; + let report = format!( + r#"{{"eventId":"00000000-0000-4000-8000-000000000001","sessionId":"{}","event":"sessionStart","nativeSessionId":"{native}","transcriptPath":"{transcript}","source":"startup"}}"#, + session.id + ); + + assert_eq!(request_to(port, "/v1/session-reports", TOKEN_1, &report).await, 404); + // A stale or foreign token authenticates as nothing. + assert_eq!(request(port, "unknown-token", &report).await, 401); + // A valid token for a different platform Session is rejected. + let mismatched = format!( + r#"{{"eventId":"00000000-0000-4000-8000-000000000001","sessionId":"{agent_id}","event":"sessionStart","nativeSessionId":"{native}"}}"# + ); + assert_eq!(request(port, TOKEN_1, &mismatched).await, 401); + // Harness-native IDs are opaque to the platform layer. + let opaque = format!( + r#"{{"eventId":"00000000-0000-4000-8000-000000000006","sessionId":"{}","event":"sessionStart","nativeSessionId":"opaque-harness-id"}}"#, + session.id + ); + assert_eq!(request(port, TOKEN_1, &opaque).await, 204); + let stored = database.get_session(session.id).await.expect("session"); + assert_eq!( + stored.status.reported.harness_session_id.as_deref(), + Some("opaque-harness-id") + ); + assert_eq!(stored.status.reported.harness_transcript_path, None); + // A transcript location must be an absolute Sandbox path. + let relative = format!( + r#"{{"eventId":"00000000-0000-4000-8000-000000000001","sessionId":"{}","event":"sessionStart","nativeSessionId":"{native}","transcriptPath":"relative.jsonl"}}"#, + session.id + ); + assert_eq!(request(port, TOKEN_1, &relative).await, 400); + + let empty = format!( + r#"{{"eventId":"00000000-0000-4000-8000-000000000001","sessionId":"{}","event":"sessionStart","nativeSessionId":""}}"#, + session.id + ); + assert_eq!(request(port, TOKEN_1, &empty).await, 400); + + assert_eq!(request(port, TOKEN_1, &report).await, 204); + let stored = database.get_session(session.id).await.expect("session"); + assert_eq!(stored.status.reported.harness_session_id.as_deref(), Some(native)); + assert_eq!( + stored.status.reported.harness_transcript_path.as_deref(), + Some(transcript) + ); + assert_eq!(stored.status.reported.activity.phase, agent::sessions::Phase::Working); + + // A replay of an older start must not overwrite the newer conversation. + assert_eq!(request(port, TOKEN_1, &opaque).await, 204); + assert_eq!( + database.get_session(session.id).await.expect("session").status.reported, + stored.status.reported + ); + + let inspect = rusqlite::Connection::open(directory.path().join("agent.db")).expect("inspect"); + let next_start = report + .replace("000000000001", "000000000007") + .replace(native, "next-conversation"); + inspect.execute_batch("CREATE TRIGGER reject_activity BEFORE UPDATE OF activity_json ON sessions BEGIN SELECT RAISE(ABORT, 'injected fold failure'); END;").expect("inject failure"); + assert_eq!(request(port, TOKEN_1, &next_start).await, 500); + assert_eq!(reported_errors.get(), 1); + assert_eq!( + database.get_session(session.id).await.expect("session").status.reported, + stored.status.reported + ); + inspect + .execute_batch("DROP TRIGGER reject_activity;") + .expect("remove failure"); + assert_eq!(request(port, TOKEN_1, &next_start).await, 204); + let applied = database.get_session(session.id).await.expect("session"); + assert_eq!( + applied.status.reported.harness_session_id.as_deref(), + Some("next-conversation") + ); + assert_eq!(request(port, TOKEN_1, &next_start).await, 204); + // Restore the conversation with a new event for the remaining assertions. + assert_eq!( + request(port, TOKEN_1, &report.replace("000000000001", "000000000008")).await, + 204 + ); + + // Lifecycle writes cannot touch the reported half. + database + .update_session_lifecycle(session.id, agent::sessions::Lifecycle::running(), 0) + .await + .expect("status update"); + let stored = database.get_session(session.id).await.expect("session"); + assert_eq!(stored.status.reported.harness_session_id.as_deref(), Some(native)); + assert_eq!(stored.status.lifecycle.state, agent::sessions::LifecycleState::Running); + + // Activity events fold into the durable report. + let event = |name: &str| { + let suffix = match name { + "turnStarted" => 2, + "waitingForInput" => 3, + "turnCompleted" => 4, + _ => 5, + }; + format!( + r#"{{"eventId":"00000000-0000-4000-8000-{suffix:012}","sessionId":"{}","event":"{name}"}}"#, + session.id + ) + }; + assert_eq!(request(port, TOKEN_1, &event("turnStarted")).await, 204); + assert_eq!(request(port, TOKEN_1, &event("waitingForInput")).await, 204); + assert_eq!(request(port, TOKEN_1, &event("turnCompleted")).await, 204); + // A successful completion whose HTTP response was lost must not count twice. + assert_eq!(request(port, TOKEN_1, &event("turnCompleted")).await, 204); + let stored = database.get_session(session.id).await.expect("session"); + assert_eq!(stored.status.reported.activity.turns, 1); + assert_eq!( + stored.status.reported.activity.phase, + agent::sessions::Phase::WaitingForInput + ); + assert!(stored.status.reported.activity.last_event_at.is_some()); + // An unknown event is a malformed report. + assert_eq!(request(port, TOKEN_1, &event("danced")).await, 400); + + // A relaunch rotates the token; the old incarnation's token stops working. + database + .record_session_launch( + session.id, + LaunchRecord { + token: TOKEN_2.parse().expect("launch token"), + sandbox: "sandbox-1".into(), + launched_at: 1, + attempts: 2, + }, + ) + .await + .expect("record relaunch"); + // The relaunch reset what the old launch reported, so the Session is + // Starting again until the new process reports; the old ID stays for resume. + let relaunched = database.get_session(session.id).await.expect("session"); + assert_eq!(relaunched.status.state, agent::sessions::State::Starting); + assert_eq!(relaunched.status.reported.activity.turns, 0); + assert_eq!(relaunched.status.reported.harness_session_id.as_deref(), Some(native)); + assert_eq!(request(port, TOKEN_1, &report).await, 401); + // A stale token's activity report is a silent no-op, not an error the hook retries. + assert_eq!(request(port, TOKEN_1, &event("turnCompleted")).await, 204); + assert_eq!( + database + .get_session(session.id) + .await + .expect("session") + .status + .reported + .activity + .turns, + 0 + ); + assert_eq!(request(port, TOKEN_2, &report).await, 204); + + server_task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn launch_bookkeeping_round_trips_and_resets() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let agent_id = "48f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + database.put(ready_record("worker", agent_id), 0).await.expect("Agent"); + let session = database + .ensure_session( + "worker", + &SessionName::new("s1").expect("name"), + agent::sessions::NewSession::for_harness(agent::Harness::ClaudeCode), + ) + .await + .expect("session"); + + assert_eq!( + database.session_launch_state(session.id).await.expect("empty state"), + None + ); + database + .record_session_launch( + session.id, + LaunchRecord { + token: "cccccccc-cccc-4ccc-8ccc-cccccccccccc".parse().expect("launch token"), + sandbox: "sandbox-1".into(), + launched_at: 42, + attempts: 3, + }, + ) + .await + .expect("record launch"); + let state = database + .session_launch_state(session.id) + .await + .expect("state") + .expect("recorded state"); + assert_eq!(state.sandbox, "sandbox-1"); + assert_eq!(state.launched_at, 42); + assert_eq!(state.attempts, 3); + + database + .reset_session_launch_attempts(session.id) + .await + .expect("reset attempts"); + let state = database + .session_launch_state(session.id) + .await + .expect("state") + .expect("recorded state"); + assert_eq!(state.attempts, 0); +} + +#[tokio::test(flavor = "local")] +async fn platform_api_bounds_stalled_connections() { + const TOKEN: &str = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"; + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let listener = agent::platform_api::bind_persistent(&directory.path().join("platform-api-port")) + .await + .expect("bind Platform API listener"); + let port = listener.local_addr().expect("local address").port(); + let server = Rc::new(agent::platform_api::Server::new( + Rc::new(database), + Rc::new(|_error| {}), + )); + let server_task = tokio::task::spawn_local(server.serve(listener)); + + let mut stalled = Vec::new(); + for _ in 0..64 { + let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .expect("connect stalled client"); + stream.write_all(b"P").await.expect("start incomplete request"); + stalled.push(stream); + tokio::task::yield_now().await; + } + tokio::time::sleep(Duration::from_millis(25)).await; + + let blocked = tokio::time::timeout( + Duration::from_millis(100), + request_to(port, "/v1/session/hooks", TOKEN, "{}"), + ) + .await; + assert!(blocked.is_err(), "a connection beyond the limit must wait for capacity"); + + drop(stalled.pop()); + let status = tokio::time::timeout( + Duration::from_secs(1), + request_to(port, "/v1/session/hooks", TOKEN, "{}"), + ) + .await + .expect("request should proceed after capacity is released"); + assert_eq!(status, 400); + + server_task.abort(); +} diff --git a/agentctl/tests/platform_linux.rs b/agentctl/tests/platform_linux.rs new file mode 100644 index 0000000..a7725b8 --- /dev/null +++ b/agentctl/tests/platform_linux.rs @@ -0,0 +1,951 @@ +#![allow(clippy::expect_used)] + +mod support; + +use std::{io::Cursor, path::PathBuf, rc::Rc}; + +use agent::{ + AgentId, + control_plane::AgentRecord, + sandbox::{PlatformAdapter as _, platform::Linux}, +}; +use sandbox::{ + EnsureSandboxRequest, Platform, SandboxPath, SandboxService, + execution::{ExecutionEvent, ExitStatus, Program}, + memory, +}; +use tempfile::TempDir; +use tokio::io::AsyncReadExt as _; + +/// Setup steps whose progress is discarded. +fn setup_phase() -> sandbox::SandboxProgress { + sandbox::ProgressReporter::from_callback(|_| {}).steps() +} + +fn is_claude_version(spec: &sandbox::execution::ExecutionSpec) -> bool { + matches!( + spec.program(), + Program::Command { executable, args } + if executable.as_str() == "/usr/bin/env" && args == &["claude", "--version"] + ) +} + +fn is_codex_version(spec: &sandbox::execution::ExecutionSpec) -> bool { + matches!( + spec.program(), + Program::Command { executable, args } + if executable.as_str() == "/usr/bin/env" && args == &["codex", "--version"] + ) +} + +fn is_podman_presence_check(spec: &sandbox::execution::ExecutionSpec) -> bool { + matches!( + spec.program(), + Program::Command { executable, args } + if executable.as_str() == "/usr/bin/test" && args == &["-x", "/usr/bin/podman"] + ) +} + +fn is_git_presence_check(spec: &sandbox::execution::ExecutionSpec) -> bool { + matches!( + spec.program(), + Program::Command { executable, args } + if executable.as_str() == "/usr/bin/env" && args == &["git", "--version"] + ) +} + +fn is_git_config(spec: &sandbox::execution::ExecutionSpec) -> bool { + matches!( + spec.program(), + Program::Command { executable, args } + if executable.as_str() == "/usr/bin/env" + && args.first().map(String::as_str) == Some("git") + && args.get(1).map(String::as_str) == Some("config") + && args.get(2).map(String::as_str) == Some("--global") + ) +} + +fn is_systemd_readiness_check(spec: &sandbox::execution::ExecutionSpec) -> bool { + matches!( + spec.program(), + Program::Command { executable, args } + if executable.as_str() == "/usr/bin/sudo" && args == &["-n", "/usr/bin/systemctl", "is-system-running", "--wait"] + ) +} + +fn completed(code: i32) -> Vec { + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Exited(ExitStatus { code }), + ] +} + +const PODMAN_CONTAINERS_CONF: &[u8] = br#"[containers] +env = [ + "SSL_CERT_FILE=/run/agent/tls/ca-bundle.pem", + "CURL_CA_BUNDLE=/run/agent/tls/ca-bundle.pem", + "REQUESTS_CA_BUNDLE=/run/agent/tls/ca-bundle.pem", + "NODE_EXTRA_CA_CERTS=/run/agent/tls/ca-bundle.pem", + "GIT_SSL_CAINFO=/run/agent/tls/ca-bundle.pem", + "NPM_CONFIG_CAFILE=/run/agent/tls/ca-bundle.pem", +] +"#; +const PODMAN_RUNTIME_CONF: &[u8] = b"[engine]\ncgroup_manager = \"cgroupfs\"\ncompat_api_enforce_docker_hub = true\nhooks_dir = [\"/etc/containers/oci/hooks.d\"]\n"; +const PODMAN_REGISTRIES_CONF: &[u8] = + b"unqualified-search-registries = [\"docker.io\"]\nshort-name-mode = \"enforcing\"\n"; +const PODMAN_MOUNTS_CONF: &[u8] = b"/etc/ssl/certs/ca-certificates.crt:/run/agent/tls/ca-bundle.pem\n"; +const PODMAN_SOCKET_DROP_IN: &[u8] = b"[Socket]\nDirectoryMode=0755\nSocketGroup=agent\nSocketMode=0660\n"; + +async fn read_file(sandbox: &sandbox::SandboxHandle, path: &str) -> Vec { + let mut bytes = Vec::new(); + sandbox + .read_file(&SandboxPath::new(path)) + .await + .expect("read file") + .read_to_end(&mut bytes) + .await + .expect("read file bytes"); + bytes +} + +fn assert_podman_setup_commands(executions: &[sandbox::execution::ExecutionSpec]) { + let count = |expected: &[&str]| { + executions + .iter() + .filter(|spec| match spec.program() { + Program::Command { executable, args } => { + executable.as_str() == "/usr/bin/sudo" + && args.iter().map(String::as_str).eq(expected.iter().copied()) + } + Program::ImageEntrypoint => false, + }) + .count() + }; + assert_eq!(count(&["-n", "/usr/bin/systemctl", "daemon-reload"]), 2); + // systemd readiness is confirmed before the first systemctl call of a setup pass. + let daemon_reload = executions + .iter() + .position(|spec| matches!(spec.program(), Program::Command { args, .. } if args.contains(&"daemon-reload".to_owned()))) + .expect("daemon-reload runs"); + assert!(executions.iter().take(daemon_reload).any(is_systemd_readiness_check)); + assert!( + executions + .iter() + .filter(|spec| is_systemd_readiness_check(spec)) + .count() + >= 2 + ); + assert_eq!( + count(&["-n", "/usr/bin/systemctl", "enable", "--now", "podman.socket"]), + 2 + ); + assert_eq!(count(&["-n", "/usr/bin/install", "-d", "-m", "0755", "/run/podman"]), 2); + assert_eq!( + count(&["-n", "/bin/chmod", "0755", "/usr/local/libexec/agent-container-ca"]), + 2 + ); + assert!(!executions.iter().any(|spec| { + match spec.program() { + Program::Command { args, .. } => args + .iter() + .any(|argument| matches!(argument.as_str(), "agent-containers" | "/dev/net/tun")), + Program::ImageEntrypoint => false, + } + })); +} + +/// Every harness the Agent declares, as preparation would report when all host logins are present. +fn declared(record: &AgentRecord) -> Vec { + record + .agent + .spec + .harnesses + .iter() + .map(|installation| installation.kind) + .collect() +} + +/// Setup acts on the installed set preparation reported, not on everything the Agent declares. +/// +/// The two run in the same convergence pass and must agree about an optional installation whose +/// host login was absent. Preparation decides and reports; setup is told. An omitted harness is +/// skipped entirely: not verified, not configured. +#[tokio::test(flavor = "local")] +async fn linux_setup_configures_only_the_harnesses_preparation_reported() { + let directory = TempDir::new().expect("temporary directory"); + let home = directory.path().join("home"); + std::fs::create_dir_all(&home).expect("home directory"); + std::fs::write(directory.path().join("instructions.md"), "test instructions\n").expect("instruction file"); + let agent_id: AgentId = "5c0fd6ac-1d5a-4f8b-9f58-6b0f4de1f6c1".parse().expect("Agent ID"); + let mut resource = support::agent("worker"); + resource.metadata.generation = 1; + resource.spec.home.source = home; + resource.spec.harnesses[0].default = true; + resource.spec.harnesses.push(agent::HarnessSpec { + kind: agent::Harness::Codex, + version: None, + auth: agent::HarnessAuthMode::Mediated, + optional: true, + default: false, + defaults: agent::ModelSelection::default(), + }); + let record = AgentRecord { + id: agent_id, + source_directory: directory.path().to_path_buf(), + manifest_path: None, + env_file: None, + agent: resource, + }; + + let backend = Rc::new(memory::Provider::new()); + backend.queue_execution_events_matching( + is_claude_version, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("2.1.266 (Claude Code)\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ], + ); + backend.queue_execution_events_matching(is_podman_presence_check, completed(1)); + let service = SandboxService::new(backend.clone()); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = service + .ensure(&EnsureSandboxRequest::new( + record.sandbox_name().expect("Sandbox name"), + spec, + )) + .await + .expect("Sandbox"); + + Linux + .setup(&record, &sandbox, &[agent::Harness::ClaudeCode], &setup_phase()) + .await + .expect("setup"); + + let writes = backend + .file_writes() + .into_iter() + .map(|path| path.as_str().to_owned()) + .collect::>(); + assert!( + writes.iter().any(|path| path == "/home/agent/.claude/CLAUDE.md"), + "the required harness is still configured: {writes:?}" + ); + assert!( + !writes.iter().any(|path| path.starts_with("/home/agent/.codex/")), + "the omitted harness must not be configured: {writes:?}" + ); + assert!( + !backend.execution_specs().iter().any(is_codex_version), + "the omitted harness must not be verified either" + ); +} + +#[tokio::test(flavor = "local")] +#[allow(clippy::too_many_lines)] +async fn linux_setup_rewrites_configuration_without_owning_workspace_initialization() { + let directory = TempDir::new().expect("temporary directory"); + let home = directory.path().join("home"); + std::fs::create_dir_all(&home).expect("home directory"); + std::fs::write(directory.path().join("instructions.md"), "test instructions\n").expect("instruction file"); + std::fs::write( + directory.path().join("environment.md"), + "# Environment\n\nhas a browser\n", + ) + .expect("environment file"); + let skill = directory.path().join("skills").join("evidence"); + std::fs::create_dir_all(skill.join("references")).expect("skill directory"); + std::fs::write(skill.join("SKILL.md"), "---\nname: evidence\n---\ncapture").expect("skill file"); + std::fs::write(skill.join("references").join("gif.md"), "palette").expect("skill reference"); + let agent_id: AgentId = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + let mut resource = support::agent("worker"); + resource.metadata.generation = 1; + resource.spec.home.source = home; + resource.spec.skills = vec![agent::SkillSpec { + source: PathBuf::from("skills/evidence"), + name: None, + }]; + resource.spec.instructions.push(agent::InstructionsSpec { + source: PathBuf::from("environment.md"), + }); + resource.spec.harnesses[0].default = true; + resource.spec.harnesses.push(agent::HarnessSpec { + kind: agent::Harness::Codex, + version: Some("0.149.1".into()), + auth: agent::HarnessAuthMode::Mediated, + optional: false, + default: false, + defaults: agent::ModelSelection::default(), + }); + let record = AgentRecord { + id: agent_id, + source_directory: directory.path().to_path_buf(), + manifest_path: None, + env_file: None, + agent: resource, + }; + + let backend = Rc::new(memory::Provider::new()); + for _ in 0..2 { + backend.queue_execution_events_matching( + is_claude_version, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("2.1.266 (Claude Code)\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ], + ); + backend.queue_execution_events_matching( + is_codex_version, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("codex-cli 0.149.1\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ], + ); + backend.queue_execution_events_matching(is_podman_presence_check, completed(1)); + } + let service = SandboxService::new(backend.clone()); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = service + .ensure( + &EnsureSandboxRequest::new(record.sandbox_name().expect("Sandbox name"), spec) + .with_environment([("AGENT_CODEX_ACCOUNT_ID".into(), "account-test".into())]), + ) + .await + .expect("Sandbox"); + let platform = Linux; + + platform + .setup(&record, &sandbox, &declared(&record), &setup_phase()) + .await + .expect("first setup"); + let first_pass_writes = backend.file_writes(); + let mutable_state = br#"{"theme":"light","projects":{"/home/agent/code/example":{"hasTrustDialogAccepted":true}}}"#; + sandbox + .write_file( + &SandboxPath::new("/home/agent/.claude/.claude.json"), + Box::pin(Cursor::new(mutable_state.to_vec())), + ) + .await + .expect("write harness-owned state"); + platform + .setup(&record, &sandbox, &declared(&record), &setup_phase()) + .await + .expect("second setup"); + + // Harnesses watch their configuration and skills live: a pass that changes nothing must not + // rewrite them. Only the home archive, consumed by tar and watched by nobody, is re-sent. + let second_pass_writes = backend + .file_writes() + .into_iter() + .skip(first_pass_writes.len() + 1) + .map(|path| path.as_str().to_owned()) + .collect::>(); + assert_eq!(second_pass_writes, ["/tmp/agent-home.tar"]); + assert!( + first_pass_writes + .iter() + .any(|path| path.as_str() == "/home/agent/.claude/skills/evidence/SKILL.md") + ); + + let preserved = read_file(&sandbox, "/home/agent/.claude/.claude.json").await; + assert_eq!(preserved, mutable_state); + let instructions = read_file(&sandbox, "/home/agent/.claude/CLAUDE.md").await; + assert_eq!(instructions, b"test instructions\n\n# Environment\n\nhas a browser\n"); + let codex_instructions = read_file(&sandbox, "/home/agent/.codex/AGENTS.md").await; + assert_eq!(codex_instructions, instructions); + for root in ["/home/agent/.claude/skills", "/home/agent/.agents/skills"] { + let skill = read_file(&sandbox, &format!("{root}/evidence/SKILL.md")).await; + assert_eq!(skill, b"---\nname: evidence\n---\ncapture"); + let reference = read_file(&sandbox, &format!("{root}/evidence/references/gif.md")).await; + assert_eq!(reference, b"palette"); + } + let codex_auth: serde_json::Value = + serde_json::from_slice(&read_file(&sandbox, "/home/agent/.codex/auth.json").await).expect("Codex auth JSON"); + assert_eq!(codex_auth["auth_mode"], "chatgpt"); + assert_eq!(codex_auth["tokens"]["account_id"], "account-test"); + assert_eq!(codex_auth["tokens"]["access_token"], codex_auth["tokens"]["id_token"]); + assert!(codex_auth["last_refresh"].is_string()); + let codex_hooks: serde_json::Value = + serde_json::from_slice(&read_file(&sandbox, "/home/agent/.codex/hooks.json").await).expect("Codex hooks JSON"); + assert_eq!( + codex_hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"], + "node /home/agent/.codex/hooks/activity-hook.mjs" + ); + assert!(codex_hooks["hooks"]["SessionStart"][0].get("matcher").is_none()); + for event in ["UserPromptSubmit", "Interrupt", "Stop", "PermissionRequest"] { + assert_eq!( + codex_hooks["hooks"][event][0]["hooks"][0]["command"], "node /home/agent/.codex/hooks/activity-hook.mjs", + "Codex registers {event}" + ); + } + assert!( + codex_hooks["hooks"].get("Notification").is_none(), + "Codex has no Notification hook" + ); + let hook_script = read_file(&sandbox, "/home/agent/.codex/hooks/activity-hook.mjs").await; + assert!( + String::from_utf8(hook_script) + .expect("UTF-8 hook") + .contains(r#""Stop":"turnCompleted""#) + ); + + let executions = backend.execution_specs(); + let commands = executions + .iter() + .filter_map(|spec| match spec.program() { + Program::Command { executable, args } => Some((executable.as_str(), args.as_slice())), + Program::ImageEntrypoint => None, + }) + .collect::>(); + assert_eq!( + commands + .iter() + .filter(|(executable, _)| *executable == "/usr/bin/env") + .count(), + 4 + ); + assert_eq!( + commands + .iter() + .filter(|(executable, _)| *executable == "/usr/bin/tar") + .count(), + 2 + ); + assert_eq!( + commands + .iter() + .filter(|(executable, args)| { + *executable == "/usr/bin/install" && args == &["-d", "-m", "0755", "/home/agent/code"] + }) + .count(), + 2 + ); + assert!(!commands.iter().any(|(executable, _)| *executable == "/usr/bin/git")); + assert!( + !commands.iter().any(|(executable, args)| { + *executable == "/usr/bin/sudo" && args.iter().any(|arg| arg == "podman.socket") + }) + ); + assert!(!commands.iter().any(|(executable, args)| { + *executable == "/usr/bin/touch" || (*executable == "/usr/bin/sudo" && args.iter().any(|arg| arg == "-R")) + })); +} + +#[tokio::test(flavor = "local")] +#[allow(clippy::too_many_lines)] +async fn linux_setup_convergently_configures_podman_container_trust() { + let directory = TempDir::new().expect("temporary directory"); + let home = directory.path().join("home"); + std::fs::create_dir_all(&home).expect("home directory"); + std::fs::write(directory.path().join("instructions.md"), "test instructions").expect("instruction file"); + let agent_id: AgentId = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + let mut resource = support::agent("worker"); + resource.metadata.generation = 1; + resource.spec.home.source = home; + let record = AgentRecord { + id: agent_id, + source_directory: directory.path().to_path_buf(), + manifest_path: None, + env_file: None, + agent: resource, + }; + let backend = Rc::new(memory::Provider::new()); + for _ in 0..2 { + backend.queue_execution_events_matching( + is_claude_version, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("2.1.266 (Claude Code)\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ], + ); + backend.queue_execution_events_matching(is_podman_presence_check, completed(0)); + } + let service = SandboxService::new(backend.clone()); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = service + .ensure(&EnsureSandboxRequest::new( + record.sandbox_name().expect("Sandbox name"), + spec, + )) + .await + .expect("Sandbox"); + + // The first setup pass races the image init: systemd is not PID 1 yet, then boots degraded. + backend.queue_execution_events_matching( + is_systemd_readiness_check, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stderr( + "System has not been booted with systemd as init system (PID 1). Can't operate.\n".into(), + ), + ExecutionEvent::Exited(ExitStatus { code: 1 }), + ], + ); + backend.queue_execution_events_matching( + is_systemd_readiness_check, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("degraded\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 1 }), + ], + ); + Linux + .setup(&record, &sandbox, &declared(&record), &setup_phase()) + .await + .expect("first setup"); + sandbox + .write_file( + &SandboxPath::new("/etc/containers/containers.conf.d/50-agent-ca.conf"), + Box::pin(Cursor::new(b"stale\n".to_vec())), + ) + .await + .expect("replace managed configuration"); + Linux + .setup(&record, &sandbox, &declared(&record), &setup_phase()) + .await + .expect("second setup"); + + assert_eq!( + read_file(&sandbox, "/etc/containers/containers.conf.d/50-agent-ca.conf").await, + PODMAN_CONTAINERS_CONF + ); + assert_eq!( + read_file(&sandbox, "/etc/containers/containers.conf.d/51-agent-runtime.conf").await, + PODMAN_RUNTIME_CONF + ); + assert_eq!( + read_file(&sandbox, "/etc/containers/mounts.conf").await, + PODMAN_MOUNTS_CONF + ); + assert_eq!( + read_file(&sandbox, "/etc/containers/registries.conf.d/50-agent-docker-hub.conf").await, + PODMAN_REGISTRIES_CONF + ); + assert_eq!( + read_file(&sandbox, "/etc/systemd/system/podman.socket.d/50-agent-access.conf").await, + PODMAN_SOCKET_DROP_IN + ); + let hook_configuration: serde_json::Value = + serde_json::from_slice(&read_file(&sandbox, "/etc/containers/oci/hooks.d/50-agent-ca.json").await) + .expect("OCI hook JSON"); + assert_eq!( + hook_configuration["hook"]["path"], + "/usr/local/libexec/agent-container-ca" + ); + assert_eq!(hook_configuration["stages"], serde_json::json!(["createRuntime"])); + let hook = + String::from_utf8(read_file(&sandbox, "/usr/local/libexec/agent-container-ca").await).expect("hook script"); + assert!(hook.starts_with("#!/bin/sh\n")); + // Distro trust paths are copied, never bind-mounted, so package managers can replace them. + assert!( + !PODMAN_MOUNTS_CONF + .windows(b"/etc/ssl/certs/ca-certificates.crt:/etc/".len()) + .any(|w| w == b"/etc/ssl/certs/ca-certificates.crt:/etc/") + ); + for path in [ + "etc/ssl/certs/ca-certificates.crt", + "etc/pki/tls/certs/ca-bundle.crt", + "etc/ssl/cert.pem", + "usr/local/share/ca-certificates/agent-mediator.crt", + "etc/pki/ca-trust/source/anchors/agent-mediator.crt", + ] { + assert!(hook.contains(path), "{path}"); + } + assert!(hook.contains("/.msb/tls/ca.pem")); + + assert_podman_setup_commands(&backend.execution_specs()); + // Two setup passes; the first retried once while systemd was not yet PID 1. + assert_eq!( + backend + .execution_specs() + .iter() + .filter(|spec| is_systemd_readiness_check(spec)) + .count(), + 3 + ); +} + +#[tokio::test(flavor = "local")] +async fn linux_setup_accepts_any_installed_version_when_none_is_declared() { + let directory = TempDir::new().expect("temporary directory"); + let home = directory.path().join("home"); + std::fs::create_dir_all(&home).expect("home directory"); + std::fs::write(directory.path().join("instructions.md"), "test instructions").expect("instruction file"); + let agent_id: AgentId = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + let mut resource = support::agent("worker"); + resource.metadata.generation = 1; + resource.spec.home.source = home; + resource.spec.harnesses[0].version = None; + let record = AgentRecord { + id: agent_id, + source_directory: PathBuf::from(directory.path()), + manifest_path: None, + env_file: None, + agent: resource, + }; + let backend = Rc::new(memory::Provider::new()); + backend.queue_execution_events_matching( + is_claude_version, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("2.1.258 (Claude Code)\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ], + ); + backend.queue_execution_events_matching(is_podman_presence_check, completed(1)); + let service = SandboxService::new(backend.clone()); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = service + .ensure(&EnsureSandboxRequest::new( + record.sandbox_name().expect("Sandbox name"), + spec, + )) + .await + .expect("Sandbox"); + + Linux + .setup(&record, &sandbox, &declared(&record), &setup_phase()) + .await + .expect("setup without a declared version"); + + assert_eq!( + backend + .execution_specs() + .iter() + .filter(|spec| is_claude_version(spec)) + .count(), + 1, + "the installation is still checked for presence" + ); +} + +#[tokio::test(flavor = "local")] +async fn linux_setup_converges_git_identity_after_home_sync() { + let directory = TempDir::new().expect("temporary directory"); + let home = directory.path().join("home"); + std::fs::create_dir_all(&home).expect("home directory"); + std::fs::write(directory.path().join("instructions.md"), "test instructions").expect("instruction file"); + let mut resource = support::agent("worker"); + resource.metadata.generation = 1; + resource.spec.home.source = home; + let record = AgentRecord { + id: "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"), + source_directory: directory.path().to_path_buf(), + manifest_path: None, + env_file: None, + agent: resource, + }; + let backend = Rc::new(memory::Provider::new()); + for _ in 0..2 { + backend.queue_execution_events_matching( + is_claude_version, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("2.1.266 (Claude Code)\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ], + ); + backend.queue_execution_events_matching(is_podman_presence_check, completed(1)); + backend.queue_execution_events_matching(is_git_presence_check, completed(0)); + } + let service = SandboxService::new(backend.clone()); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let request = |name: &str, email: &str| { + EnsureSandboxRequest::new(record.sandbox_name().expect("Sandbox name"), spec.clone()).with_environment([ + ("GIT_USER_NAME".into(), name.into()), + ("GIT_USER_EMAIL".into(), email.into()), + ]) + }; + let first = service + .ensure(&request("First User", "first@example.com")) + .await + .expect("first Sandbox"); + Linux + .setup(&record, &first, &declared(&record), &setup_phase()) + .await + .expect("first setup"); + let second = service + .ensure(&request("Second User", "second@example.com")) + .await + .expect("updated Sandbox"); + Linux + .setup(&record, &second, &declared(&record), &setup_phase()) + .await + .expect("updated setup"); + + let executions = backend.execution_specs(); + let git = executions.iter().filter(|spec| is_git_config(spec)).collect::>(); + assert_eq!(git.len(), 4); + let arguments = git + .iter() + .map(|spec| match spec.program() { + Program::Command { args, .. } => args.clone(), + Program::ImageEntrypoint => unreachable!(), + }) + .collect::>(); + assert_eq!( + arguments, + [ + ["git", "config", "--global", "user.name", "First User"], + ["git", "config", "--global", "user.email", "first@example.com"], + ["git", "config", "--global", "user.name", "Second User"], + ["git", "config", "--global", "user.email", "second@example.com"], + ] + .map(|args| args.map(str::to_owned).to_vec()) + ); + assert!( + git.iter() + .all(|spec| spec.environment().get("HOME").map(String::as_str) == Some("/home/agent")) + ); + let first_tar = executions + .iter() + .position(|spec| matches!(spec.program(), Program::Command { executable, .. } if executable.as_str() == "/usr/bin/tar")) + .expect("home synchronization"); + let first_git = executions.iter().position(is_git_config).expect("Git configuration"); + assert!( + first_tar < first_git, + "Git identity must be applied after the home overlay" + ); +} + +#[tokio::test(flavor = "local")] +async fn linux_setup_skips_git_identity_when_git_is_absent() { + let directory = TempDir::new().expect("temporary directory"); + let home = directory.path().join("home"); + std::fs::create_dir_all(&home).expect("home directory"); + std::fs::write(directory.path().join("instructions.md"), "test instructions").expect("instruction file"); + let mut resource = support::agent("worker"); + resource.metadata.generation = 1; + resource.spec.home.source = home; + let record = AgentRecord { + id: "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"), + source_directory: directory.path().to_path_buf(), + manifest_path: None, + env_file: None, + agent: resource, + }; + let backend = Rc::new(memory::Provider::new()); + backend.queue_execution_events_matching( + is_claude_version, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("2.1.266 (Claude Code)\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ], + ); + backend.queue_execution_events_matching(is_podman_presence_check, completed(1)); + backend.queue_execution_events_matching(is_git_presence_check, completed(127)); + let service = SandboxService::new(backend.clone()); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = service + .ensure( + &EnsureSandboxRequest::new(record.sandbox_name().expect("Sandbox name"), spec).with_environment([ + ("GIT_USER_NAME".into(), "Test User".into()), + ("GIT_USER_EMAIL".into(), "test@example.com".into()), + ]), + ) + .await + .expect("Sandbox"); + + Linux + .setup(&record, &sandbox, &declared(&record), &setup_phase()) + .await + .expect("setup without Git"); + + assert!(!backend.execution_specs().iter().any(is_git_config)); +} + +#[tokio::test(flavor = "local")] +async fn linux_setup_rejects_partial_git_identity() { + let directory = TempDir::new().expect("temporary directory"); + let home = directory.path().join("home"); + std::fs::create_dir_all(&home).expect("home directory"); + let mut resource = support::agent("worker"); + resource.metadata.generation = 1; + resource.spec.home.source = home; + resource.spec.instructions.clear(); + let record = AgentRecord { + id: "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"), + source_directory: directory.path().to_path_buf(), + manifest_path: None, + env_file: None, + agent: resource, + }; + let backend = Rc::new(memory::Provider::new()); + backend.queue_execution_events_matching( + is_claude_version, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("2.1.266 (Claude Code)\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ], + ); + backend.queue_execution_events_matching(is_podman_presence_check, completed(1)); + let service = SandboxService::new(backend.clone()); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = service + .ensure( + &EnsureSandboxRequest::new(record.sandbox_name().expect("Sandbox name"), spec) + .with_environment([("GIT_USER_NAME".into(), "Test User".into())]), + ) + .await + .expect("Sandbox"); + + let error = Linux + .setup(&record, &sandbox, &declared(&record), &setup_phase()) + .await + .expect_err("partial Git identity"); + + assert!(matches!(error, agent::Error::Invalid(message) if message.contains("must both be configured"))); + assert!(!backend.execution_specs().iter().any(is_git_presence_check)); + assert!(!backend.execution_specs().iter().any(is_git_config)); +} + +#[tokio::test(flavor = "local")] +async fn linux_setup_rejects_a_declared_harness_version_mismatch_before_injection() { + let directory = TempDir::new().expect("temporary directory"); + let home = directory.path().join("home"); + std::fs::create_dir_all(&home).expect("home directory"); + let agent_id: AgentId = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + let mut resource = support::agent("worker"); + resource.metadata.generation = 1; + resource.spec.home.source = home; + let record = AgentRecord { + id: agent_id, + source_directory: PathBuf::from(directory.path()), + manifest_path: None, + env_file: None, + agent: resource, + }; + let backend = Rc::new(memory::Provider::new()); + backend.queue_execution_events_matching( + is_claude_version, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("2.1.240 (Claude Code)\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ], + ); + let service = SandboxService::new(backend.clone()); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = service + .ensure(&EnsureSandboxRequest::new( + record.sandbox_name().expect("Sandbox name"), + spec, + )) + .await + .expect("Sandbox"); + + let error = Linux + .setup(&record, &sandbox, &declared(&record), &setup_phase()) + .await + .expect_err("version mismatch"); + + assert!(error.to_string().contains("does not match installed version")); + assert_eq!( + backend.execution_specs().len(), + 1, + "verification must happen before injection" + ); +} + +// The host is what holds the FIFO; Windows has no mkfifo, and the Linux Sandbox setup runs the same +// walker on every host, so one Unix host exercising it is enough. +#[cfg(unix)] +#[tokio::test(flavor = "local")] +async fn linux_setup_rejects_a_skill_tree_with_a_fifo_instead_of_blocking() { + let directory = TempDir::new().expect("temporary directory"); + let home = directory.path().join("home"); + std::fs::create_dir_all(&home).expect("home directory"); + let skill = directory.path().join("skills").join("evidence"); + std::fs::create_dir_all(&skill).expect("skill directory"); + std::fs::write(skill.join("SKILL.md"), "capture").expect("skill file"); + let status = std::process::Command::new("mkfifo") + .arg(skill.join("pipe")) + .status() + .expect("mkfifo runs"); + assert!(status.success()); + let mut resource = support::agent("worker"); + resource.metadata.generation = 1; + resource.spec.home.source = home; + resource.spec.instructions.clear(); + resource.spec.skills = vec![agent::SkillSpec { + source: PathBuf::from("skills/evidence"), + name: None, + }]; + let record = AgentRecord { + id: "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"), + source_directory: directory.path().to_path_buf(), + manifest_path: None, + env_file: None, + agent: resource, + }; + let backend = Rc::new(memory::Provider::new()); + backend.queue_execution_events_matching( + is_claude_version, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("2.1.266 (Claude Code)\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ], + ); + backend.queue_execution_events_matching(is_podman_presence_check, completed(1)); + let service = SandboxService::new(backend); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = service + .ensure(&EnsureSandboxRequest::new( + record.sandbox_name().expect("Sandbox name"), + spec, + )) + .await + .expect("Sandbox"); + + let error = Linux + .setup(&record, &sandbox, &declared(&record), &setup_phase()) + .await + .expect_err("FIFO must be rejected"); + + assert!( + matches!(&error, agent::Error::Invalid(message) if message.contains("non-regular file pipe")), + "unexpected error: {error:?}" + ); +} diff --git a/agentctl/tests/policy.rs b/agentctl/tests/policy.rs new file mode 100644 index 0000000..538bf18 --- /dev/null +++ b/agentctl/tests/policy.rs @@ -0,0 +1,260 @@ +#![allow(clippy::expect_used)] + +mod support; + +use agent::{SecretSpec, authorization::AgentPolicyEngine}; +use sandbox::SandboxName; +use sandbox_authorization::{ + Action, AuthorizationContext, AuthorizationDecision, AuthorizationRequest, PolicyEngine as _, Principal, Resource, + vocabulary::{action, context, principal_kind, resource_kind}, +}; + +#[tokio::test(flavor = "local")] +async fn allows_general_egress_but_scopes_each_secret_to_its_hosts() { + let mut agent = support::agent("worker"); + agent.spec.network.deny.push("blocked.example".into()); + agent.spec.secrets.push(SecretSpec { + environment: "GITHUB_TOKEN".into(), + optional: false, + placeholder: None, + allowed_hosts: vec!["github.com".into()], + source: Some("GH_PAT".into()), + }); + let policy = AgentPolicyEngine::new(); + let sandbox = SandboxName::new("agent-test-id").expect("Sandbox name"); + policy.set_agent(&sandbox, &agent, []); + + assert_eq!( + evaluate( + &policy, + sandbox.as_str(), + action::HTTP_REQUEST, + "externalService", + "github.com", + "github.com" + ) + .await, + AuthorizationDecision::Allow + ); + assert_eq!( + evaluate( + &policy, + sandbox.as_str(), + action::HTTP_REQUEST, + "externalService", + "blocked.example", + "blocked.example", + ) + .await, + AuthorizationDecision::Deny + ); + assert_eq!( + evaluate( + &policy, + sandbox.as_str(), + action::SECRET_USE, + resource_kind::SECRET, + "GITHUB_TOKEN", + "github.com", + ) + .await, + AuthorizationDecision::Allow + ); + assert_eq!( + evaluate( + &policy, + sandbox.as_str(), + action::SECRET_USE, + resource_kind::SECRET, + "GITHUB_TOKEN", + "example.com", + ) + .await, + AuthorizationDecision::Deny + ); +} + +#[tokio::test(flavor = "local")] +async fn host_destined_traffic_reaches_only_the_platform_api() { + let agent = support::agent("worker"); + let policy = AgentPolicyEngine::new(); + let sandbox = SandboxName::new("agent-test-id").expect("Sandbox name"); + policy.set_agent(&sandbox, &agent, []); + + // Fail closed: without a registered Platform API endpoint every host-destined + // connect is denied, even under the allow-all egress default. + assert_eq!( + connect( + &policy, + sandbox.as_str(), + Some("host.microsandbox.internal"), + "100.64.0.2:9999" + ) + .await, + AuthorizationDecision::Deny + ); + + policy.set_platform_endpoint("host.microsandbox.internal", 4_100); + + assert_eq!( + connect( + &policy, + sandbox.as_str(), + Some("host.microsandbox.internal"), + "100.64.0.2:4100" + ) + .await, + AuthorizationDecision::Allow + ); + assert_eq!( + connect( + &policy, + sandbox.as_str(), + Some("host.microsandbox.internal"), + "100.64.0.2:8080" + ) + .await, + AuthorizationDecision::Deny + ); + // A raw dial into a host-reserved range carries no hostname and is denied + // even on the Platform API port: only the alias identifies the endpoint. + assert_eq!( + connect(&policy, sandbox.as_str(), None, "100.64.0.2:4100").await, + AuthorizationDecision::Deny + ); + assert_eq!( + connect(&policy, sandbox.as_str(), None, "127.0.0.1:80").await, + AuthorizationDecision::Deny + ); + assert_eq!( + connect(&policy, sandbox.as_str(), None, "169.254.169.254:80").await, + AuthorizationDecision::Deny + ); + assert_eq!( + connect(&policy, sandbox.as_str(), None, "[fd00::2]:443").await, + AuthorizationDecision::Deny + ); + // Ordinary public egress is unaffected. + assert_eq!( + connect(&policy, sandbox.as_str(), Some("github.com"), "140.82.121.4:443").await, + AuthorizationDecision::Allow + ); + // Resolving the alias stays possible; it only reveals the gateway. + assert_eq!( + evaluate( + &policy, + sandbox.as_str(), + action::DNS_QUERY, + resource_kind::DOMAIN, + "host.microsandbox.internal", + "host.microsandbox.internal", + ) + .await, + AuthorizationDecision::Allow + ); +} + +#[tokio::test(flavor = "local")] +async fn host_destined_flag_denies_the_gateway_by_number_outside_guessed_ranges() { + let agent = support::agent("worker"); + let policy = AgentPolicyEngine::new(); + let sandbox = SandboxName::new("agent-test-id").expect("Sandbox name"); + policy.set_agent(&sandbox, &agent, []); + policy.set_platform_endpoint("host.microsandbox.internal", 4_100); + + // The default guest pool 172.16/12 is not a guessed reserved range, so + // before the Backend flag an HTTP dial to the gateway with a plausible but + // spoofed authority looked like ordinary egress and reached host loopback. + assert_eq!( + connect_flagged(&policy, sandbox.as_str(), Some("github.com"), "172.16.0.5:4100", false).await, + AuthorizationDecision::Allow + ); + // Flagged host-destined by the trusted Backend, the same dial is denied: + // the spoofed authority is not the alias, so it cannot reach host loopback. + assert_eq!( + connect_flagged(&policy, sandbox.as_str(), Some("github.com"), "172.16.0.5:4100", true).await, + AuthorizationDecision::Deny + ); + // The genuine Platform API — alias on its registered port — still succeeds. + assert_eq!( + connect_flagged( + &policy, + sandbox.as_str(), + Some("host.microsandbox.internal"), + "172.16.0.5:4100", + true + ) + .await, + AuthorizationDecision::Allow + ); + // Flagged host-destined on another port is denied even via the alias. + assert_eq!( + connect_flagged( + &policy, + sandbox.as_str(), + Some("host.microsandbox.internal"), + "172.16.0.5:8080", + true + ) + .await, + AuthorizationDecision::Deny + ); +} + +async fn connect( + policy: &AgentPolicyEngine, + agent: &str, + hostname: Option<&str>, + destination: &str, +) -> AuthorizationDecision { + connect_flagged(policy, agent, hostname, destination, false).await +} + +async fn connect_flagged( + policy: &AgentPolicyEngine, + agent: &str, + hostname: Option<&str>, + destination: &str, + destination_is_host: bool, +) -> AuthorizationDecision { + let mut authorization_context = AuthorizationContext::new() + .with_attribute(context::SANDBOX_NAME, agent) + .with_attribute(context::NETWORK_DESTINATION_ADDRESS, destination) + .with_attribute(context::NETWORK_DESTINATION_IS_HOST, destination_is_host); + if let Some(hostname) = hostname { + authorization_context.insert(context::NETWORK_HOSTNAME, hostname); + } + policy + .evaluate(AuthorizationRequest { + principal: Principal::new(principal_kind::SANDBOX, "sandbox-id"), + action: Action::new(action::NETWORK_CONNECT), + resource: Resource::new( + resource_kind::EXTERNAL_SERVICE, + hostname.map_or_else(|| destination.into(), str::to_owned), + ), + context: authorization_context, + }) + .await + .expect("policy decision") +} + +async fn evaluate( + policy: &AgentPolicyEngine, + agent: &str, + operation: &str, + resource_kind: &str, + resource: &str, + host: &str, +) -> AuthorizationDecision { + policy + .evaluate(AuthorizationRequest { + principal: Principal::new(principal_kind::SANDBOX, "sandbox-id"), + action: Action::new(operation), + resource: Resource::new(resource_kind, resource), + context: AuthorizationContext::new() + .with_attribute(context::SANDBOX_NAME, agent) + .with_attribute(context::HTTP_AUTHORITY, host), + }) + .await + .expect("policy decision") +} diff --git a/agentctl/tests/session_controller.rs b/agentctl/tests/session_controller.rs new file mode 100644 index 0000000..f964358 --- /dev/null +++ b/agentctl/tests/session_controller.rs @@ -0,0 +1,3437 @@ +#![allow(clippy::expect_used, clippy::panic)] + +mod support; + +use std::{ + cell::{Cell, RefCell}, + path::PathBuf, + rc::Rc, + time::Duration, +}; + +use agent::{ + AgentId, Condition, ConditionStatus, Error, Status, + control_plane::{AgentRecord, AgentStore as _, Convergence, WaitPolicy}, + local::home::ControlPlaneHome, + persistence, + resources::Changes, + sandbox::{Assignment as SandboxAssignment, PlatformAdapter, Provider, ProviderEnsureOutcome, ProviderId}, + sessions::{NewSession, Reconcile, SessionId, SessionName, SessionReports as _, SessionRequest, SessionStore as _}, +}; +use sandbox::{ + EnsureSandboxRequest, LocalFuture, Platform, SandboxHandle, SandboxService, + execution::{ExecutionEvent, ExitStatus, Program}, + memory as sandbox_memory, + network::{NetworkEndpointSelection, PacketMedium}, +}; +use tempfile::TempDir; +use tokio::sync::Notify; + +struct BlockingReconcile { + slow: SessionId, + slow_calls: Rc>, + active_slow: Rc>, + started: Rc, + release: Rc, +} + +fn is_session_observation(spec: &sandbox::execution::ExecutionSpec) -> bool { + matches!( + spec.program(), + Program::Command { executable, args } + if executable.as_str() == "/bin/sh" + && args.iter().any(|argument| argument.contains("session_activity")) + ) +} + +struct BlockingAgentReady { + database: persistence::Database, + started: Rc, + release: Rc, +} + +impl Reconcile for BlockingAgentReady { + fn reconcile(&self, id: AgentId) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.started.notify_one(); + self.release.notified().await; + let record = self.database.get(id).await?; + self.database + .update_status( + id, + record.agent.metadata.generation, + Status::observed( + record.agent.metadata.generation, + Some(SandboxAssignment::Materialized { + provider: ProviderId::new("memory")?, + id: "3f978c33-4d43-4ea4-b58d-10b90ef166af" + .parse() + .map_err(|error| Error::Database(format!("test Sandbox ID: {error}")))?, + harnesses: record.agent.spec.harnesses.iter().map(|i| i.kind).collect(), + }), + vec![Condition { + kind: "Ready".into(), + status: ConditionStatus::True, + reason: "SandboxReady".into(), + message: String::new(), + last_transition_time: None, + }], + ), + ) + .await + .map(drop) + }) + } +} + +struct MarkSessionReady(persistence::Database); + +struct NoopAgentReconcile; + +impl Reconcile for NoopAgentReconcile { + fn reconcile(&self, _id: AgentId) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async { Ok(()) }) + } +} + +impl Reconcile for MarkSessionReady { + fn reconcile(&self, id: SessionId) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.0 + .update_session_lifecycle(id, agent::sessions::Lifecycle::running(), 0) + .await + }) + } +} + +struct NoopPlatform; + +impl PlatformAdapter for NoopPlatform { + fn supports(&self, platform: &Platform) -> bool { + platform.os == "linux" + } + + fn setup<'a>( + &'a self, + _record: &'a AgentRecord, + _sandbox: &'a SandboxHandle, + _harnesses: &'a [agent::Harness], + _steps: &'a sandbox::SandboxProgress, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async { Ok(()) }) + } +} + +struct CountingProvider { + id: ProviderId, + service: SandboxService, + ensure_calls: Rc>, +} + +impl Provider for CountingProvider { + fn id(&self) -> &ProviderId { + &self.id + } + + fn supports<'a>(&'a self, _record: &'a AgentRecord) -> LocalFuture<'a, Result> { + Box::pin(async { Ok(true) }) + } + + fn ensure<'a>( + &'a self, + record: &'a AgentRecord, + environment: std::collections::BTreeMap, + _progress: sandbox::ProgressReporter, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + self.ensure_calls.set(self.ensure_calls.get() + 1); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = self + .service + .ensure(&EnsureSandboxRequest::new(record.sandbox_name()?, spec).with_environment(environment)) + .await + .map_err(Error::from)?; + Ok(ProviderEnsureOutcome { + sandbox, + runtime_restarted: false, + harnesses: record + .agent + .spec + .harnesses + .iter() + .map(|installation| installation.kind) + .collect(), + }) + }) + } + + fn open<'a>( + &'a self, + record: &'a AgentRecord, + id: &'a sandbox::SandboxId, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + self.service + .open(id, record.agent.spec.sandbox.resolved_retention_policy()) + .await + .map_err(Error::from) + }) + } + + fn stop<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { self.service.stop(&record.sandbox_name()?).await.map_err(Error::from) }) + } + + fn release<'a>(&'a self, record: &'a AgentRecord) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + self.service + .release( + &record.sandbox_name()?, + record.agent.spec.sandbox.resolved_retention_policy(), + ) + .await + .map_err(Error::from) + }) + } +} + +impl Reconcile for BlockingReconcile { + fn reconcile(&self, id: SessionId) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + if id == self.slow { + assert_eq!(self.active_slow.replace(self.active_slow.get() + 1), 0); + let call = self.slow_calls.get() + 1; + self.slow_calls.set(call); + if call == 1 { + self.started.notify_one(); + self.release.notified().await; + } + self.active_slow.set(0); + } + Ok(()) + }) + } +} + +fn ready_record(name: &str, id: AgentId) -> AgentRecord { + let mut resource = support::agent(name); + resource.metadata.generation = 1; + resource.status = Status::observed( + 1, + Some(SandboxAssignment::Materialized { + provider: ProviderId::new("memory").expect("Provider ID"), + id: "3f978c33-4d43-4ea4-b58d-10b90ef166af".parse().expect("Sandbox ID"), + harnesses: resource.spec.harnesses.iter().map(|i| i.kind).collect(), + }), + vec![Condition { + kind: "Ready".into(), + status: ConditionStatus::True, + reason: "SandboxReady".into(), + message: String::new(), + last_transition_time: None, + }], + ); + AgentRecord { + id, + source_directory: PathBuf::from("/source"), + manifest_path: None, + env_file: None, + agent: resource, + } +} + +/// A throwaway Sandbox service and tmux runtime for Session Service tests that +/// never reach the runtime (they resolve with `WaitPolicy::FirstPass`). +fn unused_sandboxes() -> Rc { + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider: Rc = Rc::new(CountingProvider { + id: ProviderId::new("memory").expect("Provider ID"), + service: SandboxService::new(backend), + ensure_calls: Rc::new(Cell::new(0)), + }); + Rc::new( + agent::sandbox::Service::new([provider], [Rc::new(NoopPlatform) as Rc]) + .expect("Agent Sandbox service"), + ) +} + +fn tmux_runtime() -> Rc { + Rc::new(agent::sessions::Tmux) +} + +/// A scripted Session runtime: records deliveries and launches, serves a +/// conversation the test appends to. +struct FakeRuntime { + /// Whether the harness process is present; a launch is expected when it is not. + present: Cell, + fail_observe_once: Cell, + attached: Cell, + stop_calls: Cell, + fail_stop_once: Cell, + stops_failing: Cell, + /// Seconds since the harness's last terminal or transcript activity. + idle_seconds: Cell, + fail_start: Cell, + delivery_delay: Cell, + fail_transcript: Cell, + delivered: Notify, + hold_completion: Cell, + release_completion: Notify, + ready_without_report: Cell, + fail_input_ready_once: Cell, + launch_started: Notify, + /// Holds the next observation until `release_observe`, after `observe_started`. + hold_observe: Cell, + observe_started: Notify, + release_observe: Notify, + conversation: RefCell>, + sent: RefCell>, + launches: RefCell, Option)>>, + launch_tokens: RefCell>, +} + +impl Default for FakeRuntime { + fn default() -> Self { + Self { + present: Cell::new(true), + fail_observe_once: Cell::new(false), + attached: Cell::new(false), + stop_calls: Cell::new(0), + fail_stop_once: Cell::new(false), + stops_failing: Cell::new(false), + idle_seconds: Cell::new(0), + fail_start: Cell::new(false), + delivery_delay: Cell::new(Duration::ZERO), + fail_transcript: Cell::new(false), + delivered: Notify::new(), + hold_completion: Cell::new(false), + release_completion: Notify::new(), + ready_without_report: Cell::new(false), + fail_input_ready_once: Cell::new(false), + launch_started: Notify::new(), + hold_observe: Cell::new(false), + observe_started: Notify::new(), + release_observe: Notify::new(), + conversation: RefCell::default(), + sent: RefCell::default(), + launches: RefCell::default(), + launch_tokens: RefCell::default(), + } + } +} + +fn user_turn(text: &str) -> agent::sessions::Turn { + agent::sessions::Turn { + messages: vec![agent::sessions::Message { + role: agent::sessions::Role::User, + parts: vec![agent::sessions::Part::Text { text: text.into() }], + }], + } +} + +fn assistant_text(text: &str) -> agent::sessions::Message { + agent::sessions::Message { + role: agent::sessions::Role::Assistant, + parts: vec![agent::sessions::Part::Text { text: text.into() }], + } +} + +impl agent::sessions::SessionRuntime for FakeRuntime { + fn observe<'a>( + &'a self, + _session: &'a agent::sessions::Session, + _sandbox: &'a SandboxHandle, + ) -> LocalFuture<'a, Result> { + if self.fail_observe_once.replace(false) { + return Box::pin(async { Err(Error::Session("injected observation failure".into())) }); + } + let hold = self.hold_observe.replace(false); + Box::pin(async move { + if hold { + self.observe_started.notify_one(); + self.release_observe.notified().await; + } + Ok(if self.present.get() { + agent::sessions::Observation::Alive { + attached: self.attached.get(), + idle_seconds: self.idle_seconds.get(), + } + } else { + agent::sessions::Observation::Missing + }) + }) + } + + fn start<'a>( + &'a self, + _session: &'a agent::sessions::Session, + _sandbox: &'a SandboxHandle, + _session_hook_url: &'a str, + token: &'a agent::sessions::LaunchToken, + resume: Option<&'a str>, + initial_prompt: Option<&'a str>, + ) -> LocalFuture<'a, Result<(), Error>> { + self.launches + .borrow_mut() + .push((resume.map(str::to_owned), initial_prompt.map(str::to_owned))); + self.launch_tokens.borrow_mut().push(token.clone()); + self.launch_started.notify_one(); + let fail = self.fail_start.get(); + self.present.set(!fail); + Box::pin(async move { + if fail { + Err(Error::Session("injected uncertain launch".into())) + } else { + Ok(()) + } + }) + } + + fn stop<'a>( + &'a self, + _session: &'a agent::sessions::Session, + _sandbox: &'a SandboxHandle, + ) -> LocalFuture<'a, Result<(), Error>> { + self.stop_calls.set(self.stop_calls.get() + 1); + if self.fail_stop_once.replace(false) || self.stops_failing.get() { + return Box::pin(async { Err(Error::Session("injected stop failure".into())) }); + } + self.present.set(false); + Box::pin(async { Ok(()) }) + } + + fn input_ready<'a>( + &'a self, + _session: &'a agent::sessions::Session, + _sandbox: &'a SandboxHandle, + ) -> LocalFuture<'a, Result> { + if self.fail_input_ready_once.replace(false) { + return Box::pin(async { Err(Error::Session("injected readiness failure".into())) }); + } + Box::pin(async { Ok(self.ready_without_report.get()) }) + } + + fn prompt<'a>( + &'a self, + _session: &'a agent::sessions::Session, + _sandbox: &'a SandboxHandle, + prompt: &'a str, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + tokio::time::sleep(self.delivery_delay.get()).await; + self.sent.borrow_mut().push(prompt.to_owned()); + self.conversation.borrow_mut().push(user_turn(prompt)); + self.delivered.notify_one(); + if self.hold_completion.get() { + self.release_completion.notified().await; + } + Ok(()) + }) + } + + fn turns<'a>( + &'a self, + _session: &'a agent::sessions::Session, + _sandbox: &'a SandboxHandle, + last: Option, + ) -> LocalFuture<'a, Result, Error>> { + let mut turns = self.conversation.borrow().clone(); + if let Some(last) = last + && turns.len() > last + { + turns.drain(0..turns.len() - last); + } + Box::pin(async move { + if self.fail_transcript.get() { + return Err(Error::Session("transcript unavailable".into())); + } + Ok(turns) + }) + } + + fn attach<'a>( + &'a self, + _home: &'a std::path::Path, + _target: &'a agent::sessions::AttachTarget, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async { Err(Error::Invalid("no terminal in tests".into())) }) + } +} + +/// A database, a materialized memory Sandbox for Agent `worker`, and one +/// Running Session `s1` launched with `token`; with `started`, its harness has +/// already reported its start (native ID and conversation location). +/// The Agent's materialized assignment, observing exactly these harnesses. +fn observing(record: &AgentRecord, harnesses: &[agent::Harness]) -> SandboxAssignment { + let Some(SandboxAssignment::Materialized { provider, id, .. }) = &record.agent.status.sandbox else { + panic!("the fixture Agent has a materialized Sandbox"); + }; + SandboxAssignment::Materialized { + provider: provider.clone(), + id: id.clone(), + harnesses: harnesses.to_vec(), + } +} + +/// Observes every declared harness, as a convergence with all host logins present would. +fn observe_all_harnesses(record: &mut AgentRecord) { + let declared = record + .agent + .spec + .harnesses + .iter() + .map(|installation| installation.kind) + .collect(); + if let Some(SandboxAssignment::Materialized { harnesses, .. }) = &mut record.agent.status.sandbox { + *harnesses = declared; + } +} + +async fn running_session( + directory: &TempDir, + token: &str, + started: bool, +) -> ( + persistence::Database, + Rc, + agent::sessions::Session, +) { + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let agent_id = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider_service = + SandboxService::new(backend).with_network_backend(Rc::new(sandbox_memory::NetworkBackend::for_endpoint( + "memory", + NetworkEndpointSelection::Packet(PacketMedium::Ethernet), + ))); + let mut record = ready_record("worker", agent_id); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = provider_service + .ensure(&EnsureSandboxRequest::new( + record.sandbox_name().expect("Sandbox name"), + spec, + )) + .await + .expect("materialized Sandbox"); + record.agent.status.sandbox = Some(SandboxAssignment::Materialized { + provider: ProviderId::new("memory").expect("Provider ID"), + id: sandbox.id().clone(), + harnesses: Vec::new(), + }); + observe_all_harnesses(&mut record); + database.put(record, 0).await.expect("Agent"); + let provider: Rc = Rc::new(CountingProvider { + id: ProviderId::new("memory").expect("Provider ID"), + service: provider_service, + ensure_calls: Rc::new(Cell::new(0)), + }); + let sandboxes = Rc::new( + agent::sandbox::Service::new([provider], [Rc::new(NoopPlatform) as Rc]) + .expect("Agent Sandbox service"), + ); + let session = database + .ensure_session( + "worker", + &SessionName::new("s1").expect("name"), + NewSession::for_harness(agent::Harness::ClaudeCode), + ) + .await + .expect("Session"); + let activation = database.activate_session(session.id).await.expect("activate"); + database + .update_session_lifecycle(session.id, agent::sessions::Lifecycle::running(), activation) + .await + .expect("running"); + database + .record_session_launch( + session.id, + agent::sessions::LaunchRecord { + token: token.parse().expect("launch token"), + sandbox: sandbox.id().to_string(), + launched_at: time::OffsetDateTime::now_utc().unix_timestamp(), + attempts: 1, + }, + ) + .await + .expect("launch bookkeeping"); + if started { + let token: agent::sessions::LaunchToken = token.parse().expect("launch token"); + database + .record_session_start_for_launch( + session.id, + &token, + uuid::Uuid::new_v4(), + "native-0", + Some("/home/agent/conversation.jsonl"), + time::OffsetDateTime::now_utc() - time::Duration::seconds(5), + ) + .await + .expect("start report"); + } + let session = database.get_session(session.id).await.expect("Session"); + (database, sandboxes, session) +} + +async fn resume_fixture( + directory: &TempDir, + token: &str, +) -> ( + persistence::Database, + Rc, + Rc, + agent::sessions::Session, +) { + let (database, sandboxes, session) = running_session(directory, token, true).await; + database + .reset_session_launch_attempts(session.id) + .await + .expect("reset backoff"); + let runtime = Rc::new(FakeRuntime::default()); + runtime.present.set(false); + let reconciler = Rc::new(agent::sessions::Reconciler::new( + Rc::new(database.clone()), + Rc::new(agent::sessions::AgentSandboxes::new( + Rc::new(database.clone()), + sandboxes, + )), + runtime.clone(), + "http://platform-api".into(), + )); + (database, runtime, reconciler, session) +} + +async fn interrupt_resume_after_start( + database: &persistence::Database, + runtime: &FakeRuntime, + reconciler: &Rc, + session: &agent::sessions::Session, +) { + let reconciling = { + let reconciler = reconciler.clone(); + let id = session.id; + tokio::task::spawn_local(async move { reconciler.reconcile(id).await }) + }; + runtime.launch_started.notified().await; + let token = runtime.launch_tokens.borrow().last().expect("launch token").clone(); + database + .record_session_start_for_launch( + session.id, + &token, + uuid::Uuid::new_v4(), + "native-0", + Some("/home/agent/conversation.jsonl"), + time::OffsetDateTime::now_utc() - time::Duration::seconds(5), + ) + .await + .expect("resumed start report"); + reconciling.abort(); + reconciling.await.expect_err("reconciliation interrupted"); +} + +#[tokio::test(flavor = "local")] +#[allow(clippy::too_many_lines)] +async fn prompt_waits_for_completion_and_turns_are_read_separately() { + const TOKEN: &str = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + let session_store: Rc = Rc::new(database.clone()); + let agent_store: Rc = Rc::new(database.clone()); + let runtime = Rc::new(FakeRuntime::default()); + // An earlier exchange the hook counter never saw (it was folded before a + // relaunch); it remains available through the separate turns operation. + let mut earlier = user_turn("earlier prompt"); + earlier.messages.push(assistant_text("earlier answer")); + runtime.conversation.borrow_mut().push(earlier); + let (agent_controller, agent_wakeup) = agent::control_plane::Controller::new( + agent_store.clone(), + Rc::new(NoopAgentReconcile), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Agent reconciliation error: {error}")), + ); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + Rc::new(MarkSessionReady(database.clone())), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Session reconciliation error: {error}")), + ); + let agent_task = tokio::task::spawn_local(agent_controller.run()); + let session_task = tokio::task::spawn_local(session_controller.run()); + let service = Rc::new(agent::sessions::Service::new( + session_store.clone(), + Rc::new(agent::sessions::AgentSandboxes::new(agent_store.clone(), sandboxes)), + runtime.clone(), + Convergence::new(agent_wakeup, agent_store, Changes::new()), + session_wakeup, + )); + let name = SessionName::new("s1").expect("name"); + + let sending_service = service.clone(); + let sending_name = name.clone(); + let delivered = runtime.delivered.notified(); + let send = tokio::task::spawn_local(async move { + sending_service + .prompt( + "worker", + &sending_name, + "do the thing", + true, + Some(Duration::from_secs(10)), + ) + .await + }); + tokio::time::timeout(Duration::from_secs(2), delivered) + .await + .expect("prompt delivery"); + assert_eq!(runtime.sent.borrow().as_slice(), ["do the thing"]); + assert!( + !send.is_finished(), + "the wait blocks until the harness reports the turn complete" + ); + + // The harness works, then reports the turn complete through the hook; the + // Platform API folds it durably for the service to poll. + let token: agent::sessions::LaunchToken = TOKEN.parse().expect("token"); + database + .apply_session_activity_for_launch( + session.id, + &token, + uuid::Uuid::new_v4(), + agent::sessions::ActivityEvent::TurnStarted, + time::OffsetDateTime::now_utc(), + ) + .await + .expect("fold"); + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(!send.is_finished(), "working is not done"); + runtime + .conversation + .borrow_mut() + .last_mut() + .expect("the sent turn") + .messages + .push(assistant_text("did the thing")); + database + .apply_session_activity_for_launch( + session.id, + &token, + uuid::Uuid::new_v4(), + agent::sessions::ActivityEvent::TurnCompleted, + time::OffsetDateTime::now_utc(), + ) + .await + .expect("fold"); + + send.await.expect("send task").expect("turn completed"); + + // Without wait the delivery returns immediately and reads nothing. + service + .prompt("worker", &name, "and another", false, None) + .await + .expect("send"); + assert_eq!(runtime.sent.borrow().len(), 2); + + // `turns` reads the whole conversation; `last` trims it. + let all = service.turns("worker", &name, None).await.expect("turns"); + assert_eq!(all.len(), 3); + let last = service.turns("worker", &name, Some(1)).await.expect("turns"); + assert_eq!(last.len(), 1); + agent_task.abort(); + session_task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn prompt_wait_reports_a_failed_session_instead_of_hanging() { + const TOKEN: &str = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + let session_store: Rc = Rc::new(database.clone()); + let agent_store: Rc = Rc::new(database.clone()); + let (agent_controller, agent_wakeup) = agent::control_plane::Controller::new( + agent_store.clone(), + Rc::new(NoopAgentReconcile), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Agent reconciliation error: {error}")), + ); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + Rc::new(MarkSessionReady(database.clone())), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Session reconciliation error: {error}")), + ); + let agent_task = tokio::task::spawn_local(agent_controller.run()); + let session_task = tokio::task::spawn_local(session_controller.run()); + let service = Rc::new(agent::sessions::Service::new( + session_store.clone(), + Rc::new(agent::sessions::AgentSandboxes::new(agent_store.clone(), sandboxes)), + Rc::new(FakeRuntime::default()), + Convergence::new(agent_wakeup, agent_store, Changes::new()), + session_wakeup, + )); + let name = SessionName::new("s1").expect("name"); + + let sending_service = service.clone(); + let sending_name = name.clone(); + let send = tokio::task::spawn_local(async move { + sending_service + .prompt( + "worker", + &sending_name, + "do the thing", + true, + Some(Duration::from_mins(1)), + ) + .await + }); + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(!send.is_finished()); + // No activity report arrives; the lifecycle write itself wakes the wait. + session_store + .update_session_lifecycle(session.id, agent::sessions::Lifecycle::failed("harness exited"), 1) + .await + .expect("failed"); + let error = send + .await + .expect("send task") + .expect_err("a failed Session ends the wait"); + assert!(error.to_string().contains("harness exited"), "{error}"); + agent_task.abort(); + session_task.abort(); +} + +#[tokio::test(flavor = "local")] +#[allow(clippy::too_many_lines)] +async fn prompt_wait_handles_mid_turn_input_after_a_late_start_report() { + const TOKEN: &str = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, false).await; + let session_store: Rc = Rc::new(database.clone()); + let agent_store: Rc = Rc::new(database.clone()); + let runtime = Rc::new(FakeRuntime::default()); + let (agent_controller, agent_wakeup) = agent::control_plane::Controller::new( + agent_store.clone(), + Rc::new(NoopAgentReconcile), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Agent reconciliation error: {error}")), + ); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + Rc::new(MarkSessionReady(database.clone())), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Session reconciliation error: {error}")), + ); + let agent_task = tokio::task::spawn_local(agent_controller.run()); + let session_task = tokio::task::spawn_local(session_controller.run()); + let service = Rc::new(agent::sessions::Service::new( + session_store.clone(), + Rc::new(agent::sessions::AgentSandboxes::new(agent_store.clone(), sandboxes)), + runtime.clone(), + Convergence::new(agent_wakeup, agent_store, Changes::new()), + session_wakeup, + )); + let name = SessionName::new("s1").expect("name"); + let token: agent::sessions::LaunchToken = TOKEN.parse().expect("token"); + + // The Session has launched but its harness has not reported its start: + // nothing is delivered until it does. + let sending_service = service.clone(); + let sending_name = name.clone(); + let send = tokio::task::spawn_local(async move { + sending_service + .prompt( + "worker", + &sending_name, + "steer left", + true, + Some(Duration::from_secs(10)), + ) + .await + }); + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(runtime.sent.borrow().is_empty(), "no delivery before the start report"); + + // The harness starts on its first prompt and is mid-turn when it reports. + runtime.conversation.borrow_mut().push(user_turn("first prompt")); + database + .record_session_start_for_launch( + session.id, + &token, + uuid::Uuid::new_v4(), + "native-1", + Some("/home/agent/t.jsonl"), + time::OffsetDateTime::now_utc(), + ) + .await + .expect("start report"); + // The event is stamped in the past so the input-readiness grace is over. + database + .apply_session_activity_for_launch( + session.id, + &token, + uuid::Uuid::new_v4(), + agent::sessions::ActivityEvent::TurnStarted, + time::OffsetDateTime::now_utc() - time::Duration::seconds(5), + ) + .await + .expect("fold"); + tokio::time::timeout(Duration::from_secs(1), runtime.delivered.notified()) + .await + .expect("delivery after the readiness poll"); + assert_eq!( + runtime.sent.borrow().as_slice(), + ["steer left"], + "delivered once the start is reported" + ); + // The fake appended the steering input as a new turn; a real harness folds + // it into the running turn, so model that: merge it back. + let steer = runtime.conversation.borrow_mut().pop().expect("steer turn"); + runtime + .conversation + .borrow_mut() + .last_mut() + .expect("running turn") + .messages + .extend(steer.messages); + runtime + .conversation + .borrow_mut() + .last_mut() + .expect("running turn") + .messages + .push(assistant_text("went left")); + database + .apply_session_activity_for_launch( + session.id, + &token, + uuid::Uuid::new_v4(), + agent::sessions::ActivityEvent::TurnCompleted, + time::OffsetDateTime::now_utc(), + ) + .await + .expect("fold"); + + send.await.expect("send task").expect("turn completed"); + agent_task.abort(); + session_task.abort(); +} + +/// A Session service over a started `s1` with a fake runtime, with a database +/// the test writes harness reports through. +struct ServiceHarness { + database: persistence::Database, + runtime: Rc, + service: Rc, + session: agent::sessions::Session, + token: agent::sessions::LaunchToken, + tasks: Vec>, +} + +impl ServiceHarness { + async fn start(directory: &TempDir, token: &str) -> Self { + Self::start_with_report(directory, token, true).await + } + + async fn start_with_report(directory: &TempDir, token: &str, started: bool) -> Self { + let (database, sandboxes, session) = running_session(directory, token, started).await; + let session_store: Rc = Rc::new(database.clone()); + let agent_store: Rc = Rc::new(database.clone()); + let runtime = Rc::new(FakeRuntime::default()); + let (agent_controller, agent_wakeup) = agent::control_plane::Controller::new( + agent_store.clone(), + Rc::new(NoopAgentReconcile), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Agent reconciliation error: {error}")), + ); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + Rc::new(MarkSessionReady(database.clone())), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Session reconciliation error: {error}")), + ); + let tasks = vec![ + tokio::task::spawn_local(agent_controller.run()), + tokio::task::spawn_local(session_controller.run()), + ]; + // The controller's startup pass writes a lifecycle. Finish it here so + // it cannot land after a lifecycle the test writes itself. + session_wakeup + .reconcile(session.id) + .await + .expect("startup Session reconciliation"); + let service = Rc::new(agent::sessions::Service::new( + session_store, + Rc::new(agent::sessions::AgentSandboxes::new(agent_store.clone(), sandboxes)), + runtime.clone(), + Convergence::new(agent_wakeup, agent_store, Changes::new()), + session_wakeup, + )); + Self { + database, + runtime, + service, + session, + token: token.parse().expect("token"), + tasks, + } + } + + /// Reports one activity event for the current launch, as the Platform API would. + async fn report(&self, event: agent::sessions::ActivityEvent) { + self.database + .apply_session_activity_for_launch( + self.session.id, + &self.token, + uuid::Uuid::new_v4(), + event, + time::OffsetDateTime::now_utc(), + ) + .await + .expect("fold") + .expect("current launch"); + } + + fn append_to_last_turn(&self, message: agent::sessions::Message) { + self.runtime + .conversation + .borrow_mut() + .last_mut() + .expect("a turn") + .messages + .push(message); + } + + fn prompt(&self, text: &'static str) -> tokio::task::JoinHandle> { + let service = self.service.clone(); + tokio::task::spawn_local(async move { + service + .prompt( + "worker", + &SessionName::new("s1").expect("name"), + text, + true, + Some(Duration::from_secs(10)), + ) + .await + }) + } + + async fn await_delivery(&self, answer: &mut tokio::task::JoinHandle>) { + tokio::time::timeout(Duration::from_secs(5), async { + tokio::select! { + () = self.runtime.delivered.notified() => {}, + result = answer => panic!("prompt finished before delivery acknowledgement: {result:?}"), + } + }) + .await + .expect("prompt delivery acknowledgement"); + } + + fn finish(self) { + for task in self.tasks { + task.abort(); + } + drop(self.database); + } +} + +#[tokio::test(flavor = "local")] +async fn upgrade_preflight_reports_work_and_terminal_attachments() { + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start(&directory, "10101010-1010-4010-8010-101010101010").await; + assert_eq!( + harness + .service + .upgrade_readiness() + .await + .expect("working blockers") + .blockers, + ["session/worker/s1 (working)"] + ); + + harness + .database + .set_session_archived("worker", &harness.session.name, true) + .await + .expect("archive"); + assert_eq!( + harness + .service + .upgrade_readiness() + .await + .expect("archiving blockers") + .blockers, + ["session/worker/s1 (working)"], + "an archive waiting for the turn does not let an upgrade cut it short" + ); + harness + .database + .set_session_archived("worker", &harness.session.name, false) + .await + .expect("unarchive"); + + harness.report(agent::sessions::ActivityEvent::TurnCompleted).await; + assert!( + harness + .service + .upgrade_readiness() + .await + .expect("quiescent") + .blockers + .is_empty() + ); + harness.runtime.attached.set(true); + assert_eq!( + harness + .service + .upgrade_readiness() + .await + .expect("attachment blockers") + .blockers, + ["session/worker/s1 (terminal attached)"] + ); + harness.finish(); + + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start_with_report(&directory, "11111111-1111-4111-8111-111111111111", false).await; + assert_eq!( + harness + .service + .upgrade_readiness() + .await + .expect("missing native ID") + .warnings, + ["session/worker/s1 will start a new conversation"] + ); + harness.finish(); +} + +#[tokio::test(flavor = "local")] +async fn daemon_owned_relaunch_marker_is_retryable_and_removed_after_success() { + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start(&directory, "20202020-2020-4020-8020-202020202020").await; + harness.report(agent::sessions::ActivityEvent::TurnCompleted).await; + let home = ControlPlaneHome::resolve(Some(directory.path())).expect("home"); + let matched_generation = harness + .database + .activate_session(harness.session.id) + .await + .expect("matched activation"); + harness + .database + .update_session_lifecycle( + harness.session.id, + agent::sessions::Lifecycle::idle(), + matched_generation, + ) + .await + .expect("Idle Session"); + let marker = home.pending_session_relaunch_path(); + std::fs::write(&marker, br#"{"buildVersion":"another-build"}"#).expect("write mismatched marker"); + let mismatch = agent::upgrade::consume_pending_session_relaunch(&home, &harness.service) + .await + .expect_err("wrong daemon build"); + assert!(mismatch.to_string().contains("another-build")); + assert!(marker.exists(), "a mismatched daemon retains the marker"); + std::fs::write( + &marker, + serde_json::to_vec(&serde_json::json!({"buildVersion": agent::build_version()})).expect("marker"), + ) + .expect("write marker"); + + harness.runtime.attached.set(true); + agent::upgrade::consume_pending_session_relaunch(&home, &harness.service) + .await + .expect_err("attachment prevents relaunch"); + assert!(marker.exists(), "failed pass retains its marker"); + + harness.runtime.attached.set(false); + agent::upgrade::consume_pending_session_relaunch(&home, &harness.service) + .await + .expect("relaunch"); + assert!(!marker.exists(), "successful pass removes its marker"); + assert!(!harness.runtime.present.get()); + assert_eq!(harness.runtime.stop_calls.get(), 1); + let reactivated = harness + .database + .get_session(harness.session.id) + .await + .expect("reactivated Session"); + assert_eq!( + reactivated.status.lifecycle.state, + agent::sessions::LifecycleState::Idle + ); + assert_eq!( + harness + .database + .activate_session(harness.session.id) + .await + .expect("activation after marker"), + matched_generation + 2, + "the marker pass must request one new activation" + ); + assert_eq!( + harness + .database + .session_launch_state(harness.session.id) + .await + .expect("launch state") + .expect("launch") + .attempts, + 0 + ); + + harness + .service + .relaunch_after_upgrade() + .await + .expect("idempotent retry"); + assert_eq!(harness.runtime.stop_calls.get(), 1); + harness.finish(); +} + +#[tokio::test(flavor = "local")] +async fn upgrade_reactivates_an_idle_session_whose_runtime_is_already_missing() { + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start(&directory, "30303030-3030-4030-8030-303030303030").await; + harness.report(agent::sessions::ActivityEvent::TurnCompleted).await; + let matched_generation = harness + .database + .activate_session(harness.session.id) + .await + .expect("matched activation"); + harness + .database + .update_session_lifecycle( + harness.session.id, + agent::sessions::Lifecycle::idle(), + matched_generation, + ) + .await + .expect("Idle Session"); + harness.runtime.present.set(false); + + harness + .service + .relaunch_after_upgrade() + .await + .expect("request relaunch"); + + assert_eq!( + harness + .database + .activate_session(harness.session.id) + .await + .expect("activation after upgrade"), + matched_generation + 2, + "the upgrade must request an activation before reconciliation" + ); + assert_eq!(harness.runtime.stop_calls.get(), 0); + harness.finish(); +} + +#[tokio::test(flavor = "local")] +async fn prompt_waits_for_one_more_completion_without_reading_the_transcript() { + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start(&directory, "11111111-1111-4111-8111-111111111111").await; + harness.runtime.fail_transcript.set(true); + // A previous completion does not satisfy this invocation. Neither does a + // permission wait in the current turn. + harness.report(agent::sessions::ActivityEvent::TurnCompleted).await; + let mut waiting = harness.prompt("continue"); + harness.await_delivery(&mut waiting).await; + for event in [ + agent::sessions::ActivityEvent::TurnStarted, + agent::sessions::ActivityEvent::WaitingForInput, + ] { + harness.report(event).await; + } + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut waiting) + .await + .is_err() + ); + harness.report(agent::sessions::ActivityEvent::TurnCompleted).await; + tokio::time::timeout(Duration::from_secs(2), waiting) + .await + .expect("completion reports") + .expect("task") + .expect("prompt"); + harness.finish(); +} + +#[tokio::test(flavor = "local")] +async fn prompt_waits_for_a_turn_that_started_before_delivery_finished() { + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start(&directory, "22222222-2222-4222-8222-222222222222").await; + harness.runtime.fail_transcript.set(true); + harness.runtime.hold_completion.set(true); + harness.report(agent::sessions::ActivityEvent::TurnStarted).await; + let mut waiting = harness.prompt("queued input"); + harness.await_delivery(&mut waiting).await; + // The current turn finishes and queued input starts before delivery returns. + harness.report(agent::sessions::ActivityEvent::TurnCompleted).await; + harness.report(agent::sessions::ActivityEvent::TurnStarted).await; + assert!(!waiting.is_finished(), "delivery must finish first"); + harness.runtime.release_completion.notify_one(); + assert!( + tokio::time::timeout(Duration::from_millis(300), &mut waiting) + .await + .is_err() + ); + harness.report(agent::sessions::ActivityEvent::TurnCompleted).await; + tokio::time::timeout(Duration::from_secs(2), waiting) + .await + .expect("completion reports") + .expect("task") + .expect("prompt"); + harness.finish(); +} + +#[tokio::test(flavor = "local")] +async fn prompt_settles_after_completion_and_follows_turns_started_in_the_window() { + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start(&directory, "22222222-2222-4222-8222-222222222222").await; + harness.runtime.fail_transcript.set(true); + let mut waiting = harness.prompt("steer or queue"); + harness.await_delivery(&mut waiting).await; + harness.report(agent::sessions::ActivityEvent::TurnCompleted).await; + for _ in 0..2 { + assert!( + tokio::time::timeout(Duration::from_millis(20), &mut waiting) + .await + .is_err(), + "a completion must settle before returning" + ); + harness.report(agent::sessions::ActivityEvent::TurnStarted).await; + assert!( + tokio::time::timeout(Duration::from_millis(300), &mut waiting) + .await + .is_err(), + "a turn started during settling must complete" + ); + harness.report(agent::sessions::ActivityEvent::WaitingForInput).await; + assert!( + tokio::time::timeout(Duration::from_millis(300), &mut waiting) + .await + .is_err(), + "a permission wait does not complete the new turn" + ); + harness.report(agent::sessions::ActivityEvent::TurnCompleted).await; + } + assert!( + tokio::time::timeout(Duration::from_millis(20), &mut waiting) + .await + .is_err() + ); + tokio::time::timeout(Duration::from_secs(2), waiting) + .await + .expect("settled completion") + .expect("task") + .expect("prompt"); + harness.finish(); +} + +#[tokio::test(flavor = "local")] +async fn the_first_prompt_can_start_an_unreported_conversation() { + for wait in [false, true] { + let directory = TempDir::new().expect("temporary directory"); + let harness = ServiceHarness::start(&directory, "44444444-4444-4444-8444-444444444444").await; + harness + .database + .clear_session_report(harness.session.id) + .await + .expect("no conversation yet"); + harness.runtime.ready_without_report.set(true); + let service = harness.service.clone(); + let mut answer = tokio::task::spawn_local(async move { + service + .prompt( + "worker", + &SessionName::new("s1").expect("name"), + "first input", + wait, + Some(Duration::from_secs(10)), + ) + .await + }); + tokio::time::timeout(Duration::from_secs(5), harness.runtime.delivered.notified()) + .await + .expect("first input must not wait for its own start report"); + if wait { + assert!(!answer.is_finished(), "delivery alone is not turn completion"); + harness + .database + .record_session_start_for_launch( + harness.session.id, + &harness.token, + uuid::Uuid::new_v4(), + "new-conversation", + Some("/new.jsonl"), + time::OffsetDateTime::now_utc(), + ) + .await + .expect("start report"); + harness.append_to_last_turn(assistant_text("first answer")); + harness.report(agent::sessions::ActivityEvent::TurnCompleted).await; + } + (&mut answer).await.expect("task").expect("prompt"); + harness.finish(); + } +} + +#[tokio::test(flavor = "local")] +async fn a_prompt_without_wait_still_waits_for_the_harness_to_report_in() { + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = + running_session(&directory, "33333333-3333-4333-8333-333333333333", false).await; + let session_store: Rc = Rc::new(database.clone()); + let agent_store: Rc = Rc::new(database.clone()); + let runtime = Rc::new(FakeRuntime::default()); + let (agent_controller, agent_wakeup) = agent::control_plane::Controller::new( + agent_store.clone(), + Rc::new(NoopAgentReconcile), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Agent reconciliation error: {error}")), + ); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + Rc::new(MarkSessionReady(database.clone())), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Session reconciliation error: {error}")), + ); + let agent_task = tokio::task::spawn_local(agent_controller.run()); + let session_task = tokio::task::spawn_local(session_controller.run()); + let service = Rc::new(agent::sessions::Service::new( + session_store, + Rc::new(agent::sessions::AgentSandboxes::new(agent_store.clone(), sandboxes)), + runtime.clone(), + Convergence::new(agent_wakeup, agent_store, Changes::new()), + session_wakeup, + )); + let fire_and_forget = { + let service = service.clone(); + tokio::task::spawn_local(async move { + service + .prompt("worker", &SessionName::new("s1").expect("name"), "go", false, None) + .await + }) + }; + tokio::time::sleep(Duration::from_millis(50)).await; + assert!( + runtime.sent.borrow().is_empty(), + "nothing is pasted into a harness that has not reported in" + ); + let token: agent::sessions::LaunchToken = "33333333-3333-4333-8333-333333333333".parse().expect("token"); + database + .record_session_start_for_launch( + session.id, + &token, + uuid::Uuid::new_v4(), + "native-1", + None, + time::OffsetDateTime::now_utc() - time::Duration::seconds(5), + ) + .await + .expect("start report"); + fire_and_forget.await.expect("task").expect("delivered"); + assert_eq!(runtime.sent.borrow().as_slice(), ["go"]); + agent_task.abort(); + session_task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn a_failed_initial_launch_recovers_without_replaying_the_prompt() { + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, _) = running_session(&directory, "ffffffff-ffff-4fff-8fff-ffffffffffff", true).await; + let session = database + .ensure_session( + "worker", + &SessionName::new("uncertain").expect("name"), + NewSession { + initial_prompt: Some("perform once".into()), + ..NewSession::for_harness(agent::Harness::ClaudeCode) + }, + ) + .await + .expect("Session"); + database.activate_session(session.id).await.expect("activate"); + let runtime = Rc::new(FakeRuntime::default()); + runtime.present.set(false); + runtime.fail_start.set(true); + let reconciler = agent::sessions::Reconciler::new( + Rc::new(database.clone()), + Rc::new(agent::sessions::AgentSandboxes::new( + Rc::new(database.clone()), + sandboxes, + )), + runtime.clone(), + "http://platform-api".into(), + ); + reconciler.reconcile(session.id).await.expect_err("failed launch"); + assert_eq!( + runtime.launches.borrow().as_slice(), + [(None, Some("perform once".into()))] + ); + database + .reset_session_launch_attempts(session.id) + .await + .expect("reset backoff"); + runtime.fail_start.set(false); + reconciler.reconcile(session.id).await.expect("automatic recovery"); + assert_eq!( + runtime.launches.borrow().as_slice(), + [(None, Some("perform once".into())), (None, None)] + ); + assert_eq!( + database + .get_session(session.id) + .await + .expect("Session") + .status + .lifecycle + .state, + agent::sessions::LifecycleState::Running + ); +} + +#[tokio::test(flavor = "local")] +async fn a_fresh_launch_carries_the_first_prompt_and_a_resume_does_not() { + const TOKEN: &str = "ffffffff-ffff-4fff-8fff-ffffffffffff"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, existing) = running_session(&directory, TOKEN, true).await; + let sandbox_id = database + .session_launch_state(existing.id) + .await + .expect("launch state") + .expect("recorded launch") + .sandbox; + let prompted = database + .ensure_session( + "worker", + &SessionName::new("prompted").expect("name"), + NewSession { + initial_prompt: Some("start here".into()), + ..NewSession::for_harness(agent::Harness::ClaudeCode) + }, + ) + .await + .expect("Session"); + database.activate_session(prompted.id).await.expect("activate"); + let runtime = Rc::new(FakeRuntime::default()); + runtime.present.set(false); + let reconciler = agent::sessions::Reconciler::new( + Rc::new(database.clone()), + Rc::new(agent::sessions::AgentSandboxes::new( + Rc::new(database.clone()), + sandboxes, + )), + runtime.clone(), + "http://platform-api".into(), + ); + + reconciler.reconcile(prompted.id).await.expect("first launch"); + assert_eq!( + runtime.launches.borrow().as_slice(), + [(None, Some("start here".to_owned()))], + "the first launch starts on the first prompt" + ); + // A fresh conversation later (nothing reported to resume) starts empty. + // Clear the crash backoff the first launch armed so the pass relaunches now. + database + .reset_session_launch_attempts(prompted.id) + .await + .expect("reset attempts"); + runtime.present.set(false); + reconciler.reconcile(prompted.id).await.expect("fresh relaunch"); + assert_eq!( + runtime.launches.borrow().last().expect("second launch"), + &(None, None), + "a Sandbox replacement does not replay the task" + ); + + // Once the harness has reported a conversation, a relaunch resumes it and + // does not repeat the prompt. + let token: agent::sessions::LaunchToken = "abababab-abab-4bab-8bab-abababababab".parse().expect("token"); + database + .record_session_launch( + prompted.id, + agent::sessions::LaunchRecord { + token: token.clone(), + sandbox: sandbox_id, + launched_at: 0, + attempts: 1, + }, + ) + .await + .expect("launch bookkeeping"); + database + .record_session_start_for_launch( + prompted.id, + &token, + uuid::Uuid::new_v4(), + "native-1", + Some("/home/agent/t.jsonl"), + time::OffsetDateTime::now_utc(), + ) + .await + .expect("start report"); + runtime.present.set(false); + runtime.ready_without_report.set(true); + reconciler.reconcile(prompted.id).await.expect("relaunch"); + assert_eq!( + runtime.launches.borrow().last().expect("third launch"), + &(Some("native-1".to_owned()), None) + ); + let relaunched = database.get_session(prompted.id).await.expect("Session"); + assert_eq!( + relaunched.status.reported.harness_transcript_path.as_deref(), + Some("/home/agent/t.jsonl"), + "a relaunch in the same Sandbox keeps the reported conversation" + ); + assert_eq!( + relaunched.status.state, + agent::sessions::State::WaitingForInput, + "a resumed harness settles once its input is visible" + ); +} + +#[tokio::test(flavor = "local")] +async fn a_resumed_start_report_settles_at_waiting_for_input() { + const TOKEN: &str = "45454545-4545-4545-8545-454545454545"; + let directory = TempDir::new().expect("temporary directory"); + let (database, runtime, reconciler, session) = resume_fixture(&directory, TOKEN).await; + + let reconciling = { + let reconciler = reconciler.clone(); + tokio::task::spawn_local(async move { reconciler.reconcile(session.id).await }) + }; + runtime.launch_started.notified().await; + let token = runtime.launch_tokens.borrow().last().expect("launch token").clone(); + database + .record_session_start_for_launch( + session.id, + &token, + uuid::Uuid::new_v4(), + "native-0", + Some("/home/agent/conversation.jsonl"), + time::OffsetDateTime::now_utc() - time::Duration::seconds(5), + ) + .await + .expect("resumed start report"); + runtime.ready_without_report.set(true); + reconciling.await.expect("task").expect("reconciliation"); + + assert_eq!( + database.get_session(session.id).await.expect("Session").status.state, + agent::sessions::State::WaitingForInput + ); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn an_unready_resumed_harness_is_stopped_and_fails() { + const TOKEN: &str = "56565656-5656-4565-8565-565656565656"; + let directory = TempDir::new().expect("temporary directory"); + let (database, runtime, reconciler, session) = resume_fixture(&directory, TOKEN).await; + + let reconciling = { + let reconciler = reconciler.clone(); + tokio::task::spawn_local(async move { reconciler.reconcile(session.id).await }) + }; + runtime.launch_started.notified().await; + tokio::time::advance(Duration::from_secs(16)).await; + let error = reconciling + .await + .expect("task") + .expect_err("an unready resume must fail"); + + assert!(error.to_string().contains("did not become ready"), "{error}"); + assert_eq!(runtime.stop_calls.get(), 1); + assert_eq!( + database.get_session(session.id).await.expect("Session").status.state, + agent::sessions::State::Failed + ); +} + +#[tokio::test(flavor = "local")] +async fn interrupted_resume_readiness_is_finished_by_the_next_reconciliation() { + const TOKEN: &str = "67676767-6767-4767-8767-676767676767"; + let directory = TempDir::new().expect("temporary directory"); + let (database, runtime, reconciler, session) = resume_fixture(&directory, TOKEN).await; + + interrupt_resume_after_start(&database, runtime.as_ref(), &reconciler, &session).await; + runtime.ready_without_report.set(true); + + reconciler.reconcile(session.id).await.expect("retry readiness"); + assert_eq!( + database.get_session(session.id).await.expect("Session").status.state, + agent::sessions::State::WaitingForInput + ); +} + +#[tokio::test(flavor = "local")] +async fn observation_failure_preserves_pending_resume_readiness() { + const TOKEN: &str = "89898989-8989-4898-8989-898989898989"; + let directory = TempDir::new().expect("temporary directory"); + let (database, runtime, reconciler, session) = resume_fixture(&directory, TOKEN).await; + interrupt_resume_after_start(&database, runtime.as_ref(), &reconciler, &session).await; + + runtime.fail_observe_once.set(true); + reconciler.reconcile(session.id).await.expect_err("observation failure"); + let interrupted = database.get_session(session.id).await.expect("Session"); + assert_eq!( + interrupted.status.lifecycle.state, + agent::sessions::LifecycleState::Resuming + ); + assert!( + interrupted + .status + .lifecycle + .failure + .is_some_and(|failure| failure.contains("observation failure")) + ); + runtime.ready_without_report.set(true); + reconciler.reconcile(session.id).await.expect("retry readiness"); + + assert_eq!( + database.get_session(session.id).await.expect("Session").status.state, + agent::sessions::State::WaitingForInput + ); +} + +#[tokio::test(flavor = "local")] +async fn temporarily_unready_agent_preserves_pending_resume_readiness() { + const TOKEN: &str = "90909090-9090-4909-8909-909090909090"; + let directory = TempDir::new().expect("temporary directory"); + let (database, runtime, reconciler, session) = resume_fixture(&directory, TOKEN).await; + interrupt_resume_after_start(&database, runtime.as_ref(), &reconciler, &session).await; + + let owner = database.get(session.agent_id).await.expect("Agent"); + database + .update_status(session.agent_id, owner.agent.metadata.generation, Status::default()) + .await + .expect("temporarily unready Agent"); + reconciler.reconcile(session.id).await.expect("observe unready Agent"); + assert_eq!( + database + .get_session(session.id) + .await + .expect("Session") + .status + .lifecycle + .state, + agent::sessions::LifecycleState::Resuming + ); + database + .update_status(session.agent_id, owner.agent.metadata.generation, owner.agent.status) + .await + .expect("restore ready Agent"); + runtime.ready_without_report.set(true); + reconciler.reconcile(session.id).await.expect("retry readiness"); + + assert_eq!( + database.get_session(session.id).await.expect("Session").status.state, + agent::sessions::State::WaitingForInput + ); +} + +#[tokio::test(flavor = "local")] +async fn transient_resume_readiness_failure_is_retried() { + const TOKEN: &str = "78787878-7878-4787-8787-787878787878"; + let directory = TempDir::new().expect("temporary directory"); + let (database, runtime, reconciler, session) = resume_fixture(&directory, TOKEN).await; + runtime.ready_without_report.set(true); + runtime.fail_input_ready_once.set(true); + + reconciler.reconcile(session.id).await.expect("retry readiness"); + assert_eq!( + database.get_session(session.id).await.expect("Session").status.state, + agent::sessions::State::WaitingForInput + ); +} + +/// A Session service over a two-harness Agent whose installations declare +/// manifest defaults: Claude Code (default) selects `fable`, Codex `high` effort. +struct SelectionFixture { + database: persistence::Database, + record: agent::control_plane::AgentRecord, + service: agent::sessions::Service, + agent_task: tokio::task::JoinHandle<()>, + session_task: tokio::task::JoinHandle<()>, +} + +async fn selection_fixture(directory: &TempDir) -> SelectionFixture { + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let agent_id = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + let mut record = ready_record("worker", agent_id); + record.agent.spec.harnesses[0].default = true; + record.agent.spec.harnesses[0].defaults.model = Some(agent::Model::new("fable").expect("model")); + record.agent.spec.harnesses.push(agent::HarnessSpec { + kind: agent::Harness::Codex, + version: Some("0.149.1".into()), + auth: agent::HarnessAuthMode::Mediated, + optional: true, + default: false, + defaults: agent::ModelSelection { + model: None, + effort: Some(agent::Effort::new("high").expect("effort")), + }, + }); + observe_all_harnesses(&mut record); + database.put(record.clone(), 0).await.expect("Agent"); + let agent_store: Rc = Rc::new(database.clone()); + let session_store: Rc = Rc::new(database.clone()); + let (agent_controller, agent_wakeup) = agent::control_plane::Controller::new( + agent_store.clone(), + Rc::new(NoopAgentReconcile), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Agent reconciliation error: {error}")), + ); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + Rc::new(MarkSessionReady(database.clone())), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Session reconciliation error: {error}")), + ); + let agent_task = tokio::task::spawn_local(agent_controller.run()); + let session_task = tokio::task::spawn_local(session_controller.run()); + let service = agent::sessions::Service::new( + session_store, + Rc::new(agent::sessions::AgentSandboxes::new( + agent_store.clone(), + unused_sandboxes(), + )), + tmux_runtime(), + Convergence::new(agent_wakeup, agent_store, Changes::new()), + session_wakeup, + ); + + SelectionFixture { + database, + record, + service, + agent_task, + session_task, + } +} + +/// The original race: a Session admitted before its Agent had ever converged. +/// +/// Admission cannot know whether an optional harness will be installed, because nothing has been +/// observed yet, so the Session is persisted and activated. Convergence then omits Codex. The +/// reconciler must park the Session rather than launch a harness the image ships but nothing +/// authenticated — and must start it once a later convergence installs Codex. +#[tokio::test(flavor = "local")] +async fn a_session_admitted_before_convergence_is_parked_until_its_optional_harness_is_installed() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let agent_id: AgentId = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider_service = + SandboxService::new(backend).with_network_backend(Rc::new(sandbox_memory::NetworkBackend::for_endpoint( + "memory", + NetworkEndpointSelection::Packet(PacketMedium::Ethernet), + ))); + let mut record = ready_record("worker", agent_id); + record.agent.spec.harnesses.push(agent::HarnessSpec { + kind: agent::Harness::Codex, + version: None, + auth: agent::HarnessAuthMode::Mediated, + optional: true, + default: false, + defaults: agent::ModelSelection::default(), + }); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = provider_service + .ensure(&EnsureSandboxRequest::new( + record.sandbox_name().expect("Sandbox name"), + spec, + )) + .await + .expect("materialized Sandbox"); + // Convergence found no Codex host login, so it installed and observed only Claude Code. + record.agent.status.sandbox = Some(SandboxAssignment::Materialized { + provider: ProviderId::new("memory").expect("Provider ID"), + id: sandbox.id().clone(), + harnesses: vec![agent::Harness::ClaudeCode], + }); + database.put(record.clone(), 0).await.expect("Agent"); + let provider: Rc = Rc::new(CountingProvider { + id: ProviderId::new("memory").expect("Provider ID"), + service: provider_service, + ensure_calls: Rc::new(Cell::new(0)), + }); + let sandboxes = Rc::new( + agent::sandbox::Service::new([provider], [Rc::new(NoopPlatform) as Rc]) + .expect("Agent Sandbox service"), + ); + + // The Session was admitted on Codex before the Agent had observed anything. + let session = database + .ensure_session( + "worker", + &SessionName::new("s1").expect("name"), + NewSession::for_harness(agent::Harness::Codex), + ) + .await + .expect("Session"); + database.activate_session(session.id).await.expect("activate"); + + let runtime = Rc::new(FakeRuntime::default()); + runtime.present.set(false); + let reconciler = Rc::new(agent::sessions::Reconciler::new( + Rc::new(database.clone()), + Rc::new(agent::sessions::AgentSandboxes::new( + Rc::new(database.clone()), + sandboxes, + )), + runtime.clone(), + "http://platform-api".into(), + )); + + reconciler + .reconcile(session.id) + .await + .expect("parked rather than failed"); + let parked = database.get_session(session.id).await.expect("Session"); + assert_eq!(parked.status.lifecycle.state, agent::sessions::LifecycleState::Starting); + assert!( + parked + .status + .lifecycle + .failure + .as_deref() + .is_some_and(|reason| reason.contains("does not carry harness")), + "the parked reason names the missing harness: {:?}", + parked.status.lifecycle.failure + ); + assert!( + runtime.launch_tokens.borrow().is_empty(), + "an uninstalled harness must not be launched" + ); + + // `agentctl codex login`, and the next Agent convergence installs and observes it. + observe_all_harnesses(&mut record); + database + .update_status(agent_id, record.agent.metadata.generation, record.agent.status.clone()) + .await + .expect("observed status"); + + reconciler.reconcile(session.id).await.expect("starts once installed"); + assert!( + !runtime.launch_tokens.borrow().is_empty(), + "the Session starts once its harness is installed" + ); +} + +/// A failed convergence pass keeps a valid observation, so the refusal must still apply. +/// +/// The Agent materialized its Sandbox and observed only Claude Code, then a later pass failed and +/// cleared readiness while preserving that observation. Gating on readiness would defer here, bind +/// the name to Codex, and then skip the post-convergence check when `converge` returns the failure. +#[tokio::test(flavor = "local")] +async fn an_unready_agent_with_a_materialized_observation_still_refuses_an_absent_harness() { + let directory = TempDir::new().expect("temporary directory"); + let SelectionFixture { + database, + record, + service, + agent_task, + session_task, + } = selection_fixture(&directory).await; + + database + .update_status( + record.id, + record.agent.metadata.generation, + Status::observed( + record.agent.metadata.generation, + Some(observing(&record, &[agent::Harness::ClaudeCode])), + vec![Condition { + kind: "Ready".into(), + status: ConditionStatus::False, + reason: "SshAccessFailed".into(), + message: "ssh access failed".into(), + last_transition_time: None, + }], + ), + ) + .await + .expect("observed status"); + + let name = SessionName::new("codex-session").expect("name"); + let error = service + .ensure( + "worker", + &name, + SessionRequest { + harness: Some(agent::Harness::Codex), + ..SessionRequest::default() + }, + WaitPolicy::FirstPass, + ) + .await + .expect_err("an absent harness is refused even while the Agent is not ready"); + assert!(error.to_string().contains("is not installed"), "{error}"); + assert!(matches!( + database.get_agent_session("worker", &name).await, + Err(Error::NotFound) + )); + + agent_task.abort(); + session_task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn a_session_on_an_optional_harness_the_agent_does_not_carry_is_refused_without_persisting() { + let directory = TempDir::new().expect("temporary directory"); + let SelectionFixture { + database, + record, + service, + agent_task, + session_task, + } = selection_fixture(&directory).await; + + database + .update_status( + record.id, + record.agent.metadata.generation, + Status::observed( + record.agent.metadata.generation, + Some(observing(&record, &[agent::Harness::ClaudeCode])), + record.agent.status.conditions.clone(), + ), + ) + .await + .expect("observed status"); + + let name = SessionName::new("codex-session").expect("name"); + let error = service + .ensure( + "worker", + &name, + SessionRequest { + harness: Some(agent::Harness::Codex), + ..SessionRequest::default() + }, + WaitPolicy::FirstPass, + ) + .await + .expect_err("an uninstalled optional harness is refused"); + assert!(error.to_string().contains("is not installed"), "{error}"); + assert!(matches!( + database.get_agent_session("worker", &name).await, + Err(Error::NotFound) + )); + + agent_task.abort(); + session_task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn session_ensure_resolves_explicit_and_implicit_harnesses() { + let directory = TempDir::new().expect("temporary directory"); + let SelectionFixture { + database, + service, + agent_task, + session_task, + .. + } = selection_fixture(&directory).await; + + let invalid_name = SessionName::new("invalid-prompt").expect("name"); + let oversized_prompt = "'".repeat(17_000); + let error = service + .ensure( + "worker", + &invalid_name, + SessionRequest { + initial_prompt: Some(oversized_prompt.clone()), + ..SessionRequest::default() + }, + WaitPolicy::FirstPass, + ) + .await + .expect_err("oversized initial prompt"); + assert!(error.to_string().contains("encoded launch argument")); + assert!(matches!( + database.get_agent_session("worker", &invalid_name).await, + Err(Error::NotFound) + )); + + let explicit = service + .ensure( + "worker", + &SessionName::new("explicit").expect("name"), + SessionRequest { + harness: Some(agent::Harness::Codex), + ..SessionRequest::default() + }, + WaitPolicy::FirstPass, + ) + .await + .expect("explicit harness Session"); + let implicit = service + .ensure( + "worker", + &SessionName::new("implicit").expect("name"), + SessionRequest::default(), + WaitPolicy::FirstPass, + ) + .await + .expect("implicit default Session"); + + assert_eq!(explicit.session.harness, agent::Harness::Codex); + assert_eq!(implicit.session.harness, agent::Harness::ClaudeCode); + // Manifest defaults fill omitted selections per installation, and nothing else. + assert_eq!(explicit.session.model_selection.model_str(), None); + assert_eq!(explicit.session.model_selection.effort_str(), Some("high")); + assert_eq!(implicit.session.model_selection.model_str(), Some("fable")); + assert_eq!(implicit.session.model_selection.effort_str(), None); + + let conflict = service + .ensure( + "worker", + &SessionName::new("explicit").expect("name"), + SessionRequest { + harness: Some(agent::Harness::ClaudeCode), + ..SessionRequest::default() + }, + WaitPolicy::FirstPass, + ) + .await + .expect_err("an existing Session keeps its harness"); + assert!(conflict.to_string().contains("already uses harness \"codex\"")); + + agent_task.abort(); + session_task.abort(); +} + +impl SelectionFixture { + async fn ensure(&self, name: &str, request: SessionRequest) -> Result { + let name = SessionName::new(name).expect("name"); + let target = self + .service + .ensure("worker", &name, request, WaitPolicy::FirstPass) + .await?; + Ok(target.session) + } +} + +fn selection(model: Option<&str>, effort: Option<&str>) -> SessionRequest { + SessionRequest { + model_selection: agent::ModelSelection { + model: model.map(|model| agent::Model::new(model).expect("model")), + effort: effort.map(|effort| agent::Effort::new(effort).expect("effort")), + }, + ..SessionRequest::default() + } +} + +fn recorded(session: &agent::sessions::Session) -> (Option<&str>, Option<&str>) { + ( + session.model_selection.model_str(), + session.model_selection.effort_str(), + ) +} + +#[tokio::test(flavor = "local")] +async fn session_ensure_resolves_model_and_effort_with_manifest_defaults() { + let directory = TempDir::new().expect("temporary directory"); + let fixture = selection_fixture(&directory).await; + let implicit = fixture + .ensure("implicit", SessionRequest::default()) + .await + .expect("implicit default Session"); + assert_eq!(recorded(&implicit), (Some("fable"), None)); + + let chosen = fixture + .ensure("chosen", selection(Some("claude-opus-5"), Some("low"))) + .await + .expect("explicit selections Session"); + assert_eq!(chosen.harness, agent::Harness::ClaudeCode); + assert_eq!(recorded(&chosen), (Some("claude-opus-5"), Some("low"))); + + let same = fixture + .ensure("chosen", selection(Some("claude-opus-5"), None)) + .await + .expect("repeating the recorded selection is not a conflict"); + assert_eq!((same.id, recorded(&same)), (chosen.id, recorded(&chosen))); + let model_conflict = fixture + .ensure("chosen", selection(Some("fable"), None)) + .await + .expect_err("an existing Session keeps its model"); + assert!( + model_conflict + .to_string() + .contains("already uses model \"claude-opus-5\", not \"fable\""), + "{model_conflict}" + ); + let effort_conflict = fixture + .ensure("implicit", selection(None, Some("max"))) + .await + .expect_err("an existing Session keeps the harness default effort"); + assert!( + effort_conflict + .to_string() + .contains("leaves the effort to the harness default, not \"max\""), + "{effort_conflict}" + ); + + // A changed manifest default never reaches an existing Session. + let mut changed = fixture.record.clone(); + changed.agent.spec.harnesses[0].defaults.model = Some(agent::Model::new("claude-sonnet-5").expect("model")); + fixture + .database + .put(changed, 1) + .await + .expect("changed manifest defaults"); + let relaunched = fixture + .ensure("implicit", SessionRequest::default()) + .await + .expect("existing Session under changed defaults"); + assert_eq!(recorded(&relaunched), (Some("fable"), None)); + let fresh = fixture + .ensure("fresh", SessionRequest::default()) + .await + .expect("new Session under changed defaults"); + assert_eq!(recorded(&fresh), (Some("claude-sonnet-5"), None)); + fixture.agent_task.abort(); + fixture.session_task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn session_reconciliation_never_ensures_the_agent_sandbox() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let agent_id = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider_service = + SandboxService::new(backend).with_network_backend(Rc::new(sandbox_memory::NetworkBackend::for_endpoint( + "memory", + NetworkEndpointSelection::Packet(PacketMedium::Ethernet), + ))); + let mut record = ready_record("worker", agent_id); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = provider_service + .ensure(&EnsureSandboxRequest::new( + record.sandbox_name().expect("Sandbox name"), + spec, + )) + .await + .expect("materialized Sandbox"); + record.agent.status.sandbox = Some(SandboxAssignment::Materialized { + provider: ProviderId::new("memory").expect("Provider ID"), + id: sandbox.id().clone(), + harnesses: Vec::new(), + }); + observe_all_harnesses(&mut record); + database.put(record, 0).await.expect("Agent"); + let session = database + .ensure_session( + "worker", + &SessionName::new("s1").expect("name"), + NewSession::for_harness(agent::Harness::ClaudeCode), + ) + .await + .expect("Session"); + + let ensure_calls = Rc::new(Cell::new(0)); + let provider: Rc = Rc::new(CountingProvider { + id: ProviderId::new("memory").expect("Provider ID"), + service: provider_service, + ensure_calls: ensure_calls.clone(), + }); + let sandboxes = Rc::new( + agent::sandbox::Service::new([provider], [Rc::new(NoopPlatform) as Rc]) + .expect("Agent Sandbox service"), + ); + let sessions: Rc = Rc::new(database.clone()); + let agents: Rc = Rc::new(database); + let reconciler = agent::sessions::Reconciler::new( + sessions, + Rc::new(agent::sessions::AgentSandboxes::new(agents, sandboxes)), + tmux_runtime(), + "http://platform-api".into(), + ); + + let _result = reconciler.reconcile(session.id).await; + + assert_eq!( + ensure_calls.get(), + 0, + "Session reconciliation must not own Sandbox ensure effects" + ); +} + +#[tokio::test(flavor = "local")] +#[allow(clippy::too_many_lines)] +async fn idle_stop_uses_guest_activity_age_and_explicit_activation_relaunches() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let agent_id = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + let backend = Rc::new(sandbox_memory::Provider::new()); + let provider_service = SandboxService::new(backend.clone()).with_network_backend(Rc::new( + sandbox_memory::NetworkBackend::for_endpoint( + "memory", + NetworkEndpointSelection::Packet(PacketMedium::Ethernet), + ), + )); + let mut record = ready_record("worker", agent_id); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + let sandbox = provider_service + .ensure(&EnsureSandboxRequest::new( + record.sandbox_name().expect("Sandbox name"), + spec, + )) + .await + .expect("materialized Sandbox"); + record.agent.status.sandbox = Some(SandboxAssignment::Materialized { + provider: ProviderId::new("memory").expect("Provider ID"), + id: sandbox.id().clone(), + harnesses: Vec::new(), + }); + observe_all_harnesses(&mut record); + database.put(record, 0).await.expect("Agent"); + let session = database + .ensure_session( + "worker", + &SessionName::new("idle").expect("name"), + NewSession::for_harness(agent::Harness::ClaudeCode), + ) + .await + .expect("Session"); + let activation = database.activate_session(session.id).await.expect("activate Session"); + database + .update_session_lifecycle(session.id, agent::sessions::Lifecycle::running(), activation) + .await + .expect("running status"); + database + .record_session_launch( + session.id, + agent::sessions::LaunchRecord { + token: "dddddddd-dddd-4ddd-8ddd-dddddddddddd".parse().expect("launch token"), + sandbox: sandbox.id().to_string(), + launched_at: time::OffsetDateTime::now_utc().unix_timestamp(), + attempts: 4, + }, + ) + .await + .expect("launch bookkeeping"); + + backend.queue_execution_events_matching( + is_session_observation, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout("0 1900\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ], + ); + let provider: Rc = Rc::new(CountingProvider { + id: ProviderId::new("memory").expect("Provider ID"), + service: provider_service, + ensure_calls: Rc::new(Cell::new(0)), + }); + let sandboxes = Rc::new( + agent::sandbox::Service::new([provider], [Rc::new(NoopPlatform) as Rc]) + .expect("Agent Sandbox service"), + ); + let sessions: Rc = Rc::new(database.clone()); + let agents: Rc = Rc::new(database.clone()); + let reconciler = agent::sessions::Reconciler::new( + sessions, + Rc::new(agent::sessions::AgentSandboxes::new(agents, sandboxes)), + tmux_runtime(), + "http://platform-api".into(), + ); + + reconciler.reconcile(session.id).await.expect("idle reconciliation"); + let idle = database.get_session(session.id).await.expect("Idle Session"); + assert_eq!(idle.status.lifecycle.state, agent::sessions::LifecycleState::Idle); + assert_eq!( + database + .session_launch_state(session.id) + .await + .expect("launch state") + .expect("recorded launch") + .attempts, + 0, + "an idle stop must not contribute to crash backoff" + ); + let after_idle = backend.execution_specs().len(); + reconciler + .reconcile(session.id) + .await + .expect("stable Idle reconciliation"); + assert_eq!( + backend.execution_specs().len(), + after_idle, + "periodic passes must leave Idle Sessions stopped" + ); + + database + .activate_session(session.id) + .await + .expect("explicit reactivation"); + backend.queue_execution_events_matching( + is_session_observation, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Exited(ExitStatus { code: 10 }), + ], + ); + reconciler + .reconcile(session.id) + .await + .expect("reactivation reconciliation"); + assert_eq!( + database + .get_session(session.id) + .await + .expect("running Session") + .status + .lifecycle + .state, + agent::sessions::LifecycleState::Running + ); + + let commands = backend.execution_specs(); + assert!(commands.iter().any(|spec| matches!( + spec.program(), + Program::Command { executable, args } + if executable.as_str() == "/bin/sh" + && args.iter().any(|argument| { + argument.contains("/usr/bin/tmux list-sessions") + && argument.contains("/usr/bin/date +%s") + }) + ))); + assert!(commands.iter().any(|spec| matches!( + spec.program(), + Program::Command { executable, args } + if executable.as_str() == "/bin/sh" + && args.iter().any(|argument| argument.contains("/usr/bin/tmux kill-session")) + ))); + assert!(commands.iter().any(|spec| { + matches!( + spec.program(), + Program::Command { executable, args } + if executable.as_str() == "/usr/bin/tmux" + && args.windows(2).any(|arguments| arguments == [";", "new-session"]) + ) && spec + .working_directory() + .is_some_and(|path| path.as_str() == "/home/agent/code") + && spec.environment().get("LANG").map(String::as_str) == Some("C.UTF-8") + && matches!(spec.program(), Program::Command { args, .. } + if args.iter().any(|argument| argument == "CONTAINER_HOST=unix:///run/podman/podman.sock")) + })); +} + +#[tokio::test(flavor = "local")] +async fn session_ensure_persists_intent_before_waiting_for_agent_convergence() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let agent_store: Rc = Rc::new(database.clone()); + let session_store: Rc = Rc::new(database.clone()); + let started = Rc::new(Notify::new()); + let release = Rc::new(Notify::new()); + let agent_reconciler: Rc> = Rc::new(BlockingAgentReady { + database: database.clone(), + started: started.clone(), + release: release.clone(), + }); + let session_reconciler: Rc> = Rc::new(MarkSessionReady(database.clone())); + let (agent_controller, agent_wakeup) = agent::control_plane::Controller::new( + agent_store.clone(), + agent_reconciler, + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Agent reconciliation error: {error}")), + ); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + session_reconciler, + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Session reconciliation error: {error}")), + ); + let agent_task = tokio::task::spawn_local(agent_controller.run()); + let session_task = tokio::task::spawn_local(session_controller.run()); + tokio::time::sleep(Duration::from_millis(20)).await; + + let agent_id = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + let mut resource = support::agent("worker"); + resource.metadata.generation = 1; + database + .put( + AgentRecord { + id: agent_id, + source_directory: PathBuf::from("/source"), + manifest_path: None, + env_file: None, + agent: resource, + }, + 0, + ) + .await + .expect("Agent"); + let service = Rc::new(agent::sessions::Service::new( + session_store, + Rc::new(agent::sessions::AgentSandboxes::new( + agent_store.clone(), + unused_sandboxes(), + )), + tmux_runtime(), + Convergence::new(agent_wakeup, agent_store, Changes::new()), + session_wakeup, + )); + let ensure_service = service.clone(); + let ensure = tokio::task::spawn_local(async move { + ensure_service + .ensure( + "worker", + &SessionName::new("s1").expect("name"), + SessionRequest::default(), + WaitPolicy::FirstPass, + ) + .await + }); + + started.notified().await; + let sessions = database.list_agent_sessions("worker").await.expect("Sessions"); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].name.as_str(), "s1"); + assert_eq!( + sessions[0].status.lifecycle.state, + agent::sessions::LifecycleState::Starting + ); + release.notify_one(); + let target = ensure.await.expect("ensure task").expect("ready Session"); + + assert_eq!(target.session.name.as_str(), "s1"); + assert_eq!( + target.session.status.lifecycle.state, + agent::sessions::LifecycleState::Running + ); + agent_task.abort(); + session_task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn controller_is_concurrent_across_sessions_and_serial_per_session() { + let directory = TempDir::new().expect("temporary directory"); + let database = persistence::Database::open(&directory.path().join("agent.db")).expect("database"); + let agent_id = "38f41de4-6ff7-4679-ae46-678bc61e4dcb".parse().expect("Agent ID"); + database.put(ready_record("worker", agent_id), 0).await.expect("Agent"); + + let session_store: Rc = Rc::new(database.clone()); + let slow = database + .ensure_session( + "worker", + &SessionName::new("slow").expect("name"), + NewSession::for_harness(agent::Harness::ClaudeCode), + ) + .await + .expect("slow Session"); + let slow_calls = Rc::new(Cell::new(0)); + let started = Rc::new(Notify::new()); + let release = Rc::new(Notify::new()); + let reconciler: Rc> = Rc::new(BlockingReconcile { + slow: slow.id, + slow_calls: slow_calls.clone(), + active_slow: Rc::new(Cell::new(0)), + started: started.clone(), + release: release.clone(), + }); + let (controller, wakeup) = agent::sessions::Controller::new( + session_store, + reconciler, + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected reconciliation error: {error}")), + ); + let controller_task = tokio::task::spawn_local(controller.run()); + + // The startup scan performs the first slow pass and blocks it. + started.notified().await; + let first_wakeup = wakeup.clone(); + let first = tokio::task::spawn_local(async move { first_wakeup.reconcile(slow.id).await }); + let rerun_wakeup = wakeup.clone(); + let rerun = tokio::task::spawn_local(async move { rerun_wakeup.reconcile(slow.id).await }); + + let fast = database + .ensure_session( + "worker", + &SessionName::new("fast").expect("name"), + NewSession::for_harness(agent::Harness::ClaudeCode), + ) + .await + .expect("fast Session"); + tokio::time::timeout(Duration::from_secs(1), wakeup.reconcile(fast.id)) + .await + .expect("fast Session should not wait for slow Session") + .expect("fast reconciliation"); + + release.notify_one(); + first.await.expect("first task").expect("first reconciliation"); + rerun.await.expect("rerun task").expect("rerun reconciliation"); + assert_eq!(slow_calls.get(), 2); + controller_task.abort(); +} + +#[tokio::test(flavor = "local")] +async fn prompt_wait_does_not_follow_a_replacement_session_with_the_same_name() { + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start(&directory, "44444444-4444-4444-8444-444444444444").await; + let mut waiting = harness.prompt("continue"); + harness.await_delivery(&mut waiting).await; + // Ensure the waiter will reread on its next activity poll. + harness.report(agent::sessions::ActivityEvent::TurnCompleted).await; + tokio::time::sleep(Duration::from_millis(50)).await; + harness.database.mark_deleting("worker").await.expect("delete"); + harness + .database + .finalize_deletion(harness.session.agent_id, 1) + .await + .expect("finalize"); + harness + .database + .put( + ready_record( + "worker", + "f50fbec8-03a9-43ea-b65d-c15a86e9eb65".parse().expect("Agent ID"), + ), + 0, + ) + .await + .expect("replacement"); + let replacement = harness + .database + .ensure_session( + "worker", + &harness.session.name, + NewSession::for_harness(agent::Harness::ClaudeCode), + ) + .await + .expect("Session"); + harness + .database + .update_session_lifecycle(replacement.id, agent::sessions::Lifecycle::running(), 0) + .await + .expect("running"); + let error = tokio::time::timeout(Duration::from_secs(1), waiting) + .await + .expect("original Session removal ends wait") + .expect("task") + .expect_err("original Session disappeared"); + assert!(matches!(error, Error::NotFound)); + harness.finish(); +} + +#[tokio::test(flavor = "local")] +async fn completion_timeout_starts_after_delivery() { + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start(&directory, "44444444-4444-4444-8444-444444444444").await; + harness.runtime.delivery_delay.set(Duration::from_millis(300)); + let started = tokio::time::Instant::now(); + let error = harness + .service + .prompt( + "worker", + &harness.session.name, + "go", + true, + Some(Duration::from_millis(100)), + ) + .await + .expect_err("completion timeout"); + assert!(error.to_string().contains("prompt was submitted")); + assert!(started.elapsed() >= Duration::from_millis(400)); + assert_eq!(harness.runtime.sent.borrow().as_slice(), ["go"]); + harness.finish(); +} + +#[tokio::test(flavor = "local")] +async fn an_unsupported_completion_timeout_is_rejected_before_delivery() { + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start(&directory, "44444444-4444-4444-8444-444444444444").await; + + let error = harness + .service + .prompt( + "worker", + &harness.session.name, + "go", + true, + Some(Duration::from_mins(31)), + ) + .await + .expect_err("unsupported completion timeout"); + + assert!(error.to_string().contains("must not exceed 30m")); + assert!(harness.runtime.sent.borrow().is_empty()); + harness.finish(); +} + +#[tokio::test(flavor = "local")] +async fn queued_deliveries_do_not_expire_and_remain_serialized() { + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start(&directory, "44444444-4444-4444-8444-444444444444").await; + harness.runtime.hold_completion.set(true); + let send = |text| { + let service = harness.service.clone(); + let name = harness.session.name.clone(); + tokio::task::spawn_local(async move { + service + .prompt("worker", &name, text, false, Some(Duration::from_millis(50))) + .await + }) + }; + let mut first = send("first"); + harness.await_delivery(&mut first).await; + let second = send("second"); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(!first.is_finished()); + assert!(!second.is_finished()); + assert_eq!(harness.runtime.sent.borrow().as_slice(), ["first"]); + harness.runtime.hold_completion.set(false); + harness.runtime.release_completion.notify_one(); + first.await.expect("task").expect("first delivery"); + second.await.expect("task").expect("second delivery"); + assert_eq!(harness.runtime.sent.borrow().as_slice(), ["first", "second"]); + harness.finish(); +} + +/// A Session marked for deletion is released by the Session controller: the +/// harness is stopped first, and only a successful stop removes the Session. +#[tokio::test(flavor = "local")] +async fn deleting_a_session_stops_its_harness_before_the_session_is_removed() { + const TOKEN: &str = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + let runtime = Rc::new(FakeRuntime::default()); + let reconciler = agent::sessions::Reconciler::new( + Rc::new(database.clone()), + Rc::new(agent::sessions::AgentSandboxes::new( + Rc::new(database.clone()), + sandboxes, + )), + runtime.clone(), + "http://platform-api".into(), + ); + let name = SessionName::new("s1").expect("name"); + + database + .mark_session_deleting("worker", &name) + .await + .expect("mark deleting"); + + runtime.fail_stop_once.set(true); + reconciler + .reconcile(session.id) + .await + .expect_err("a harness that cannot be stopped fails the release"); + assert_eq!(runtime.stop_calls.get(), 1); + assert!( + database + .get_session(session.id) + .await + .expect("the Session survives a failed release") + .is_deleting(), + "the request survives so a later pass retries it" + ); + + reconciler.reconcile(session.id).await.expect("release"); + assert_eq!(runtime.stop_calls.get(), 2); + assert!(matches!(database.get_session(session.id).await, Err(Error::NotFound))); + assert!(database.list_all_sessions().await.expect("sessions").is_empty()); + reconciler + .reconcile(session.id) + .await + .expect("reconciling a removed Session is a no-op"); + assert_eq!(runtime.stop_calls.get(), 2); +} + +/// Nothing is left to stop when the Sandbox that held the harness is gone, so +/// the Session is removed without touching a Sandbox. +#[tokio::test(flavor = "local")] +async fn deleting_a_session_whose_sandbox_is_gone_still_removes_it() { + const TOKEN: &str = "ffffffff-ffff-4fff-8fff-ffffffffffff"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + let owner = database.get(session.agent_id).await.expect("Agent record"); + database + .update_status(session.agent_id, owner.agent.metadata.generation, Status::default()) + .await + .expect("Agent without a materialized Sandbox"); + let runtime = Rc::new(FakeRuntime::default()); + let reconciler = agent::sessions::Reconciler::new( + Rc::new(database.clone()), + Rc::new(agent::sessions::AgentSandboxes::new( + Rc::new(database.clone()), + sandboxes, + )), + runtime.clone(), + "http://platform-api".into(), + ); + + database + .mark_session_deleting("worker", &SessionName::new("s1").expect("name")) + .await + .expect("mark deleting"); + reconciler.reconcile(session.id).await.expect("release"); + + assert_eq!(runtime.stop_calls.get(), 0); + assert!(matches!(database.get_session(session.id).await, Err(Error::NotFound))); +} + +/// `delete` hides the Session immediately and returns once the controller has +/// released it, and the operations that address a Session by name stop finding it. +#[tokio::test(flavor = "local")] +async fn deleting_a_session_through_the_service_releases_it_and_hides_it_at_once() { + const TOKEN: &str = "abababab-abab-4bab-8bab-abababababab"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + let session_store: Rc = Rc::new(database.clone()); + let agent_store: Rc = Rc::new(database.clone()); + let runtime = Rc::new(FakeRuntime::default()); + let (agent_controller, agent_wakeup) = agent::control_plane::Controller::new( + agent_store.clone(), + Rc::new(NoopAgentReconcile), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Agent reconciliation error: {error}")), + ); + let sandboxes = Rc::new(agent::sessions::AgentSandboxes::new(agent_store.clone(), sandboxes)); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + Rc::new(agent::sessions::Reconciler::new( + session_store.clone(), + sandboxes.clone(), + runtime.clone(), + "http://platform-api".into(), + )), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Session reconciliation error: {error}")), + ); + let agent_task = tokio::task::spawn_local(agent_controller.run()); + let session_task = tokio::task::spawn_local(session_controller.run()); + let service = agent::sessions::Service::new( + session_store.clone(), + sandboxes, + runtime.clone(), + Convergence::new(agent_wakeup, agent_store, Changes::new()), + session_wakeup, + ); + let name = SessionName::new("s1").expect("name"); + + assert_eq!(service.list(None).await.expect("sessions").len(), 1); + service.delete("worker", &name).await.expect("delete Session"); + + assert_eq!(runtime.stop_calls.get(), 1, "the harness is stopped, not left running"); + assert!(matches!(service.get("worker", &name).await, Err(Error::NotFound))); + assert!(service.list(None).await.expect("sessions").is_empty()); + assert!(matches!( + service.turns("worker", &name, None).await, + Err(Error::NotFound) + )); + assert!(matches!(database.get_session(session.id).await, Err(Error::NotFound))); + assert!(matches!(service.delete("worker", &name).await, Err(Error::NotFound))); + + agent_task.abort(); + session_task.abort(); +} + +/// A delete whose harness cannot be stopped yet reports that it is still +/// pending, rather than looking like it was refused. +#[tokio::test(flavor = "local")] +async fn a_delete_that_cannot_stop_the_harness_yet_reports_that_it_is_pending() { + const TOKEN: &str = "bcbcbcbc-bcbc-4cbc-8cbc-bcbcbcbcbcbc"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + let session_store: Rc = Rc::new(database.clone()); + let agent_store: Rc = Rc::new(database.clone()); + let runtime = Rc::new(FakeRuntime::default()); + runtime.stops_failing.set(true); + let (agent_controller, agent_wakeup) = agent::control_plane::Controller::new( + agent_store.clone(), + Rc::new(NoopAgentReconcile), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Agent reconciliation error: {error}")), + ); + let sandboxes = Rc::new(agent::sessions::AgentSandboxes::new(agent_store.clone(), sandboxes)); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + Rc::new(agent::sessions::Reconciler::new( + session_store.clone(), + sandboxes.clone(), + runtime.clone(), + "http://platform-api".into(), + )), + Duration::from_mins(1), + Rc::new(|_, _| {}), + ); + let agent_task = tokio::task::spawn_local(agent_controller.run()); + let session_task = tokio::task::spawn_local(session_controller.run()); + let service = agent::sessions::Service::new( + session_store.clone(), + sandboxes, + runtime.clone(), + Convergence::new(agent_wakeup, agent_store, Changes::new()), + session_wakeup, + ); + let name = SessionName::new("s1").expect("name"); + + let pending = service.delete("worker", &name).await.expect_err("the stop failed"); + assert!( + pending + .to_string() + .contains("is marked for deletion and will be retried"), + "{pending}" + ); + assert!( + database + .get_session(session.id) + .await + .expect("still marked") + .is_deleting() + ); + + agent_task.abort(); + session_task.abort(); +} + +async fn turn_completed(database: &persistence::Database, session: &agent::sessions::Session, token: &str) { + database + .apply_session_activity_for_launch( + session.id, + &token.parse().expect("launch token"), + uuid::Uuid::new_v4(), + agent::sessions::ActivityEvent::WaitingForInput, + time::OffsetDateTime::now_utc(), + ) + .await + .expect("turn completed"); +} + +/// Archiving waits for the turn in progress, then stops the harness and keeps +/// it stopped; unarchiving leaves it stopped until the next attach. +#[tokio::test(flavor = "local")] +async fn archiving_waits_for_the_turn_and_keeps_the_harness_stopped_until_attached() { + const TOKEN: &str = "acacacac-acac-4cac-8cac-acacacacacac"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + let runtime = Rc::new(FakeRuntime::default()); + let reconciler = agent::sessions::Reconciler::new( + Rc::new(database.clone()), + Rc::new(agent::sessions::AgentSandboxes::new( + Rc::new(database.clone()), + sandboxes, + )), + runtime.clone(), + "http://platform-api".into(), + ); + let name = SessionName::new("s1").expect("name"); + let state = || async { database.get_session(session.id).await.expect("Session").status.state }; + + assert_eq!(state().await, agent::sessions::State::Working); + database + .set_session_archived("worker", &name, true) + .await + .expect("archive"); + reconciler.reconcile(session.id).await.expect("archive pass"); + assert_eq!(runtime.stop_calls.get(), 0, "a turn in progress is not cut short"); + assert_eq!( + state().await, + agent::sessions::State::Archiving, + "archived, with its harness still finishing the turn" + ); + + turn_completed(&database, &session, TOKEN).await; + reconciler.reconcile(session.id).await.expect("archive pass"); + assert_eq!(runtime.stop_calls.get(), 1, "the harness stops once the turn has ended"); + assert_eq!(state().await, agent::sessions::State::Archived); + reconciler.reconcile(session.id).await.expect("periodic pass"); + assert_eq!(runtime.stop_calls.get(), 1, "an archived Session is left alone"); + + database + .set_session_archived("worker", &name, false) + .await + .expect("unarchive"); + assert_eq!( + state().await, + agent::sessions::State::Idle, + "Idle at once, before the reconciler settles its lifecycle" + ); + reconciler.reconcile(session.id).await.expect("unarchive pass"); + assert_eq!(state().await, agent::sessions::State::Idle); + assert!(runtime.launches.borrow().is_empty(), "unarchiving launches nothing"); + + runtime.ready_without_report.set(true); + database.activate_session(session.id).await.expect("attach"); + reconciler.reconcile(session.id).await.expect("attach pass"); + assert_eq!( + runtime.launches.borrow().as_slice(), + [(Some("native-0".to_owned()), None)], + "the first attach after unarchiving resumes the conversation" + ); +} + +/// An archive whose harness could not be stopped stays archived, so +/// unarchiving it never falls through to relaunching the harness. +#[tokio::test(flavor = "local")] +async fn unarchiving_after_a_failed_archive_does_not_relaunch_the_harness() { + const TOKEN: &str = "adadadad-adad-4dad-8dad-adadadadadad"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + turn_completed(&database, &session, TOKEN).await; + let runtime = Rc::new(FakeRuntime::default()); + let reconciler = agent::sessions::Reconciler::new( + Rc::new(database.clone()), + Rc::new(agent::sessions::AgentSandboxes::new( + Rc::new(database.clone()), + sandboxes, + )), + runtime.clone(), + "http://platform-api".into(), + ); + let name = SessionName::new("s1").expect("name"); + + database + .set_session_archived("worker", &name, true) + .await + .expect("archive"); + runtime.fail_stop_once.set(true); + reconciler + .reconcile(session.id) + .await + .expect_err("a harness that cannot be stopped fails the pass"); + let failed = database.get_session(session.id).await.expect("Session"); + assert_eq!( + failed.status.state, + agent::sessions::State::Archiving, + "archived, with a harness that may still run" + ); + assert!(failed.status.lifecycle.failure.is_some()); + + database + .set_session_archived("worker", &name, false) + .await + .expect("unarchive"); + assert_eq!( + database.get_session(session.id).await.expect("Session").status.state, + agent::sessions::State::WaitingForInput, + "until the pass, the harness the failed stop left running reports for itself, not Idle" + ); + reconciler.reconcile(session.id).await.expect("unarchive pass"); + let adopted = database.get_session(session.id).await.expect("Session"); + assert_eq!( + adopted.status.lifecycle.state, + agent::sessions::LifecycleState::Running, + "a harness the failed stop left running is adopted" + ); + assert!(runtime.launches.borrow().is_empty()); + + database + .set_session_archived("worker", &name, true) + .await + .expect("archive again"); + runtime.fail_stop_once.set(true); + reconciler + .reconcile(session.id) + .await + .expect_err("the stop fails again"); + runtime.present.set(false); + database + .set_session_archived("worker", &name, false) + .await + .expect("unarchive again"); + reconciler.reconcile(session.id).await.expect("unarchive pass"); + assert_eq!( + database.get_session(session.id).await.expect("Session").status.state, + agent::sessions::State::Idle, + "with the harness gone, the Session is Idle" + ); + assert!(runtime.launches.borrow().is_empty()); +} + +/// A Session that only looks mid-turn is archived at once: its harness has +/// exited, or it has been quiet too long to be working. +#[tokio::test(flavor = "local")] +async fn archiving_does_not_wait_for_a_turn_that_is_not_happening() { + const TOKEN: &str = "afafafaf-afaf-4faf-8faf-afafafafafaf"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + let runtime = Rc::new(FakeRuntime::default()); + let reconciler = agent::sessions::Reconciler::new( + Rc::new(database.clone()), + Rc::new(agent::sessions::AgentSandboxes::new( + Rc::new(database.clone()), + sandboxes, + )), + runtime.clone(), + "http://platform-api".into(), + ); + let name = SessionName::new("s1").expect("name"); + let state = || async { database.get_session(session.id).await.expect("Session").status.state }; + // Working, as a never-prompted Claude Code Session reads after its start report. + database + .apply_session_activity_for_launch( + session.id, + &TOKEN.parse().expect("launch token"), + uuid::Uuid::new_v4(), + agent::sessions::ActivityEvent::TurnStarted, + time::OffsetDateTime::now_utc() - time::Duration::hours(1), + ) + .await + .expect("an old report"); + assert_eq!(state().await, agent::sessions::State::Working); + + runtime.idle_seconds.set(3_600); + database + .set_session_archived("worker", &name, true) + .await + .expect("archive"); + reconciler.reconcile(session.id).await.expect("archive pass"); + assert_eq!(runtime.stop_calls.get(), 1, "a quiet harness is not mid-turn"); + assert_eq!(state().await, agent::sessions::State::Archived); +} + +/// Through the service, an archived Session cannot be attached until it is +/// unarchived, and the first attach after that works. +#[tokio::test(flavor = "local")] +async fn an_archived_session_is_attached_again_only_after_unarchiving() { + const TOKEN: &str = "aeaeaeae-aeae-4eae-8eae-aeaeaeaeaeae"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + turn_completed(&database, &session, TOKEN).await; + let session_store: Rc = Rc::new(database.clone()); + let agent_store: Rc = Rc::new(database.clone()); + let runtime = Rc::new(FakeRuntime::default()); + let (agent_controller, agent_wakeup) = agent::control_plane::Controller::new( + agent_store.clone(), + Rc::new(NoopAgentReconcile), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Agent reconciliation error: {error}")), + ); + let sandboxes = Rc::new(agent::sessions::AgentSandboxes::new(agent_store.clone(), sandboxes)); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + Rc::new(agent::sessions::Reconciler::new( + session_store.clone(), + sandboxes.clone(), + runtime.clone(), + "http://platform-api".into(), + )), + Duration::from_mins(1), + Rc::new(|_, error| panic!("unexpected Session reconciliation error: {error}")), + ); + let agent_task = tokio::task::spawn_local(agent_controller.run()); + let session_task = tokio::task::spawn_local(session_controller.run()); + let service = agent::sessions::Service::new( + session_store.clone(), + sandboxes, + runtime.clone(), + Convergence::new(agent_wakeup, agent_store, Changes::new()), + session_wakeup, + ); + let name = SessionName::new("s1").expect("name"); + let attach = || { + service.ensure( + "worker", + &name, + agent::sessions::SessionRequest::default(), + WaitPolicy::FirstPass, + ) + }; + + let archived = service.set_archived("worker", &name, true).await.expect("archive"); + assert_eq!(archived.status.state, agent::sessions::State::Archived); + let refused = attach().await.expect_err("archived Sessions are not attached"); + assert!(refused.to_string().contains("is archived"), "{refused}"); + service + .set_archived("worker", &name, true) + .await + .expect("archiving again is safe"); + + let unarchived = service.set_archived("worker", &name, false).await.expect("unarchive"); + assert_eq!(unarchived.status.state, agent::sessions::State::Idle); + runtime.ready_without_report.set(true); + attach().await.expect("the first attach after unarchiving works"); + + agent_task.abort(); + session_task.abort(); +} + +/// A failed archive pass records the Session as archived, but a retry still +/// waits for the harness's turn rather than reading that as the turn's end. +#[tokio::test(flavor = "local")] +async fn a_retried_archive_pass_still_waits_for_the_turn() { + const TOKEN: &str = "a1a1a1a1-a1a1-4a1a-8a1a-a1a1a1a1a1a1"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + let runtime = Rc::new(FakeRuntime::default()); + let reconciler = agent::sessions::Reconciler::new( + Rc::new(database.clone()), + Rc::new(agent::sessions::AgentSandboxes::new( + Rc::new(database.clone()), + sandboxes, + )), + runtime.clone(), + "http://platform-api".into(), + ); + database + .set_session_archived("worker", &SessionName::new("s1").expect("name"), true) + .await + .expect("archive"); + runtime.fail_observe_once.set(true); + reconciler + .reconcile(session.id) + .await + .expect_err("a failed observation fails the pass"); + + reconciler.reconcile(session.id).await.expect("retry"); + assert_eq!(runtime.stop_calls.get(), 0, "the turn in progress is not cut short"); + turn_completed(&database, &session, TOKEN).await; + reconciler.reconcile(session.id).await.expect("retry after the turn"); + assert_eq!(runtime.stop_calls.get(), 1); + assert_eq!( + database.get_session(session.id).await.expect("Session").status.state, + agent::sessions::State::Archived + ); +} + +/// A prompt waiting for its turn keeps waiting while an archive has not yet +/// stopped the harness, and completes with the turn. +#[tokio::test(flavor = "local")] +async fn a_prompt_wait_outlasts_an_archive_that_has_not_stopped_the_harness() { + let directory = TempDir::new().expect("directory"); + let harness = ServiceHarness::start(&directory, "a2a2a2a2-a2a2-4a2a-8a2a-a2a2a2a2a2a2").await; + let service = harness.service.clone(); + let name = harness.session.name.clone(); + let mut waiting = tokio::task::spawn_local(async move { + service + .prompt("worker", &name, "go", true, Some(Duration::from_secs(5))) + .await + }); + harness.await_delivery(&mut waiting).await; + harness + .database + .set_session_archived("worker", &harness.session.name, true) + .await + .expect("archive"); + harness + .database + .update_session_lifecycle( + harness.session.id, + agent::sessions::Lifecycle::archived_with("injected stop failure"), + 0, + ) + .await + .expect("a failed archive pass"); + tokio::time::sleep(Duration::from_millis(600)).await; + assert!(!waiting.is_finished(), "the turn is still running"); + + harness + .database + .apply_session_activity_for_launch( + harness.session.id, + &harness.token, + uuid::Uuid::new_v4(), + agent::sessions::ActivityEvent::TurnCompleted, + time::OffsetDateTime::now_utc(), + ) + .await + .expect("turn completed"); + waiting.await.expect("task").expect("the wait ends with the turn"); + harness.finish(); +} + +/// Records the run state of the Agent `worker` as `agentctl stop` and `start` do. +async fn set_worker_run_state(database: &persistence::Database, state: agent::RunState) { + agent::control_plane::ControlPlane::new(Rc::new(database.clone()), Rc::new(support::IgnoreNotifications)) + .set_run_state("worker", state) + .await + .expect("run state"); +} + +#[tokio::test(flavor = "local")] +async fn work_in_a_stopped_agent_is_refused_without_creating_a_session() { + const TOKEN: &str = "a1a1a1a1-a1a1-4a1a-8a1a-a1a1a1a1a1a1"; + let directory = TempDir::new().expect("temporary directory"); + let harness = ServiceHarness::start(&directory, TOKEN).await; + set_worker_run_state(&harness.database, agent::RunState::Stopped).await; + let stored = harness.database.get_by_name("worker").await.expect("Agent"); + assert!( + stored.agent.spec.is_stopped(), + "the run state is stored with the desired state" + ); + let s1 = SessionName::new("s1").expect("name"); + let is_stopped = |error: &Error| matches!(error, Error::Stopped(name) if name == "worker"); + + let error = harness + .service + .prompt("worker", &s1, "hello", false, None) + .await + .expect_err("nothing runs to prompt"); + assert!(is_stopped(&error), "{error:?}"); + let error = harness + .service + .turns("worker", &s1, None) + .await + .expect_err("no guest to read"); + assert!(is_stopped(&error), "{error:?}"); + for name in ["s1", "s2"] { + let name = SessionName::new(name).expect("name"); + let error = harness + .service + .ensure("worker", &name, SessionRequest::default(), WaitPolicy::UntilConverged) + .await + .expect_err("nothing runs to attach to"); + assert!(is_stopped(&error), "{error:?}"); + } + assert!(matches!( + harness + .database + .get_agent_session("worker", &SessionName::new("s2").expect("name")) + .await, + Err(Error::NotFound) + )); + assert!(harness.runtime.sent.borrow().is_empty()); + harness.finish(); +} + +#[tokio::test(flavor = "local")] +async fn a_session_of_a_stopped_agent_goes_idle_and_the_next_attach_resumes_it() { + const TOKEN: &str = "b2b2b2b2-b2b2-4b2b-8b2b-b2b2b2b2b2b2"; + let directory = TempDir::new().expect("temporary directory"); + let (database, runtime, reconciler, session) = resume_fixture(&directory, TOKEN).await; + set_worker_run_state(&database, agent::RunState::Stopped).await; + + reconciler.reconcile(session.id).await.expect("idle"); + let state = |database: persistence::Database| async move { + database.get_session(session.id).await.expect("Session").status.state + }; + assert_eq!(state(database.clone()).await, agent::sessions::State::Idle); + assert!( + runtime.launch_tokens.borrow().is_empty(), + "no harness launches in a stopped Agent" + ); + + // A start alone launches nothing; the Session waits for its next attach. + set_worker_run_state(&database, agent::RunState::Running).await; + reconciler.reconcile(session.id).await.expect("still idle"); + assert_eq!(state(database.clone()).await, agent::sessions::State::Idle); + assert!(runtime.launch_tokens.borrow().is_empty()); + + database.activate_session(session.id).await.expect("attach"); + runtime.ready_without_report.set(true); + reconciler.reconcile(session.id).await.expect("resumed"); + assert_eq!(runtime.launch_tokens.borrow().len(), 1); + assert_eq!( + runtime + .launches + .borrow() + .last() + .and_then(|(resume, _)| resume.as_deref()), + Some("native-0"), + "the harness resumes its conversation" + ); +} + +#[tokio::test(flavor = "local")] +async fn a_session_pass_in_flight_across_a_stop_and_start_launches_no_harness() { + const TOKEN: &str = "c3c3c3c3-c3c3-4c3c-8c3c-c3c3c3c3c3c3"; + let directory = TempDir::new().expect("temporary directory"); + let (database, sandboxes, session) = running_session(&directory, TOKEN, true).await; + let runtime = Rc::new(FakeRuntime::default()); + let session_store: Rc = Rc::new(database.clone()); + let (session_controller, session_wakeup) = agent::sessions::Controller::new( + session_store.clone(), + Rc::new(agent::sessions::Reconciler::new( + session_store.clone(), + Rc::new(agent::sessions::AgentSandboxes::new( + Rc::new(database.clone()), + sandboxes.clone(), + )), + runtime.clone(), + "http://platform-api".into(), + )), + Duration::from_mins(1), + Rc::new(|_, _| {}), + ); + let agents = Rc::new( + agent::control_plane::Reconciler::new( + Rc::new(database.clone()), + sandboxes, + agent::progress::ProvisioningState::default(), + ) + .with_session_notifier(Rc::new(agent::sessions::AgentNotifier::new( + session_store, + session_wakeup.clone(), + Rc::new(|_| {}), + ))), + ); + let task = tokio::task::spawn_local(session_controller.run()); + session_wakeup.reconcile(session.id).await.expect("startup pass"); + + // A pass observing the running harness is held while the Agent stops. + runtime.hold_observe.set(true); + session_wakeup.notify(session.id); + runtime.observe_started.notified().await; + set_worker_run_state(&database, agent::RunState::Stopped).await; + let stopping = tokio::task::spawn_local({ + let agents = agents.clone(); + let id = session.agent_id; + async move { agents.reconcile(id).await } + }); + while !database + .get(session.agent_id) + .await + .expect("Agent") + .agent + .status + .is_stopped() + { + tokio::task::yield_now().await; + } + // The VM took the harness with it, and nothing launches into a stopped VM. + runtime.present.set(false); + runtime.fail_start.set(true); + // A start is asked for while the stop still waits for its Sessions. + set_worker_run_state(&database, agent::RunState::Running).await; + runtime.release_observe.notify_one(); + stopping.await.expect("stop task").expect("stop pass"); + runtime.fail_start.set(false); + let started = runtime.launch_tokens.borrow().len(); + agents.reconcile(session.agent_id).await.expect("start pass"); + session_wakeup + .reconcile(session.id) + .await + .expect("a pass after the start"); + + assert_eq!( + runtime.launch_tokens.borrow().len(), + started, + "a start launches no harness; the next attach does" + ); + assert_eq!( + database.get_session(session.id).await.expect("Session").status.state, + agent::sessions::State::Idle + ); + task.abort(); +} diff --git a/agentctl/tests/sessions.rs b/agentctl/tests/sessions.rs new file mode 100644 index 0000000..daa5330 --- /dev/null +++ b/agentctl/tests/sessions.rs @@ -0,0 +1,22 @@ +#![allow(clippy::expect_used)] + +use agent::sessions::{LaunchToken, SessionName}; + +#[test] +fn session_names_are_validated_at_construction_and_deserialization() { + let name = SessionName::new("review_1").expect("portable Session name"); + assert_eq!(name.as_str(), "review_1"); + + assert!(SessionName::new("contains spaces").is_err()); + assert!(serde_json::from_str::(r#""contains spaces""#).is_err()); +} + +#[test] +fn launch_tokens_are_typed_and_redacted() { + let raw = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + let token = raw.parse::().expect("UUID launch token"); + + assert_eq!(token, raw.parse::().expect("same UUID launch token")); + assert_eq!(format!("{token:?}"), "LaunchToken([redacted])"); + assert!("not-a-token".parse::().is_err()); +} diff --git a/agentctl/tests/ssh_access.rs b/agentctl/tests/ssh_access.rs new file mode 100644 index 0000000..540814c --- /dev/null +++ b/agentctl/tests/ssh_access.rs @@ -0,0 +1,667 @@ +#![allow(clippy::expect_used)] + +mod support; + +use std::{path::PathBuf, rc::Rc}; + +use agent::{ + AccessSpec, AgentId, Error, FailureKind, ReconcileFailure, + control_plane::{AgentRecord, AgentStore as _, memory::InMemoryAgentStore}, + local::home::ControlPlaneHome, + ssh::{self, Access, memory::InMemoryHostKeyStore}, +}; +use sandbox::{ + EnsureSandboxRequest, Platform, SandboxHandle, SandboxPath, SandboxService, + execution::{ExecutionEvent, ExecutionSpec, ExitStatus, Program}, + memory, +}; +use tempfile::TempDir; +use tokio::io::AsyncReadExt as _; + +const AGENTCTL: &str = "/usr/local/bin/agentctl"; + +fn command(spec: &ExecutionSpec) -> Option<(&str, Vec<&str>)> { + match spec.program() { + Program::Command { executable, args } => Some((executable.as_str(), args.iter().map(String::as_str).collect())), + Program::ImageEntrypoint => None, + } +} + +fn is_command(spec: &ExecutionSpec, executable: &str, expected: &[&str]) -> bool { + command(spec).is_some_and(|(actual, args)| actual == executable && args == expected) +} + +fn is_server_check(spec: &ExecutionSpec) -> bool { + is_command(spec, "/usr/bin/test", &["-x", "/usr/sbin/sshd"]) +} + +fn is_systemctl_check(spec: &ExecutionSpec) -> bool { + is_command(spec, "/usr/bin/test", &["-x", "/usr/bin/systemctl"]) +} + +fn is_environment_policy_check(spec: &ExecutionSpec) -> bool { + is_command( + spec, + "/usr/bin/sudo", + &[ + "-n", + "/usr/sbin/sshd", + "-T", + "-f", + "/var/lib/agent/ssh/sshd_config", + "-C", + "user=agent,host=localhost,addr=127.0.0.1,laddr=127.0.0.1,lport=2222", + ], + ) +} + +fn is_environment_snapshot(spec: &ExecutionSpec) -> bool { + is_command(spec, "/usr/bin/env", &["-0"]) +} + +fn is_runtime_directory_install(spec: &ExecutionSpec) -> bool { + is_command( + spec, + "/usr/bin/sudo", + &[ + "-n", + "/usr/bin/install", + "-d", + "-m", + "0755", + "-o", + "root", + "-g", + "root", + "/var/lib/agent/ssh", + "/run/sshd", + ], + ) +} + +fn is_systemd_running_check(spec: &ExecutionSpec) -> bool { + is_command(spec, "/usr/bin/test", &["-d", "/run/systemd/system"]) +} + +fn is_disable(spec: &ExecutionSpec) -> bool { + is_command( + spec, + "/usr/bin/sudo", + &["-n", "/usr/bin/systemctl", "disable", "--now", "agent-ssh.service"], + ) +} + +fn is_state_check(spec: &ExecutionSpec) -> bool { + is_command(spec, "/usr/bin/test", &["-e", "/var/lib/agent/ssh"]) +} + +fn exited(code: i32) -> Vec { + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Exited(ExitStatus { code }), + ] +} + +fn environment(contents: &'static [u8]) -> Vec { + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout(contents.into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ] +} + +fn valid_environment_policy() -> Vec { + environment(b"permituserenvironment yes\nusepam no\n") +} + +fn queue_valid_environment_policy(backend: &memory::Provider) { + backend.queue_execution_events_matching(is_environment_policy_check, valid_environment_policy()); +} + +fn count_sudo(backend: &memory::Provider, expected: &[&str]) -> usize { + backend + .execution_specs() + .iter() + .filter(|spec| is_command(spec, "/usr/bin/sudo", expected)) + .count() +} + +fn assert_service_reconciled_idempotently(backend: &memory::Provider) { + assert_eq!( + count_sudo(backend, &["-n", "/usr/bin/systemctl", "enable", "agent-ssh.service"]), + 2 + ); + assert_eq!( + count_sudo(backend, &["-n", "/usr/bin/systemctl", "restart", "agent-ssh.service"]), + 1 + ); + assert_eq!( + count_sudo(backend, &["-n", "/usr/bin/systemctl", "start", "agent-ssh.service"]), + 1 + ); +} + +fn assert_runtime_created_before_policy(backend: &memory::Provider) { + let executions = backend.execution_specs(); + let runtime_directory = executions + .iter() + .position(is_runtime_directory_install) + .expect("OpenSSH runtime directory install"); + let policy_validation = executions + .iter() + .position(is_environment_policy_check) + .expect("effective-policy validation"); + assert!( + runtime_directory < policy_validation, + "OpenSSH's runtime directory exists before policy validation" + ); +} + +async fn read_guest_file(sandbox: &SandboxHandle, path: &str) -> Option> { + let mut reader = sandbox.read_file(&SandboxPath::new(path)).await.ok()?; + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes).await.expect("guest file bytes"); + Some(bytes) +} + +async fn assert_guest_environment(sandbox: &SandboxHandle) { + assert_eq!( + read_guest_file(sandbox, "/home/agent/.ssh/environment").await, + Some( + b"CONTAINER_HOST=unix:///run/podman/podman.sock\nGIT_USER_NAME=Agent #1 \"Reviewer\"\nLANG=C.UTF-8\nNODE_EXTRA_CA_CERTS=/.msb/tls/ca.pem\nPATH=/home/agent/.cargo/bin:/usr/local/go/bin:/usr/bin\n" + .to_vec() + ) + ); +} + +fn record(name: &str, id: &str, ssh: bool) -> AgentRecord { + let mut resource = support::agent(name); + resource.metadata.generation = 1; + if ssh { + resource.spec.access = vec![AccessSpec::Ssh {}]; + } + AgentRecord { + id: id.parse::().expect("Agent ID"), + source_directory: PathBuf::from("/source").join(name), + manifest_path: None, + env_file: None, + agent: resource, + } +} + +struct Fixture { + _directory: TempDir, + home: ControlPlaneHome, + store: Rc, + keys: Rc, + access: Access, + backend: Rc, +} + +impl Fixture { + fn new() -> Self { + let directory = TempDir::new().expect("temporary directory"); + let home = ControlPlaneHome::resolve(Some(&directory.path().join("agent-home"))).expect("home"); + home.prepare().expect("prepare home"); + let store = Rc::new(InMemoryAgentStore::new()); + let keys = Rc::new(InMemoryHostKeyStore::new()); + let access = Access::new(&home, PathBuf::from(AGENTCTL), keys.clone(), store.clone()).with_user_home(None); + Self { + _directory: directory, + home, + store, + keys, + access, + backend: Rc::new(memory::Provider::new()), + } + } + + async fn store(&self, record: &AgentRecord, expected_generation: u64) { + self.store + .put(record.clone(), expected_generation) + .await + .expect("store record"); + } + + async fn sandbox(&self, record: &AgentRecord) -> SandboxHandle { + let service = SandboxService::new(self.backend.clone()); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + service + .ensure(&EnsureSandboxRequest::new( + record.sandbox_name().expect("Sandbox name"), + spec, + )) + .await + .expect("Sandbox") + } + + fn ssh_home(&self) -> ssh::SshHome { + ssh::SshHome::new(&self.home) + } + + fn config(&self) -> String { + std::fs::read_to_string(self.ssh_home().config_path()).expect("generated config") + } + + fn known_hosts(&self) -> String { + std::fs::read_to_string(self.ssh_home().known_hosts_path()).unwrap_or_default() + } +} + +#[tokio::test(flavor = "local")] +async fn access_is_idempotent_and_only_public_material_enters_the_guest() { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + for _ in 0..2 { + fixture + .backend + .queue_execution_events_matching(is_server_check, exited(0)); + queue_valid_environment_policy(&fixture.backend); + fixture.backend.queue_execution_events_matching( + is_environment_snapshot, + environment( + b"PATH=/home/agent/.cargo/bin:/usr/local/go/bin:/usr/bin\0NODE_EXTRA_CA_CERTS=/.msb/tls/ca.pem\0GIT_USER_NAME=Agent #1 \"Reviewer\"\0TERM=dumb\0HOME=/image-home\0", + ), + ); + } + + assert!(fixture.access.reconcile(&record, &sandbox).await.expect("first pass")); + assert_runtime_created_before_policy(&fixture.backend); + let host_key = read_guest_file(&sandbox, "/var/lib/agent/ssh/ssh_host_ed25519_key") + .await + .expect("host key in guest"); + let host_public = read_guest_file(&sandbox, "/var/lib/agent/ssh/ssh_host_ed25519_key.pub") + .await + .expect("host public key in guest"); + let authorized = read_guest_file(&sandbox, "/var/lib/agent/ssh/authorized_keys") + .await + .expect("authorized_keys in guest"); + let ssh_home = fixture.ssh_home(); + let client_private = std::fs::read_to_string(ssh_home.identity_path(record.id)).expect("client private key"); + let client_public = std::fs::read_to_string(ssh_home.public_identity_path(record.id)).expect("client public key"); + + assert!(fixture.keys.contains(record.id)); + assert!(host_key.starts_with(b"-----BEGIN OPENSSH PRIVATE KEY-----")); + assert!(client_private.starts_with("-----BEGIN OPENSSH PRIVATE KEY-----")); + assert_ne!(host_key, client_private.as_bytes(), "host and client keys differ"); + assert_eq!(authorized, client_public.as_bytes()); + assert_guest_environment(&sandbox).await; + assert!(client_public.starts_with("ssh-ed25519 AAAA")); + let host_public = String::from_utf8(host_public).expect("UTF-8 public key"); + assert_eq!( + fixture.known_hosts(), + format!("agent-{id} {host_public}agentctl-worker {host_public}", id = record.id), + "known_hosts is pre-seeded under the incarnation alias and, for clients without HostKeyAlias, the Host alias" + ); + let expected_config = format!( + "\nHost agentctl-worker\n User agent\n ProxyCommand {AGENTCTL} ssh-proxy agent/worker\n HostKeyAlias agent-{id}\n IdentityFile {identity}\n UserKnownHostsFile {known_hosts}\n IdentitiesOnly yes\n", + id = record.id, + // The same renderer the config uses: on Windows the paths are quoted with escaped backslashes. + identity = ssh::render_path(&ssh_home.identity_path(record.id), None), + known_hosts = ssh::render_path(&ssh_home.known_hosts_path(), None), + ); + assert!(fixture.config().ends_with(&expected_config), "{}", fixture.config()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mode = |path: &std::path::Path| std::fs::metadata(path).expect("metadata").permissions().mode() & 0o777; + assert_eq!(mode(&ssh_home.identity_path(record.id)), 0o600); + assert_eq!(mode(&ssh_home.agent_directory(record.id)), 0o700); + assert_eq!(mode(ssh_home.root()), 0o700); + } + let info = fixture.access.describe("worker").await.expect("descriptor"); + assert_eq!(info.alias, "agentctl-worker"); + assert_eq!(info.identity_file, ssh_home.identity_path(record.id)); + assert_eq!(info.proxy_command, format!("{AGENTCTL} ssh-proxy agent/worker")); + + assert!(fixture.access.reconcile(&record, &sandbox).await.expect("second pass")); + assert_eq!( + read_guest_file(&sandbox, "/var/lib/agent/ssh/ssh_host_ed25519_key").await, + Some(host_key), + "the incarnation keeps its host key" + ); + assert_eq!( + std::fs::read_to_string(ssh_home.identity_path(record.id)).expect("client key"), + client_private, + "the incarnation keeps its client key" + ); + assert_eq!(fixture.known_hosts().lines().count(), 2); + assert_service_reconciled_idempotently(&fixture.backend); + assert_eq!( + count_sudo( + &fixture.backend, + &["-n", "/bin/chmod", "0600", "/var/lib/agent/ssh/ssh_host_ed25519_key"] + ), + 2 + ); + let guest_files = [ + "/var/lib/agent/ssh/ssh_host_ed25519_key", + "/var/lib/agent/ssh/ssh_host_ed25519_key.pub", + "/var/lib/agent/ssh/authorized_keys", + "/var/lib/agent/ssh/sshd_config", + "/etc/systemd/system/agent-ssh.service", + "/home/agent/.ssh/environment", + ]; + for path in guest_files { + let contents = read_guest_file(&sandbox, path).await.expect("guest file"); + assert!( + !String::from_utf8_lossy(&contents).contains(client_private.trim()), + "{path} must not carry the client private key" + ); + } +} + +#[tokio::test(flavor = "local")] +async fn an_image_without_a_server_fails_permanently_before_any_key_exists() { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + fixture + .backend + .queue_execution_events_matching(is_server_check, exited(1)); + + let error = fixture + .access + .reconcile(&record, &sandbox) + .await + .expect_err("missing server"); + assert!( + matches!(&error, Error::Invalid(message) if message.contains("cannot provide SSH access") && message.contains("/usr/sbin/sshd is missing") && message.contains("re-apply")) + ); + assert_eq!(ReconcileFailure::classify(&error).kind, FailureKind::Invalid); + assert!(!fixture.keys.contains(record.id)); + assert!(!fixture.ssh_home().agent_directory(record.id).exists()); + assert!(!fixture.ssh_home().known_hosts_path().exists()); + assert!( + read_guest_file(&sandbox, "/var/lib/agent/ssh/authorized_keys") + .await + .is_none() + ); +} + +#[tokio::test(flavor = "local")] +async fn an_image_without_systemd_support_fails_permanently() { + for (predicate, expected) in [ + (is_systemctl_check as fn(&ExecutionSpec) -> bool, "systemctl is missing"), + (is_systemd_running_check, "systemd is not the running init"), + ] { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + fixture.backend.queue_execution_events_matching(predicate, exited(1)); + + let error = fixture + .access + .reconcile(&record, &sandbox) + .await + .expect_err("incomplete image contract"); + assert!( + matches!(&error, Error::Invalid(message) if message.contains(expected)), + "{error}" + ); + assert!(!fixture.keys.contains(record.id)); + } +} + +#[tokio::test(flavor = "local")] +async fn an_image_that_blocks_the_managed_environment_fails_permanently() { + for response in [environment(b"permituserenvironment yes\nusepam yes\n"), exited(1)] { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + fixture + .backend + .queue_execution_events_matching(is_environment_policy_check, response); + + let error = fixture + .access + .reconcile(&record, &sandbox) + .await + .expect_err("environment policy"); + assert!( + matches!(&error, Error::Invalid(message) if message.contains("cannot provide SSH access")), + "{error}" + ); + assert!( + fixture.keys.contains(record.id), + "the real host key is retained for retry" + ); + assert!( + read_guest_file(&sandbox, "/var/lib/agent/ssh/ssh_host_ed25519_key") + .await + .is_some(), + "the effective policy is evaluated with the real host key" + ); + assert!( + read_guest_file(&sandbox, "/var/lib/agent/ssh/authorized_keys") + .await + .is_none(), + "login state is not installed before the policy passes" + ); + assert_eq!( + count_sudo( + &fixture.backend, + &["-n", "/usr/bin/systemctl", "enable", "agent-ssh.service"] + ), + 0 + ); + } +} + +#[tokio::test(flavor = "local")] +async fn a_failed_server_stop_keeps_the_state_for_the_next_pass() { + let fixture = Fixture::new(); + let mut record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + queue_valid_environment_policy(&fixture.backend); + assert!(fixture.access.reconcile(&record, &sandbox).await.expect("grant")); + + let known_hosts_before = fixture.known_hosts(); + record.agent.spec.access.clear(); + record.agent.metadata.generation = 2; + fixture.store(&record, 1).await; + fixture.backend.queue_execution_events_matching( + is_disable, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stderr("Failed to stop agent-ssh.service: Connection timed out\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 1 }), + ], + ); + let error = fixture + .access + .reconcile(&record, &sandbox) + .await + .expect_err("a running server is not forgotten"); + assert!( + matches!(&error, Error::SandboxSetup(message) if message.contains("Connection timed out")), + "{error}" + ); + assert_eq!( + count_sudo(&fixture.backend, &["-n", "/bin/rm", "-rf", "/var/lib/agent/ssh"]), + 0 + ); + assert!( + fixture.keys.contains(record.id), + "the host key stays while the server that holds it may still run" + ); + assert_eq!( + fixture.known_hosts(), + known_hosts_before, + "known_hosts keeps matching that server" + ); + assert!(fixture.ssh_home().identity_path(record.id).is_file()); + assert!( + read_guest_file(&sandbox, "/var/lib/agent/ssh/authorized_keys") + .await + .is_some(), + "guest state stays until the server is confirmed stopped" + ); + + // A unit the image never shipped is the one failure that is not a running server. + fixture.backend.queue_execution_events_matching( + is_disable, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stderr("Failed to disable unit: Unit file agent-ssh.service does not exist.\n".into()), + ExecutionEvent::Exited(ExitStatus { code: 1 }), + ], + ); + assert!(!fixture.access.reconcile(&record, &sandbox).await.expect("withdraw")); + assert_eq!( + count_sudo(&fixture.backend, &["-n", "/bin/rm", "-rf", "/var/lib/agent/ssh"]), + 1 + ); +} + +#[tokio::test(flavor = "local")] +async fn withdrawing_access_without_systemd_removes_only_the_files() { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", false); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + fixture + .backend + .queue_execution_events_matching(is_state_check, exited(0)); + fixture + .backend + .queue_execution_events_matching(is_systemd_running_check, exited(1)); + assert!(!fixture.access.reconcile(&record, &sandbox).await.expect("withdraw")); + assert!(!fixture.backend.execution_specs().iter().any(is_disable)); + assert_eq!( + count_sudo(&fixture.backend, &["-n", "/bin/rm", "-rf", "/var/lib/agent/ssh"]), + 1 + ); +} + +#[tokio::test(flavor = "local")] +async fn withdrawing_access_removes_guest_and_host_state() { + let fixture = Fixture::new(); + let mut record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + fixture + .backend + .queue_execution_events_matching(is_server_check, exited(0)); + queue_valid_environment_policy(&fixture.backend); + assert!(fixture.access.reconcile(&record, &sandbox).await.expect("grant")); + + record.agent.spec.access.clear(); + record.agent.metadata.generation = 2; + fixture.store(&record, 1).await; + fixture + .backend + .queue_execution_events_matching(is_state_check, exited(0)); + assert!(!fixture.access.reconcile(&record, &sandbox).await.expect("withdraw")); + + assert_eq!( + count_sudo( + &fixture.backend, + &["-n", "/usr/bin/systemctl", "disable", "--now", "agent-ssh.service"] + ), + 1 + ); + assert_eq!( + count_sudo(&fixture.backend, &["-n", "/bin/rm", "-rf", "/var/lib/agent/ssh"]), + 1 + ); + assert!(!fixture.keys.contains(record.id)); + assert!(!fixture.ssh_home().agent_directory(record.id).exists()); + assert_eq!(fixture.known_hosts(), ""); + assert!(!fixture.config().contains("Host ")); + + // A later pass finds no guest state and leaves systemd alone. + fixture + .backend + .queue_execution_events_matching(is_state_check, exited(1)); + assert!(!fixture.access.reconcile(&record, &sandbox).await.expect("steady")); + assert_eq!( + count_sudo( + &fixture.backend, + &["-n", "/usr/bin/systemctl", "disable", "--now", "agent-ssh.service"] + ), + 1 + ); +} + +#[tokio::test(flavor = "local")] +async fn deletion_removes_host_material_and_config_lists_only_active_ssh_agents() { + let fixture = Fixture::new(); + let worker = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + let reviewer = record("reviewer", "5c1f4a1e-0ad5-4a37-9c94-4c0f2e6d7a10", true); + let plain = record("plain", "9e2d6b5a-3d3a-4a2b-8a3c-1f9d2c3b4a55", false); + let mut leaving = record("leaving", "0b7e2f31-6a94-4d0e-9d61-3ac7d1a2b3c4", true); + leaving.agent.metadata.deletion_timestamp = Some(time::OffsetDateTime::now_utc()); + for record in [&worker, &reviewer, &plain, &leaving] { + fixture.store(record, 0).await; + } + let sandbox = fixture.sandbox(&worker).await; + fixture + .backend + .queue_execution_events_matching(is_server_check, exited(0)); + queue_valid_environment_policy(&fixture.backend); + assert!(fixture.access.reconcile(&worker, &sandbox).await.expect("grant")); + + let aliases = fixture + .config() + .lines() + .filter_map(|line| line.strip_prefix("Host ")) + .map(str::to_owned) + .collect::>(); + assert_eq!(aliases, ["agentctl-reviewer", "agentctl-worker"]); + + assert!(matches!( + fixture.access.describe("plain").await, + Err(Error::Invalid(message)) if message.contains("does not declare SSH access") + )); + assert!(matches!(fixture.access.describe("nobody").await, Err(Error::NotFound))); + + fixture.access.remove(&worker).await.expect("remove on deletion"); + assert!(!fixture.keys.contains(worker.id)); + assert!(!fixture.ssh_home().agent_directory(worker.id).exists()); + assert_eq!(fixture.known_hosts(), ""); + fixture.access.remove(&worker).await.expect("removal is idempotent"); +} + +#[tokio::test(flavor = "local")] +async fn descriptor_json_is_the_documented_shape() { + let fixture = Fixture::new(); + let worker = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&worker, 0).await; + let info = fixture.access.describe("worker").await.expect("descriptor"); + let value = serde_json::to_value(&info).expect("JSON"); + let object = value.as_object().expect("object"); + let mut expected = [ + "type", + "agent", + "agentId", + "alias", + "user", + "identityFile", + "knownHostsFile", + "configFile", + "proxyCommand", + "workingDirectory", + ]; + expected.sort_unstable(); + assert_eq!(object.keys().map(String::as_str).collect::>(), expected); + assert_eq!(value["type"], "ssh"); + assert_eq!(value["agent"], "worker"); + assert_eq!(value["agentId"], "38f41de4-6ff7-4679-ae46-678bc61e4dcb"); + assert_eq!(value["alias"], "agentctl-worker"); + assert_eq!(value["user"], "agent"); + assert_eq!(value["proxyCommand"], format!("{AGENTCTL} ssh-proxy agent/worker")); + assert_eq!(value["workingDirectory"], "/home/agent/code"); + let decoded: ssh::AccessInfo = serde_json::from_value(value).expect("round trip"); + assert_eq!(decoded, info); +} diff --git a/agentctl/tests/support/mod.rs b/agentctl/tests/support/mod.rs new file mode 100644 index 0000000..2b8442f --- /dev/null +++ b/agentctl/tests/support/mod.rs @@ -0,0 +1,99 @@ +#![allow(dead_code)] + +use std::path::PathBuf; + +use agent::{ + API_VERSION, Agent, Harness, HarnessAuthMode, HarnessSpec, HomeSpec, InstructionsSpec, KIND, Metadata, + ModelSelection, NetworkAllow, NetworkMode, NetworkSpec, PlatformManifestSpec, SandboxManifestSpec, Spec, Status, +}; +use sandbox::{ + ByteQuantity, CpuQuantity, Platform, RetentionPolicy, RootFilesystem, SandboxResources, image::ImageSource, +}; +/// Drops reconciliation wake-ups, for tests that reconcile by hand or not at all. +pub(crate) struct IgnoreNotifications; + +impl agent::control_plane::Notifier for IgnoreNotifications { + fn notify(&self, _id: agent::AgentId) {} +} + +pub(crate) fn agent(name: &str) -> Agent { + Agent { + api_version: API_VERSION.into(), + kind: KIND.into(), + metadata: Metadata { + name: name.into(), + generation: 0, + deletion_timestamp: None, + }, + spec: Spec { + run_state: None, + sandbox: SandboxManifestSpec { + image: ImageSource::Build { + context: PathBuf::from("image"), + dockerfile: PathBuf::from("Dockerfile"), + target: None, + }, + platform: PlatformManifestSpec { + os: "linux".into(), + architecture: Some(Platform::native("linux").architecture), + variant: None, + os_version: None, + os_features: std::collections::BTreeSet::new(), + }, + resources: SandboxResources::new( + "2".parse::().expect("test CPU should be valid"), + "1Gi".parse::().expect("test memory should be valid"), + RootFilesystem::layered( + "4Gi" + .parse::() + .expect("test root filesystem should be valid"), + ), + ), + init_system: sandbox::init::InitSystem::Backend, + retention_policy: Some(RetentionPolicy::Retain), + mounts: Vec::new(), + }, + home: HomeSpec { + source: PathBuf::from("home"), + }, + instructions: vec![InstructionsSpec { + source: PathBuf::from("instructions.md"), + }], + skills: vec![], + harnesses: vec![HarnessSpec { + kind: Harness::ClaudeCode, + version: Some("2.1.266".into()), + auth: HarnessAuthMode::Mediated, + optional: false, + default: false, + defaults: ModelSelection::default(), + }], + environment: Vec::new(), + secrets: Vec::new(), + access: Vec::new(), + network: NetworkSpec { + mode: NetworkMode::Mediated, + allow: NetworkAllow::All, + deny: Vec::new(), + }, + }, + status: Status::default(), + } +} + +pub(crate) struct TempDirectory(tempfile::TempDir); + +impl TempDirectory { + pub(crate) fn new(label: &str) -> Self { + Self( + tempfile::Builder::new() + .prefix(&format!("agent-platform-{label}-")) + .tempdir() + .expect("temporary directory should be created"), + ) + } + + pub(crate) fn path(&self) -> &std::path::Path { + self.0.path() + } +} diff --git a/agentctl/tests/tmux_delivery.mjs b/agentctl/tests/tmux_delivery.mjs new file mode 100644 index 0000000..94e7f6f --- /dev/null +++ b/agentctl/tests/tmux_delivery.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { access, mkdtemp, readFile, rm, utimes, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout } from "node:timers/promises"; + +const script = await readFile(process.argv[2], "utf8"); +const observationScript = await readFile(process.argv[3], "utf8"); +const stopScript = await readFile(process.argv[4], "utf8"); +const directory = await mkdtemp(join(tmpdir(), "tmux-delivery-")); +const socket = join(directory, "socket"); +const received = join(directory, "received"); +const terminal = join(directory, "terminal.mjs"); +const env = { ...process.env, TMUX: `${socket},0,0` }; +const tmux = (...args) => execFileSync("/usr/bin/tmux", ["-S", socket, ...args], { encoding: "utf8" }); +await writeFile(terminal, `import { createInterface } from "node:readline"; +import { appendFileSync } from "node:fs"; +for await (const line of createInterface({ input: process.stdin })) appendFileSync(${JSON.stringify(received)}, line + "\\n");`); +tmux("new-session", "-d", "-s", "input", `node ${terminal}`); +async function deliver(text) { + const file = join(directory, text); + await writeFile(file, text); + const child = spawn("/bin/sh", ["-c", script, "deliver", file, text, "=input:"], { env, stdio: "inherit" }); + return new Promise((resolve) => child.on("exit", resolve)); +} +try { + await setTimeout(100); + assert.equal(await deliver("first"), 0); + assert.equal(await deliver("second"), 0); + await setTimeout(100); + assert.equal(await readFile(received, "utf8"), "first\nsecond\n"); + for (const file of ["first", "second"]) { + await assert.rejects(access(join(directory, file)), { code: "ENOENT" }); + } + assert.equal(tmux("list-buffers"), ""); + + const observe = (transcript) => execFileSync("/bin/sh", ["-c", observationScript, "observe", "input", transcript], { env, encoding: "utf8" }).trim(); + const transcript = join(directory, "transcript with spaces.jsonl"); + await setTimeout(1200); + const baseline = observe(""); + assert.ok(Number(baseline.split(" ")[1]) >= 1, baseline); + assert.ok(Number(observe(transcript).split(" ")[1]) >= 1, "missing transcript uses terminal activity"); + await writeFile(transcript, "not parsed as JSON"); + await utimes(transcript, 1, 1); + assert.ok(Number(observe(transcript).split(" ")[1]) >= 1, "old transcript does not mask terminal activity"); + // A timestamp ahead of the guest clock also exercises the zero-age clamp. + const future = Date.now() / 1000 + 60; + await utimes(transcript, future, future); + assert.equal(observe(transcript), "0 0", "recent transcript keeps a quiet terminal active"); + + const stop = (target, environment = env) => + spawn("/bin/sh", ["-c", stopScript, "stop", target], { env: environment, stdio: "inherit" }); + const exited = (child) => new Promise((resolve) => child.on("exit", resolve)); + tmux("new-session", "-d", "-s", "stoppable", "sleep 600"); + assert.equal(await exited(stop("=stoppable")), 0); + assert.equal(tmux("list-sessions", "-F", "#{session_name}").trim(), "input", "the stopped Session is gone"); + assert.equal(await exited(stop("=stoppable")), 0, "an already stopped Session is stopped"); + const noServer = { ...process.env, TMUX: `${join(directory, "no-server")},0,0` }; + assert.equal(await exited(stop("=stoppable", noServer)), 0, "a stopped server has no Session left"); + console.log("tmux: submissions stay separate, cleanup succeeds, transcript freshness counts as activity, and stopping is idempotent"); +} finally { + tmux("kill-server"); + await rm(directory, { recursive: true, force: true }); +} diff --git a/agentctl/tests/tmux_scrollback.mjs b/agentctl/tests/tmux_scrollback.mjs new file mode 100644 index 0000000..e44e790 --- /dev/null +++ b/agentctl/tests/tmux_scrollback.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout } from "node:timers/promises"; + +// Arguments come from the runtime's actual command builders, not a copy of its policy. +const options = JSON.parse(process.argv[2]); +const attach = JSON.parse(process.argv[3]); +const session = process.argv[4]; +const directory = await mkdtemp(join(tmpdir(), "tmux-scrollback-")); +const socket = join(directory, "socket"); +const env = { ...process.env, TERM: "xterm-256color", TMUX: "" }; +const tmux = (...args) => execFileSync("tmux", ["-S", socket, "-f", "/dev/null", ...args], { encoding: "utf8", env }).trim(); +const format = (target, value) => tmux("display-message", "-p", "-t", target, `#{${value}}`); +async function until(predicate) { + for (let attempt = 0; attempt < 100; attempt++) { + if (predicate()) return; + await setTimeout(50); + } + assert.fail("terminal condition timed out"); +} +let client; +try { + const program = join(directory, "output.sh"); + await writeFile(program, `#!/bin/sh +[ "$1" != alternate ] || printf '\\033[?1049h' +i=1 +while [ "$i" -le 100 ]; do printf 'retained-line-%03d\\n' "$i"; i=$((i+1)); done +printf 'OUTPUT-READY' +if [ "$1" = alternate ]; then + stty raw -echo + printf '\\033[?1000h\\033[?1006h' + exec cat > ${directory}/mouse-events +fi +exec sleep 120 +`); + tmux(...options, "new-session", "-d", "-x", "80", "-y", "10", "-s", session, `sh ${program} normal`); + const pane = `=${session}:`; + await until(() => tmux("capture-pane", "-p", "-t", pane).includes("OUTPUT-READY")); + assert.equal(format(pane, "history_limit"), "50000", "limit must precede pane creation"); + assert.equal(tmux("show-options", "-gv", "mouse"), "on"); + assert.equal(tmux("show-options", "-sv", "focus-events"), "on"); + assert.equal(tmux("show-options", "-sv", "extended-keys"), "on"); + const features = tmux("show-options", "-s", "terminal-features"); + tmux(...options, "display-message", "-p", "configured"); + assert.equal(tmux("show-options", "-s", "terminal-features"), features, "reconfiguration must not append entries"); + assert.ok(features.includes('terminal-features[99] xterm*:extkeys')); + assert.ok(Number(format(pane, "history_size")) >= 90); + const history = tmux("capture-pane", "-p", "-S", "-", "-t", pane); + assert.ok(history.includes("retained-line-001")); + console.log("PASS: runtime options precede pane creation; repeated configuration is idempotent"); + console.log("PASS: normal-screen output is retained in 50,000-line pane history"); + + tmux("set-option", "-g", "history-limit", "2000", ";", "new-session", "-d", "-s", "legacy", "sleep 120"); + tmux(...options, "new-session", "-d", "-s", "after-upgrade", "sleep 120"); + assert.equal(format("=legacy:", "history_limit"), "2000"); + assert.equal(format("=after-upgrade:", "history_limit"), "50000"); + console.log("PASS: new panes on an existing server get the new limit; old pane limits remain unchanged"); + + tmux("new-session", "-d", "-x", "80", "-y", "10", "-s", "alternate", `sh ${program} alternate`); + await until(() => tmux("capture-pane", "-p", "-t", "=alternate:").includes("OUTPUT-READY")); + assert.equal(format("=alternate:", "alternate_on"), "1"); + assert.equal(format("=alternate:", "history_size"), "0"); + console.log("PASS: alternate-screen applications retain their own scrolling responsibility"); + + // Exercise real PTY clients, including upgrading a session-local mouse override. + const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`; + const command = ["tmux", "-S", socket, ...attach].map(quote).join(" "); + for (let attempt = 0; attempt < 2; attempt++) { + tmux("set-option", "-t", pane, "mouse", "off"); + client = spawn("script", ["-q", "-c", command, "/dev/null"], { env, stdio: ["pipe", "ignore", "pipe"] }); + let errors = ""; + client.stderr.on("data", (data) => { errors += data; }); + await until(() => format(pane, "session_attached") === "1"); + assert.equal(tmux("show-options", "-v", "-t", pane, "mouse"), "on", errors); + client.stdin.write("\x1b[<64;5;5M"); + await until(() => format(pane, "pane_in_mode") === "1"); + tmux("send-keys", "-X", "-t", pane, "cancel"); + const exited = new Promise((resolve) => client.once("exit", resolve)); + tmux("detach-client", "-s", session); + assert.equal(await exited, 0, errors); + client = undefined; + assert.ok(tmux("capture-pane", "-p", "-S", "-", "-t", pane).includes("retained-line-001")); + } + console.log("PASS: attach repairs mouse mode; wheel enters copy mode; history survives detach/reattach"); + + const alternateAttach = attach.map((arg) => arg.replace(session, "alternate")); + const alternateCommand = ["tmux", "-S", socket, ...alternateAttach].map(quote).join(" "); + client = spawn("script", ["-q", "-c", alternateCommand, "/dev/null"], { env, stdio: ["pipe", "ignore", "ignore"] }); + await until(() => format("=alternate:", "session_attached") === "1"); + const wheel = "\x1b[<64;5;5M"; + client.stdin.write(wheel); + const received = join(directory, "mouse-events"); + await until(() => existsSync(received) && readFileSync(received, "utf8").includes(wheel)); + assert.equal(format("=alternate:", "pane_in_mode"), "0"); + const exited = new Promise((resolve) => client.once("exit", resolve)); + tmux("detach-client", "-s", "alternate"); + assert.equal(await exited, 0); + client = undefined; + console.log("PASS: wheel events reach a fullscreen application that requests mouse input"); + +} finally { + client?.kill(); + try { tmux("kill-server"); } finally { await rm(directory, { recursive: true, force: true }); } +} diff --git a/agentctl/tests/tmux_suspend.mjs b/agentctl/tests/tmux_suspend.mjs new file mode 100644 index 0000000..0ba0dca --- /dev/null +++ b/agentctl/tests/tmux_suspend.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { setTimeout } from "node:timers/promises"; + +// Arguments come from the runtime's actual command builders, not a copy of its policy. +const options = JSON.parse(process.argv[2]); +const attach = JSON.parse(process.argv[3]); +const session = process.argv[4]; +const directory = await mkdtemp(join(tmpdir(), "tmux-suspend-")); +const socket = join(directory, "socket"); +const env = { ...process.env, TERM: "xterm-256color", TMUX: "" }; +const tmux = (...args) => execFileSync("tmux", ["-S", socket, "-f", "/dev/null", ...args], { encoding: "utf8", env }).trim(); +const format = (target, value) => tmux("display-message", "-p", "-t", target, `#{${value}}`); +const stopped = (pid) => execFileSync("ps", ["-o", "stat=", "-p", String(pid)], { encoding: "utf8" }).trim().startsWith("T"); +const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`; +async function until(predicate) { + for (let attempt = 0; attempt < 100; attempt++) { + if (predicate()) return; + await setTimeout(50); + } + assert.fail("terminal condition timed out"); +} + +// `cat -v` stands in for a harness or a foreground job and shows a delivered Ctrl-Z as `^Z`; +// `stty -isig` keeps the byte from raising a signal, so the capture reveals whether it arrived. +const HARNESS = "stty -isig; exec cat -v"; +let client; +// Attachment is complete only once typed input reaches the pane: a key sent before the client +// has put its own terminal into raw mode is eaten by that terminal instead. The runtime's attach +// arguments name one session; retarget them by name to drive a second session with the same server. +async function attached(name, target, probe) { + const args = attach.map((arg) => arg.replaceAll(session, name)); + const command = ["tmux", "-S", socket, ...args].map(quote).join(" "); + client = spawn("script", ["-q", "-c", command, "/dev/null"], { env, stdio: ["pipe", "ignore", "ignore"] }); + await until(() => format(`=${name}:`, "session_attached") === "1"); + client.stdin.write(`${probe}\n`); + await until(() => tmux("capture-pane", "-p", "-t", target).includes(probe)); +} +async function detach(name) { + const exited = new Promise((resolve) => client.once("exit", resolve)); + tmux("detach-client", "-s", name); + await exited; + client = undefined; +} +try { + // 1. The Session's own harness pane: Ctrl-Z is swallowed and the harness keeps reading input. + tmux(...options, "new-session", "-d", "-x", "80", "-y", "10", "-s", session, HARNESS); + const pane = `=${session}:`; + assert.notEqual(format(pane, "pane_start_command"), "", "a harness pane starts with a command"); + const paneOut = () => tmux("capture-pane", "-p", "-t", pane); + await attached(session, pane, "probe-one"); + client.stdin.write("\x1a"); + client.stdin.write("first-line\n"); + await until(() => paneOut().includes("first-line")); + assert.ok(!paneOut().includes("^Z"), `Ctrl-Z must not reach the harness pane:\n${paneOut()}`); + await detach(session); + console.log("PASS: Ctrl-Z in the harness pane is swallowed and the harness keeps reading input"); + + // 2. A window opened with Ctrl-b c runs a shell; job control makes suspension recoverable there. + tmux("new-window", "-d", "-t", pane); + const shell = `=${session}:1`; + assert.equal(format(shell, "pane_start_command"), ""); + tmux("send-keys", "-t", shell, "cat", "Enter"); + let job; + await until(() => { + try { + job = Number(execFileSync("pgrep", ["-P", format(shell, "pane_pid"), "-x", "cat"], { encoding: "utf8" })); + return true; + } catch { + return false; + } + }); + tmux("select-window", "-t", shell); + await attached(session, shell, "probe-two"); + client.stdin.write("\x1a"); + await until(() => stopped(job)); + await detach(session); + tmux("select-window", "-t", `=${session}:0`); + console.log("PASS: Ctrl-Z still suspends a foreground job in a shell window"); + + // 3. The binding is server-wide but scoped by session name, so a non-Agent session with a + // command-started pane still receives Ctrl-Z (proves the swallow above is not global). + const other = "user-shell-x"; + tmux("new-session", "-d", "-x", "80", "-y", "10", "-s", other, HARNESS); + const otherPane = `=${other}:`; + const otherOut = () => tmux("capture-pane", "-p", "-t", otherPane); + await attached(other, otherPane, "probe-three"); + client.stdin.write("\x1a"); + client.stdin.write("third-line\n"); + await until(() => otherOut().includes("third-line")); + assert.ok(otherOut().includes("^Z"), `Ctrl-Z must reach a non-Agent pane:\n${otherOut()}`); + await detach(other); + console.log("PASS: Ctrl-Z reaches a command pane in a non-Agent session (binding is scoped)"); +} finally { + client?.kill(); + try { tmux("kill-server"); } finally { await rm(directory, { recursive: true, force: true }); } +} diff --git a/agentctl/tests/variants.rs b/agentctl/tests/variants.rs new file mode 100644 index 0000000..d1b94ae --- /dev/null +++ b/agentctl/tests/variants.rs @@ -0,0 +1,283 @@ +#![allow(clippy::expect_used)] + +mod support; + +use std::path::Path; + +use agent::{AgentVariantName, manifest}; + +fn agent_directory() -> support::TempDirectory { + let directory = support::TempDirectory::new("variants"); + let yaml = serde_yaml_ng::to_string(&support::agent("base")).expect("base YAML"); + std::fs::write(directory.path().join("agent.yaml"), yaml).expect("base manifest"); + directory +} + +fn write(directory: &Path, name: &str, body: &str) { + std::fs::write(directory.join(name), body).expect("variant manifest"); +} + +fn variant(extends: &str, name: &str, spec: &str) -> String { + format!( + "apiVersion: agents.platform/v1alpha1\nkind: AgentVariant\nextends: {extends}\nmetadata:\n name: {name}\n{spec}" + ) +} + +#[test] +fn resolves_one_level_and_multilevel_variants_from_the_base_outward() { + let directory = agent_directory(); + write( + directory.path(), + "agent.nested.yaml", + &variant( + "agent.yaml", + "nested", + "spec:\n sandbox:\n resources:\n cpu: '1'\n memory: 2Gi\n", + ), + ); + write( + directory.path(), + "agent.mine.yaml", + &variant( + "agent.nested.yaml", + "mine", + "spec:\n sandbox:\n resources:\n cpu: '3'\n", + ), + ); + + let resolved = manifest::resolve(&directory.path().join("agent.mine.yaml")).expect("resolved variant"); + let value = serde_json::to_value(&resolved.agent).expect("Agent JSON"); + assert_eq!(resolved.agent.metadata.name, "mine"); + assert_eq!(value["spec"]["sandbox"]["resources"]["cpu"], "3"); + assert_eq!(value["spec"]["sandbox"]["resources"]["memory"], "2Gi"); + assert_eq!( + resolved + .chain + .iter() + .filter_map(|path| path.file_name().and_then(|name| name.to_str())) + .collect::>(), + ["agent.mine.yaml", "agent.nested.yaml", "agent.yaml"] + ); +} + +#[test] +fn mappings_merge_while_arrays_replace_and_empty_arrays_clear() { + let directory = agent_directory(); + write( + directory.path(), + "agent.arrays.yaml", + &variant( + "agent.yaml", + "arrays", + "spec:\n sandbox:\n mounts:\n - type: tmpfs\n target: /tmp\n capacity: 2Gi\n instructions: []\n harnesses:\n - type: codex\n auth: mediated\n", + ), + ); + + let agent = manifest::resolve(&directory.path().join("agent.arrays.yaml")) + .expect("array variant") + .agent; + assert_eq!(agent.spec.sandbox.mounts.len(), 1); + assert!(agent.spec.instructions.is_empty()); + assert_eq!(agent.spec.harnesses.len(), 1); + assert_eq!(agent.spec.harnesses[0].kind, agent::Harness::Codex); + assert_eq!(agent.spec.home.source, Path::new("home")); +} + +#[test] +fn changing_a_tagged_mapping_type_replaces_the_previous_variant() { + let directory = agent_directory(); + write( + directory.path(), + "agent.reference.yaml", + &variant( + "agent.yaml", + "reference", + "spec:\n sandbox:\n image:\n type: reference\n reference: example.invalid/agent:latest\n", + ), + ); + + let agent = manifest::resolve(&directory.path().join("agent.reference.yaml")) + .expect("tagged mapping replacement") + .agent; + assert!(matches!( + agent.spec.sandbox.image, + sandbox::image::ImageSource::Reference { .. } + )); +} + +#[test] +fn null_removes_optional_fields_and_required_removal_fails_final_validation() { + let directory = agent_directory(); + write( + directory.path(), + "agent.optional.yaml", + &variant( + "agent.yaml", + "optional", + "spec:\n sandbox:\n retentionPolicy: null\n", + ), + ); + let optional = manifest::resolve(&directory.path().join("agent.optional.yaml")).expect("optional removal"); + assert_eq!(optional.agent.spec.sandbox.retention_policy, None); + + write( + directory.path(), + "agent.required.yaml", + &variant("agent.yaml", "required", "spec:\n sandbox:\n resources: null\n"), + ); + let error = manifest::resolve(&directory.path().join("agent.required.yaml")).expect_err("required removal"); + assert!(error.to_string().contains("resources")); + assert!(error.to_string().contains("agent.required.yaml")); +} + +#[test] +fn rejects_unknown_variant_fields_even_when_null() { + let directory = agent_directory(); + for (name, spec) in [ + ("unknown", "spec:\n mystery: null\n"), + ( + "nested-unknown", + "spec:\n sandbox:\n resources:\n mystery: null\n", + ), + ] { + let filename = format!("agent.{name}.yaml"); + write(directory.path(), &filename, &variant("agent.yaml", name, spec)); + let error = manifest::resolve(&directory.path().join(filename)).expect_err("unknown field"); + assert!(error.to_string().contains("unknown field"), "{error}"); + } +} + +#[test] +fn rejects_missing_bases_cross_directory_paths_and_absolute_paths() { + let directory = agent_directory(); + for (name, extends, expected) in [ + ("missing", "agent.absent.yaml", "could not read"), + ("parent", "../agent.yaml", "extends must name"), + ("absolute", "/tmp/agent.yaml", "extends must name"), + ] { + let filename = format!("agent.{name}.yaml"); + write(directory.path(), &filename, &variant(extends, name, "")); + let error = manifest::resolve(&directory.path().join(filename)).expect_err("invalid base"); + assert!(error.to_string().contains(expected), "{error}"); + } +} + +#[test] +fn reports_the_complete_cycle() { + let directory = agent_directory(); + write( + directory.path(), + "agent.one.yaml", + &variant("agent.two.yaml", "one", ""), + ); + write( + directory.path(), + "agent.two.yaml", + &variant("agent.one.yaml", "two", ""), + ); + let error = manifest::resolve(&directory.path().join("agent.one.yaml")).expect_err("cycle"); + assert!( + error + .to_string() + .contains("agent.one.yaml -> agent.two.yaml -> agent.one.yaml"), + "{error}" + ); +} + +#[test] +fn base_errors_include_the_complete_inheritance_chain() { + let directory = agent_directory(); + write( + directory.path(), + "agent.parent.yaml", + &variant("agent.missing.yaml", "parent", ""), + ); + write( + directory.path(), + "agent.leaf.yaml", + &variant("agent.parent.yaml", "leaf", ""), + ); + + let error = manifest::resolve(&directory.path().join("agent.leaf.yaml")).expect_err("missing base"); + let message = error.to_string(); + assert!(message.contains("inheritance chain:"), "{message}"); + assert!(message.contains("agent.leaf.yaml\n extends agent.parent.yaml\n extends agent.missing.yaml")); +} + +#[test] +fn limits_inheritance_to_sixteen_manifests() { + let directory = agent_directory(); + for index in 1..=16 { + let extends = if index == 16 { + "agent.yaml".to_owned() + } else { + format!("agent.v{}.yaml", index + 1) + }; + write( + directory.path(), + &format!("agent.v{index}.yaml"), + &variant(&extends, &format!("v{index}"), ""), + ); + } + let error = manifest::resolve(&directory.path().join("agent.v1.yaml")).expect_err("depth limit"); + assert!(error.to_string().contains("exceeds 16 manifests"), "{error}"); +} + +#[test] +fn requires_names_and_matching_api_versions_in_every_variant() { + let directory = agent_directory(); + write( + directory.path(), + "agent.nameless.yaml", + "apiVersion: agents.platform/v1alpha1\nkind: AgentVariant\nextends: agent.yaml\nmetadata: {}\n", + ); + let error = manifest::resolve(&directory.path().join("agent.nameless.yaml")).expect_err("name required"); + assert!(error.to_string().contains("metadata.name")); + + write( + directory.path(), + "agent.version.yaml", + "apiVersion: agents.platform/v2\nkind: AgentVariant\nextends: agent.yaml\nmetadata:\n name: version\n", + ); + let error = manifest::resolve(&directory.path().join("agent.version.yaml")).expect_err("version mismatch"); + assert!(error.to_string().contains("apiVersion")); +} + +#[test] +fn validates_filename_grammar() { + let nested_build: AgentVariantName = "nested-build".parse().expect("variant name"); + assert_eq!(nested_build.as_str(), "nested-build"); + assert_eq!(nested_build.filename(), "agent.nested-build.yaml"); + assert_eq!( + serde_json::to_string(&nested_build).expect("serialized name"), + r#""nested-build""# + ); + assert_eq!( + serde_json::from_str::(r#""nested-build""#).expect("deserialized name"), + nested_build + ); + + for accepted in [ + "agent.nested.yaml", + "agent.nested-build.yaml", + "agent.worktree.yaml", + "agent.mine.yaml", + "agent.large-local.yaml", + ] { + assert!(manifest::is_manifest_filename(Path::new(accepted)), "{accepted}"); + } + assert!(manifest::is_manifest_filename(Path::new("agent.yaml"))); + for rejected in [ + "agent-copy.yaml", + "agent..yaml", + "agent.Nested.yaml", + "my-agent.yaml", + "agent.yaml.bak", + "agent.trailing-.yaml", + ] { + assert!(!manifest::is_manifest_filename(Path::new(rejected)), "{rejected}"); + } + for rejected in ["", "Nested", "nested_build", "nested-", "../nested"] { + assert!(rejected.parse::().is_err(), "{rejected}"); + } +} diff --git a/agentctl/tests/vnc_access.rs b/agentctl/tests/vnc_access.rs new file mode 100644 index 0000000..8c5efba --- /dev/null +++ b/agentctl/tests/vnc_access.rs @@ -0,0 +1,517 @@ +#![allow(clippy::expect_used)] + +mod support; + +use std::{path::PathBuf, rc::Rc}; + +use agent::{ + AccessSpec, AgentId, Error, FailureKind, ReconcileFailure, + control_plane::{AgentRecord, AgentStore as _, memory::InMemoryAgentStore}, + vnc::Access, +}; +use sandbox::{ + EnsureSandboxRequest, Platform, SandboxHandle, SandboxService, + execution::{ExecutionEvent, ExecutionSpec, ExitStatus, Program}, + memory, +}; + +const AGENTCTL: &str = "/usr/local/bin/agentctl"; +const DESCRIPTOR: &str = "/etc/agent-access.d/vnc.conf"; +const UNITS: [&str; 2] = ["agent-vnc.socket", "agent-vnc-web.service"]; + +fn command(spec: &ExecutionSpec) -> Option<(&str, Vec<&str>)> { + match spec.program() { + Program::Command { executable, args } => Some((executable.as_str(), args.iter().map(String::as_str).collect())), + Program::ImageEntrypoint => None, + } +} + +fn is_command(spec: &ExecutionSpec, executable: &str, expected: &[&str]) -> bool { + command(spec).is_some_and(|(actual, args)| actual == executable && args == expected) +} + +fn is_test(path: &'static str, test: &'static str) -> impl Fn(&ExecutionSpec) -> bool { + move |spec| is_command(spec, "/usr/bin/test", &[test, path]) +} + +fn is_descriptor_read(spec: &ExecutionSpec) -> bool { + is_command(spec, "/bin/cat", &[DESCRIPTOR]) +} + +fn is_listener_check(port: u16) -> impl Fn(&ExecutionSpec) -> bool { + move |spec| { + command(spec).is_some_and(|(executable, args)| { + executable == "/usr/bin/ss" && args == ["-ltnH", "sport", "=", &format!(":{port}")] + }) + } +} + +fn is_enable(spec: &ExecutionSpec) -> bool { + is_command( + spec, + "/usr/bin/sudo", + &["-n", "/usr/bin/systemctl", "enable", "--now", UNITS[0], UNITS[1]], + ) +} + +fn is_disable(spec: &ExecutionSpec) -> bool { + is_command( + spec, + "/usr/bin/sudo", + &["-n", "/usr/bin/systemctl", "disable", "--now", UNITS[0], UNITS[1]], + ) +} + +fn is_active_check(spec: &ExecutionSpec) -> bool { + is_command( + spec, + "/usr/bin/sudo", + &["-n", "/usr/bin/systemctl", "is-active", UNITS[0], UNITS[1]], + ) +} + +fn is_any_listener_check(spec: &ExecutionSpec) -> bool { + command(spec).is_some_and(|(executable, _)| executable == "/usr/bin/ss") +} + +fn exited(code: i32) -> Vec { + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Exited(ExitStatus { code }), + ] +} + +fn output(contents: &'static [u8]) -> Vec { + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Stdout(contents.into()), + ExecutionEvent::Exited(ExitStatus { code: 0 }), + ] +} + +const VALID_DESCRIPTOR: &[u8] = b"units=agent-vnc.socket agent-vnc-web.service\nport=5900\nweb-port=6080\n"; + +/// Answers the image-contract probes as an image with systemd and `ss` whose VNC descriptor is +/// `descriptor`, or which has none. +fn queue_image(backend: &memory::Provider, descriptor: Option<&'static [u8]>) { + for path in ["/usr/bin/systemctl", "/usr/bin/ss"] { + backend.queue_execution_events_matching(is_test(path, "-x"), exited(0)); + } + backend.queue_execution_events_matching(is_test("/run/systemd/system", "-d"), exited(0)); + backend.queue_execution_events_matching(is_test(DESCRIPTOR, "-f"), exited(i32::from(descriptor.is_none()))); + if let Some(descriptor) = descriptor { + backend.queue_execution_events_matching(is_descriptor_read, output(descriptor)); + } +} + +fn queue_desktop_image(backend: &memory::Provider) { + queue_image(backend, Some(VALID_DESCRIPTOR)); +} + +fn queue_listening(backend: &memory::Provider, listening: bool) { + for port in [5900u16, 6080] { + let events = if listening { + output(b"LISTEN 0 0 127.0.0.1:port 0.0.0.0:*\n") + } else { + output(b"") + }; + backend.queue_execution_events_matching(is_listener_check(port), events); + } +} + +fn record(name: &str, id: &str, vnc: bool) -> AgentRecord { + let mut resource = support::agent(name); + resource.metadata.generation = 1; + if vnc { + resource.spec.access = vec![AccessSpec::Vnc {}]; + } + AgentRecord { + id: id.parse::().expect("Agent ID"), + source_directory: PathBuf::from("/source").join(name), + manifest_path: None, + env_file: None, + agent: resource, + } +} + +struct Fixture { + store: Rc, + access: Access, + backend: Rc, +} + +impl Fixture { + fn new() -> Self { + let store = Rc::new(InMemoryAgentStore::new()); + let access = Access::new(PathBuf::from(AGENTCTL), store.clone()); + Self { + store, + access, + backend: Rc::new(memory::Provider::new()), + } + } + + async fn store(&self, record: &AgentRecord, expected_generation: u64) { + self.store + .put(record.clone(), expected_generation) + .await + .expect("store record"); + } + + async fn sandbox(&self, record: &AgentRecord) -> SandboxHandle { + let service = SandboxService::new(self.backend.clone()); + let spec = record + .agent + .spec + .sandbox + .resolve_from(&record.source_directory, &Platform::native("linux").architecture); + service + .ensure(&EnsureSandboxRequest::new( + record.sandbox_name().expect("Sandbox name"), + spec, + )) + .await + .expect("Sandbox") + } +} + +#[tokio::test(flavor = "local")] +async fn granting_access_enables_the_units_the_image_declared() { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + queue_desktop_image(&fixture.backend); + queue_listening(&fixture.backend, true); + + assert!(fixture.access.reconcile(&record, &sandbox).await.expect("grant")); + + assert_eq!( + fixture + .access + .describe("worker") + .await + .expect("described") + .web_guest_port, + Some(6080), + "a granted Agent reports the viewer port its image declared" + ); + assert!( + fixture.backend.execution_specs().iter().any(is_enable), + "the units named by the image are enabled, and no unit file is written" + ); + assert!( + !fixture + .backend + .execution_specs() + .iter() + .any(|spec| command(spec).is_some_and(|(executable, _)| executable == "/bin/chmod")), + "the units belong to the image, so nothing here installs or chmods one" + ); +} + +#[tokio::test(flavor = "local")] +async fn an_image_offering_no_browser_viewer_is_granted_and_described_without_one() { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + queue_image(&fixture.backend, Some(b"units=agent-vnc.socket\nport=5900\n")); + fixture.backend.queue_execution_events_matching( + is_listener_check(5900), + output(b"LISTEN 0 0 127.0.0.1:5900 0.0.0.0:*\n"), + ); + + assert!(fixture.access.reconcile(&record, &sandbox).await.expect("grant")); + assert!( + !fixture + .backend + .execution_specs() + .iter() + .any(|spec| is_listener_check(6080)(spec)), + "a port the image never promised is not checked" + ); + let info = fixture.access.describe("worker").await.expect("declared access"); + assert_eq!( + info.web_guest_port, None, + "the descriptor reports the absence rather than a port that serves nothing" + ); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn a_viewer_that_binds_its_port_after_being_enabled_is_waited_for() { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + queue_desktop_image(&fixture.backend); + fixture.backend.queue_execution_events_matching( + is_listener_check(5900), + output(b"LISTEN 0 0 127.0.0.1:5900 0.0.0.0:*\n"), + ); + // systemd reports a simple service started once it has forked, before it has bound its port. + fixture + .backend + .queue_execution_events_matching(is_listener_check(6080), output(b"")); + fixture.backend.queue_execution_events_matching( + is_listener_check(6080), + output(b"LISTEN 0 0 127.0.0.1:6080 0.0.0.0:*\n"), + ); + + assert!(fixture.access.reconcile(&record, &sandbox).await.expect("grant")); + assert_eq!( + fixture + .backend + .execution_specs() + .iter() + .filter(|spec| is_listener_check(6080)(spec)) + .count(), + 2, + "the viewer port is checked again rather than failing the pass" + ); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn a_grant_that_leaves_a_declared_port_silent_is_a_failure() { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + queue_desktop_image(&fixture.backend); + fixture + .backend + .queue_execution_events_matching(is_listener_check(5900), output(b"")); + + let error = fixture + .access + .reconcile(&record, &sandbox) + .await + .expect_err("a silent port is an error"); + assert!( + error + .to_string() + .contains("nothing is listening on guest port 5900 after 10s"), + "{error}" + ); +} + +#[tokio::test(flavor = "local")] +async fn withdrawing_access_disables_the_units_and_proves_they_stopped() { + let fixture = Fixture::new(); + let withdrawn = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", false); + fixture.store(&withdrawn, 0).await; + let sandbox = fixture.sandbox(&withdrawn).await; + queue_withdrawable_image(&fixture.backend); + // The Agent's own server on the viewer port is none of withdrawal's business. + queue_listening(&fixture.backend, true); + + assert!(!fixture.access.reconcile(&withdrawn, &sandbox).await.expect("withdraw")); + let specs = fixture.backend.execution_specs(); + assert!( + specs.iter().any(is_disable), + "the declared units are disabled and stopped" + ); + assert!(specs.iter().any(is_active_check), "the units are confirmed inactive"); + assert!( + !specs.iter().any(is_any_listener_check), + "once access is withdrawn the declared ports are ordinary guest ports" + ); +} + +/// Answers a withdrawal's probes: systemd running and a desktop image's descriptor. +fn queue_withdrawable_image(backend: &memory::Provider) { + queue_desktop_image(backend); + backend.queue_execution_events_matching(is_active_check, output(b"inactive\ninactive\n")); +} + +#[tokio::test(flavor = "local")] +async fn a_withdrawal_that_succeeded_is_not_repeated_until_the_incarnation_is_forgotten() { + let fixture = Fixture::new(); + let withdrawn = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", false); + fixture.store(&withdrawn, 0).await; + let sandbox = fixture.sandbox(&withdrawn).await; + let disables = || { + fixture + .backend + .execution_specs() + .iter() + .filter(|spec| is_disable(spec)) + .count() + }; + + queue_withdrawable_image(&fixture.backend); + assert!(!fixture.access.reconcile(&withdrawn, &sandbox).await.expect("withdraw")); + let probes = fixture.backend.execution_specs().len(); + assert!(!fixture.access.reconcile(&withdrawn, &sandbox).await.expect("resync")); + assert_eq!( + fixture.backend.execution_specs().len(), + probes, + "a resync after a successful withdrawal runs nothing in the guest" + ); + assert_eq!(disables(), 1); + + fixture.access.forget(withdrawn.id); + queue_withdrawable_image(&fixture.backend); + assert!( + !fixture + .access + .reconcile(&withdrawn, &sandbox) + .await + .expect("withdraw again") + ); + assert_eq!(disables(), 2, "a forgotten incarnation is withdrawn again"); +} + +#[tokio::test(flavor = "local", start_paused = true)] +async fn a_failed_grant_forgets_an_earlier_withdrawal() { + let fixture = Fixture::new(); + let withdrawn = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", false); + let granted = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&withdrawn, 0).await; + let sandbox = fixture.sandbox(&withdrawn).await; + let disables = || { + fixture + .backend + .execution_specs() + .iter() + .filter(|spec| is_disable(spec)) + .count() + }; + + queue_withdrawable_image(&fixture.backend); + assert!(!fixture.access.reconcile(&withdrawn, &sandbox).await.expect("withdraw")); + assert_eq!(disables(), 1); + + // The units are enabled, but the RFB port never listens, so the grant fails partway. + queue_desktop_image(&fixture.backend); + fixture + .access + .reconcile(&granted, &sandbox) + .await + .expect_err("a port that never listens fails the grant"); + assert!(fixture.backend.execution_specs().iter().any(is_enable)); + + queue_withdrawable_image(&fixture.backend); + assert!( + !fixture + .access + .reconcile(&withdrawn, &sandbox) + .await + .expect("withdraw again") + ); + assert_eq!( + disables(), + 2, + "the units a failed grant enabled are disabled rather than skipped as already withdrawn" + ); +} + +#[tokio::test(flavor = "local")] +async fn a_unit_still_active_after_withdrawal_is_reported() { + let fixture = Fixture::new(); + let withdrawn = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", false); + fixture.store(&withdrawn, 0).await; + let sandbox = fixture.sandbox(&withdrawn).await; + fixture + .backend + .queue_execution_events_matching(is_active_check, output(b"inactive\nactive\n")); + queue_withdrawable_image(&fixture.backend); + + let error = fixture + .access + .reconcile(&withdrawn, &sandbox) + .await + .expect_err("a surviving unit is an error"); + assert!( + error.to_string().contains("agent-vnc-web.service is active"), + "withdrawal is verified rather than assumed: {error}" + ); +} + +#[tokio::test(flavor = "local")] +async fn an_image_that_declares_no_vnc_access_is_reported_as_an_image_problem() { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + queue_image(&fixture.backend, None); + + let error = fixture + .access + .reconcile(&record, &sandbox) + .await + .expect_err("a missing descriptor is an error"); + assert!(matches!(error, Error::Invalid(_)), "{error:?}"); + let message = error.to_string(); + assert!(message.contains("/etc/agent-access.d/vnc.conf is missing"), "{message}"); + assert!( + message.contains("remove `vnc` from spec.access"), + "the message names the remedies: {message}" + ); + assert_eq!( + ReconcileFailure::classify(&error).kind, + FailureKind::Invalid, + "an immutable image cannot be fixed by retrying" + ); +} + +#[tokio::test(flavor = "local")] +async fn a_descriptor_declaring_the_wrong_port_is_refused() { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", true); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + queue_image( + &fixture.backend, + Some(b"units=agent-vnc.socket\nport=5901\nweb-port=6080\n"), + ); + + let error = fixture + .access + .reconcile(&record, &sandbox) + .await + .expect_err("a port the caller could not be told about is an error"); + assert!( + error.to_string().contains("but VNC access uses guest port 5900"), + "{error}" + ); +} + +#[tokio::test(flavor = "local")] +async fn an_image_without_the_descriptor_is_left_alone_when_no_access_is_declared() { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", false); + fixture.store(&record, 0).await; + let sandbox = fixture.sandbox(&record).await; + queue_image(&fixture.backend, None); + + assert!(!fixture.access.reconcile(&record, &sandbox).await.expect("pass")); + assert!( + !fixture.backend.execution_specs().iter().any(is_disable), + "an image with no VNC access to withdraw is not touched" + ); +} + +#[tokio::test(flavor = "local")] +async fn describing_an_agent_without_declared_access_names_the_remedy() { + let fixture = Fixture::new(); + let record = record("worker", "38f41de4-6ff7-4679-ae46-678bc61e4dcb", false); + fixture.store(&record, 0).await; + + let error = fixture.access.describe("worker").await.expect_err("no declared access"); + assert!(error.to_string().contains("access: [{type: vnc}]"), "{error}"); + + let declared = self::record("viewer", "9a5b0a5a-6a4f-4f22-9f2a-2d1a3c4b5e6f", true); + fixture.store(&declared, 0).await; + let info = fixture.access.describe("viewer").await.expect("declared access"); + assert_eq!(info.kind, "vnc"); + assert_eq!(info.guest_port, 5900); + assert_eq!( + info.web_guest_port, None, + "which ports the image offers is observed, so it is unknown until a pass has looked" + ); + assert_eq!( + info.forward_command, + format!("{AGENTCTL} port-forward agent/viewer :5900") + ); +} diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..ae186a7 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,5 @@ +allow-expect-in-consts = true +allow-expect-in-tests = true +allow-panic-in-tests = true +allow-unwrap-in-consts = true +allow-unwrap-in-tests = true diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..ad87614 --- /dev/null +++ b/deny.toml @@ -0,0 +1,47 @@ +# This policy intentionally contains no per-crate allowlist. New dependencies remain ordinary +# Cargo.toml and Cargo.lock changes. + +[graph] +all-features = true +# The targets agentctl is released for. +targets = [ + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc", +] + +[advisories] +yanked = "deny" +ignore = [ + # bincode 2 is unmaintained and has no maintained successor release. Microsandbox and its + # libkrun crates use it to serialize their own data; the advisory reports no vulnerability. + { id = "RUSTSEC-2025-0141", reason = "unmaintained bincode 2 used by Microsandbox; no known vulnerability or safe upgrade" }, +] + +[sources] +# crates.io is allowed by default. Additional registries fail until deliberately configured. +unknown-registry = "deny" +# Do not maintain a git repository allowlist, but require an explicit revision specifier. +unknown-git = "allow" +required-git-spec = "rev" + +[licenses] +# The licenses the released binaries may contain. agentctl/notices/about.toml accepts the same list, so a new license +# is a reviewed change to both. +allow = [ + "0BSD", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "MPL-2.0", + "Unicode-3.0", + "Unlicense", + "Zlib", +] diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..9946197 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.97.1" +components = ["clippy", "rustfmt"] +profile = "minimal" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..7530651 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +max_width = 120 diff --git a/sandbox/.gitattributes b/sandbox/.gitattributes new file mode 100644 index 0000000..5922768 --- /dev/null +++ b/sandbox/.gitattributes @@ -0,0 +1,2 @@ +# Check out with LF on every platform: tests and scripts read these files byte for byte. +* text=auto eol=lf diff --git a/sandbox/AGENTS.md b/sandbox/AGENTS.md new file mode 100644 index 0000000..6f0ee7a --- /dev/null +++ b/sandbox/AGENTS.md @@ -0,0 +1,5 @@ +# AGENTS.md + +The Sandbox crates here are part of the agent platform. Its architecture rules and development targets, which cover +these crates, are in [../agentctl/AGENTS.md](../agentctl/AGENTS.md). Updating the Microsandbox pin is described in +[MICROSANDBOX.md](MICROSANDBOX.md). diff --git a/sandbox/MICROSANDBOX.md b/sandbox/MICROSANDBOX.md new file mode 100644 index 0000000..1595035 --- /dev/null +++ b/sandbox/MICROSANDBOX.md @@ -0,0 +1,52 @@ +# Microsandbox pin + +`sandbox-microsandbox` builds on the Digdir fork of Microsandbox, +[digdir/microsandbox](https://github.com/digdir/microsandbox), and on the host runtime published with each of its +releases. How the fork is synchronized with upstream and how runtimes are released is in its +[MAINTAINING-digdir.md](https://github.com/digdir/microsandbox/blob/main-digdir/MAINTAINING-digdir.md). This document +covers the pin in this repository. + +## What is pinned + +- The `microsandbox*` Git dependencies in the root `Cargo.toml`, all at the same revision, and their resolution in + `Cargo.lock`. +- The SHA-256 digest of every supported host runtime bundle, in the digest table in + `microsandbox/src/client.rs`. + +The SDK only launches a runtime of exactly its own version, so the source revision and the runtime digests always +come from the same Digdir release. Pin only tagged revisions: a `v` runtime release tag, or a +`v-source.` tag that reuses an already published runtime. The tags keep the pinned commits +reachable after the fork's `main-digdir` is rewritten. + +Consumers of the sandbox crates, such as Altinn Studio's GitHub runner coordinator, pin a revision of this +repository and download the matching runtime bundle themselves. + +## Updating + +Update only to a runtime release that is complete and verified, or to a source tag whose reuse of the runtime is +recorded in the fork. + +1. Update every `microsandbox*` revision in the root `Cargo.toml` together, and regenerate `Cargo.lock`. Every + Git-sourced Microsandbox package must resolve to the same revision and version. +2. For a runtime release, replace the digest table in `microsandbox/src/client.rs` with the digests from the + release's `checksums.sha256`. A source tag keeps the runtime version and digests unchanged. +3. Search for every remaining reference to the previous pin, rather than relying on a list of files: + + ```sh + git grep -n -e '' -e '' + git grep -n -F -f <(printf '%s\n' ) + ``` + +4. Run `make fmt lint build test`, and `make test-e2e` on a host with Docker, Internet access and hardware + virtualization. +5. Exercise a first-run runtime installation from an empty provider home, so that stale local artifacts cannot hide + a release or checksum error, and an upgrade from a provider home and database populated by the previous pin, + where migrations run. + +A pin update is internal maintenance unless it changes behavior Agent users can see. Use the `skip-changelog` label +for internal-only updates; otherwise describe the effect under `Unreleased` in `agentctl/CHANGELOG.md`. + +## Rollback + +Roll back by restoring the previous tagged revision, `Cargo.lock` resolution and digest table together. Never combine +source from one Digdir version with runtime artifacts from another. diff --git a/sandbox/authorization/Cargo.toml b/sandbox/authorization/Cargo.toml new file mode 100644 index 0000000..9ede523 --- /dev/null +++ b/sandbox/authorization/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "sandbox-authorization" +description = "Context-aware authorization contracts for sandbox-originated operations" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tokio.workspace = true + +[lints] +workspace = true diff --git a/sandbox/authorization/src/lib.rs b/sandbox/authorization/src/lib.rs new file mode 100644 index 0000000..9cb9f38 --- /dev/null +++ b/sandbox/authorization/src/lib.rs @@ -0,0 +1,343 @@ +//! Context-aware authorization contracts for sandbox-originated operations. +//! +//! Trusted enforcement points construct requests, policy engines make decisions, +//! and the component performing an operation enforces the result. This crate does +//! not perform operations or depend on an enforcement implementation. + +use std::{collections::BTreeMap, future::Future, pin::Pin}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Stable vocabulary shared by trusted authorization request producers. +pub mod vocabulary { + /// Built-in Sandbox-originated Actions. + pub mod action { + /// Resolve a DNS name. + pub const DNS_QUERY: &str = "dns.query"; + /// Send a complete HTTP request. + pub const HTTP_REQUEST: &str = "http.request"; + /// Open a transport connection. + pub const NETWORK_CONNECT: &str = "network.connect"; + /// Use host-owned secret material at an authorized location. + pub const SECRET_USE: &str = "secret.use"; + // Spawn another session in-sandbox + pub const SESSION_SPAWN: &str = "session.spawn"; + // Spawn another session in a different sandbox + pub const AGENT_SPAWN: &str = "agent.spawn"; + } + + /// Built-in Principal kinds. + pub mod principal_kind { + /// One materialized Sandbox. + pub const SANDBOX: &str = "sandbox"; + } + + /// Built-in Resource kinds. + pub mod resource_kind { + /// A DNS domain name. + pub const DOMAIN: &str = "domain"; + /// An external network service. + pub const EXTERNAL_SERVICE: &str = "externalService"; + /// Host-owned secret material. + pub const SECRET: &str = "secret"; + } + + /// Built-in trusted Context attribute names. + pub mod context { + /// Stable Sandbox name supplied by its trusted Network Backend. + pub const SANDBOX_NAME: &str = "sandbox.name"; + /// DNS record type. + pub const DNS_RECORD_TYPE: &str = "dns.recordType"; + /// DNS resolver socket address. + pub const DNS_RESOLVER: &str = "dns.resolver"; + /// HTTP authority. + pub const HTTP_AUTHORITY: &str = "http.authority"; + /// HTTP method. + pub const HTTP_METHOD: &str = "http.method"; + /// HTTP path without query data. + pub const HTTP_PATH: &str = "http.path"; + /// HTTP scheme. + pub const HTTP_SCHEME: &str = "http.scheme"; + /// HTTP/2 stream identifier. + pub const HTTP_STREAM_ID: &str = "http.streamId"; + /// HTTP protocol version. + pub const HTTP_VERSION: &str = "http.version"; + /// Network destination socket address. + pub const NETWORK_DESTINATION_ADDRESS: &str = "network.destinationAddress"; + /// Whether the destination is the host, reported by the trusted Network + /// Backend when it rewrites a gateway-bound flow to host loopback. + pub const NETWORK_DESTINATION_IS_HOST: &str = "network.destinationIsHost"; + /// Network hostname supplied by a trusted parser. + pub const NETWORK_HOSTNAME: &str = "network.hostname"; + /// Network source socket address. + pub const NETWORK_SOURCE_ADDRESS: &str = "network.sourceAddress"; + /// Network transport protocol. + pub const NETWORK_TRANSPORT: &str = "network.transport"; + /// Authorized secret injection locations. + pub const SECRET_LOCATIONS: &str = "secret.locations"; + } +} + +/// A non-`Send` future executed by a Tokio local runtime. +pub type LocalFuture<'a, T> = Pin + 'a>>; + +/// Authenticated sandbox entity on whose authority an Action is requested. +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Principal { + /// Principal category, such as `agent`, `sandbox`, `session`, or `execution`. + pub kind: String, + /// Stable identifier within that category. + pub id: String, +} + +impl Principal { + /// Creates a Principal from trusted identity information. + #[must_use] + pub fn new(kind: impl Into, id: impl Into) -> Self { + Self { + kind: kind.into(), + id: id.into(), + } + } +} + +/// Stable name of a requested operation. +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(transparent)] +pub struct Action(String); + +impl Action { + /// Creates an Action name. + #[must_use] + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// Returns the stable Action name. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for Action { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// Target against which an Action is requested. +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Resource { + /// Resource category, such as `externalService`, `secret`, `repository`, or `tool`. + pub kind: String, + /// Stable identifier within that category. + pub id: String, +} + +impl Resource { + /// Creates a Resource identifier. + #[must_use] + pub fn new(kind: impl Into, id: impl Into) -> Self { + Self { + kind: kind.into(), + id: id.into(), + } + } +} + +/// One typed trusted Context value. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(untagged)] +#[non_exhaustive] +pub enum AuthorizationValue { + /// Textual value. + String(String), + /// Signed integer value. + Integer(i64), + /// Boolean value. + Boolean(bool), + /// Ordered textual values whose boundaries must be preserved. + Strings(Vec), +} + +impl AuthorizationValue { + /// Returns a textual value. + #[must_use] + pub fn as_str(&self) -> Option<&str> { + match self { + Self::String(value) => Some(value), + Self::Integer(_) | Self::Boolean(_) | Self::Strings(_) => None, + } + } + + /// Returns an integer value. + #[must_use] + pub const fn as_integer(&self) -> Option { + match self { + Self::Integer(value) => Some(*value), + Self::String(_) | Self::Boolean(_) | Self::Strings(_) => None, + } + } + + /// Returns a Boolean value. + #[must_use] + pub const fn as_bool(&self) -> Option { + match self { + Self::Boolean(value) => Some(*value), + Self::String(_) | Self::Integer(_) | Self::Strings(_) => None, + } + } + + /// Returns an ordered string-list value. + #[must_use] + pub fn as_strings(&self) -> Option<&[String]> { + match self { + Self::Strings(values) => Some(values), + Self::String(_) | Self::Integer(_) | Self::Boolean(_) => None, + } + } +} + +impl From for AuthorizationValue { + fn from(value: String) -> Self { + Self::String(value) + } +} + +impl From<&str> for AuthorizationValue { + fn from(value: &str) -> Self { + Self::String(value.to_string()) + } +} + +impl From for AuthorizationValue { + fn from(value: i64) -> Self { + Self::Integer(value) + } +} + +impl From for AuthorizationValue { + fn from(value: u32) -> Self { + Self::Integer(i64::from(value)) + } +} + +impl From for AuthorizationValue { + fn from(value: bool) -> Self { + Self::Boolean(value) + } +} + +impl From> for AuthorizationValue { + fn from(value: Vec) -> Self { + Self::Strings(value) + } +} + +/// Trusted facts relevant to an authorization decision. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AuthorizationContext { + /// Extensible attributes established by trusted enforcement components. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub attributes: BTreeMap, +} + +impl AuthorizationContext { + /// Creates an empty Context. + #[must_use] + pub const fn new() -> Self { + Self { + attributes: BTreeMap::new(), + } + } + + /// Adds one trusted attribute. + #[must_use] + pub fn with_attribute(mut self, name: impl Into, value: impl Into) -> Self { + self.attributes.insert(name.into(), value.into()); + self + } + + /// Inserts or replaces one trusted attribute. + pub fn insert(&mut self, name: impl Into, value: impl Into) { + self.attributes.insert(name.into(), value.into()); + } + + /// Returns one trusted attribute. + #[must_use] + pub fn get(&self, name: &str) -> Option<&AuthorizationValue> { + self.attributes.get(name) + } +} + +/// Complete input evaluated by an Authorization Policy Engine. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AuthorizationRequest { + /// Authenticated sandbox authority behind the request. + pub principal: Principal, + /// Requested operation. + pub action: Action, + /// Target of the operation. + pub resource: Resource, + /// Trusted facts relevant to this request. + pub context: AuthorizationContext, +} + +/// Result of policy evaluation before domain-specific enforcement. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum AuthorizationDecision { + /// The Action may proceed. + Allow, + /// The Action must not proceed. + Deny, +} + +/// Policy-evaluation failures, distinct from an intentional deny decision. +#[derive(Debug, Error)] +pub enum Error { + /// The configured engine could not evaluate a request. + #[error("authorization policy engine failed: {0}")] + Engine(String), +} + +/// Evaluates Authorization Requests without performing their Actions. +pub trait PolicyEngine { + /// Evaluates one request. + fn evaluate(&self, request: AuthorizationRequest) -> LocalFuture<'_, Result>; +} + +/// Deterministic policy engine for local wiring and tests. +pub struct StaticPolicy { + decision: AuthorizationDecision, +} + +impl StaticPolicy { + /// Creates a policy that allows every request. + #[must_use] + pub const fn allow_all() -> Self { + Self { + decision: AuthorizationDecision::Allow, + } + } + + /// Creates a policy that denies every request. + #[must_use] + pub const fn deny_all() -> Self { + Self { + decision: AuthorizationDecision::Deny, + } + } +} + +impl PolicyEngine for StaticPolicy { + fn evaluate(&self, _request: AuthorizationRequest) -> LocalFuture<'_, Result> { + Box::pin(async move { Ok(self.decision) }) + } +} diff --git a/sandbox/authorization/tests/architecture.rs b/sandbox/authorization/tests/architecture.rs new file mode 100644 index 0000000..dbcf42e --- /dev/null +++ b/sandbox/authorization/tests/architecture.rs @@ -0,0 +1,12 @@ +#![allow(clippy::expect_used)] + +use std::{fs, path::Path}; + +#[test] +fn sandbox_authorization_contracts_do_not_depend_on_enforcement_points() { + let manifest = fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")) + .expect("Sandbox Authorization Cargo.toml should be readable"); + + assert!(!manifest.contains("agent =")); + assert!(!manifest.contains("sandbox =")); +} diff --git a/sandbox/authorization/tests/policy.rs b/sandbox/authorization/tests/policy.rs new file mode 100644 index 0000000..08886a2 --- /dev/null +++ b/sandbox/authorization/tests/policy.rs @@ -0,0 +1,23 @@ +#![allow(clippy::expect_used)] + +use sandbox_authorization::{ + Action, AuthorizationContext, AuthorizationDecision, AuthorizationRequest, PolicyEngine as _, Principal, Resource, + StaticPolicy, +}; + +#[tokio::test(flavor = "local")] +async fn static_policy_returns_its_configured_decision() { + evaluate(&StaticPolicy::allow_all(), AuthorizationDecision::Allow).await; + evaluate(&StaticPolicy::deny_all(), AuthorizationDecision::Deny).await; +} + +async fn evaluate(policy: &StaticPolicy, expected: AuthorizationDecision) { + let request = AuthorizationRequest { + principal: Principal::new("agent", "worker"), + action: Action::new("network.connect"), + resource: Resource::new("externalService", "api.github.com"), + context: AuthorizationContext::default(), + }; + let decision = policy.evaluate(request).await.expect("policy evaluation"); + assert_eq!(decision, expected); +} diff --git a/sandbox/core/Cargo.toml b/sandbox/core/Cargo.toml new file mode 100644 index 0000000..73d8c06 --- /dev/null +++ b/sandbox/core/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "sandbox" +description = "Backend-neutral sandbox lifecycle building blocks" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +bytes.workspace = true +futures-core.workspace = true +serde.workspace = true +sha2.workspace = true +thiserror.workspace = true +time.workspace = true +tokio.workspace = true +tokio-util = { workspace = true, features = ["rt"] } +uuid.workspace = true +zeroize.workspace = true + +[dev-dependencies] +serde_json.workspace = true + +[lints] +workspace = true diff --git a/sandbox/core/examples/worktree/Cargo.toml b/sandbox/core/examples/worktree/Cargo.toml new file mode 100644 index 0000000..9f1a6ac --- /dev/null +++ b/sandbox/core/examples/worktree/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "sandbox-worktree" +description = "Runs a coding harness in a retained Microsandbox VM with the current worktree bind-mounted" +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true +version.workspace = true + +[dependencies] +clap.workspace = true +futures-util.workspace = true +sandbox = { path = "../.." } +sandbox-microsandbox = { path = "../../../microsandbox" } +sha2.workspace = true +tokio.workspace = true + +[lints] +workspace = true diff --git a/sandbox/core/examples/worktree/Dockerfile b/sandbox/core/examples/worktree/Dockerfile new file mode 100644 index 0000000..072aeb2 --- /dev/null +++ b/sandbox/core/examples/worktree/Dockerfile @@ -0,0 +1,176 @@ +FROM archlinux:base@sha256:b0deabeb3d283da2c7f7dbf0eea051b7b2cd0554e0b737cc457fd21683bdcdd1 + +# The repository's rust-toolchain.toml selects the toolchain inside a checkout, and Renovate moves +# this ARG together with it. The agent user (uid 1000, created below) owns RUSTUP_HOME, so a +# checkout pinned ahead of this image installs its toolchain on first use instead of failing. +ARG RUST_VERSION=1.97.1 + +ENV CARGO_HOME=/usr/local/cargo +ENV DOTNET_ROOT=/usr/local/share/dotnet +ENV RUSTUP_HOME=/usr/local/rustup +ENV PATH=/usr/local/cargo/bin:/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/bin + +RUN pacman -Syu --noconfirm --needed \ + base-devel \ + clang \ + curl \ + docker \ + docker-buildx \ + git \ + icu \ + iproute2 \ + jq \ + krb5 \ + lldb \ + openssl \ + perf \ + pkgconf \ + procps-ng \ + qemu-base \ + ripgrep \ + rustup \ + strace \ + sudo \ + tmux \ + && pacman -Scc --noconfirm \ + && rustup set profile minimal \ + && rustup default "${RUST_VERSION}" \ + && rustup component add clippy rustfmt \ + && chown -R 1000:1000 /usr/local/rustup + +ARG TARGETARCH +ARG DOTNET_VERSION=10.0.302 +ARG DOTNET_INSTALL_SCRIPT_COMMIT=6f559c420847ded38591392dafe785ad511f39f5 +ARG DOTNET_INSTALL_SCRIPT_SHA256=082f7685e156738a1b2e2ed8381a621870d4ce8e8c59278034556f05c186eb2e +ARG FLUX_VERSION=2.9.4 +ARG GO_VERSION=1.26.4 +ARG HELM_VERSION=3.21.1 +ARG KIND_VERSION=0.32.0 +ARG KUBECTL_VERSION=1.35.7 +ARG NODE_VERSION=22.23.2 + +RUN case "${TARGETARCH}" in \ + amd64) PORTABLE_ARCH=amd64; NODE_ARCH=x64; DOTNET_ARCH=x64 ;; \ + arm64) PORTABLE_ARCH=arm64; NODE_ARCH=arm64; DOTNET_ARCH=arm64 ;; \ + *) echo "unsupported target architecture: ${TARGETARCH}" >&2; exit 1 ;; \ + esac \ + && GO_ARCHIVE="go${GO_VERSION}.linux-${PORTABLE_ARCH}.tar.gz" \ + && GO_SHA256="$(curl -fsSL 'https://go.dev/dl/?mode=json&include=all' \ + | jq -r --arg version "go${GO_VERSION}" --arg filename "${GO_ARCHIVE}" \ + '.[] | select(.version == $version) | .files[] | select(.filename == $filename) | .sha256')" \ + && test -n "${GO_SHA256}" \ + && test "${GO_SHA256}" != "null" \ + && curl -fsSL "https://go.dev/dl/${GO_ARCHIVE}" -o /tmp/go.tar.gz \ + && echo "${GO_SHA256} /tmp/go.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/go.tar.gz -C /usr/local \ + && NODE_ARCHIVE="node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \ + && NODE_SHA256="$(curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/SHASUMS256.txt" \ + | awk -v filename="${NODE_ARCHIVE}" '$2 == filename { print $1 }')" \ + && test -n "${NODE_SHA256}" \ + && curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/${NODE_ARCHIVE}" -o /tmp/node.tar.xz \ + && echo "${NODE_SHA256} /tmp/node.tar.xz" | sha256sum -c - \ + && tar -xJf /tmp/node.tar.xz --strip-components=1 -C /usr/local \ + && curl -fsSL \ + "https://raw.githubusercontent.com/dotnet/install-scripts/${DOTNET_INSTALL_SCRIPT_COMMIT}/src/dotnet-install.sh" \ + -o /tmp/dotnet-install.sh \ + && echo "${DOTNET_INSTALL_SCRIPT_SHA256} /tmp/dotnet-install.sh" | sha256sum -c - \ + && bash /tmp/dotnet-install.sh \ + --architecture "${DOTNET_ARCH}" \ + --install-dir "${DOTNET_ROOT}" \ + --no-path \ + --version "${DOTNET_VERSION}" \ + && ln -s "${DOTNET_ROOT}/dotnet" /usr/local/bin/dotnet \ + && KIND_BINARY="kind-linux-${PORTABLE_ARCH}" \ + && KIND_SHA256="$(curl -fsSL \ + "https://github.com/kubernetes-sigs/kind/releases/download/v${KIND_VERSION}/${KIND_BINARY}.sha256sum" \ + | awk '{ print $1 }')" \ + && curl -fsSL \ + "https://github.com/kubernetes-sigs/kind/releases/download/v${KIND_VERSION}/${KIND_BINARY}" \ + -o /tmp/kind \ + && echo "${KIND_SHA256} /tmp/kind" | sha256sum -c - \ + && install -m 0755 /tmp/kind /usr/local/bin/kind \ + && HELM_ARCHIVE="helm-v${HELM_VERSION}-linux-${PORTABLE_ARCH}.tar.gz" \ + && HELM_SHA256="$(curl -fsSL "https://get.helm.sh/${HELM_ARCHIVE}.sha256sum" | awk '{ print $1 }')" \ + && curl -fsSL "https://get.helm.sh/${HELM_ARCHIVE}" -o /tmp/helm.tar.gz \ + && echo "${HELM_SHA256} /tmp/helm.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/helm.tar.gz -C /tmp \ + && install -m 0755 "/tmp/linux-${PORTABLE_ARCH}/helm" /usr/local/bin/helm \ + && FLUX_ARCHIVE="flux_${FLUX_VERSION}_linux_${PORTABLE_ARCH}.tar.gz" \ + && FLUX_SHA256="$(curl -fsSL \ + "https://github.com/fluxcd/flux2/releases/download/v${FLUX_VERSION}/flux_${FLUX_VERSION}_checksums.txt" \ + | awk -v filename="${FLUX_ARCHIVE}" '$2 == filename { print $1 }')" \ + && test -n "${FLUX_SHA256}" \ + && curl -fsSL \ + "https://github.com/fluxcd/flux2/releases/download/v${FLUX_VERSION}/${FLUX_ARCHIVE}" \ + -o /tmp/flux.tar.gz \ + && echo "${FLUX_SHA256} /tmp/flux.tar.gz" | sha256sum -c - \ + && tar -xzf /tmp/flux.tar.gz -C /usr/local/bin flux \ + && KUBECTL_SHA256="$(curl -fsSL \ + "https://dl.k8s.io/release/v${KUBECTL_VERSION}/bin/linux/${PORTABLE_ARCH}/kubectl.sha256")" \ + && curl -fsSL \ + "https://dl.k8s.io/release/v${KUBECTL_VERSION}/bin/linux/${PORTABLE_ARCH}/kubectl" \ + -o /tmp/kubectl \ + && echo "${KUBECTL_SHA256} /tmp/kubectl" | sha256sum -c - \ + && install -m 0755 /tmp/kubectl /usr/local/bin/kubectl \ + && rm -rf \ + /tmp/dotnet-install.sh \ + /tmp/flux.tar.gz \ + /tmp/go.tar.gz \ + /tmp/helm.tar.gz \ + /tmp/kind \ + /tmp/kubectl \ + /tmp/linux-* \ + /tmp/node.tar.xz + +ARG YARN_VERSION=4.18.0 + +ENV COREPACK_HOME=/usr/local/share/corepack + +# Agent Sandboxes mount their CA bundle here; this step's Node downloads trust it. +RUN if [ -r /run/agent/tls/ca-bundle.pem ]; then \ + export NODE_EXTRA_CA_CERTS=/run/agent/tls/ca-bundle.pem; \ + fi; \ + mkdir -p "${COREPACK_HOME}" \ + && corepack enable \ + && corepack install --global "yarn@${YARN_VERSION}" \ + && chmod -R a+rX "${COREPACK_HOME}" + +ARG CODEX_VERSION=0.159.3 +ARG CLAUDE_CODE_VERSION=2.1.286 + +RUN if [ -r /run/agent/tls/ca-bundle.pem ]; then \ + export NODE_EXTRA_CA_CERTS=/run/agent/tls/ca-bundle.pem; \ + fi; \ + npm install --global "@openai/codex@${CODEX_VERSION}" \ + && npm install --global --allow-scripts=@anthropic-ai/claude-code \ + "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" + +RUN { getent group docker >/dev/null || groupadd --system docker; } \ + && { getent group kvm >/dev/null || groupadd --system kvm; } \ + && useradd --create-home --shell /usr/bin/bash --uid 1000 --groups docker,kvm agent \ + && printf 'agent ALL=(ALL) NOPASSWD:ALL\n' >/etc/sudoers.d/agent \ + && chmod 0440 /etc/sudoers.d/agent \ + && install -d /etc/systemd/system/init.scope.d \ + && printf '[Scope]\nTasksMax=infinity\n' >/etc/systemd/system/init.scope.d/50-agent-tasks.conf \ + && install -d /usr/lib/sysctl.d \ + && printf 'fs.inotify.max_user_instances = 1024\nfs.inotify.max_user_watches = 1048576\nfs.inotify.max_queued_events = 32768\n' \ + >/usr/lib/sysctl.d/50-agent-inotify.conf \ + && systemctl mask serial-getty@hvc0.service \ + && systemctl enable docker.service \ + && rm -f /etc/machine-id /var/lib/dbus/machine-id \ + && touch /etc/machine-id + +ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 +ENV DOTNET_NOLOGO=1 +ENV DISABLE_AUTOUPDATER=1 +ENV PATH=/home/agent/.local/bin:${PATH} + +COPY tmpfiles.conf /usr/lib/tmpfiles.d/worktree.conf + +ENV HOME=/home/agent + +USER agent +WORKDIR /workspace/digdir-agents + +ENTRYPOINT ["/usr/lib/systemd/systemd"] +CMD ["codex", "--yolo"] diff --git a/sandbox/core/examples/worktree/README.md b/sandbox/core/examples/worktree/README.md new file mode 100644 index 0000000..f1804d6 --- /dev/null +++ b/sandbox/core/examples/worktree/README.md @@ -0,0 +1,24 @@ +# Worktree sandbox + +Runs Codex or Claude Code directly through the Sandbox SDK in a microVM (Microsandbox), with the current Git worktree +bind mounted along with Codex/Claude configuration from the current user's home directory. It exercises the Sandbox +layer alone, without `agentd`; the Agent-layer equivalent is `agentctl/examples/self-dev`. + +Installed tools: +- .NET 10 +- Rust +- Go +- nodejs +- container tooling: docker with Buildx, kind, kubectl, flux, helm + +Note that though this protects e.g. the host filesystem, it has permissive network access. +Defaults: 4 CPU, 8Gi memory, a 64Gi direct root filesystem and a 4Gi `/tmp`. + +Run from the repository root: + +```sh +cargo run -p sandbox-worktree # Start Codex +cargo run -p sandbox-worktree -- --harness claude # Start Claude Code +cargo run -p sandbox-worktree -- delete # Delete the Sandbox +cargo run -p sandbox-worktree -- --name my-sandbox # Override the worktree-derived name +``` diff --git a/sandbox/core/examples/worktree/src/main.rs b/sandbox/core/examples/worktree/src/main.rs new file mode 100644 index 0000000..5111ca4 --- /dev/null +++ b/sandbox/core/examples/worktree/src/main.rs @@ -0,0 +1,297 @@ +use std::{ + collections::BTreeMap, + env, + error::Error, + io, + path::{Path, PathBuf}, + process::Command as ProcessCommand, + rc::Rc, +}; + +use clap::{Parser, Subcommand, ValueEnum}; +use sandbox::{ + ByteQuantity, CpuQuantity, EnsureSandboxRequest, Platform, RetentionPolicy, RootFilesystem, SandboxFeature, + SandboxName, SandboxPath, SandboxResources, SandboxService, SandboxSpec, + execution::{ExecutionSpec, ExitStatus, Program}, + image::ImageSource, + mount::Mount, + terminal::{AttachTerminalRequest, TerminalAttachOutcome}, +}; +use sandbox_microsandbox::MicrosandboxProvider; +use sha2::{Digest as _, Sha256}; + +mod progress; + +const SANDBOX_HOME: &str = "/home/agent"; +const SANDBOX_REPOSITORY: &str = "/workspace/digdir-agents"; +const SANDBOX_WORKSPACE: &str = "/workspace/digdir-agents"; +const WORKTREE_ID_HEX_LENGTH: usize = 12; + +#[derive(Debug, Parser)] +#[command(about = "Develop the Sandbox SDK from a retained Microsandbox VM")] +struct Arguments { + /// Sandbox name. Defaults to a stable name derived from the current worktree. + #[arg(long, global = true)] + name: Option, + + /// CPU assigned to the development Sandbox. + #[arg(long, default_value = "4")] + cpu: CpuQuantity, + + /// Memory assigned to the development Sandbox. + #[arg(long, default_value = "8Gi")] + memory: ByteQuantity, + + /// Writable root filesystem capacity. + #[arg(long, default_value = "64Gi")] + root_filesystem: ByteQuantity, + + /// Interactive coding harness to start. + #[arg(long, value_enum, default_value_t)] + harness: Harness, + + #[command(subcommand)] + command: Option, +} + +#[derive(Clone, Copy, Debug, Default, ValueEnum)] +enum Harness { + #[default] + Codex, + Claude, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Delete the Sandbox. + Delete, +} + +struct HostPaths { + claude_home: PathBuf, + codex_home: PathBuf, + repository: PathBuf, +} + +#[tokio::main(flavor = "local")] +async fn main() -> Result<(), Box> { + let arguments = Arguments::parse(); + let host_home = host_home()?; + let repository = worktree_repository()?; + let sandbox_name = arguments + .name + .clone() + .map_or_else(|| worktree_sandbox_name(&repository), Ok)?; + let state_home = resolve_state_home(&host_home)?; + let service = SandboxService::new(Rc::new( + MicrosandboxProvider::open(state_home.join("microsandbox")).await?, + )); + match arguments.command { + Some(Command::Delete) => { + progress::wait_for_operation("Delete Sandbox", service.delete(&sandbox_name)).await?; + return Ok(()); + } + None => {} + } + + let paths = resolve_host_paths(&host_home, repository)?; + let request = EnsureSandboxRequest::new( + sandbox_name.clone(), + SandboxSpec { + image: ImageSource::Build { + context: PathBuf::from(env!("CARGO_MANIFEST_DIR")), + dockerfile: PathBuf::from("Dockerfile"), + target: None, + }, + platform: native_linux_platform(), + resources: SandboxResources::new( + arguments.cpu, + arguments.memory, + RootFilesystem::direct(arguments.root_filesystem), + ), + init_system: sandbox::init::InitSystem::Image, + retention_policy: RetentionPolicy::Retain, + }, + ) + .with_mounts([ + Mount::Bind { + source: paths.codex_home, + target: SandboxPath::new(format!("{SANDBOX_HOME}/.codex")), + read_only: false, + }, + Mount::Bind { + source: paths.claude_home, + target: SandboxPath::new(format!("{SANDBOX_HOME}/.claude")), + read_only: false, + }, + Mount::Bind { + source: paths.repository, + target: SandboxPath::new(SANDBOX_REPOSITORY), + read_only: false, + }, + Mount::Tmpfs { + target: SandboxPath::new("/tmp"), + capacity: "4Gi".parse()?, + }, + ]) + .requiring_features([SandboxFeature::NestedContainers, SandboxFeature::TerminalAttach]); + let sandbox = progress::wait_for_sandbox(service.ensure(&request)).await?; + + let run_result = match sandbox + .attach_terminal(AttachTerminalRequest::new(interactive_spec(arguments.harness))) + .await + { + Ok(TerminalAttachOutcome::Exited(status)) => Ok(status), + Ok(TerminalAttachOutcome::Detached) => Ok(ExitStatus { code: 0 }), + Ok(_) => Err(io::Error::other("unsupported terminal attachment outcome").into()), + Err(error) => Err(Box::new(error) as Box), + }; + let release_result = progress::wait_for_operation("Stop Sandbox", sandbox.release()).await; + let status = combine_run_and_release(run_result, release_result)?; + if !status.success() { + std::process::exit(status.code); + } + Ok(()) +} + +fn combine_run_and_release( + run: Result>, + release: Result<(), Box>, +) -> Result> { + match (run, release) { + (Ok(status), Ok(())) => Ok(status), + (Err(run), Ok(())) => Err(run), + (Ok(_), Err(release)) => Err(release), + (Err(run), Err(release)) => Err(io::Error::other(format!( + "Terminal Execution failed: {run}; stopping the Sandbox also failed: {release}" + )) + .into()), + } +} + +fn interactive_spec(harness: Harness) -> ExecutionSpec { + ExecutionSpec::new(harness.program()) + .with_working_directory(SandboxPath::new(SANDBOX_WORKSPACE)) + .with_environment(sandbox_environment()) +} + +impl Harness { + fn program(self) -> Program { + let (executable, args) = match self { + Self::Codex => ("/usr/local/bin/codex", vec!["--yolo".to_string()]), + Self::Claude => ( + "/usr/local/bin/claude", + vec!["--dangerously-skip-permissions".to_string()], + ), + }; + Program::Command { + executable: SandboxPath::new(executable), + args, + } + } +} + +fn sandbox_environment() -> BTreeMap { + BTreeMap::from([ + ("CARGO_HOME".to_string(), format!("{SANDBOX_HOME}/.cargo")), + ( + "CARGO_TARGET_DIR".to_string(), + format!("{SANDBOX_HOME}/.cache/sandbox-worktree/target"), + ), + ("CODEX_HOME".to_string(), format!("{SANDBOX_HOME}/.codex")), + ("CLAUDE_CONFIG_DIR".to_string(), format!("{SANDBOX_HOME}/.claude")), + ("HOME".to_string(), SANDBOX_HOME.to_string()), + ]) +} + +fn resolve_host_paths(host_home: &Path, repository: PathBuf) -> Result> { + let codex_home = harness_home("CODEX_HOME", ".codex", "Codex home", host_home)?; + let claude_home = harness_home("CLAUDE_CONFIG_DIR", ".claude", "Claude home", host_home)?; + Ok(HostPaths { + claude_home, + codex_home, + repository, + }) +} + +fn worktree_repository() -> Result> { + let repository = current_git_repository()?; + if !repository.join("Cargo.toml").is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "{} does not contain the digdir-agents Rust workspace", + repository.display() + ), + ) + .into()); + } + Ok(repository) +} + +fn worktree_sandbox_name(repository: &Path) -> Result { + let digest = Sha256::digest(repository.as_os_str().as_encoded_bytes()); + let mut suffix = String::with_capacity(WORKTREE_ID_HEX_LENGTH); + for &byte in &digest[..WORKTREE_ID_HEX_LENGTH / 2] { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + suffix.push(char::from(DIGITS[usize::from(byte >> 4)])); + suffix.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + SandboxName::new(format!("worktree-{suffix}")) +} + +fn harness_home(variable: &str, default: &str, label: &str, host_home: &Path) -> Result { + let path = env::var_os(variable).map_or_else(|| host_home.join(default), PathBuf::from); + std::fs::create_dir_all(&path)?; + canonical_directory(&path, label) +} + +fn resolve_state_home(host_home: &Path) -> Result { + let state_home = host_home.join(".sandbox/worktree"); + std::fs::create_dir_all(&state_home)?; + canonical_directory(&state_home, "Sandbox state home") +} + +fn current_git_repository() -> Result> { + let output = ProcessCommand::new("git") + .args(["rev-parse", "--show-toplevel"]) + .output()?; + if !output.status.success() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "current directory is not inside a Git repository", + ) + .into()); + } + let path = String::from_utf8(output.stdout)?; + Ok(canonical_directory(Path::new(path.trim()), "repository")?) +} + +fn canonical_directory(path: &Path, label: &str) -> Result { + let path = std::fs::canonicalize(path)?; + if !path.is_dir() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{label} {} is not a directory", path.display()), + )); + } + Ok(path) +} + +fn host_home() -> Result { + env::var_os("HOME") + .or_else(|| env::var_os("USERPROFILE")) + .map(PathBuf::from) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is not set")) +} + +fn native_linux_platform() -> Platform { + Platform::new( + "linux", + match env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + architecture => architecture, + }, + ) +} diff --git a/sandbox/core/examples/worktree/src/progress.rs b/sandbox/core/examples/worktree/src/progress.rs new file mode 100644 index 0000000..5d55164 --- /dev/null +++ b/sandbox/core/examples/worktree/src/progress.rs @@ -0,0 +1,320 @@ +use std::{ + error::Error as StdError, + future::Future, + io::{self, IsTerminal as _}, + pin::pin, + time::{Duration, Instant}, +}; + +use futures_util::StreamExt as _; +use sandbox::{ + Error, OperationEvent, Outcome, PendingSandbox, ProgressUnit, SandboxHandle, + progress::{Measurement, Progress, ProgressCursor, Update}, +}; +use tokio::time::{MissedTickBehavior, interval}; + +const SPINNER_INTERVAL: Duration = Duration::from_millis(80); +const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +/// Output lines shown when an operation fails in an interactive terminal. +const FAILURE_OUTPUT_LINES: usize = 40; + +pub(crate) async fn wait_for_sandbox(mut pending: PendingSandbox<'_>) -> Result> { + let mut display = ProgressDisplay::stderr(); + let mut progress = Progress::new(); + let mut ticker = spinner_ticker(); + + loop { + tokio::select! { + event = pending.next() => match event { + Some(Ok(OperationEvent::Progress(event))) => { + progress.apply(&event); + display.show(&progress)?; + } + Some(Ok(OperationEvent::Ready(sandbox))) => { + progress.succeed(); + display.show(&progress)?; + display.ready()?; + return Ok(sandbox); + } + Some(Ok(_)) => {} + Some(Err(error)) => { + progress.fail(error.to_string()); + display.failed(&progress)?; + return Err(error.into()); + } + None => { + progress.fail(Error::OperationStreamEnded.to_string()); + display.failed(&progress)?; + return Err(Error::OperationStreamEnded.into()); + } + }, + _ = ticker.tick() => display.tick(&progress)?, + } + } +} + +pub(crate) async fn wait_for_operation( + label: &str, + operation: impl Future>, +) -> Result> +where + E: StdError + 'static, +{ + let mut display = ProgressDisplay::stderr(); + display.start(label)?; + let started = Instant::now(); + let mut operation = pin!(operation); + let mut ticker = spinner_ticker(); + + loop { + tokio::select! { + result = &mut operation => { + let elapsed = started.elapsed(); + return match result { + Ok(value) => { + display.operation_completed(label, elapsed)?; + Ok(value) + } + Err(error) => { + display.operation_failed(label, elapsed)?; + Err(Box::new(error)) + } + }; + } + _ = ticker.tick() => display.tick_label(label)?, + } + } +} + +fn spinner_ticker() -> tokio::time::Interval { + let mut ticker = interval(SPINNER_INTERVAL); + ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + ticker +} + +struct ProgressDisplay { + output: W, + interactive: bool, + cursor: ProgressCursor, + /// Phase and steps already announced in plain output. + announced: Option<(String, Vec)>, + frame: usize, + line_visible: bool, +} + +impl ProgressDisplay { + fn stderr() -> Self { + let output = io::stderr(); + let interactive = output.is_terminal(); + Self::new(output, interactive) + } +} + +impl ProgressDisplay { + fn new(output: W, interactive: bool) -> Self { + Self { + output, + interactive, + cursor: ProgressCursor::default(), + announced: None, + frame: 0, + line_visible: false, + } + } + + /// Prints what finished since the last call, then the activity in progress. + fn show(&mut self, progress: &Progress) -> io::Result<()> { + let updates = self.cursor.updates(progress); + if !updates.is_empty() { + self.clear_line()?; + } + for update in updates { + match update { + Update::OutputSkipped(count) if !self.interactive => { + writeln!(self.output, " … {count} lines skipped")?; + } + Update::Output(line) if !self.interactive => writeln!(self.output, " {}", line.text)?, + Update::StepFinished(step) if !self.interactive => { + let elapsed = Duration::from_millis(step.elapsed_ms); + match (step.outcome, step.measurement) { + (Outcome::Failed, _) => writeln!(self.output, " ✗ {} ({})", step.name, duration(elapsed))?, + (_, Some(measurement)) => writeln!( + self.output, + " ✓ {}: {} ({})", + step.name, + format_measurement(measurement), + duration(elapsed) + )?, + _ => writeln!(self.output, " ✓ {} ({})", step.name, duration(elapsed))?, + } + } + Update::PhaseFinished(phase) => { + let elapsed = duration(Duration::from_millis(phase.elapsed_ms)); + match phase.outcome { + Outcome::Reused => writeln!(self.output, "✓ Reused {} ({elapsed})", phase.phase.label)?, + Outcome::Failed => writeln!(self.output, "✗ {} ({elapsed})", phase.phase.label)?, + _ => writeln!(self.output, "✓ {} ({elapsed})", phase.phase.label)?, + } + } + Update::OutputSkipped(_) | Update::Output(_) | Update::StepFinished(_) => {} + } + } + if self.interactive { + self.render(progress) + } else { + self.announce(progress) + } + } + + /// Names a newly started phase or step in plain output. + fn announce(&mut self, progress: &Progress) -> io::Result<()> { + let Some(current) = progress.current() else { + return Ok(()); + }; + if self + .announced + .as_ref() + .is_none_or(|(phase, _)| *phase != current.phase.id) + { + writeln!(self.output, "→ {}", current.phase.label)?; + self.announced = Some((current.phase.id.to_string(), Vec::new())); + } + if let Some((_, steps)) = &mut self.announced { + for step in ¤t.steps { + let id = step.id.to_string(); + if !steps.contains(&id) { + writeln!(self.output, " → {}", step.name)?; + steps.push(id); + } + } + } + Ok(()) + } + + fn ready(&mut self) -> io::Result<()> { + self.clear_line()?; + writeln!(self.output, "✓ Sandbox ready") + } + + fn failed(&mut self, progress: &Progress) -> io::Result<()> { + self.show(progress)?; + self.clear_line()?; + if self.interactive && !progress.output().is_empty() { + writeln!(self.output, " Backend output:")?; + for line in progress.output().tail(FAILURE_OUTPUT_LINES) { + writeln!(self.output, " {}", line.text)?; + } + } + Ok(()) + } + + fn operation_completed(&mut self, label: &str, elapsed: Duration) -> io::Result<()> { + self.clear_line()?; + writeln!(self.output, "✓ {label} ({})", duration(elapsed)) + } + + fn operation_failed(&mut self, label: &str, elapsed: Duration) -> io::Result<()> { + self.clear_line()?; + writeln!(self.output, "✗ {label} ({})", duration(elapsed)) + } + + fn start(&mut self, label: &str) -> io::Result<()> { + if self.interactive { + self.render_line(label) + } else { + writeln!(self.output, "→ {label}") + } + } + + fn tick(&mut self, progress: &Progress) -> io::Result<()> { + if !self.interactive || progress.current().is_none() { + return Ok(()); + } + self.frame = (self.frame + 1) % SPINNER_FRAMES.len(); + self.render(progress) + } + + fn tick_label(&mut self, label: &str) -> io::Result<()> { + if !self.interactive { + return Ok(()); + } + self.frame = (self.frame + 1) % SPINNER_FRAMES.len(); + self.render_line(label) + } + + fn render(&mut self, progress: &Progress) -> io::Result<()> { + let Some(current) = progress.current() else { + return Ok(()); + }; + let mut line = current.phase.label.to_string(); + if let Some(step) = progress.current_step() { + line.push_str(" · "); + line.push_str(&step.name); + if let Some(measurement) = step.measurement { + line.push_str(": "); + line.push_str(&format_measurement(measurement)); + } + } + self.render_line(&line) + } + + fn render_line(&mut self, line: &str) -> io::Result<()> { + write!(self.output, "\r\x1b[2K{} {line}", SPINNER_FRAMES[self.frame])?; + self.output.flush()?; + self.line_visible = true; + Ok(()) + } + + fn clear_line(&mut self) -> io::Result<()> { + if self.interactive && self.line_visible { + write!(self.output, "\r\x1b[2K")?; + self.output.flush()?; + self.line_visible = false; + } + Ok(()) + } +} + +fn format_measurement(measurement: Measurement) -> String { + format_progress(measurement.completed, measurement.total, measurement.unit) +} + +fn format_progress(completed: u64, total: Option, unit: ProgressUnit) -> String { + match (unit, total) { + (ProgressUnit::Bytes, Some(total)) => format!("{} / {}", bytes(completed), bytes(total)), + (ProgressUnit::Bytes, None) => bytes(completed), + (ProgressUnit::Items, Some(total)) => format!("{completed} / {total}"), + _ => completed.to_string(), + } +} + +fn bytes(value: u64) -> String { + const KIB: u64 = 1024; + const MIB: u64 = KIB * 1024; + const GIB: u64 = MIB * 1024; + if value >= GIB { + scaled_bytes(value, GIB, "GiB") + } else if value >= MIB { + scaled_bytes(value, MIB, "MiB") + } else if value >= KIB { + scaled_bytes(value, KIB, "KiB") + } else { + format!("{value} B") + } +} + +fn scaled_bytes(value: u64, unit: u64, suffix: &str) -> String { + let whole = value / unit; + let decimal = (value % unit).saturating_mul(10) / unit; + format!("{whole}.{decimal} {suffix}") +} + +fn duration(value: Duration) -> String { + if value.as_secs() >= 60 { + format!("{}m {:02}s", value.as_secs() / 60, value.as_secs() % 60) + } else if value.as_secs() > 0 { + format!("{:.1}s", value.as_secs_f64()) + } else { + format!("{}ms", value.as_millis()) + } +} diff --git a/sandbox/core/examples/worktree/tmpfiles.conf b/sandbox/core/examples/worktree/tmpfiles.conf new file mode 100644 index 0000000..1111441 --- /dev/null +++ b/sandbox/core/examples/worktree/tmpfiles.conf @@ -0,0 +1,2 @@ +d /home/agent 0755 agent agent - +z /dev/kvm 0660 root kvm - diff --git a/sandbox/core/src/backend.rs b/sandbox/core/src/backend.rs new file mode 100644 index 0000000..9b1c291 --- /dev/null +++ b/sandbox/core/src/backend.rs @@ -0,0 +1,293 @@ +//! Interfaces implemented by Sandbox Providers. +//! +//! Application code normally provisions through [`crate::SandboxService`] and +//! operates the resulting [`crate::SandboxHandle`]. + +use std::{collections::BTreeMap, future::Future, pin::Pin}; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +pub use crate::feature::SandboxBackendCapabilities; + +use crate::{ + Error, Hostname, PendingOperation, Platform, RootFilesystem, SandboxName, SandboxPath, execution, file_transfer, + image, + init::InitSystem, + mount::Mount, + network, + resource::{ByteQuantity, CpuQuantity}, + terminal, volume, +}; + +/// A non-`Send` future executed by a Tokio local runtime. +pub type LocalFuture<'a, T> = Pin + 'a>>; + +/// Identifies one materialization independently of backend-specific identifiers. +/// +/// The Sandbox lifecycle service assigns a fresh ID before calling a Backend's +/// create operation. Deleting and recreating the same [`SandboxName`] produces +/// a different ID. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct SandboxId(Uuid); + +impl SandboxId { + pub(crate) fn generate() -> Self { + Self(Uuid::new_v4()) + } + + /// Returns the UUID representation. + #[must_use] + pub const fn as_uuid(&self) -> &Uuid { + &self.0 + } +} + +impl std::fmt::Display for SandboxId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(formatter) + } +} + +impl std::str::FromStr for SandboxId { + type Err = uuid::Error; + + fn from_str(value: &str) -> Result { + value.parse().map(Self) + } +} + +/// The lifecycle state reported by a sandbox backend. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SandboxState { + /// The Sandbox exists but is not running. + Stopped, + /// The Sandbox is running. + Running, +} + +/// Host-observed evidence that a running guest is making progress. +/// +/// A Backend reports it without a round trip to the guest, so a guest that no +/// longer responds still reports its last heartbeat. Only a change of the +/// sequence is meaningful: it restarts whenever the Sandbox starts. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GuestHeartbeat(u64); + +impl GuestHeartbeat { + /// Wraps a heartbeat sequence observed by a Backend. + #[must_use] + pub const fn new(sequence: u64) -> Self { + Self(sequence) + } + + /// Returns the observed sequence. + #[must_use] + pub const fn sequence(self) -> u64 { + self.0 + } +} + +/// Desired compute and writable root filesystem resources assigned to one Sandbox. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SandboxResources { + cpu: CpuQuantity, + memory: ByteQuantity, + root_filesystem: RootFilesystem, +} + +impl SandboxResources { + /// Creates a positive, normalized resource assignment. + #[must_use] + pub const fn new(cpu: CpuQuantity, memory: ByteQuantity, root_filesystem: RootFilesystem) -> Self { + Self { + cpu, + memory, + root_filesystem, + } + } + + /// Returns the desired CPU quantity. + #[must_use] + pub const fn cpu(self) -> CpuQuantity { + self.cpu + } + + /// Returns the desired Sandbox memory quantity. + #[must_use] + pub const fn memory(self) -> ByteQuantity { + self.memory + } + + /// Returns the desired writable root filesystem capacity. + #[must_use] + pub const fn root_filesystem(self) -> RootFilesystem { + self.root_filesystem + } +} + +/// Materialized inputs passed to a sandbox backend. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CreateSandboxRequest { + /// Backend-neutral identity assigned by the lifecycle owner. + pub id: SandboxId, + /// The resolved image to run. + pub image: image::ResolvedImage, + /// The stable caller-provided name. + pub name: SandboxName, + /// Hostname reported inside the Sandbox, resolved by the lifecycle owner. + pub hostname: Hostname, + /// Desired mutable compute and writable root filesystem resources. + pub resources: SandboxResources, + /// Process responsible for initializing the Sandbox after backend setup. + pub init_system: InitSystem, + /// Filesystem attachments materialized when the Sandbox is created. + pub mounts: Vec, + /// Non-secret environment inherited by image init and Sandbox Executions. + pub environment: BTreeMap, + /// Immutable Network attachment selected by the caller. + pub network: Option, +} + +/// A backend-neutral view of a materialized sandbox. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Sandbox { + /// The image used to create this Sandbox. + pub image: image::ResolvedImage, + /// The stable backend-neutral identifier. + pub id: SandboxId, + /// The caller-provided name. + pub name: SandboxName, + /// Hostname the Sandbox reports to its guest. + pub hostname: Hostname, + /// Current desired compute and writable root filesystem resources. + pub resources: SandboxResources, + /// Process responsible for initializing the Sandbox after backend setup. + pub init_system: InitSystem, + /// The current lifecycle state. + pub state: SandboxState, + /// The latest guest heartbeat, when the Backend observes one for a running + /// Sandbox. Absent while the guest boots and when the Backend cannot tell. + pub guest_heartbeat: Option, + /// Filesystem attachments materialized in the Sandbox. + pub mounts: Vec, + /// Non-secret environment inherited by image init and Sandbox Executions. + pub environment: BTreeMap, + /// Immutable Network attachment materialized with the Sandbox. + pub network: Option, +} + +/// Provides core Sandbox lifecycle, execution, runtime file transfer, storage, +/// and mount behavior. +/// +/// Network enforcement and agent automation intentionally remain outside this +/// interface. A Backend reports and opens data-plane endpoints that an +/// independently implemented [`network::NetworkBackend`] can consume. +pub trait SandboxBackend { + /// Reports functionality implemented for a supported Platform. + /// + /// An offered endpoint must be the exclusive path for Sandbox egress. The + /// Backend blocks traffic not represented by the selected endpoint rather + /// than allowing it to bypass the Network Backend. + fn capabilities<'a>(&'a self, platform: &'a Platform) + -> LocalFuture<'a, Result>; + + /// Creates a stopped Sandbox. + fn create(&self, request: CreateSandboxRequest) -> PendingOperation<'_, Sandbox>; + + /// Reconciles the mutable resource assignment of an existing Sandbox. + fn update_resources<'a>(&'a self, id: &'a SandboxId, resources: SandboxResources) -> PendingOperation<'a, Sandbox>; + + /// Replaces the non-secret environment of a stopped Sandbox. + fn update_environment<'a>( + &'a self, + id: &'a SandboxId, + environment: BTreeMap, + ) -> PendingOperation<'a, Sandbox>; + + /// Finds a Sandbox by its stable caller-provided name. + fn find<'a>(&'a self, name: &'a SandboxName) -> LocalFuture<'a, Result>; + + /// Inspects a Sandbox by identifier. + fn inspect<'a>(&'a self, id: &'a SandboxId) -> LocalFuture<'a, Result>; + + /// Starts a Sandbox. + fn start<'a>(&'a self, id: &'a SandboxId) -> PendingOperation<'a, ()>; + + /// Stops a Sandbox. + fn stop<'a>(&'a self, id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>>; + + /// Deletes a Sandbox. + fn delete<'a>(&'a self, id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>>; + + /// Opens the data-plane endpoint for the Sandbox's immutable Network attachment. + fn open_network_endpoint<'a>( + &'a self, + id: &'a SandboxId, + ) -> LocalFuture<'a, Result>; + + /// Starts an Execution with its SDK-assigned identity and opens its transient event stream. + fn start_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + request: execution::StartExecutionRequest, + ) -> LocalFuture<'a, Result>; + + /// Starts a terminal Execution with its SDK-assigned identity and bidirectional input and output. + fn start_terminal_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + request: terminal::StartTerminalExecutionRequest, + ) -> LocalFuture<'a, Result>; + + /// Attaches the caller's terminal to an interactive Execution. + fn attach_terminal<'a>( + &'a self, + sandbox_id: &'a SandboxId, + request: terminal::AttachTerminalRequest, + ) -> LocalFuture<'a, Result>; + + /// Requests graceful termination of a live Execution. + fn terminate_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + execution_id: &'a execution::ExecutionId, + ) -> LocalFuture<'a, Result<(), Error>>; + + /// Forces a live Execution to stop. + fn kill_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + execution_id: &'a execution::ExecutionId, + ) -> LocalFuture<'a, Result<(), Error>>; + + /// Opens one regular file in a running Sandbox for streamed reading. + fn read_file<'a>( + &'a self, + sandbox_id: &'a SandboxId, + path: &'a SandboxPath, + ) -> LocalFuture<'a, Result>; + + /// Creates or replaces one regular file in a running Sandbox from a byte stream. + /// + /// The replacement is atomic: a concurrent reader in the Sandbox observes either the previous + /// file or the complete new one, never a truncated or partially written file. A replaced + /// regular file keeps its mode and ownership. + fn write_file<'a>( + &'a self, + sandbox_id: &'a SandboxId, + path: &'a SandboxPath, + contents: file_transfer::ByteReader, + ) -> LocalFuture<'a, Result<(), Error>>; + + /// Creates or returns a named Volume, using the SDK-assigned identity when materializing it. + fn ensure_volume(&self, request: volume::EnsureVolumeRequest) -> LocalFuture<'_, Result>; + + /// Finds a Volume by name. + fn find_volume<'a>(&'a self, name: &'a volume::VolumeName) -> LocalFuture<'a, Result>; + + /// Deletes a Volume. + fn delete_volume<'a>(&'a self, id: &'a volume::VolumeId) -> LocalFuture<'a, Result<(), Error>>; +} diff --git a/sandbox/core/src/execution.rs b/sandbox/core/src/execution.rs new file mode 100644 index 0000000..02c0ec9 --- /dev/null +++ b/sandbox/core/src/execution.rs @@ -0,0 +1,270 @@ +//! Streaming command execution inside a running Sandbox. + +use std::{collections::BTreeMap, future::poll_fn, pin::Pin}; + +use bytes::{Bytes, BytesMut}; +use futures_core::Stream; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{Error, SandboxPath}; + +/// Identifies a live Execution within a Sandbox. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct ExecutionId(Uuid); + +impl ExecutionId { + pub(crate) fn generate() -> Self { + Self(Uuid::new_v4()) + } + + /// Returns the UUID representation. + #[must_use] + pub const fn as_uuid(&self) -> &Uuid { + &self.0 + } +} + +impl std::fmt::Display for ExecutionId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(formatter) + } +} + +impl std::str::FromStr for ExecutionId { + type Err = uuid::Error; + + fn from_str(value: &str) -> Result { + value.parse().map(Self) + } +} + +/// Program selection for an Execution. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Program { + /// Use the default OCI entrypoint and command from the Image. + ImageEntrypoint, + /// Run one executable with explicit arguments. + Command { + /// Executable path inside the Sandbox. + executable: SandboxPath, + /// Arguments passed directly to the executable. + args: Vec, + }, +} + +/// Desired command and process environment. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ExecutionSpec { + /// Program to run. + program: Program, + /// Working directory inside the Sandbox, or the Image default when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + working_directory: Option, + /// Environment additions for the process. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + environment: BTreeMap, +} + +impl ExecutionSpec { + /// Creates a process specification from a program selection. + #[must_use] + pub const fn new(program: Program) -> Self { + Self { + program, + working_directory: None, + environment: BTreeMap::new(), + } + } + + /// Uses the Image's default OCI entrypoint and command. + #[must_use] + pub const fn image_entrypoint() -> Self { + Self::new(Program::ImageEntrypoint) + } + + /// Runs one executable with explicit arguments. + #[must_use] + pub fn command(executable: SandboxPath, args: impl IntoIterator) -> Self { + Self::new(Program::Command { + executable, + args: args.into_iter().collect(), + }) + } + + /// Sets the working directory inside the Sandbox. + #[must_use] + pub fn with_working_directory(mut self, path: SandboxPath) -> Self { + self.working_directory = Some(path); + self + } + + /// Adds environment variables for the process. + #[must_use] + pub fn with_environment(mut self, values: impl IntoIterator) -> Self { + self.environment.extend(values); + self + } + + /// Returns the selected program. + #[must_use] + pub const fn program(&self) -> &Program { + &self.program + } + + /// Returns the optional working directory. + #[must_use] + pub const fn working_directory(&self) -> Option<&SandboxPath> { + self.working_directory.as_ref() + } + + /// Returns process environment additions. + #[must_use] + pub const fn environment(&self) -> &BTreeMap { + &self.environment + } +} + +/// Starts an Execution in a running Sandbox. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StartExecutionRequest { + /// Backend-neutral identity assigned before dispatch. + id: ExecutionId, + /// Desired command and process environment. + spec: ExecutionSpec, +} + +impl StartExecutionRequest { + /// Creates a request with a freshly assigned Execution identifier. + #[must_use] + pub fn new(spec: ExecutionSpec) -> Self { + Self { + id: ExecutionId::generate(), + spec, + } + } + + /// Returns the assigned Execution identifier. + #[must_use] + pub const fn id(&self) -> &ExecutionId { + &self.id + } + + /// Returns the desired command and process environment. + #[must_use] + pub const fn spec(&self) -> &ExecutionSpec { + &self.spec + } + + /// Decomposes the request for a Backend implementation. + #[must_use] + pub fn into_parts(self) -> (ExecutionId, ExecutionSpec) { + (self.id, self.spec) + } +} + +/// Process exit status reported by a Sandbox Backend. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ExitStatus { + /// Process exit code. + pub code: i32, +} + +impl ExitStatus { + /// Reports whether the process exited with code zero. + #[must_use] + pub const fn success(self) -> bool { + self.code == 0 + } +} + +/// One event from a live Execution. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ExecutionEvent { + /// The process started. + Started { + /// Backend-reported process identifier, when available. + process_id: Option, + }, + /// Raw standard-output bytes. + Stdout(Bytes), + /// Raw standard-error bytes. + Stderr(Bytes), + /// The process exited. + Exited(ExitStatus), + /// The process could not be started. + Failed { + /// Backend-neutral failure description. + message: String, + }, +} + +/// A non-`Send` stream of events from one live Execution. +pub type ExecutionEventStream = Pin>>>; + +/// A newly started, addressable Execution and its transient event stream. +pub struct StartedExecution { + /// Identifier used for Execution control operations. + pub id: ExecutionId, + /// Events emitted until the Execution exits or fails. + pub events: ExecutionEventStream, +} + +/// Collected output from a completed Execution. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExecutionOutput { + /// Addressable Execution identifier. + pub id: ExecutionId, + /// Process exit status. + pub status: ExitStatus, + /// Complete raw standard output. + pub stdout: Bytes, + /// Complete raw standard error. + pub stderr: Bytes, +} + +impl StartedExecution { + /// Consumes the live event stream and collects all process output in memory. + /// + /// Callers expecting unbounded output should consume [`Self::events`] + /// directly. + /// + /// # Errors + /// + /// Returns an error when the process cannot start or the event stream ends + /// without an exit status. + pub async fn collect(mut self) -> Result { + let execution_id = self.id.clone(); + let mut stdout = BytesMut::new(); + let mut stderr = BytesMut::new(); + + while let Some(event) = poll_fn(|context| self.events.as_mut().poll_next(context)).await { + match event? { + ExecutionEvent::Started { .. } => {} + ExecutionEvent::Stdout(chunk) => stdout.extend_from_slice(&chunk), + ExecutionEvent::Stderr(chunk) => stderr.extend_from_slice(&chunk), + ExecutionEvent::Exited(status) => { + return Ok(ExecutionOutput { + id: execution_id, + status, + stdout: stdout.freeze(), + stderr: stderr.freeze(), + }); + } + ExecutionEvent::Failed { message } => { + return Err(Error::ExecutionFailed { + id: execution_id, + message, + }); + } + } + } + + Err(Error::ExecutionStreamEnded { id: execution_id }) + } +} diff --git a/sandbox/core/src/feature.rs b/sandbox/core/src/feature.rs new file mode 100644 index 0000000..a487bae --- /dev/null +++ b/sandbox/core/src/feature.rs @@ -0,0 +1,170 @@ +//! Discoverable functionality reported by Sandbox SDK interfaces. + +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; + +use crate::{ + image::ImageOperationCapabilities, mount::MountKindSet, network::NetworkEndpointCapabilities, + root_filesystem::RootFilesystemModeSet, +}; + +/// Optional functionality that callers may require from a Sandbox implementation. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub enum SandboxFeature { + /// Run addressable commands inside a Sandbox. + Execution, + /// Run bidirectional terminal Executions inside a Sandbox. + TerminalExecution, + /// Attach the caller's terminal to an interactive Sandbox Execution. + TerminalAttach, + /// Stream regular files to and from a running Sandbox. + FileTransfer, + /// Attach storage whose lifecycle is independent of a Sandbox. + PersistentVolumes, + /// Run a container engine inside the Sandbox. + NestedContainers, + /// Hand Sandbox initialization to the init system supplied by the Image. + ImageInit, +} + +/// A deterministic set of Sandbox Features. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(transparent)] +pub struct SandboxFeatureSet(BTreeSet); + +impl SandboxFeatureSet { + /// Creates an empty feature set. + #[must_use] + pub const fn new() -> Self { + Self(BTreeSet::new()) + } + + /// Reports whether one feature is available. + #[must_use] + pub fn contains(&self, feature: SandboxFeature) -> bool { + self.0.contains(&feature) + } + + /// Adds a feature. + pub fn insert(&mut self, feature: SandboxFeature) { + self.0.insert(feature); + } + + /// Adds every feature in another set. + pub fn extend(&mut self, other: &Self) { + self.0.extend(other.0.iter().copied()); + } + + /// Iterates over available features in stable order. + pub fn iter(&self) -> impl Iterator + '_ { + self.0.iter().copied() + } +} + +impl From<[SandboxFeature; N]> for SandboxFeatureSet { + fn from(features: [SandboxFeature; N]) -> Self { + Self(features.into_iter().collect()) + } +} + +/// Platform-specific functionality reported by a Sandbox Backend. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SandboxBackendCapabilities { + /// Optional Sandbox operations implemented by the Backend. + pub features: SandboxFeatureSet, + /// Filesystem attachment forms accepted during Sandbox creation. + pub mounts: MountKindSet, + /// Root filesystem materialization modes accepted during Sandbox creation. + pub root_filesystems: RootFilesystemModeSet, + /// Network endpoint forms the Backend can expose. + pub network: NetworkEndpointCapabilities, +} + +/// Consumer-visible functionality available from a configured Sandbox Service. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SandboxCapabilities { + features: SandboxFeatureSet, + mount_kinds: MountKindSet, + root_filesystem_modes: RootFilesystemModeSet, + prepared_image_export: ImageOperationCapabilities, + prepared_image_import: ImageOperationCapabilities, + network_available: bool, +} + +impl SandboxCapabilities { + pub(crate) const fn new( + features: SandboxFeatureSet, + mount_kinds: MountKindSet, + root_filesystem_modes: RootFilesystemModeSet, + prepared_image_export: ImageOperationCapabilities, + prepared_image_import: ImageOperationCapabilities, + network_available: bool, + ) -> Self { + Self { + features, + mount_kinds, + root_filesystem_modes, + prepared_image_export, + prepared_image_import, + network_available, + } + } + + /// Returns optional Sandbox operations supported by the configured Provider. + #[must_use] + pub const fn features(&self) -> &SandboxFeatureSet { + &self.features + } + + /// Returns filesystem attachment forms accepted during Sandbox creation. + #[must_use] + pub const fn mount_kinds(&self) -> &MountKindSet { + &self.mount_kinds + } + + /// Returns root-filesystem modes accepted by both the Sandbox Backend and + /// the Image Backend's resolve operation. + #[must_use] + pub const fn root_filesystem_modes(&self) -> &RootFilesystemModeSet { + &self.root_filesystem_modes + } + + /// Returns prepared-image export support for this Platform. + #[must_use] + pub const fn prepared_image_export(&self) -> &ImageOperationCapabilities { + &self.prepared_image_export + } + + /// Returns prepared-image import support for this Platform. + #[must_use] + pub const fn prepared_image_import(&self) -> &ImageOperationCapabilities { + &self.prepared_image_import + } + + /// Reports whether the configured Network Backend can use this Provider. + #[must_use] + pub const fn network_available(&self) -> bool { + self.network_available + } +} + +impl SandboxBackendCapabilities { + /// Creates one coherent capability report. + #[must_use] + pub const fn new( + features: SandboxFeatureSet, + mounts: MountKindSet, + root_filesystems: RootFilesystemModeSet, + network: NetworkEndpointCapabilities, + ) -> Self { + Self { + features, + mounts, + root_filesystems, + network, + } + } +} diff --git a/sandbox/core/src/file_transfer.rs b/sandbox/core/src/file_transfer.rs new file mode 100644 index 0000000..4e9eda3 --- /dev/null +++ b/sandbox/core/src/file_transfer.rs @@ -0,0 +1,61 @@ +//! Streaming transfer of regular files to and from a running Sandbox. + +use std::{path::Path, pin::Pin}; + +use tokio::{ + fs::File, + io::{AsyncRead, AsyncWriteExt as _}, +}; + +use crate::{Error, SandboxHandle, SandboxPath}; + +/// An owned, non-`Send` asynchronous byte reader. +pub type ByteReader = Pin>; + +/// Streams one host file into a running Sandbox. +/// +/// # Errors +/// +/// Returns an error when the host file cannot be opened or the Backend cannot +/// complete the transfer. +pub async fn copy_file_to_sandbox( + sandbox: &SandboxHandle, + host_path: &Path, + sandbox_path: &SandboxPath, +) -> Result<(), Error> { + let source = File::open(host_path).await.map_err(|source| Error::Io { + operation: "open host file for Sandbox transfer", + source, + })?; + + sandbox.write_file(sandbox_path, Box::pin(source)).await +} + +/// Streams one file from a running Sandbox into a host file. +/// +/// # Errors +/// +/// Returns an error when the Backend cannot read the Sandbox file or the host +/// file cannot be written. +pub async fn copy_file_from_sandbox( + sandbox: &SandboxHandle, + sandbox_path: &SandboxPath, + host_path: &Path, +) -> Result<(), Error> { + let mut source = sandbox.read_file(sandbox_path).await?; + let mut destination = File::create(host_path).await.map_err(|source| Error::Io { + operation: "create host file for Sandbox transfer", + source, + })?; + + tokio::io::copy(&mut source, &mut destination) + .await + .map_err(|source| Error::Io { + operation: "copy Sandbox file to host", + source, + })?; + destination.flush().await.map_err(|source| Error::Io { + operation: "flush host file copied from Sandbox", + source, + }) +} diff --git a/sandbox/core/src/image.rs b/sandbox/core/src/image.rs new file mode 100644 index 0000000..5b484a7 --- /dev/null +++ b/sandbox/core/src/image.rs @@ -0,0 +1,365 @@ +//! Image materialization is separate from Sandbox lifecycle backends. + +use std::{ + collections::BTreeSet, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; + +use crate::{Error, LocalFuture, PendingOperation, Platform, RootFilesystemMode, RootFilesystemModeSet}; + +/// Transient credentials used while resolving an OCI registry reference. +/// +/// Credentials configure a Provider's image materialization domain. They are +/// never part of a persisted [`ImageSource`] or [`ResolvedImage`]. +#[derive(Clone, Eq, PartialEq)] +pub enum RegistryAuthentication { + /// Access the registry without credentials. + Anonymous, + /// Authenticate with a username and password or access token. + Basic { + /// Registry username. + username: String, + /// Registry password or access token. + password: String, + }, +} + +/// The portable OCI identity form supplied to an Image Backend. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub enum ImageSourceKind { + /// A Dockerfile and build context. + Build, + /// An OCI registry reference. + Reference, +} + +/// Deterministic set of supported OCI Image Source forms. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ImageSourceKindSet(BTreeSet); + +impl ImageSourceKindSet { + /// Reports whether no Image Source form is supported. + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Reports whether an Image Source form is supported. + #[must_use] + pub fn contains(&self, kind: ImageSourceKind) -> bool { + self.0.contains(&kind) + } + + /// Iterates over supported forms in stable order. + pub fn iter(&self) -> impl Iterator + '_ { + self.0.iter().copied() + } +} + +impl From<[ImageSourceKind; N]> for ImageSourceKindSet { + fn from(kinds: [ImageSourceKind; N]) -> Self { + Self(kinds.into_iter().collect()) + } +} + +/// An operation exposed by an Image Backend. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ImageOperation { + /// Resolve an OCI identity into a provider-consumable image. + Resolve, + /// Export a provider-owned prepared representation. + PreparedImageExport, + /// Import a provider-owned prepared representation. + PreparedImageImport, +} + +/// OCI source forms and materialization modes supported by one Image operation. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ImageOperationCapabilities { + /// OCI Image Source forms accepted by the operation. + pub sources: ImageSourceKindSet, + /// Root filesystem modes accepted by the operation. + pub root_filesystem_modes: RootFilesystemModeSet, +} + +impl ImageOperationCapabilities { + /// Creates one operation capability report. + #[must_use] + pub const fn new(sources: ImageSourceKindSet, root_filesystem_modes: RootFilesystemModeSet) -> Self { + Self { + sources, + root_filesystem_modes, + } + } + + /// Reports whether the operation accepts at least one source and mode pair. + /// + /// Operation capabilities describe the Cartesian product of the two sets, + /// so either set being empty makes the operation unavailable. + #[must_use] + pub fn is_available(&self) -> bool { + !self.sources.is_empty() && !self.root_filesystem_modes.is_empty() + } +} + +/// Platform-specific functionality reported by an Image Backend. +/// +/// An operation is unavailable when either of its capability sets is empty. +/// Materialization formats are provider-owned and are not transferable between +/// providers; the portable identity remains the OCI build or registry reference +/// from which a prepared image is derived. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ImageBackendCapabilities { + /// Supported image resolution inputs. + pub resolve: ImageOperationCapabilities, + /// Supported prepared-image exports. + pub prepared_image_export: ImageOperationCapabilities, + /// Supported prepared-image imports. + pub prepared_image_import: ImageOperationCapabilities, +} + +impl ImageBackendCapabilities { + /// Creates one coherent Image Backend capability report. + #[must_use] + pub const fn new( + resolve: ImageOperationCapabilities, + prepared_image_export: ImageOperationCapabilities, + prepared_image_import: ImageOperationCapabilities, + ) -> Self { + Self { + resolve, + prepared_image_export, + prepared_image_import, + } + } +} + +/// Describes how to obtain the immutable image for a sandbox. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase", tag = "type")] +pub enum ImageSource { + /// Build an image from a Dockerfile and context. + Build { + /// Build context, relative to the applied manifest by default. + context: PathBuf, + /// Dockerfile path relative to the build context. + dockerfile: PathBuf, + /// Optional named Dockerfile stage to build. + #[serde(default, skip_serializing_if = "Option::is_none")] + target: Option, + }, + /// Resolve an image from an OCI registry reference. + Reference { + /// OCI image reference, optionally pinned by digest. Tags are resolved + /// once when the Sandbox is created and do not update it in place. + reference: String, + }, +} + +impl ImageSource { + /// Returns this source's capability kind. + #[must_use] + pub const fn kind(&self) -> ImageSourceKind { + match self { + Self::Build { .. } => ImageSourceKind::Build, + Self::Reference { .. } => ImageSourceKind::Reference, + } + } + + /// Validates fields understood by the generic image layer. + /// + /// # Errors + /// + /// Returns [`Error::Invalid`] when a required path or reference is empty. + pub fn validate(&self) -> Result<(), Error> { + match self { + Self::Build { + context, + dockerfile, + target, + } => { + if context.as_os_str().is_empty() { + return Err(Error::invalid("image.context", "must not be empty")); + } + if dockerfile.as_os_str().is_empty() { + return Err(Error::invalid("image.dockerfile", "must not be empty")); + } + if target.as_ref().is_some_and(|target| target.trim().is_empty()) { + return Err(Error::invalid("image.target", "must not be empty")); + } + } + Self::Reference { reference } if reference.trim().is_empty() => { + return Err(Error::invalid("image.reference", "must not be empty")); + } + Self::Reference { .. } => {} + } + Ok(()) + } + + /// Resolves paths relative to a caller-supplied source directory. + #[must_use] + pub fn resolve_from(&self, source_directory: &std::path::Path) -> Self { + match self { + Self::Build { + context, + dockerfile, + target, + } => Self::Build { + context: if context.is_relative() { + source_directory.join(context) + } else { + context.clone() + }, + dockerfile: dockerfile.clone(), + target: target.clone(), + }, + Self::Reference { .. } => self.clone(), + } + } +} + +/// Inputs for resolving an image for one Sandbox Platform. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ResolveRequest { + /// Source description for the image. + pub source: ImageSource, + /// Platform the resulting image must support. + pub platform: Platform, + /// Filesystem representation required by the Sandbox consuming the image. + pub root_filesystem_mode: RootFilesystemMode, +} + +impl ResolveRequest { + pub(crate) fn validate(&self) -> Result<(), Error> { + self.source.validate()?; + self.platform.validate() + } +} + +/// An image resolved to a backend-consumable immutable digest and Platform. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ResolvedImage { + /// The source used to resolve the image. + pub source: ImageSource, + /// The actual Platform selected from the image. + pub platform: Platform, + /// Immutable digest of the platform-specific OCI image manifest. + /// + /// Resolving a multi-platform image index selects its matching manifest; + /// an index digest is never returned here. + pub manifest_digest: String, +} + +/// Description of a transportable, fully materialized OCI image. +/// +/// A prepared image is a pristine derivative of [`ResolvedImage`], never a +/// parallel image identity. The artifact stored at the caller-selected path is +/// opaque: its representation is owned by the Provider and can only be returned +/// to a compatible Provider. The generic API assumes neither a guest operating +/// system nor a concrete filesystem format. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PreparedImage { + /// Immutable OCI Image represented by the prepared artifact. + pub image: ResolvedImage, + /// Root filesystem representation for which the image was materialized. + pub root_filesystem_mode: RootFilesystemMode, + /// Content digest of the complete filesystem artifact. + pub artifact_digest: String, + /// Logical size of the uncompressed filesystem artifact. + pub virtual_size_bytes: u64, +} + +impl PreparedImage { + /// Checks that backend-returned metadata is coherent with the request. + /// + /// This validates metadata only. Artifact integrity and the binding between + /// the opaque artifact and requested OCI identity are Image Backend + /// obligations. + pub(crate) fn validate_for(&self, request: &ResolveRequest) -> Result<(), Error> { + self.image.validate()?; + if self.image.source != request.source { + return Err(Error::invalid( + "preparedImage.image.source", + "must match the requested Image Source", + )); + } + if !self.image.platform.satisfies(&request.platform) { + return Err(Error::ImagePlatformMismatch { + requested: Box::new(request.platform.clone()), + actual: Box::new(self.image.platform.clone()), + }); + } + if self.root_filesystem_mode != request.root_filesystem_mode { + return Err(Error::invalid( + "preparedImage.rootFilesystemMode", + "must match the requested root filesystem mode", + )); + } + if self.artifact_digest.is_empty() { + return Err(Error::invalid("preparedImage.artifactDigest", "must not be empty")); + } + if self.virtual_size_bytes == 0 { + return Err(Error::invalid( + "preparedImage.virtualSizeBytes", + "must be greater than zero", + )); + } + Ok(()) + } +} + +impl ResolvedImage { + pub(crate) fn validate(&self) -> Result<(), Error> { + self.source.validate()?; + self.platform.validate()?; + if self.manifest_digest.is_empty() { + return Err(Error::invalid("image.manifestDigest", "must not be empty")); + } + Ok(()) + } +} + +/// Owns image materialization for a paired Sandbox Backend. +/// +/// Every operation is capability-discovered per [`Platform`]. Implementations +/// must define all operations explicitly, including providers for which prepared +/// transport is unnecessary and therefore reported with empty capability sets. +pub trait ImageBackend { + /// Reports functionality available for one Platform. + /// + /// Discovery must be side-effect-free and stable for the duration of the + /// caller's operation. Implementations may perform asynchronous host discovery. + fn capabilities<'a>(&'a self, platform: &'a Platform) -> LocalFuture<'a, Result>; + + /// Builds, fetches, or reuses the requested image. + fn resolve<'a>(&'a self, request: &'a ResolveRequest) -> PendingOperation<'a, ResolvedImage>; + + /// Exports a resolved, fully materialized image to an opaque artifact. + /// + /// Returned metadata must be derived from the exported artifact and describe + /// the OCI identity and Platform actually materialized. + fn export_prepared_image<'a>( + &'a self, + request: &'a ResolveRequest, + destination: &'a Path, + ) -> PendingOperation<'a, PreparedImage>; + + /// Validates and imports an opaque prepared image into this Backend's + /// materialization domain. + /// + /// Before returning, the implementation must validate artifact integrity, + /// bind the artifact to the requested OCI identity and Platform, and return + /// metadata that reflects the validated artifact rather than merely asserting + /// values supplied by the request. + fn import_prepared_image<'a>( + &'a self, + request: &'a ResolveRequest, + source: &'a Path, + ) -> PendingOperation<'a, PreparedImage>; +} diff --git a/sandbox/core/src/init.rs b/sandbox/core/src/init.rs new file mode 100644 index 0000000..35d1602 --- /dev/null +++ b/sandbox/core/src/init.rs @@ -0,0 +1,14 @@ +//! Selection of the process responsible for initializing a Sandbox. + +use serde::{Deserialize, Serialize}; + +/// Selects which init system owns the Sandbox after backend setup. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum InitSystem { + /// Keep the Sandbox Backend's built-in init process. + #[default] + Backend, + /// Hand initialization to the init system supplied by the Image. + Image, +} diff --git a/sandbox/core/src/lib.rs b/sandbox/core/src/lib.rs new file mode 100644 index 0000000..8f3d2e1 --- /dev/null +++ b/sandbox/core/src/lib.rs @@ -0,0 +1,40 @@ +//! Backend-neutral sandbox lifecycle building blocks. +//! +//! This crate deliberately contains no agent automation. Higher layers may +//! compose these primitives without making CI users depend on agent code. + +pub mod backend; +pub mod execution; +mod feature; +pub mod file_transfer; +pub mod image; +pub mod init; +pub mod memory; +pub mod mount; +mod name; +pub mod network; +mod path; +mod platform; +pub mod progress; +pub mod provider; +pub mod resource; +mod root_filesystem; +pub mod secret_store; +mod service; +pub mod terminal; +pub mod volume; + +pub use backend::{GuestHeartbeat, LocalFuture, Sandbox, SandboxId, SandboxResources, SandboxState}; +pub use feature::{SandboxCapabilities, SandboxFeature, SandboxFeatureSet}; +pub use name::{Hostname, InvalidHostname, InvalidSandboxName, MAX_SANDBOX_NAME_BYTES, SandboxName}; +pub use path::SandboxPath; +pub use platform::Platform; +pub use progress::{ + MeasuredStep, OperationEvent, Outcome, OutputStream, PendingOperation, PendingSandbox, Phase, PhaseSpan, + ProgressEvent, ProgressReporter, ProgressStep, ProgressUnit, SandboxPhase, SandboxProgress, StepId, +}; +pub use resource::{ByteQuantity, CpuQuantity, ParseQuantityError}; +pub use root_filesystem::{RootFilesystem, RootFilesystemMode, RootFilesystemModeSet}; +pub use service::{ + EnsureSandboxRequest, Error, ErrorKind, ResourceKind, RetentionPolicy, SandboxHandle, SandboxService, SandboxSpec, +}; diff --git a/sandbox/core/src/memory.rs b/sandbox/core/src/memory.rs new file mode 100644 index 0000000..b4129ee --- /dev/null +++ b/sandbox/core/src/memory.rs @@ -0,0 +1,1108 @@ +//! Single-threaded in-memory implementations used for tests and early wiring. + +use std::{ + cell::RefCell, + collections::{BTreeMap, BTreeSet, VecDeque}, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + num::{NonZeroU32, NonZeroUsize}, + pin::Pin, + rc::Rc, + task::{Context, Poll}, +}; + +use futures_core::Stream; +use sha2::{Digest as _, Sha256}; +use tokio::{io::AsyncReadExt as _, sync::mpsc}; +use tokio_util::sync::PollSender; +use zeroize::Zeroizing; + +use crate::{ + Error, GuestHeartbeat, LocalFuture, PendingOperation, Platform, ResourceKind, RootFilesystemMode, + RootFilesystemModeSet, Sandbox, SandboxFeature, SandboxId, SandboxName, SandboxPath, SandboxResources, + SandboxState, + backend::{CreateSandboxRequest, SandboxBackend, SandboxBackendCapabilities}, + execution, file_transfer, image, + mount::{MountKind, MountKindSet}, + network, + provider::SandboxProvider, + secret_store::{SecretMaterial, SecretReference, SecretStore}, + terminal, volume, +}; + +impl SandboxProvider for Provider { + fn backend(&self) -> &dyn SandboxBackend { + self + } + + fn image_backend(&self) -> &dyn image::ImageBackend { + &MemoryImageBackend + } +} + +/// In-memory Sandbox Provider for tests and early wiring. +pub struct Provider { + state: Rc>, + supported_platforms: BTreeSet, +} + +#[derive(Default)] +struct BackendState { + by_id: BTreeMap, + by_name: BTreeMap, + executions: BTreeSet<(SandboxId, execution::ExecutionId)>, + files: BTreeMap<(SandboxId, SandboxPath), Vec>, + file_writes: Vec, + execution_specs: Vec, + matched_execution_events: VecDeque, + queued_execution_events: VecDeque>, + queued_terminal_events: VecDeque>, + volumes_by_id: BTreeMap, + volumes_by_name: BTreeMap, + network_endpoints: BTreeMap, + network_properties: BTreeMap, +} + +struct MatchedExecutionEvents { + predicate: Rc bool>, + events: Vec, +} + +impl Provider { + /// Creates an empty Provider. + #[must_use] + pub fn new() -> Self { + Self::with_platforms(test_platform(), []) + } + + /// Creates an empty Provider with one or more supported Platforms. + #[must_use] + pub fn with_platforms(platform: Platform, additional_platforms: impl IntoIterator) -> Self { + let mut supported_platforms: BTreeSet<_> = additional_platforms.into_iter().collect(); + supported_platforms.insert(platform); + Self { + state: Rc::new(RefCell::new(BackendState::default())), + supported_platforms, + } + } + + /// Returns the number of materialized sandboxes. + #[must_use] + pub fn count(&self) -> usize { + self.state.borrow().by_id.len() + } + + /// Supplies the events returned by the next Execution started by this Provider. + pub fn queue_execution_events(&self, events: Vec) { + self.state.borrow_mut().queued_execution_events.push_back(events); + } + + /// Supplies events for the next Execution whose specification matches the predicate. + /// + /// Unlike [`Self::queue_execution_events`], unrelated Executions do not + /// consume this response while exercising multi-command reconciliation. + pub fn queue_execution_events_matching( + &self, + predicate: impl Fn(&execution::ExecutionSpec) -> bool + 'static, + events: Vec, + ) { + self.state + .borrow_mut() + .matched_execution_events + .push_back(MatchedExecutionEvents { + predicate: Rc::new(predicate), + events, + }); + } + + /// Returns every normal Execution specification observed by this Provider. + #[must_use] + pub fn execution_specs(&self) -> Vec { + self.state.borrow().execution_specs.clone() + } + + /// Returns the path of every file write observed by this Provider, in order. + #[must_use] + pub fn file_writes(&self) -> Vec { + self.state.borrow().file_writes.clone() + } + + /// Supplies the events returned by the next terminal Execution. + pub fn queue_terminal_events(&self, events: Vec) { + self.state.borrow_mut().queued_terminal_events.push_back(events); + } + + /// Reports `heartbeat` as the guest's latest heartbeat until it is set + /// again or the Sandbox stops. + /// + /// # Errors + /// + /// Returns an error when the Sandbox does not exist. + pub fn set_guest_heartbeat(&self, id: &SandboxId, heartbeat: Option) -> Result<(), Error> { + let mut storage = self.state.borrow_mut(); + let sandbox = storage + .by_id + .get_mut(id) + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, id))?; + sandbox.guest_heartbeat = heartbeat; + Ok(()) + } + + fn set_state(&self, id: &SandboxId, state: SandboxState) -> Result<(), Error> { + let mut storage = self.state.borrow_mut(); + let sandbox = storage + .by_id + .get_mut(id) + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, id))?; + sandbox.state = state; + if state == SandboxState::Stopped { + sandbox.guest_heartbeat = None; + } + Ok(()) + } + + fn ensure_running(&self, id: &SandboxId) -> Result<(), Error> { + let storage = self.state.borrow(); + let sandbox = storage + .by_id + .get(id) + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, id))?; + if sandbox.state != SandboxState::Running { + return Err(Error::invalid("sandbox.state", "must be running")); + } + Ok(()) + } + + fn end_execution(&self, sandbox_id: &SandboxId, execution_id: &execution::ExecutionId) -> Result<(), Error> { + if self + .state + .borrow_mut() + .executions + .remove(&(sandbox_id.clone(), execution_id.clone())) + { + Ok(()) + } else { + Err(Error::not_found(ResourceKind::Execution, execution_id)) + } + } +} + +impl Default for Provider { + fn default() -> Self { + Self::new() + } +} + +/// In-memory Secret Store for tests and local composition. +#[derive(Clone, Default)] +pub struct MemorySecretStore { + values: Rc>>>>, +} + +impl SecretStore for MemorySecretStore { + fn set<'a>(&'a self, name: &'a str, value: &'a [u8]) -> LocalFuture<'a, Result> { + Box::pin(async move { + let reference = SecretReference::from_opaque(name); + self.values + .borrow_mut() + .insert(reference.clone(), Zeroizing::new(value.to_vec())); + Ok(reference) + }) + } + + fn resolve<'a>(&'a self, reference: &'a SecretReference) -> LocalFuture<'a, Result> { + Box::pin(async move { + self.values + .borrow() + .get(reference) + .map(|value| SecretMaterial::new(value.to_vec())) + .ok_or_else(|| Error::not_found(ResourceKind::Secret, reference.as_str())) + }) + } +} + +struct MemoryExecutionEventStream { + events: VecDeque, + execution: Option<(SandboxId, execution::ExecutionId)>, + state: Rc>, +} + +struct MemoryTerminalEventStream { + events: VecDeque, + execution: Option<(SandboxId, execution::ExecutionId)>, + state: Rc>, +} + +struct MemoryTerminalControl { + execution: (SandboxId, execution::ExecutionId), + state: Rc>, +} + +impl Stream for MemoryExecutionEventStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll> { + let event = self.events.pop_front(); + if (event.is_none() + || event.as_ref().is_some_and(|event| { + matches!( + event, + execution::ExecutionEvent::Exited(_) | execution::ExecutionEvent::Failed { .. } + ) + })) + && let Some(execution) = self.execution.take() + { + self.state.borrow_mut().executions.remove(&execution); + } + Poll::Ready(event.map(Ok)) + } +} + +impl Stream for MemoryTerminalEventStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll> { + let event = self.events.pop_front(); + if (event.is_none() + || event.as_ref().is_some_and(|event| { + matches!( + event, + terminal::TerminalEvent::Exited(_) | terminal::TerminalEvent::Failed { .. } + ) + })) + && let Some(execution) = self.execution.take() + { + self.state.borrow_mut().executions.remove(&execution); + } + Poll::Ready(event.map(Ok)) + } +} +impl terminal::TerminalControl for MemoryTerminalControl { + fn write_input(&self, _bytes: bytes::Bytes) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { self.ensure_live() }) + } + + fn close_input(&self) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { self.ensure_live() }) + } + + fn resize(&self, _size: terminal::TerminalSize) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { self.ensure_live() }) + } +} + +impl MemoryTerminalControl { + fn ensure_live(&self) -> Result<(), Error> { + if self.state.borrow().executions.contains(&self.execution) { + Ok(()) + } else { + Err(Error::not_found(ResourceKind::Execution, &self.execution.1)) + } + } +} + +impl SandboxBackend for Provider { + fn capabilities<'a>( + &'a self, + platform: &'a Platform, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + if !self.supported_platforms.contains(platform) { + return Err(Error::UnsupportedPlatform(platform.clone())); + } + Ok(SandboxBackendCapabilities::new( + [ + SandboxFeature::Execution, + SandboxFeature::TerminalExecution, + SandboxFeature::FileTransfer, + SandboxFeature::PersistentVolumes, + SandboxFeature::ImageInit, + ] + .into(), + MountKindSet::from([MountKind::Volume, MountKind::Bind, MountKind::Tmpfs]), + RootFilesystemModeSet::from([RootFilesystemMode::Layered, RootFilesystemMode::Direct]), + network::NetworkEndpointCapabilities::new() + .with_packet_medium(network::PacketMedium::Ethernet) + .with_packet_medium(network::PacketMedium::Ip), + )) + }) + } + + fn create(&self, request: CreateSandboxRequest) -> PendingOperation<'_, Sandbox> { + PendingOperation::run(move |_progress| { + Box::pin(async move { + let mut storage = self.state.borrow_mut(); + if let Some(id) = storage.by_name.get(&request.name) { + return storage + .by_id + .get(id) + .cloned() + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, id)); + } + let id = request.id; + if let Some(network::NetworkAttachment { + endpoint: network::NetworkEndpointSelection::Packet(medium), + .. + }) = &request.network + { + storage + .network_properties + .insert(id.clone(), packet_properties(*medium)?); + } + let sandbox = Sandbox { + id: id.clone(), + image: request.image, + init_system: request.init_system, + name: request.name, + hostname: request.hostname, + resources: request.resources, + state: SandboxState::Stopped, + guest_heartbeat: None, + mounts: request.mounts, + environment: request.environment, + network: request.network, + }; + storage.by_name.insert(sandbox.name.clone(), id.clone()); + storage.by_id.insert(id, sandbox.clone()); + Ok(sandbox) + }) + }) + } + + fn update_resources<'a>(&'a self, id: &'a SandboxId, resources: SandboxResources) -> PendingOperation<'a, Sandbox> { + PendingOperation::run(move |_progress| { + Box::pin(async move { + let mut storage = self.state.borrow_mut(); + let sandbox = storage + .by_id + .get_mut(id) + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, id))?; + if sandbox.resources.root_filesystem().mode() != resources.root_filesystem().mode() { + return Err(Error::Immutable("resources.rootFilesystem.mode")); + } + sandbox.resources = resources; + Ok(sandbox.clone()) + }) + }) + } + + fn update_environment<'a>( + &'a self, + id: &'a SandboxId, + environment: BTreeMap, + ) -> PendingOperation<'a, Sandbox> { + PendingOperation::run(move |_progress| { + Box::pin(async move { + let mut storage = self.state.borrow_mut(); + let sandbox = storage + .by_id + .get_mut(id) + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, id))?; + if sandbox.state != SandboxState::Stopped { + return Err(Error::invalid("sandbox.state", "must be stopped")); + } + sandbox.environment = environment; + Ok(sandbox.clone()) + }) + }) + } + + fn find<'a>(&'a self, name: &'a SandboxName) -> LocalFuture<'a, Result> { + Box::pin(async move { + let storage = self.state.borrow(); + let id = storage + .by_name + .get(name) + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, name))?; + storage + .by_id + .get(id) + .cloned() + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, id)) + }) + } + + fn inspect<'a>(&'a self, id: &'a SandboxId) -> LocalFuture<'a, Result> { + Box::pin(async move { + self.state + .borrow() + .by_id + .get(id) + .cloned() + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, id)) + }) + } + + fn start<'a>(&'a self, id: &'a SandboxId) -> PendingOperation<'a, ()> { + PendingOperation::run(move |_progress| Box::pin(async move { self.set_state(id, SandboxState::Running) })) + } + + fn stop<'a>(&'a self, id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + self.set_state(id, SandboxState::Stopped)?; + let mut storage = self.state.borrow_mut(); + storage.executions.retain(|(sandbox_id, _)| sandbox_id != id); + storage.network_endpoints.remove(id); + Ok(()) + }) + } + + fn delete<'a>(&'a self, id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + let mut storage = self.state.borrow_mut(); + let sandbox = storage + .by_id + .remove(id) + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, id))?; + storage.by_name.remove(&sandbox.name); + storage.executions.retain(|(sandbox_id, _)| sandbox_id != id); + storage.files.retain(|(sandbox_id, _), _| sandbox_id != id); + storage.network_endpoints.remove(id); + storage.network_properties.remove(id); + Ok(()) + }) + } + + fn open_network_endpoint<'a>( + &'a self, + id: &'a SandboxId, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + let mut storage = self.state.borrow_mut(); + let sandbox = storage + .by_id + .get(id) + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, id))?; + let attachment = sandbox + .network + .as_ref() + .ok_or(Error::invalid("network", "Sandbox has no attachment"))?; + let network::NetworkEndpointSelection::Packet(_) = attachment.endpoint else { + return Err(Error::UnsupportedNetworkEndpoint(attachment.endpoint.clone())); + }; + let capacity = + NonZeroUsize::new(64).ok_or_else(|| Error::Backend("invalid memory queue capacity".into()))?; + let properties = storage + .network_properties + .get(id) + .cloned() + .ok_or_else(|| Error::Backend("missing persisted memory Network interface configuration".into()))?; + let (endpoint, peer) = packet_endpoint_pair(capacity, properties); + storage.network_endpoints.insert(id.clone(), peer); + Ok(network::NetworkEndpoint::Packet(endpoint)) + }) + } + + fn start_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + request: execution::StartExecutionRequest, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + let (id, spec) = request.into_parts(); + self.ensure_running(sandbox_id)?; + let mut storage = self.state.borrow_mut(); + let execution_key = (sandbox_id.clone(), id.clone()); + storage.executions.insert(execution_key.clone()); + storage.execution_specs.push(spec.clone()); + let matched = storage + .matched_execution_events + .iter() + .position(|response| (response.predicate)(&spec)) + .and_then(|index| storage.matched_execution_events.remove(index)) + .map(|response| response.events); + let events = matched + .or_else(|| storage.queued_execution_events.pop_front()) + .unwrap_or_else(|| { + vec![ + execution::ExecutionEvent::Started { process_id: None }, + execution::ExecutionEvent::Exited(execution::ExitStatus { code: 0 }), + ] + }); + + Ok(execution::StartedExecution { + id, + events: Box::pin(MemoryExecutionEventStream { + events: events.into(), + execution: Some(execution_key), + state: self.state.clone(), + }), + }) + }) + } + + fn start_terminal_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + request: terminal::StartTerminalExecutionRequest, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + let (id, _spec, _initial_size) = request.into_parts(); + self.ensure_running(sandbox_id)?; + let mut storage = self.state.borrow_mut(); + let execution_key = (sandbox_id.clone(), id.clone()); + storage.executions.insert(execution_key.clone()); + let events = storage.queued_terminal_events.pop_front().unwrap_or_else(|| { + vec![ + terminal::TerminalEvent::Started { process_id: None }, + terminal::TerminalEvent::Exited(execution::ExitStatus { code: 0 }), + ] + }); + let control = Rc::new(MemoryTerminalControl { + execution: execution_key.clone(), + state: self.state.clone(), + }); + + Ok(terminal::StartedTerminalExecution { + id, + control, + events: Box::pin(MemoryTerminalEventStream { + events: events.into(), + execution: Some(execution_key), + state: self.state.clone(), + }), + }) + }) + } + + fn attach_terminal<'a>( + &'a self, + sandbox_id: &'a SandboxId, + _request: terminal::AttachTerminalRequest, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + self.ensure_running(sandbox_id)?; + Err(Error::UnsupportedFeature(SandboxFeature::TerminalAttach)) + }) + } + fn terminate_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + execution_id: &'a execution::ExecutionId, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { self.end_execution(sandbox_id, execution_id) }) + } + + fn kill_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + execution_id: &'a execution::ExecutionId, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { self.end_execution(sandbox_id, execution_id) }) + } + + fn read_file<'a>( + &'a self, + sandbox_id: &'a SandboxId, + path: &'a SandboxPath, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + self.ensure_running(sandbox_id)?; + let contents = self + .state + .borrow() + .files + .get(&(sandbox_id.clone(), path.clone())) + .cloned() + .ok_or_else(|| Error::not_found(ResourceKind::File, path.as_str()))?; + Ok(Box::pin(std::io::Cursor::new(contents)) as file_transfer::ByteReader) + }) + } + + fn write_file<'a>( + &'a self, + sandbox_id: &'a SandboxId, + path: &'a SandboxPath, + mut contents: file_transfer::ByteReader, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + self.ensure_running(sandbox_id)?; + let mut bytes = Vec::new(); + contents.read_to_end(&mut bytes).await.map_err(|source| Error::Io { + operation: "read Sandbox file-transfer input", + source, + })?; + self.ensure_running(sandbox_id)?; + let mut storage = self.state.borrow_mut(); + storage.files.insert((sandbox_id.clone(), path.clone()), bytes); + storage.file_writes.push(path.clone()); + Ok(()) + }) + } + + fn ensure_volume(&self, request: volume::EnsureVolumeRequest) -> LocalFuture<'_, Result> { + Box::pin(async move { + let (id, name) = request.into_parts(); + let mut storage = self.state.borrow_mut(); + if let Some(existing_id) = storage.volumes_by_name.get(&name) { + return storage + .volumes_by_id + .get(existing_id) + .cloned() + .ok_or_else(|| Error::not_found(ResourceKind::Volume, existing_id)); + } + let volume = volume::Volume { id: id.clone(), name }; + storage.volumes_by_name.insert(volume.name.clone(), id.clone()); + storage.volumes_by_id.insert(id, volume.clone()); + Ok(volume) + }) + } + + fn find_volume<'a>(&'a self, name: &'a volume::VolumeName) -> LocalFuture<'a, Result> { + Box::pin(async move { + let storage = self.state.borrow(); + let id = storage + .volumes_by_name + .get(name) + .ok_or_else(|| Error::not_found(ResourceKind::Volume, name))?; + storage + .volumes_by_id + .get(id) + .cloned() + .ok_or_else(|| Error::not_found(ResourceKind::Volume, id)) + }) + } + + fn delete_volume<'a>(&'a self, id: &'a volume::VolumeId) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + let mut storage = self.state.borrow_mut(); + let volume = storage + .volumes_by_id + .remove(id) + .ok_or_else(|| Error::not_found(ResourceKind::Volume, id))?; + storage.volumes_by_name.remove(&volume.name); + Ok(()) + }) + } +} + +/// Sandbox-facing peer paired with an in-memory [`network::PacketEndpoint`]. +/// +/// This type supports endpoint contract tests and keeps the in-memory Sandbox +/// Backend's side of each live connection open. +pub struct PacketPeer { + from_sandbox: Option>, + to_sandbox: mpsc::Receiver, + maximum_packet_length: usize, +} + +impl PacketPeer { + /// Emits one packet as if it came from the Sandbox's virtual network device. + /// + /// # Errors + /// + /// Returns an error when the packet is too large or the Network Backend has + /// closed its receiving direction. + pub async fn emit_from_sandbox(&self, packet: network::NetworkPacket) -> Result<(), network::NetworkEndpointError> { + if packet.len() > self.maximum_packet_length { + return Err(network::NetworkEndpointError::PacketTooLarge { + actual: packet.len(), + maximum: self.maximum_packet_length, + }); + } + let sender = self + .from_sandbox + .as_ref() + .ok_or(network::NetworkEndpointError::Closed)?; + sender + .send(packet) + .await + .map_err(|_| network::NetworkEndpointError::Closed) + } + + /// Receives the next packet addressed to the Sandbox. + pub async fn receive_for_sandbox(&mut self) -> Option { + self.to_sandbox.recv().await + } + + /// Closes the direction in which the Sandbox emits packets. + pub fn close_from_sandbox(&mut self) { + self.from_sandbox = None; + } + + /// Closes the direction in which the Sandbox receives packets. + pub fn close_to_sandbox(&mut self) { + self.to_sandbox.close(); + } +} + +/// Creates a bounded in-memory packet endpoint and its Sandbox-facing peer. +#[must_use] +pub fn packet_endpoint_pair( + capacity: NonZeroUsize, + properties: network::PacketEndpointProperties, +) -> (network::PacketEndpoint, PacketPeer) { + let (from_sandbox, network_receiver) = mpsc::channel(capacity.get()); + let (network_sender, to_sandbox) = mpsc::channel(capacity.get()); + let maximum_packet_length = properties.maximum_frame_length().get() as usize; + ( + network::PacketEndpoint::new( + properties, + MemoryPacketReceiver { + receiver: network_receiver, + }, + MemoryPacketSender { + sender: PollSender::new(network_sender), + maximum_packet_length, + }, + ), + PacketPeer { + from_sandbox: Some(from_sandbox), + to_sandbox, + maximum_packet_length, + }, + ) +} + +struct MemoryPacketReceiver { + receiver: mpsc::Receiver, +} + +impl network::BatchReceiver for MemoryPacketReceiver { + fn poll_receive( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + output: &mut network::NetworkPacketBatch, + ) -> Poll> { + if output.is_full() { + return Poll::Ready(Err(network::NetworkEndpointError::FullReceiveBatch)); + } + + let mut received = 0; + while !output.is_full() { + match self.receiver.poll_recv(context) { + Poll::Ready(Some(packet)) => { + if output.push_back(packet).is_err() { + return Poll::Ready(Err(network::NetworkEndpointError::FullReceiveBatch)); + } + received += 1; + } + Poll::Ready(None) => { + return NonZeroUsize::new(received) + .map_or(Poll::Ready(Ok(network::NetworkTransferProgress::Closed)), |received| { + Poll::Ready(Ok(network::NetworkTransferProgress::Items(received))) + }); + } + Poll::Pending => { + return NonZeroUsize::new(received).map_or(Poll::Pending, |received| { + Poll::Ready(Ok(network::NetworkTransferProgress::Items(received))) + }); + } + } + } + + NonZeroUsize::new(received).map_or( + Poll::Ready(Err(network::NetworkEndpointError::FullReceiveBatch)), + |received| Poll::Ready(Ok(network::NetworkTransferProgress::Items(received))), + ) + } +} + +struct MemoryPacketSender { + sender: PollSender, + maximum_packet_length: usize, +} + +impl network::BatchSender for MemoryPacketSender { + fn poll_send( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + pending: &mut network::NetworkPacketBatch, + ) -> Poll> { + if pending.is_empty() { + return Poll::Ready(Err(network::NetworkEndpointError::EmptySendBatch)); + } + + let mut sent = 0; + while let Some(packet) = pending.front() { + if packet.len() > self.maximum_packet_length { + return match NonZeroUsize::new(sent) { + Some(sent) => Poll::Ready(Ok(network::NetworkTransferProgress::Items(sent))), + None => Poll::Ready(Err(network::NetworkEndpointError::PacketTooLarge { + actual: packet.len(), + maximum: self.maximum_packet_length, + })), + }; + } + + match self.sender.poll_reserve(context) { + Poll::Ready(Ok(())) => { + let Some(packet) = pending.pop_front() else { + return Poll::Ready(Err(network::NetworkEndpointError::EmptySendBatch)); + }; + if let Err(error) = self.sender.send_item(packet) { + if let Some(packet) = error.into_inner() + && pending.push_front(packet).is_err() + { + return Poll::Ready(Err(network::NetworkEndpointError::Backend( + "failed to restore an unaccepted Network packet".into(), + ))); + } + return NonZeroUsize::new(sent) + .map_or(Poll::Ready(Ok(network::NetworkTransferProgress::Closed)), |sent| { + Poll::Ready(Ok(network::NetworkTransferProgress::Items(sent))) + }); + } + sent += 1; + } + Poll::Ready(Err(_)) => { + return NonZeroUsize::new(sent) + .map_or(Poll::Ready(Ok(network::NetworkTransferProgress::Closed)), |sent| { + Poll::Ready(Ok(network::NetworkTransferProgress::Items(sent))) + }); + } + Poll::Pending => { + return NonZeroUsize::new(sent).map_or(Poll::Pending, |sent| { + Poll::Ready(Ok(network::NetworkTransferProgress::Items(sent))) + }); + } + } + } + + NonZeroUsize::new(sent).map_or( + Poll::Ready(Err(network::NetworkEndpointError::EmptySendBatch)), + |sent| Poll::Ready(Ok(network::NetworkTransferProgress::Items(sent))), + ) + } + + fn poll_flush(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown( + mut self: Pin<&mut Self>, + _context: &mut Context<'_>, + ) -> Poll> { + self.sender.abort_send(); + self.sender.close(); + Poll::Ready(Ok(())) + } +} + +/// Independently composable in-memory Network Backend. +pub struct NetworkBackend { + id: network::NetworkBackendId, + endpoint: network::NetworkEndpointSelection, + state: RefCell, +} + +#[derive(Default)] +struct NetworkBackendState { + attached: BTreeSet, + endpoints: BTreeMap, +} + +impl NetworkBackend { + /// Creates a Network Backend selecting an explicit endpoint contract. + #[must_use] + pub fn for_endpoint(id: impl Into, endpoint: network::NetworkEndpointSelection) -> Self { + Self { + id: network::NetworkBackendId::new(id), + endpoint, + state: RefCell::new(NetworkBackendState::default()), + } + } + + /// Reports whether one Sandbox retains Network Backend state. + #[must_use] + pub fn is_attached(&self, sandbox_id: &SandboxId) -> bool { + self.state.borrow().attached.contains(sandbox_id) + } +} + +impl network::NetworkBackend for NetworkBackend { + fn id(&self) -> network::NetworkBackendId { + self.id.clone() + } + + fn is_running(&self, sandbox_id: &SandboxId) -> bool { + self.state.borrow().endpoints.contains_key(sandbox_id) + } + + fn select_endpoint( + &self, + available: &network::NetworkEndpointCapabilities, + ) -> Option { + available.supports(&self.endpoint).then(|| self.endpoint.clone()) + } + + fn start(&self, request: network::StartNetworkRequest) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + let actual = request.endpoint.selection(); + if actual != self.endpoint { + return Err(Error::NetworkEndpointMismatch { + expected: self.endpoint.clone(), + actual, + }); + } + let mut state = self.state.borrow_mut(); + state.attached.insert(request.sandbox_id.clone()); + state.endpoints.insert(request.sandbox_id, request.endpoint); + Ok(()) + }) + } + + fn stop<'a>(&'a self, sandbox_id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + self.state.borrow_mut().endpoints.remove(sandbox_id); + Ok(()) + }) + } + + fn delete<'a>(&'a self, sandbox_id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + let mut state = self.state.borrow_mut(); + state.endpoints.remove(sandbox_id); + state.attached.remove(sandbox_id); + Ok(()) + }) + } +} + +/// Deterministically resolves OCI Image Sources to synthetic digests. +#[derive(Default)] +pub struct MemoryImageBackend; + +impl image::ImageBackend for MemoryImageBackend { + fn capabilities<'a>( + &'a self, + _platform: &'a Platform, + ) -> LocalFuture<'a, Result> { + Box::pin(async { + Ok(image::ImageBackendCapabilities::new( + image::ImageOperationCapabilities::new( + [image::ImageSourceKind::Build, image::ImageSourceKind::Reference].into(), + [RootFilesystemMode::Layered, RootFilesystemMode::Direct].into(), + ), + image::ImageOperationCapabilities::default(), + image::ImageOperationCapabilities::default(), + )) + }) + } + + fn resolve<'a>(&'a self, request: &'a image::ResolveRequest) -> PendingOperation<'a, image::ResolvedImage> { + PendingOperation::run(move |_progress| { + Box::pin(async move { + Ok(image::ResolvedImage { + source: request.source.clone(), + platform: request.platform.clone(), + manifest_digest: memory_manifest_digest(request), + }) + }) + }) + } + + fn export_prepared_image<'a>( + &'a self, + _request: &'a image::ResolveRequest, + _destination: &'a std::path::Path, + ) -> PendingOperation<'a, image::PreparedImage> { + unsupported_prepared_image(image::ImageOperation::PreparedImageExport) + } + + fn import_prepared_image<'a>( + &'a self, + _request: &'a image::ResolveRequest, + _source: &'a std::path::Path, + ) -> PendingOperation<'a, image::PreparedImage> { + unsupported_prepared_image(image::ImageOperation::PreparedImageImport) + } +} + +fn memory_manifest_digest(request: &image::ResolveRequest) -> String { + let mut digest = Sha256::new(); + digest.update(b"sandbox.memory-image-manifest.v1\0"); + match &request.source { + image::ImageSource::Build { + context, + dockerfile, + target, + } => { + update_digest_part(&mut digest, b"build"); + update_digest_part(&mut digest, context.as_os_str().as_encoded_bytes()); + update_digest_part(&mut digest, dockerfile.as_os_str().as_encoded_bytes()); + update_optional_digest_part(&mut digest, target.as_deref()); + } + image::ImageSource::Reference { reference } => { + update_digest_part(&mut digest, b"reference"); + update_digest_part(&mut digest, reference.as_bytes()); + } + } + update_digest_part(&mut digest, request.platform.os.as_bytes()); + update_digest_part(&mut digest, request.platform.architecture.as_bytes()); + update_optional_digest_part(&mut digest, request.platform.variant.as_deref()); + update_optional_digest_part(&mut digest, request.platform.os_version.as_deref()); + for feature in &request.platform.os_features { + update_digest_part(&mut digest, feature.as_bytes()); + } + let mut encoded = String::with_capacity("sha256:".len() + 64); + encoded.push_str("sha256:"); + for byte in digest.finalize() { + const HEX: &[u8; 16] = b"0123456789abcdef"; + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + encoded +} + +fn update_optional_digest_part(digest: &mut Sha256, value: Option<&str>) { + match value { + Some(value) => { + digest.update([1]); + update_digest_part(digest, value.as_bytes()); + } + None => digest.update([0]), + } +} + +fn update_digest_part(digest: &mut Sha256, value: &[u8]) { + digest.update(value.len().to_le_bytes()); + digest.update(value); +} + +fn unsupported_prepared_image<'a>(operation: image::ImageOperation) -> PendingOperation<'a, image::PreparedImage> { + PendingOperation::run(move |_progress| Box::pin(async move { Err(Error::UnsupportedImageOperation(operation)) })) +} + +fn test_platform() -> Platform { + let architecture = match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + architecture => architecture, + }; + Platform::new("linux", architecture) +} + +fn packet_properties(medium: network::PacketMedium) -> Result { + let mtu = NonZeroU32::new(1_500).ok_or_else(|| Error::Backend("invalid memory Network MTU".into()))?; + let maximum_frame_length = NonZeroU32::new(match medium { + network::PacketMedium::Ethernet => 1_514, + network::PacketMedium::Ip => 1_500, + }) + .ok_or_else(|| Error::Backend("invalid memory Network frame length".into()))?; + let ipv4 = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 2)); + let ipv6 = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 2)); + let addresses = vec![ + network::InterfaceAddress::new(ipv4, 24) + .map_err(|error| Error::Backend(format!("invalid memory IPv4 configuration: {error}")))?, + network::InterfaceAddress::new(ipv6, 64) + .map_err(|error| Error::Backend(format!("invalid memory IPv6 configuration: {error}")))?, + ]; + let interface = network::NetworkInterfaceConfiguration::new( + network::MacAddress::new([0x02, 0, 0, 0, 0, 2]), + mtu, + addresses, + vec![ + IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)), + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)), + ], + vec![ + IpAddr::V4(Ipv4Addr::new(192, 0, 2, 53)), + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 53)), + ], + ); + Ok(network::PacketEndpointProperties::new( + medium, + interface, + maximum_frame_length, + )) +} diff --git a/sandbox/core/src/mount.rs b/sandbox/core/src/mount.rs new file mode 100644 index 0000000..0bf259d --- /dev/null +++ b/sandbox/core/src/mount.rs @@ -0,0 +1,78 @@ +//! Attachments materialized inside a Sandbox. + +use std::{collections::BTreeSet, path::PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::{ByteQuantity, SandboxPath, volume::VolumeId}; + +/// One form of filesystem attachment supported by a Sandbox Backend. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub enum MountKind { + /// Persistent SDK-managed storage. + Volume, + /// A caller-selected host path. + Bind, + /// Anonymous in-memory storage. + Tmpfs, +} + +/// Deterministic set of supported Mount forms. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct MountKindSet(BTreeSet); + +impl MountKindSet { + /// Reports whether a Mount form is supported. + #[must_use] + pub fn contains(&self, kind: MountKind) -> bool { + self.0.contains(&kind) + } + + /// Iterates over supported forms in stable order. + pub fn iter(&self) -> impl Iterator + '_ { + self.0.iter().copied() + } +} + +impl From<[MountKind; N]> for MountKindSet { + fn from(kinds: [MountKind; N]) -> Self { + Self(kinds.into_iter().collect()) + } +} + +/// One attachment inside a Sandbox. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Mount { + /// Persistent storage managed through the Sandbox SDK. + Volume { + id: VolumeId, + target: SandboxPath, + read_only: bool, + }, + /// A host path made visible to the Sandbox. + Bind { + source: PathBuf, + target: SandboxPath, + read_only: bool, + }, + /// Anonymous in-memory storage with an explicit capacity limit. + Tmpfs { + target: SandboxPath, + capacity: ByteQuantity, + }, +} + +impl Mount { + /// Returns this attachment's capability kind. + #[must_use] + pub const fn kind(&self) -> MountKind { + match self { + Self::Volume { .. } => MountKind::Volume, + Self::Bind { .. } => MountKind::Bind, + Self::Tmpfs { .. } => MountKind::Tmpfs, + } + } +} diff --git a/sandbox/core/src/name.rs b/sandbox/core/src/name.rs new file mode 100644 index 0000000..2fed099 --- /dev/null +++ b/sandbox/core/src/name.rs @@ -0,0 +1,197 @@ +use std::{fmt, str::FromStr}; + +use serde::{Deserialize, Deserializer, Serialize, de}; +use thiserror::Error; + +/// Maximum length of a portable Sandbox name. +pub const MAX_SANDBOX_NAME_BYTES: usize = 63; + +/// A portable, user-visible Sandbox name. +/// +/// Names use the Kubernetes DNS-1123 label form: lowercase ASCII letters, +/// digits, and hyphens, with an alphanumeric character at both ends. This is +/// also a strict subset of the names accepted by Microsandbox. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct SandboxName(String); + +impl SandboxName { + /// Validates and creates a Sandbox name. + /// + /// # Errors + /// + /// Returns an error when the value is empty, exceeds 63 bytes, or is not a + /// DNS-1123 label. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate(&value)?; + Ok(Self(value)) + } + + /// Returns whether `character` may appear anywhere in a Sandbox name. + /// + /// This is the per-keystroke filter for interactive input; the positional + /// rules (alphanumeric first and last byte) still apply at validation. + #[must_use] + pub const fn accepts(character: char) -> bool { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + } + + /// Returns the name as text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl AsRef for SandboxName { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for SandboxName { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for SandboxName { + type Err = InvalidSandboxName; + + fn from_str(value: &str) -> Result { + Self::new(value) + } +} + +impl TryFrom for SandboxName { + type Error = InvalidSandboxName; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl TryFrom<&str> for SandboxName { + type Error = InvalidSandboxName; + + fn try_from(value: &str) -> Result { + Self::new(value) + } +} + +impl<'de> Deserialize<'de> for SandboxName { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(de::Error::custom) + } +} + +/// The hostname a Sandbox reports to its guest. +/// +/// Hostnames share the portable DNS-1123 label form of [`SandboxName`], which +/// keeps them within the Linux UTS limit and usable as a DNS label. A Sandbox +/// defaults to its own name as hostname; a caller supplies a different value +/// when the user-facing identity differs from the Sandbox name. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct Hostname(SandboxName); + +impl Hostname { + /// Validates and creates a Sandbox hostname. + /// + /// # Errors + /// + /// Returns an error unless the value is a lowercase DNS label of at most + /// [`MAX_SANDBOX_NAME_BYTES`] bytes. + pub fn new(value: impl Into) -> Result { + SandboxName::new(value).map(Self).map_err(InvalidHostname) + } + + /// Returns the hostname as text. + #[must_use] + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl From for Hostname { + fn from(name: SandboxName) -> Self { + Self(name) + } +} + +impl AsRef for Hostname { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for Hostname { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for Hostname { + type Err = InvalidHostname; + + fn from_str(value: &str) -> Result { + Self::new(value) + } +} + +/// Why a value cannot be used as a Sandbox hostname. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InvalidHostname(InvalidSandboxName); + +impl fmt::Display for InvalidHostname { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "hostname is not a portable DNS label: {}", self.0) + } +} + +impl std::error::Error for InvalidHostname {} + +/// Why a value cannot be used as a portable Sandbox name. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum InvalidSandboxName { + /// The value was empty. + #[error("Sandbox name must not be empty")] + Empty, + /// The value exceeded the portable length limit. + #[error("Sandbox name must not exceed {MAX_SANDBOX_NAME_BYTES} bytes (got {length})")] + TooLong { + /// Actual UTF-8 byte length. + length: usize, + }, + /// The value was not a DNS-1123 label. + #[error( + "Sandbox name must contain only lowercase ASCII letters, digits, or hyphens and must start and end with a letter or digit" + )] + InvalidSyntax, +} + +fn validate(value: &str) -> Result<(), InvalidSandboxName> { + if value.is_empty() { + return Err(InvalidSandboxName::Empty); + } + if value.len() > MAX_SANDBOX_NAME_BYTES { + return Err(InvalidSandboxName::TooLong { length: value.len() }); + } + let bytes = value.as_bytes(); + if !is_alphanumeric(bytes[0]) + || !is_alphanumeric(bytes[bytes.len() - 1]) + || !bytes.iter().all(|byte| is_alphanumeric(*byte) || *byte == b'-') + { + return Err(InvalidSandboxName::InvalidSyntax); + } + Ok(()) +} + +const fn is_alphanumeric(byte: u8) -> bool { + byte.is_ascii_lowercase() || byte.is_ascii_digit() +} diff --git a/sandbox/core/src/network.rs b/sandbox/core/src/network.rs new file mode 100644 index 0000000..45e6b7a --- /dev/null +++ b/sandbox/core/src/network.rs @@ -0,0 +1,1018 @@ +//! Independently implemented networking attached to a Sandbox at creation. + +use std::{ + collections::{BTreeSet, VecDeque}, + net::{IpAddr, SocketAddr}, + num::{NonZeroU32, NonZeroUsize}, + pin::Pin, + task::{Context, Poll}, +}; + +use bytes::Bytes; +use serde::{Deserialize, Serialize}; +use thiserror::Error as ThisError; +use tokio::io::{AsyncRead, AsyncWrite}; + +use crate::{Error, LocalFuture, SandboxId, SandboxName}; + +/// Stable identity of one configured Network Backend. +/// +/// The identity is stored with the Sandbox so another implementation cannot be +/// substituted without recreating it. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct NetworkBackendId(String); + +impl NetworkBackendId { + /// Creates a stable Network Backend identity. + #[must_use] + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// Returns the identity as text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for NetworkBackendId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +/// Packet representation exposed by a Sandbox Backend. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum PacketMedium { + /// Complete Ethernet frames without a frame check sequence. + Ethernet, + /// Complete IPv4 or IPv6 packets without a link-layer header. + Ip, +} + +/// Stable identity of a versioned control protocol implemented by a Sandbox Backend. +/// +/// The protocol remains opaque to the generic Sandbox SDK. Its concrete +/// Sandbox and Network Backend implementations jointly define the messages and +/// their semantics. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct NetworkControlProtocolId(String); + +impl NetworkControlProtocolId { + /// Creates a control protocol identity. + #[must_use] + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// Returns the identity as text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for NetworkControlProtocolId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +/// Network endpoint forms a Sandbox Backend can expose. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct NetworkEndpointCapabilities { + packet_media: BTreeSet, + intercepted: bool, + control_protocols: BTreeSet, +} + +impl NetworkEndpointCapabilities { + /// Creates an empty set of endpoint capabilities. + #[must_use] + pub const fn new() -> Self { + Self { + packet_media: BTreeSet::new(), + intercepted: false, + control_protocols: BTreeSet::new(), + } + } + + /// Adds a packet medium. + #[must_use] + pub fn with_packet_medium(mut self, medium: PacketMedium) -> Self { + self.packet_media.insert(medium); + self + } + + /// Adds intercepted TCP-stream and UDP-datagram support. + #[must_use] + pub const fn with_intercepted(mut self) -> Self { + self.intercepted = true; + self + } + + /// Adds a versioned control protocol. + #[must_use] + pub fn with_control_protocol(mut self, protocol: NetworkControlProtocolId) -> Self { + self.control_protocols.insert(protocol); + self + } + + /// Reports whether no packet or intercepted endpoint is available. + #[must_use] + pub fn is_empty(&self) -> bool { + self.packet_media.is_empty() && !self.intercepted && self.control_protocols.is_empty() + } + + /// Reports whether the Sandbox Backend can materialize a selection. + #[must_use] + pub fn supports(&self, selection: &NetworkEndpointSelection) -> bool { + match selection { + NetworkEndpointSelection::Packet(medium) => self.packet_media.contains(medium), + NetworkEndpointSelection::Intercepted => self.intercepted, + NetworkEndpointSelection::Control(protocol) => self.control_protocols.contains(protocol), + } + } + + /// Iterates over available packet media in stable order. + pub fn packet_media(&self) -> impl Iterator + '_ { + self.packet_media.iter().copied() + } + + /// Reports whether intercepted TCP streams and UDP datagrams are available. + #[must_use] + pub const fn supports_intercepted(&self) -> bool { + self.intercepted + } + + /// Iterates over available control protocols in stable order. + pub fn control_protocols(&self) -> impl Iterator { + self.control_protocols.iter() + } +} + +/// Immutable endpoint contract selected for one Sandbox Network. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum NetworkEndpointSelection { + /// Exchange raw packets in the selected representation. + Packet(PacketMedium), + /// Exchange intercepted TCP streams and UDP datagrams. + Intercepted, + /// Exchange messages using a jointly implemented, versioned control protocol. + Control(NetworkControlProtocolId), +} + +/// Immutable association between a Sandbox, Network Backend and endpoint contract. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct NetworkAttachment { + /// Network Backend selected when the Sandbox was created. + pub backend: NetworkBackendId, + /// Endpoint contract negotiated when the Sandbox was created. + pub endpoint: NetworkEndpointSelection, +} + +/// A bounded, reusable queue used by Network endpoint operations. +/// +/// A receiver appends items and a sender removes accepted items from the front. +/// Keeping the allocation in the caller lets successive polls reuse it and +/// bounds the work performed in one poll. +#[derive(Debug)] +pub struct NetworkBatch { + items: VecDeque, + limit: NonZeroUsize, +} + +impl NetworkBatch { + /// Creates an empty batch with a fixed item limit. + #[must_use] + pub fn new(limit: NonZeroUsize) -> Self { + Self { + items: VecDeque::with_capacity(limit.get()), + limit, + } + } + + /// Returns the maximum number of items held by this batch. + #[must_use] + pub const fn limit(&self) -> NonZeroUsize { + self.limit + } + + /// Returns the current number of items. + #[must_use] + pub fn len(&self) -> usize { + self.items.len() + } + + /// Returns whether the batch contains no items. + #[must_use] + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + /// Returns whether the batch has reached its fixed limit. + #[must_use] + pub fn is_full(&self) -> bool { + self.items.len() == self.limit.get() + } + + /// Returns the number of items that can still be appended. + #[must_use] + pub fn remaining(&self) -> usize { + self.limit.get() - self.items.len() + } + + /// Appends an item, returning it unchanged when the batch is full. + /// + /// # Errors + /// + /// Returns the supplied item when the batch is already full. + pub fn push_back(&mut self, item: T) -> Result<(), T> { + if self.is_full() { + Err(item) + } else { + self.items.push_back(item); + Ok(()) + } + } + + /// Prepends an item, returning it unchanged when the batch is full. + /// + /// # Errors + /// + /// Returns the supplied item when the batch is already full. + pub fn push_front(&mut self, item: T) -> Result<(), T> { + if self.is_full() { + Err(item) + } else { + self.items.push_front(item); + Ok(()) + } + } + + /// Returns the first item without removing it. + #[must_use] + pub fn front(&self) -> Option<&T> { + self.items.front() + } + + /// Removes and returns the first item. + pub fn pop_front(&mut self) -> Option { + self.items.pop_front() + } +} + +/// One complete packet transferred across a [`PacketEndpoint`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NetworkPacket(Bytes); + +impl NetworkPacket { + /// Wraps the bytes of one complete packet. + #[must_use] + pub fn new(bytes: impl Into) -> Self { + Self(bytes.into()) + } + + /// Returns the packet bytes. + #[must_use] + pub const fn as_bytes(&self) -> &Bytes { + &self.0 + } + + /// Consumes the packet and returns its bytes. + #[must_use] + pub fn into_bytes(self) -> Bytes { + self.0 + } + + /// Returns the complete packet length. + #[must_use] + pub const fn len(&self) -> usize { + self.0.len() + } + + /// Returns whether the packet has no bytes. + #[must_use] + pub const fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl AsRef<[u8]> for NetworkPacket { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} + +impl From for NetworkPacket { + fn from(bytes: Bytes) -> Self { + Self(bytes) + } +} + +/// A bounded batch of raw packets. +pub type NetworkPacketBatch = NetworkBatch; + +/// Six-octet MAC address assigned to a Sandbox network interface. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(transparent)] +pub struct MacAddress([u8; 6]); + +impl MacAddress { + /// Creates a MAC address from its six octets. + #[must_use] + pub const fn new(octets: [u8; 6]) -> Self { + Self(octets) + } + + /// Returns the six address octets. + #[must_use] + pub const fn octets(self) -> [u8; 6] { + self.0 + } +} + +impl std::fmt::Display for MacAddress { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let octets = self.0; + write!( + formatter, + "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + octets[0], octets[1], octets[2], octets[3], octets[4], octets[5] + ) + } +} + +/// An IP address assigned to an interface and its routing prefix length. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct InterfaceAddress { + address: IpAddr, + prefix_length: u8, +} + +impl InterfaceAddress { + /// Creates an interface address when the prefix fits its IP family. + /// + /// # Errors + /// + /// Returns [`InvalidPrefixLength`] for IPv4 prefixes above 32 or IPv6 + /// prefixes above 128. + pub const fn new(address: IpAddr, prefix_length: u8) -> Result { + let maximum = if address.is_ipv4() { 32 } else { 128 }; + if prefix_length > maximum { + Err(InvalidPrefixLength { address, prefix_length }) + } else { + Ok(Self { address, prefix_length }) + } + } + + /// Returns the assigned IPv4 or IPv6 address. + #[must_use] + pub const fn address(self) -> IpAddr { + self.address + } + + /// Returns the CIDR prefix length. + #[must_use] + pub const fn prefix_length(self) -> u8 { + self.prefix_length + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct SerializedInterfaceAddress { + address: IpAddr, + prefix_length: u8, +} + +impl<'de> Deserialize<'de> for InterfaceAddress { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = SerializedInterfaceAddress::deserialize(deserializer)?; + Self::new(value.address, value.prefix_length).map_err(serde::de::Error::custom) + } +} + +/// An interface prefix length that is invalid for its IP family. +#[derive(Clone, Copy, Debug, Eq, PartialEq, ThisError)] +#[error("prefix length {prefix_length} is invalid for {address}")] +pub struct InvalidPrefixLength { + address: IpAddr, + prefix_length: u8, +} + +/// Immutable network configuration assigned by a Sandbox Backend. +/// +/// This is the small, backend-neutral equivalent of a CNI result. The Sandbox +/// Backend persists it with the materialization and every compatible packet +/// Network Backend consumes the same values. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct NetworkInterfaceConfiguration { + mac_address: MacAddress, + mtu: NonZeroU32, + addresses: Vec, + default_gateways: Vec, + dns_servers: Vec, +} + +impl NetworkInterfaceConfiguration { + /// Creates immutable Sandbox interface configuration. + #[must_use] + pub const fn new( + mac_address: MacAddress, + mtu: NonZeroU32, + addresses: Vec, + default_gateways: Vec, + dns_servers: Vec, + ) -> Self { + Self { + mac_address, + mtu, + addresses, + default_gateways, + dns_servers, + } + } + + /// Returns the MAC address configured inside the Sandbox. + #[must_use] + pub const fn mac_address(&self) -> MacAddress { + self.mac_address + } + + /// Returns the interface maximum transmission unit. + #[must_use] + pub const fn mtu(&self) -> NonZeroU32 { + self.mtu + } + + /// Returns all IPv4 and IPv6 addresses assigned to the interface. + #[must_use] + pub fn addresses(&self) -> &[InterfaceAddress] { + &self.addresses + } + + /// Returns default gateways for the configured address families. + #[must_use] + pub fn default_gateways(&self) -> &[IpAddr] { + &self.default_gateways + } + + /// Returns DNS server addresses supplied to the Sandbox. + #[must_use] + pub fn dns_servers(&self) -> &[IpAddr] { + &self.dns_servers + } +} + +/// Immutable properties shared by both directions of a packet endpoint. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PacketEndpointProperties { + medium: PacketMedium, + interface: NetworkInterfaceConfiguration, + maximum_frame_length: NonZeroU32, +} + +impl PacketEndpointProperties { + /// Creates packet endpoint properties. + #[must_use] + pub const fn new( + medium: PacketMedium, + interface: NetworkInterfaceConfiguration, + maximum_frame_length: NonZeroU32, + ) -> Self { + Self { + medium, + interface, + maximum_frame_length, + } + } + + /// Returns the packet representation used by the endpoint. + #[must_use] + pub const fn medium(&self) -> PacketMedium { + self.medium + } + + /// Returns the Sandbox interface configuration chosen by the Backend. + #[must_use] + pub const fn interface(&self) -> &NetworkInterfaceConfiguration { + &self.interface + } + + /// Returns the maximum complete frame or IP packet length accepted by the endpoint. + #[must_use] + pub const fn maximum_frame_length(&self) -> NonZeroU32 { + self.maximum_frame_length + } +} + +/// An error encountered while driving a Network endpoint. +#[derive(Debug, ThisError)] +pub enum NetworkEndpointError { + /// The caller supplied a full receive batch, so no progress was possible. + #[error("cannot receive Network items into a full batch")] + FullReceiveBatch, + /// The caller supplied an empty send batch, so no progress was possible. + #[error("cannot send Network items from an empty batch")] + EmptySendBatch, + /// A packet exceeded the maximum length supported by the endpoint. + #[error("Network packet length {actual} exceeds endpoint maximum {maximum}")] + PacketTooLarge { + /// Actual complete packet length. + actual: usize, + /// Maximum complete packet length supported by the endpoint. + maximum: usize, + }, + /// A control message exceeded the maximum length supported by the endpoint. + #[error("Network control message length {actual} exceeds endpoint maximum {maximum}")] + ControlMessageTooLarge { + /// Actual complete message length. + actual: usize, + /// Maximum complete message length supported by the endpoint. + maximum: usize, + }, + /// The Sandbox-facing endpoint closed during an operation. + #[error("Sandbox Network endpoint is closed")] + Closed, + /// A platform I/O operation failed. + #[error("{operation}: {source}")] + Io { + /// Operation being performed. + operation: &'static str, + /// Underlying platform error. + #[source] + source: std::io::Error, + }, + /// An endpoint implementation failed without a more specific portable representation. + #[error("Network endpoint implementation error: {0}")] + Backend(String), +} + +/// Progress made during a bounded Network transfer operation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NetworkTransferProgress { + /// One or more items were transferred. + Items(NonZeroUsize), + /// The Sandbox-facing direction closed after queued items were drained. + Closed, +} + +/// Receives a bounded batch of Network items emitted by a Sandbox. +/// +/// Implementations append no more than [`NetworkBatch::remaining`] items. +/// They register the current waker before returning [`Poll::Pending`] and +/// return progress instead of `Pending` after appending any items. +pub trait BatchReceiver { + /// Polls for a bounded batch of items. + fn poll_receive( + self: Pin<&mut Self>, + context: &mut Context<'_>, + output: &mut NetworkBatch, + ) -> Poll>; +} + +/// Sends a bounded batch of Network items to a Sandbox with explicit backpressure. +/// +/// Implementations remove only accepted items from the front of `pending`. +/// They register the current waker before returning [`Poll::Pending`] and +/// return progress instead of `Pending` after accepting any items. +pub trait BatchSender { + /// Polls to accept a bounded batch of items for the Sandbox. + fn poll_send( + self: Pin<&mut Self>, + context: &mut Context<'_>, + pending: &mut NetworkBatch, + ) -> Poll>; + + /// Polls until every accepted item has been flushed. + fn poll_flush(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll>; + + /// Gracefully and idempotently closes the direction used to send items. + fn poll_shutdown(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll>; +} + +/// Independently driven directions of a [`PacketEndpoint`]. +pub struct PacketEndpointParts { + /// Properties applying to both directions. + pub properties: PacketEndpointProperties, + /// Packets emitted by the Sandbox and consumed by the Network Backend. + pub from_sandbox: Pin + Send>>, + /// Packets emitted by the Network Backend and consumed by the Sandbox. + pub to_sandbox: Pin + Send>>, +} + +/// Owned, bidirectional raw-packet endpoint exposed by a Sandbox Backend. +pub struct PacketEndpoint { + parts: PacketEndpointParts, +} + +impl PacketEndpoint { + /// Combines independently implemented receive and send directions. + #[must_use] + pub fn new(properties: PacketEndpointProperties, from_sandbox: R, to_sandbox: S) -> Self + where + R: BatchReceiver + Send + 'static, + S: BatchSender + Send + 'static, + { + Self { + parts: PacketEndpointParts { + properties, + from_sandbox: Box::pin(from_sandbox), + to_sandbox: Box::pin(to_sandbox), + }, + } + } + + /// Returns properties applying to both endpoint directions. + #[must_use] + pub const fn properties(&self) -> &PacketEndpointProperties { + &self.parts.properties + } + + /// Splits the endpoint into directions that can be driven independently. + #[must_use] + pub fn into_parts(self) -> PacketEndpointParts { + self.parts + } +} + +/// One opaque message transferred across a [`NetworkControlEndpoint`]. +#[derive(Clone, Eq, PartialEq)] +pub struct NetworkControlMessage(zeroize::Zeroizing>); + +impl NetworkControlMessage { + /// Wraps one complete protocol message. + #[must_use] + pub fn new(bytes: impl AsRef<[u8]>) -> Self { + Self(zeroize::Zeroizing::new(bytes.as_ref().to_vec())) + } + + /// Returns the complete message bytes. + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + self.0.as_slice() + } + + /// Consumes the message and returns its bytes. + #[must_use] + pub fn into_bytes(self) -> zeroize::Zeroizing> { + self.0 + } + + /// Returns the complete message length. + #[must_use] + pub fn len(&self) -> usize { + self.0.len() + } + + /// Returns whether the message contains no bytes. + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl AsRef<[u8]> for NetworkControlMessage { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} + +impl std::fmt::Debug for NetworkControlMessage { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("NetworkControlMessage") + .field("length", &self.len()) + .finish_non_exhaustive() + } +} + +impl From for NetworkControlMessage { + fn from(bytes: Bytes) -> Self { + Self::new(bytes) + } +} + +impl From>> for NetworkControlMessage { + fn from(bytes: zeroize::Zeroizing>) -> Self { + Self(bytes) + } +} + +/// A bounded batch of opaque Network control messages. +pub type NetworkControlMessageBatch = NetworkBatch; + +/// Immutable properties shared by both directions of a control endpoint. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NetworkControlEndpointProperties { + protocol: NetworkControlProtocolId, + maximum_message_length: NonZeroUsize, +} + +impl NetworkControlEndpointProperties { + /// Creates control endpoint properties. + #[must_use] + pub const fn new(protocol: NetworkControlProtocolId, maximum_message_length: NonZeroUsize) -> Self { + Self { + protocol, + maximum_message_length, + } + } + + /// Returns the jointly implemented protocol identity. + #[must_use] + pub const fn protocol(&self) -> &NetworkControlProtocolId { + &self.protocol + } + + /// Returns the maximum complete control message length. + #[must_use] + pub const fn maximum_message_length(&self) -> NonZeroUsize { + self.maximum_message_length + } +} + +/// Independently driven directions of a [`NetworkControlEndpoint`]. +pub struct NetworkControlEndpointParts { + /// Properties applying to both directions. + pub properties: NetworkControlEndpointProperties, + /// Messages emitted by the Sandbox Backend's trusted runtime. + pub from_sandbox: Pin + Send>>, + /// Messages returned by the Network Backend to the trusted runtime. + pub to_sandbox: Pin + Send>>, +} + +/// Owned endpoint for a versioned protocol between compatible Sandbox and Network Backends. +pub struct NetworkControlEndpoint { + parts: NetworkControlEndpointParts, +} + +impl NetworkControlEndpoint { + /// Combines independently implemented receive and send directions. + #[must_use] + pub fn new(properties: NetworkControlEndpointProperties, from_sandbox: R, to_sandbox: S) -> Self + where + R: BatchReceiver + Send + 'static, + S: BatchSender + Send + 'static, + { + Self { + parts: NetworkControlEndpointParts { + properties, + from_sandbox: Box::pin(from_sandbox), + to_sandbox: Box::pin(to_sandbox), + }, + } + } + + /// Returns properties applying to both endpoint directions. + #[must_use] + pub const fn properties(&self) -> &NetworkControlEndpointProperties { + &self.parts.properties + } + + /// Splits the endpoint into directions that can be driven independently. + #[must_use] + pub fn into_parts(self) -> NetworkControlEndpointParts { + self.parts + } +} + +/// Network destination identified by either an address or a host name. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum NetworkHost { + /// An IPv4 or IPv6 address. + Ip(IpAddr), + /// A name supplied by the intercepted connection mechanism. + Name(String), +} + +/// Destination of an intercepted transport-layer flow. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct NetworkDestination { + /// Destination host or address. + pub host: NetworkHost, + /// Destination transport port. + pub port: u16, +} + +/// Bidirectional byte stream carried by an intercepted endpoint. +pub trait NetworkByteStream: AsyncRead + AsyncWrite {} + +impl NetworkByteStream for T where T: AsyncRead + AsyncWrite + ?Sized {} + +/// One outbound stream accepted from a Sandbox. +pub struct OutboundStream { + /// Source address when the Sandbox Backend can report it. + pub source: Option, + /// Original destination requested by the Sandbox. + pub destination: NetworkDestination, + /// Bidirectional stream bytes. + pub stream: Pin>, +} + +/// Identifies a datagram flow for routing responses back to a Sandbox. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct DatagramFlowId(u64); + +impl DatagramFlowId { + /// Creates a flow identifier scoped to one live intercepted endpoint. + #[must_use] + pub const fn new(value: u64) -> Self { + Self(value) + } +} + +/// One datagram emitted by a Sandbox. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OutboundDatagram { + /// Opaque route used to deliver a response to the originating flow. + pub flow: DatagramFlowId, + /// Source address when the Sandbox Backend can report it. + pub source: Option, + /// Original destination requested by the Sandbox. + pub destination: NetworkDestination, + /// Complete transport payload. + pub payload: Bytes, +} + +/// One datagram returned to a Sandbox flow. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InboundDatagram { + /// Opaque route copied from an outbound datagram. + pub flow: DatagramFlowId, + /// Complete transport payload. + pub payload: Bytes, +} + +/// A bounded batch of datagrams emitted by a Sandbox. +pub type OutboundDatagramBatch = NetworkBatch; + +/// A bounded batch of datagrams returned to a Sandbox. +pub type InboundDatagramBatch = NetworkBatch; + +/// Accepts intercepted outbound TCP streams from a Sandbox. +pub trait StreamAcceptor { + /// Polls for the next outbound stream, or `None` after stream interception closes. + /// + /// The implementation registers the current waker before returning + /// [`Poll::Pending`]. + fn poll_accept( + self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll, NetworkEndpointError>>; +} + +/// Independently driven directions of a [`DatagramEndpoint`]. +pub struct DatagramEndpointParts { + /// UDP datagrams emitted by the Sandbox and consumed by the Network Backend. + pub from_sandbox: Pin + Send>>, + /// UDP datagrams emitted by the Network Backend and consumed by the Sandbox. + pub to_sandbox: Pin + Send>>, +} + +/// Owned, bidirectional endpoint for intercepted UDP datagrams. +pub struct DatagramEndpoint { + parts: DatagramEndpointParts, +} + +impl DatagramEndpoint { + /// Combines independently implemented receive and send directions. + #[must_use] + pub fn new(from_sandbox: R, to_sandbox: S) -> Self + where + R: BatchReceiver + Send + 'static, + S: BatchSender + Send + 'static, + { + Self { + parts: DatagramEndpointParts { + from_sandbox: Box::pin(from_sandbox), + to_sandbox: Box::pin(to_sandbox), + }, + } + } + + /// Splits the endpoint into directions that can be driven independently. + #[must_use] + pub fn into_parts(self) -> DatagramEndpointParts { + self.parts + } +} + +/// Independently driven parts of an [`InterceptedEndpoint`]. +pub struct InterceptedEndpointParts { + /// Intercepted outbound TCP streams. + pub streams: Pin>, + /// Intercepted bidirectional UDP datagrams. + pub datagrams: DatagramEndpoint, +} + +/// Owned endpoint exposing intercepted TCP streams and UDP datagrams. +pub struct InterceptedEndpoint { + parts: InterceptedEndpointParts, +} + +impl InterceptedEndpoint { + /// Combines TCP stream acceptance with a UDP datagram endpoint. + #[must_use] + pub fn new(streams: A, datagrams: DatagramEndpoint) -> Self + where + A: StreamAcceptor + Send + 'static, + { + Self { + parts: InterceptedEndpointParts { + streams: Box::pin(streams), + datagrams, + }, + } + } + + /// Splits the endpoint into independently driven TCP and UDP parts. + #[must_use] + pub fn into_parts(self) -> InterceptedEndpointParts { + self.parts + } +} + +/// Owned Network data plane opened by a Sandbox Backend. +pub enum NetworkEndpoint { + /// Raw Ethernet or IP packet exchange. + Packet(PacketEndpoint), + /// Intercepted TCP streams and UDP datagrams. + Intercepted(InterceptedEndpoint), + /// Versioned control integration with a trusted Sandbox runtime. + Control(NetworkControlEndpoint), +} + +impl NetworkEndpoint { + /// Returns the immutable contract represented by this endpoint. + #[must_use] + pub fn selection(&self) -> NetworkEndpointSelection { + match self { + Self::Packet(endpoint) => NetworkEndpointSelection::Packet(endpoint.properties().medium()), + Self::Intercepted(_) => NetworkEndpointSelection::Intercepted, + Self::Control(endpoint) => NetworkEndpointSelection::Control(endpoint.properties().protocol().clone()), + } + } +} + +/// Inputs for starting or reconnecting one Sandbox's Network Backend. +pub struct StartNetworkRequest { + /// Sandbox whose immutable Network attachment is being started. + pub sandbox_id: SandboxId, + /// Stable name used for caller-owned Network Backend configuration. + pub sandbox_name: SandboxName, + /// Fresh data-plane endpoint opened by the Sandbox Backend. + pub endpoint: NetworkEndpoint, +} + +/// Implements network processing and enforcement independently of a Sandbox Backend. +/// +/// Implementations may use `sandbox-authorization`, a Secret Store, and their +/// own protocol-specific policy enforcement without adding those concerns to +/// the generic Sandbox lifecycle contract. +pub trait NetworkBackend { + /// Returns the stable identity persisted in each attached Sandbox. + fn id(&self) -> NetworkBackendId; + + /// Reports whether this process already drives the Sandbox's live endpoint. + /// + /// The lifecycle service uses this to keep repeated reconciliation + /// idempotent without opening a second endpoint. A newly started control + /// plane returns `false` and re-establishes the retained attachment. + fn is_running(&self, sandbox_id: &SandboxId) -> bool; + + /// Selects one endpoint contract from those offered by a Sandbox Backend. + /// + /// The implementation owns preference and completeness requirements. For + /// example, one implementation may require Ethernet packets while another + /// requires both intercepted TCP streams and UDP datagrams. + /// + /// Returns `None` when no offered endpoint can meet the Network Backend's requirements. + fn select_endpoint(&self, available: &NetworkEndpointCapabilities) -> Option; + + /// Starts or reconnects the Network attached to a Sandbox. + /// + /// This operation must be idempotent for the same Sandbox and attachment. + /// It takes unique ownership of the fresh endpoint and returns only after + /// the Network Backend is ready to process Sandbox traffic. + fn start(&self, request: StartNetworkRequest) -> LocalFuture<'_, Result<(), Error>>; + + /// Stops live network processing while retaining the immutable attachment. + fn stop<'a>(&'a self, sandbox_id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>>; + + /// Deletes all Network Backend state belonging to a deleted Sandbox. + fn delete<'a>(&'a self, sandbox_id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>>; +} diff --git a/sandbox/core/src/path.rs b/sandbox/core/src/path.rs new file mode 100644 index 0000000..b7009eb --- /dev/null +++ b/sandbox/core/src/path.rs @@ -0,0 +1,22 @@ +//! Paths interpreted inside a Sandbox. + +use serde::{Deserialize, Serialize}; + +/// A path interpreted inside a Sandbox rather than on the caller's host. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct SandboxPath(String); + +impl SandboxPath { + /// Creates a Sandbox path from its backend-neutral representation. + #[must_use] + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// Returns the path as text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} diff --git a/sandbox/core/src/platform.rs b/sandbox/core/src/platform.rs new file mode 100644 index 0000000..18398f9 --- /dev/null +++ b/sandbox/core/src/platform.rs @@ -0,0 +1,120 @@ +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; + +use crate::Error; + +/// OCI-aligned operating-system and architecture requirements for a Sandbox. +/// +/// Values remain open strings so the generic SDK does not need a release for +/// every platform value introduced by an image or Sandbox Backend. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Platform { + /// Operating system, such as `linux` or `windows`. + pub os: String, + /// CPU architecture, such as `amd64` or `arm64`. + pub architecture: String, + /// Architecture variant, such as an ARM version. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub variant: Option, + /// Operating-system version required for compatibility. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub os_version: Option, + /// Operating-system features required by the image. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub os_features: BTreeSet, +} + +impl Platform { + /// Creates a Platform without variant or operating-system constraints. + #[must_use] + pub fn new(os: impl Into, architecture: impl Into) -> Self { + Self { + os: os.into(), + architecture: architecture.into(), + variant: None, + os_version: None, + os_features: BTreeSet::new(), + } + } + + /// Creates a Platform for the host CPU architecture and the requested + /// guest operating system. + /// + /// Architecture names use their OCI spelling so the result can be used + /// directly for image and Sandbox selection. + #[must_use] + pub fn native(os: impl Into) -> Self { + let architecture = match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + architecture => architecture, + }; + Self::new(os, architecture) + } + + /// Returns whether this concrete Platform satisfies a requested Platform. + /// + /// Omitted variant and operating-system constraints act as wildcards. A + /// concrete image may therefore add, but not contradict, those details. + #[must_use] + pub fn satisfies(&self, requested: &Self) -> bool { + self.os == requested.os + && self.architecture == requested.architecture + && requested + .variant + .as_ref() + .is_none_or(|variant| self.variant.as_ref() == Some(variant)) + && requested + .os_version + .as_ref() + .is_none_or(|version| self.os_version.as_ref() == Some(version)) + && self.os_features.is_superset(&requested.os_features) + } + + pub(crate) fn validate(&self) -> Result<(), Error> { + if self.os.is_empty() { + return Err(Error::invalid("platform.os", "must not be empty")); + } + if self.architecture.is_empty() { + return Err(Error::invalid("platform.architecture", "must not be empty")); + } + if self.variant.as_ref().is_some_and(String::is_empty) { + return Err(Error::invalid("platform.variant", "must not be empty when present")); + } + if self.os_version.as_ref().is_some_and(String::is_empty) { + return Err(Error::invalid("platform.osVersion", "must not be empty when present")); + } + if self.os_features.contains("") { + return Err(Error::invalid("platform.osFeatures", "must not contain an empty value")); + } + Ok(()) + } +} + +impl std::fmt::Display for Platform { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}/{}", self.os, self.architecture)?; + if let Some(variant) = &self.variant { + write!(formatter, "/{variant}")?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::Platform; + + #[test] + fn native_uses_oci_architecture_names() { + let expected = match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + architecture => architecture, + }; + + assert_eq!(Platform::native("linux"), Platform::new("linux", expected)); + } +} diff --git a/sandbox/core/src/progress/fold.rs b/sandbox/core/src/progress/fold.rs new file mode 100644 index 0000000..5be2788 --- /dev/null +++ b/sandbox/core/src/progress/fold.rs @@ -0,0 +1,913 @@ +//! What an operation's progress means: its events folded into one value. +//! +//! Renderers and observers read a [`Progress`] instead of interpreting events +//! themselves. A [`ProgressCursor`] yields what finished and what was printed +//! since it last looked, so a late or slow observer reads the same value as +//! one that saw every event. + +use std::collections::{HashMap, VecDeque}; + +use time::OffsetDateTime; + +use super::{Outcome, OutputStream, Phase, ProgressEvent, ProgressUnit, StepId}; + +/// Output lines retained per operation. +const OUTPUT_LINES: usize = 1_000; +/// Output text retained per operation, so that a serialized [`Progress`] +/// stays small enough to send in one message even when escaping multiplies it. +const OUTPUT_BYTES: usize = 512 * 1_024; +/// Finished steps retained per phase. +const FINISHED_STEPS: usize = 64; +/// Longest retained output line; longer output is split. +const LINE_BYTES: usize = 4_096; + +/// Progress of one operation, folded from its events. +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Progress { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + finished: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + current: Option, + #[serde(default, skip_serializing_if = "OutputLog::is_unused")] + output: OutputLog, + #[serde(default)] + status: OperationStatus, +} + +/// Whether the operation is still running and how it ended. +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase", tag = "state")] +pub enum OperationStatus { + /// The operation has not ended. + #[default] + Running, + /// The operation produced its result. + Succeeded, + /// The operation failed. + Failed { + /// Failure detail. + detail: String, + }, +} + +/// A phase that ended. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FinishedPhase { + /// Phase identity. + pub phase: Phase, + /// How it ended. + pub outcome: Outcome, + /// Time spent in the phase, in milliseconds. + pub elapsed_ms: u64, + /// The phase's most recent finished steps. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub steps: Vec, + /// Earlier finished steps no longer retained. + #[serde(default, skip_serializing_if = "is_zero")] + pub omitted_steps: u64, + /// Output lines produced before the phase ended, which orders it among them. + pub output_sequence: u64, +} + +/// The phase in progress. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ActivePhase { + /// Phase identity. + pub phase: Phase, + /// When the phase started. + #[serde(with = "time::serde::rfc3339")] + pub started_at: OffsetDateTime, + /// Steps in progress, in the order they started. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub steps: Vec, + /// The phase's most recent finished steps. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub finished_steps: Vec, + /// Earlier finished steps no longer retained. + #[serde(default, skip_serializing_if = "is_zero")] + pub omitted_steps: u64, +} + +/// A step in progress. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ActiveStep { + /// Step occurrence identity. + pub id: StepId, + /// Human-readable step name. + pub name: String, + /// When the step started. + #[serde(with = "time::serde::rfc3339")] + pub started_at: OffsetDateTime, + /// The step's quantity, for a measured step once it has reported one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub measurement: Option, + /// Unit of a measured step, fixed when it started. + #[serde(skip)] + unit: Option, +} + +/// A step that ended. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FinishedStep { + /// Human-readable step name. + pub name: String, + /// How it ended. + pub outcome: Outcome, + /// Time spent in the step, in milliseconds. + pub elapsed_ms: u64, + /// The step's final quantity, for a measured step that reported one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub measurement: Option, + /// Output lines produced before the step ended, which orders it among them. + pub output_sequence: u64, +} + +/// The single quantity a measured step reports. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Measurement { + /// Unit, fixed when the step started. + pub unit: ProgressUnit, + /// Work completed so far. + pub completed: u64, + /// Total work, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total: Option, +} + +/// The most recent output lines of an operation, numbered in the order produced. +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OutputLog { + lines: VecDeque, + next_sequence: u64, + #[serde(skip)] + bytes: usize, + #[serde(skip)] + partial: HashMap<(StepId, OutputStream), Partial>, +} + +/// One complete line of step output. +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OutputLine { + /// Position in the operation's output. + pub sequence: u64, + /// Name of the step that produced it. + pub step: String, + /// Stream it was produced on. + pub stream: OutputStream, + /// Line text without its terminator. + pub text: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct Partial { + step: String, + stream: OutputStream, + bytes: Vec, +} + +/// Something that happened since a [`ProgressCursor`] last looked. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Update<'a> { + /// Output lines were no longer retained when the cursor caught up. + OutputSkipped(u64), + /// One new output line. + Output(&'a OutputLine), + /// A step finished. + StepFinished(&'a FinishedStep), + /// A phase finished. + PhaseFinished(&'a FinishedPhase), +} + +/// Position of an observer in one [`Progress`]. +/// +/// A cursor that is ahead of the progress it reads, as happens when a new +/// operation starts, starts over. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProgressCursor { + phases: usize, + steps: u64, + output: u64, +} + +impl Progress { + /// Creates the progress of an operation that has not reported anything yet. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Phases that ended, in order. + #[must_use] + pub fn finished(&self) -> &[FinishedPhase] { + &self.finished + } + + /// The phase in progress. + #[must_use] + pub const fn current(&self) -> Option<&ActivePhase> { + self.current.as_ref() + } + + /// The operation's retained output. + #[must_use] + pub const fn output(&self) -> &OutputLog { + &self.output + } + + /// Whether the operation is running, succeeded or failed. + #[must_use] + pub const fn status(&self) -> &OperationStatus { + &self.status + } + + /// The newest step in progress, which renderers show as the current activity. + #[must_use] + pub fn current_step(&self) -> Option<&ActiveStep> { + self.current.as_ref().and_then(|phase| phase.steps.last()) + } + + /// A smaller copy for listings: finished phases keep their outcome but not + /// their steps, and only the last `output_lines` lines are kept. + #[must_use] + pub fn summary(&self, output_lines: usize) -> Self { + let finished = self + .finished + .iter() + .map(|phase| FinishedPhase { + steps: Vec::new(), + omitted_steps: phase.omitted_steps + phase.steps.len() as u64, + ..phase.clone() + }) + .collect(); + let current = self.current.as_ref().map(|phase| ActivePhase { + finished_steps: Vec::new(), + omitted_steps: phase.omitted_steps + phase.finished_steps.len() as u64, + ..phase.clone() + }); + Self { + finished, + current, + output: OutputLog::retained(self.output.tail(output_lines).cloned(), self.output.next_sequence), + status: self.status.clone(), + } + } + + /// A copy without the output lines before `sequence`, for an observer that + /// already has them. + #[must_use] + pub fn output_from(&self, sequence: u64) -> Self { + Self { + output: OutputLog::retained( + self.output + .lines + .iter() + .filter(|line| line.sequence >= sequence) + .cloned(), + self.output.next_sequence, + ), + ..self.clone() + } + } + + /// Folds one event into the progress. + pub fn apply(&mut self, event: &ProgressEvent) { + match event { + ProgressEvent::PhaseStarted { phase } => self.start_phase(phase.clone()), + ProgressEvent::PhaseEnded { + phase, + outcome, + elapsed, + } => { + if self.current.as_ref().is_some_and(|current| current.phase == *phase) { + self.end_phase(*outcome, milliseconds(*elapsed)); + } + } + ProgressEvent::StepStarted { id, name, unit, total } => { + // A step outside any phase breaks the contract and is not shown. + if let Some(current) = &mut self.current { + current.steps.push(ActiveStep { + id: id.clone(), + name: name.clone(), + started_at: OffsetDateTime::now_utc(), + // A figure is shown once there is one: a step that never + // reports its quantity shows none rather than zero. + measurement: unit.zip(*total).map(|(unit, total)| Measurement { + unit, + completed: 0, + total: Some(total), + }), + unit: *unit, + }); + } + } + ProgressEvent::StepProgress { id, completed, total } => { + if let Some(step) = self.active_step_mut(id) + && let Some(unit) = step.unit + { + let previous = step.measurement.and_then(|measurement| measurement.total); + step.measurement = Some(Measurement { + unit, + completed: *completed, + total: total.or(previous), + }); + } + } + ProgressEvent::StepOutput { id, stream, bytes } => { + if let Some(step) = self.active_step(id).map(|step| step.name.clone()) { + self.output.append(id, &step, *stream, bytes); + } + } + ProgressEvent::StepEnded { id, outcome, elapsed } => { + self.output.flush(Some(id)); + self.end_step(id, *outcome, Some(milliseconds(*elapsed))); + } + } + } + + /// Records that the operation produced its result. + pub fn succeed(&mut self) { + self.close(Outcome::Completed); + self.status = OperationStatus::Succeeded; + } + + /// Records that the operation failed. + pub fn fail(&mut self, detail: impl Into) { + self.close(Outcome::Failed); + self.status = OperationStatus::Failed { detail: detail.into() }; + } + + fn close(&mut self, outcome: Outcome) { + self.output.flush(None); + if self.current.is_some() { + let elapsed = self.current.as_ref().map_or(0, |current| since(current.started_at)); + self.end_phase(outcome, elapsed); + } + } + + fn start_phase(&mut self, phase: Phase) { + if self.current.is_some() { + self.close(Outcome::Failed); + } + self.current = Some(ActivePhase { + phase, + started_at: OffsetDateTime::now_utc(), + steps: Vec::new(), + finished_steps: Vec::new(), + omitted_steps: 0, + }); + } + + fn end_phase(&mut self, outcome: Outcome, elapsed_ms: u64) { + let open = self + .current + .as_ref() + .map(|current| current.steps.iter().map(|step| step.id.clone()).collect::>()) + .unwrap_or_default(); + for id in open { + self.output.flush(Some(&id)); + self.end_step(&id, Outcome::Failed, None); + } + if let Some(current) = self.current.take() { + self.finished.push(FinishedPhase { + phase: current.phase, + outcome, + elapsed_ms, + steps: current.finished_steps, + omitted_steps: current.omitted_steps, + output_sequence: self.output.next_sequence, + }); + } + } + + fn end_step(&mut self, id: &StepId, outcome: Outcome, elapsed_ms: Option) { + let Some(current) = &mut self.current else { + return; + }; + let Some(position) = current.steps.iter().position(|step| step.id == *id) else { + return; + }; + let step = current.steps.remove(position); + if current.finished_steps.len() == FINISHED_STEPS { + current.finished_steps.remove(0); + current.omitted_steps += 1; + } + current.finished_steps.push(FinishedStep { + elapsed_ms: elapsed_ms.unwrap_or_else(|| since(step.started_at)), + name: step.name, + outcome, + measurement: step.measurement, + output_sequence: self.output.next_sequence, + }); + } + + fn active_step(&self, id: &StepId) -> Option<&ActiveStep> { + self.current.as_ref()?.steps.iter().find(|step| step.id == *id) + } + + fn active_step_mut(&mut self, id: &StepId) -> Option<&mut ActiveStep> { + self.current.as_mut()?.steps.iter_mut().find(|step| step.id == *id) + } +} + +impl OutputLog { + /// Retained lines, oldest first. + #[must_use] + pub fn lines(&self) -> impl ExactSizeIterator { + self.lines.iter() + } + + /// Whether no line has been retained. + #[must_use] + pub fn is_empty(&self) -> bool { + self.lines.is_empty() + } + + /// The most recent `count` lines, oldest first. + pub fn tail(&self, count: usize) -> impl Iterator { + self.lines.iter().skip(self.lines.len().saturating_sub(count)) + } + + /// Whether no line was ever produced; only then can the log be omitted, + /// since its next sequence tells an observer where the output stands. + const fn is_unused(&self) -> bool { + self.next_sequence == 0 + } + + fn retained(lines: impl Iterator, next_sequence: u64) -> Self { + let lines = lines.collect::>(); + Self { + bytes: lines.iter().map(|line| line.text.len()).sum(), + lines, + next_sequence, + partial: HashMap::new(), + } + } + + fn first_sequence(&self) -> u64 { + self.lines.front().map_or(self.next_sequence, |line| line.sequence) + } + + fn append(&mut self, id: &StepId, step: &str, stream: OutputStream, bytes: &[u8]) { + let key = (id.clone(), stream); + let mut partial = self.partial.remove(&key).unwrap_or_else(|| Partial { + step: step.to_owned(), + stream, + bytes: Vec::new(), + }); + for &byte in bytes { + if byte == b'\n' { + self.push(&partial.step, partial.stream, &std::mem::take(&mut partial.bytes)); + } else { + partial.bytes.push(byte); + if partial.bytes.len() == LINE_BYTES { + self.push(&partial.step, partial.stream, &std::mem::take(&mut partial.bytes)); + } + } + } + if !partial.bytes.is_empty() { + self.partial.insert(key, partial); + } + } + + /// Ends unterminated lines of one step, or of every step. + fn flush(&mut self, id: Option<&StepId>) { + let keys = self + .partial + .keys() + .filter(|(step, _)| id.is_none_or(|id| step == id)) + .cloned() + .collect::>(); + for key in keys { + if let Some(partial) = self.partial.remove(&key) { + self.push(&partial.step, partial.stream, &partial.bytes); + } + } + } + + fn push(&mut self, step: &str, stream: OutputStream, bytes: &[u8]) { + let text = String::from_utf8_lossy(bytes); + let text = text.trim_end(); + if text.trim_start().is_empty() { + return; + } + while self.lines.len() >= OUTPUT_LINES || (!self.lines.is_empty() && self.bytes + text.len() > OUTPUT_BYTES) { + if let Some(line) = self.lines.pop_front() { + self.bytes -= line.text.len(); + } + } + self.bytes += text.len(); + self.lines.push_back(OutputLine { + sequence: self.next_sequence, + step: step.to_owned(), + stream, + text: text.to_owned(), + }); + self.next_sequence += 1; + } +} + +impl ProgressCursor { + /// Creates a cursor at the start of an operation. + #[must_use] + pub const fn new() -> Self { + Self { + phases: 0, + steps: 0, + output: 0, + } + } + + /// Sequence number of the first output line not yet returned. + #[must_use] + pub const fn output_sequence(&self) -> u64 { + self.output + } + + /// Returns what happened since the last call, in the order it happened, + /// and moves past it. + pub fn updates<'a>(&mut self, progress: &'a Progress) -> Vec> { + if self.phases > progress.finished.len() || self.output > progress.output.next_sequence { + *self = Self::default(); + } + let mut finished = Vec::new(); + for phase in &progress.finished[self.phases..] { + push_steps(&mut finished, &phase.steps, phase.omitted_steps, self.steps); + finished.push((phase.output_sequence, Update::PhaseFinished(phase))); + self.steps = 0; + } + self.phases = progress.finished.len(); + if let Some(current) = &progress.current { + push_steps( + &mut finished, + ¤t.finished_steps, + current.omitted_steps, + self.steps, + ); + self.steps = current.omitted_steps + current.finished_steps.len() as u64; + } + + let mut updates = Vec::new(); + let first = progress.output.first_sequence(); + if self.output < first { + updates.push(Update::OutputSkipped(first - self.output)); + self.output = first; + } + let mut finished = finished.into_iter().peekable(); + for line in progress.output.lines.iter().filter(|line| line.sequence >= self.output) { + while let Some((_, update)) = finished.next_if(|(before, _)| *before <= line.sequence) { + updates.push(update); + } + updates.push(Update::Output(line)); + } + updates.extend(finished.map(|(_, update)| update)); + self.output = progress.output.next_sequence; + updates + } +} + +/// Adds the steps not yet seen, each with the output position it ended at. +fn push_steps<'a>(finished: &mut Vec<(u64, Update<'a>)>, steps: &'a [FinishedStep], omitted: u64, seen: u64) { + let skip = usize::try_from(seen.saturating_sub(omitted)).unwrap_or(usize::MAX); + finished.extend( + steps + .iter() + .skip(skip) + .map(|step| (step.output_sequence, Update::StepFinished(step))), + ); +} + +fn milliseconds(duration: std::time::Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + +fn since(started_at: OffsetDateTime) -> u64 { + u64::try_from((OffsetDateTime::now_utc() - started_at).whole_milliseconds()).unwrap_or(0) +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +const fn is_zero(value: &u64) -> bool { + *value == 0 +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::SandboxPhase; + + fn phase_started() -> ProgressEvent { + ProgressEvent::PhaseStarted { + phase: SandboxPhase::ImageResolve.phase(), + } + } + + fn step_started(id: &StepId, name: &str, unit: Option) -> ProgressEvent { + ProgressEvent::StepStarted { + id: id.clone(), + name: name.into(), + unit, + total: None, + } + } + + fn output(id: &StepId, text: &str) -> ProgressEvent { + ProgressEvent::StepOutput { + id: id.clone(), + stream: OutputStream::Stdout, + bytes: text.as_bytes().to_vec().into(), + } + } + + fn ended(id: &StepId) -> ProgressEvent { + ProgressEvent::StepEnded { + id: id.clone(), + outcome: Outcome::Completed, + elapsed: Duration::from_millis(5), + } + } + + #[test] + fn a_measured_step_shows_no_figure_until_it_reports_one() { + let mut progress = Progress::new(); + let build = StepId::generate(); + progress.apply(&phase_started()); + progress.apply(&step_started(&build, "Build", Some(ProgressUnit::Bytes))); + assert_eq!(progress.current_step().expect("step").measurement, None); + + let export = StepId::generate(); + progress.apply(&step_started(&export, "Export", Some(ProgressUnit::Bytes))); + progress.apply(&ProgressEvent::StepProgress { + id: export.clone(), + completed: 7, + total: None, + }); + progress.apply(&ended(&build)); + progress.apply(&ended(&export)); + progress.succeed(); + + let steps = &progress.finished()[0].steps; + assert_eq!(steps[0].measurement, None, "a step that never reported has no figure"); + assert_eq!( + steps[1].measurement, + Some(Measurement { + unit: ProgressUnit::Bytes, + completed: 7, + total: None, + }) + ); + } + + #[test] + fn retained_output_is_bounded_by_bytes_as_well_as_lines() { + let mut progress = Progress::new(); + let step = StepId::generate(); + progress.apply(&phase_started()); + progress.apply(&step_started(&step, "Build", None)); + let long = "x".repeat(LINE_BYTES - 1); + for _ in 0..OUTPUT_LINES { + progress.apply(&output(&step, &format!("{long}\n"))); + } + + let retained = progress + .output() + .tail(usize::MAX) + .map(|line| line.text.len()) + .sum::(); + assert!(retained <= OUTPUT_BYTES, "{retained} bytes retained"); + assert_eq!( + progress.output().tail(usize::MAX).count(), + OUTPUT_BYTES / (LINE_BYTES - 1), + "the oldest lines make room" + ); + assert_eq!( + progress.output().next_sequence, + OUTPUT_LINES as u64, + "every line is still numbered" + ); + let from = progress.output_from(0); + assert_eq!( + from.output().tail(usize::MAX).count(), + progress.output().tail(usize::MAX).count() + ); + } + + #[test] + fn folds_phases_measured_steps_and_line_output() { + let mut progress = Progress::new(); + let pull = StepId::generate(); + progress.apply(&ProgressEvent::PhaseStarted { + phase: SandboxPhase::ImageResolve.phase(), + }); + progress.apply(&step_started(&pull, "Pull", Some(ProgressUnit::Bytes))); + progress.apply(&ProgressEvent::StepProgress { + id: pull.clone(), + completed: 40, + total: Some(100), + }); + progress.apply(&output(&pull, "layer 1 do")); + progress.apply(&output(&pull, "ne\nlayer 2 done\npartial")); + + let step = progress.current_step().expect("step in progress"); + assert_eq!( + step.measurement, + Some(Measurement { + unit: ProgressUnit::Bytes, + completed: 40, + total: Some(100), + }) + ); + let lines = progress + .output() + .lines() + .map(|line| line.text.as_str()) + .collect::>(); + assert_eq!(lines, ["layer 1 done", "layer 2 done"]); + + progress.apply(&ended(&pull)); + progress.apply(&ProgressEvent::PhaseEnded { + phase: SandboxPhase::ImageResolve.phase(), + outcome: Outcome::Completed, + elapsed: Duration::from_millis(9), + }); + progress.succeed(); + assert_eq!( + progress.output().lines().last().map(|line| line.text.as_str()), + Some("partial") + ); + assert_eq!(progress.finished()[0].steps[0].name, "Pull"); + assert_eq!(progress.status(), &OperationStatus::Succeeded); + } + + #[test] + fn failing_ends_open_work_and_keeps_the_detail() { + let mut progress = Progress::new(); + let build = StepId::generate(); + progress.apply(&phase_started()); + progress.apply(&step_started(&build, "Build", None)); + progress.fail("build failed"); + assert!(progress.current().is_none()); + let phase = &progress.finished()[0]; + assert_eq!(phase.outcome, Outcome::Failed); + assert_eq!(phase.steps[0].outcome, Outcome::Failed); + assert_eq!( + progress.status(), + &OperationStatus::Failed { + detail: "build failed".into() + } + ); + } + + #[test] + fn a_cursor_yields_each_line_and_finished_step_once_and_reports_skipped_output() { + let mut progress = Progress::new(); + let mut cursor = ProgressCursor::default(); + let step = StepId::generate(); + progress.apply(&phase_started()); + progress.apply(&step_started(&step, "Build", None)); + progress.apply(&output(&step, "one\n")); + assert_eq!(cursor.updates(&progress).len(), 1); + assert!(cursor.updates(&progress).is_empty(), "nothing new"); + + for line in 0..(OUTPUT_LINES + 5) { + progress.apply(&output(&step, &format!("{line}\n"))); + } + progress.apply(&ended(&step)); + let updates = cursor.updates(&progress); + assert_eq!(updates[0], Update::OutputSkipped(5)); + assert!(matches!( + updates.last(), + Some(Update::StepFinished(step)) if step.name == "Build" + )); + assert_eq!( + updates + .iter() + .filter(|update| matches!(update, Update::Output(_))) + .count(), + OUTPUT_LINES + ); + + progress.succeed(); + assert!( + matches!(cursor.updates(&progress).as_slice(), [Update::PhaseFinished(_)]), + "the phase's step was already reported" + ); + } + + #[test] + fn a_late_cursor_sees_the_whole_retained_history_and_a_stale_one_starts_over() { + let mut progress = Progress::new(); + let step = StepId::generate(); + progress.apply(&phase_started()); + progress.apply(&step_started(&step, "Build", None)); + progress.apply(&ended(&step)); + progress.succeed(); + let mut cursor = ProgressCursor::default(); + assert_eq!(cursor.updates(&progress).len(), 2); + + let fresh = Progress::new(); + assert!(cursor.updates(&fresh).is_empty()); + assert_eq!(cursor, ProgressCursor::default()); + } + + #[test] + fn a_summary_keeps_outcomes_and_the_output_tail() { + let mut progress = Progress::new(); + let step = StepId::generate(); + progress.apply(&phase_started()); + progress.apply(&step_started(&step, "Build", None)); + progress.apply(&output(&step, "one\ntwo\nthree\n")); + progress.apply(&ended(&step)); + progress.fail("build failed"); + let summary = progress.summary(2); + assert!(summary.finished()[0].steps.is_empty()); + assert_eq!(summary.finished()[0].omitted_steps, 1); + assert_eq!(summary.finished()[0].outcome, Outcome::Failed); + let lines = summary + .output() + .lines() + .map(|line| line.text.as_str()) + .collect::>(); + assert_eq!(lines, ["two", "three"]); + assert_eq!(summary.status(), progress.status()); + } + + #[test] + fn output_from_keeps_later_lines_and_a_cursor_reads_them_as_new() { + let mut progress = Progress::new(); + let step = StepId::generate(); + progress.apply(&phase_started()); + progress.apply(&step_started(&step, "Build", None)); + progress.apply(&output(&step, "one\ntwo\n")); + let mut cursor = ProgressCursor::default(); + assert_eq!(cursor.updates(&progress.output_from(0)).len(), 2); + progress.apply(&output(&step, "three\n")); + let trimmed = progress.output_from(2); + assert_eq!(trimmed.output().lines().count(), 1); + assert!(matches!( + cursor.updates(&trimmed).as_slice(), + [Update::Output(line)] if line.text == "three" + )); + } + + #[test] + fn updates_keep_output_and_endings_in_the_order_they_happened() { + let mut progress = Progress::new(); + let first = StepId::generate(); + let second = StepId::generate(); + progress.apply(&phase_started()); + progress.apply(&step_started(&first, "First", None)); + progress.apply(&output(&first, "first output\n")); + progress.apply(&ended(&first)); + progress.apply(&step_started(&second, "Second", None)); + progress.apply(&output(&second, "second output\n")); + let order = ProgressCursor::new() + .updates(&progress) + .into_iter() + .map(|update| match update { + Update::Output(line) => line.text.clone(), + Update::StepFinished(step) => format!("end {}", step.name), + other => format!("{other:?}"), + }) + .collect::>(); + assert_eq!(order, ["first output", "end First", "second output"]); + } + + #[test] + fn progress_round_trips_through_json_without_partial_lines() { + let mut progress = Progress::new(); + let step = StepId::generate(); + progress.apply(&phase_started()); + progress.apply(&step_started(&step, "Build", Some(ProgressUnit::Items))); + progress.apply(&ProgressEvent::StepProgress { + id: step.clone(), + completed: 1, + total: Some(2), + }); + progress.apply(&output(&step, "done\nhalf")); + let json = serde_json::to_value(&progress).expect("progress JSON"); + assert_eq!(json["current"]["phase"]["id"], "imageResolve"); + assert_eq!(json["current"]["steps"][0]["measurement"]["unit"], "items"); + assert_eq!(json["status"]["state"], "running"); + let decoded: Progress = serde_json::from_value(json).expect("progress from JSON"); + assert_eq!(decoded.output().lines().count(), 1); + + let trimmed = serde_json::to_value(progress.output_from(1)).expect("trimmed progress JSON"); + let decoded: Progress = serde_json::from_value(trimmed).expect("trimmed progress from JSON"); + assert_eq!(decoded.output().lines().count(), 0); + assert_eq!( + decoded.output().next_sequence, + 1, + "an observer that has every line still learns where the output stands" + ); + } +} diff --git a/sandbox/core/src/progress/mod.rs b/sandbox/core/src/progress/mod.rs new file mode 100644 index 0000000..b0fe0da --- /dev/null +++ b/sandbox/core/src/progress/mod.rs @@ -0,0 +1,651 @@ +//! Observable progress for Sandbox operations. +//! +//! Implementations report through phase spans and step tokens, and consumers +//! read the events folded into a [`Progress`]. A step belongs to the phase in +//! progress and reports at most one quantity, in the unit it started with. +//! Every phase and step ends once, as completed, reused or failed: a +//! producer ends the ones it finishes, and the fold ends whatever is still +//! open when the operation ends. + +use std::{ + borrow::Cow, + fmt, + future::{IntoFuture, poll_fn}, + pin::Pin, + rc::Rc, + task::{Context, Poll}, + time::{Duration, Instant}, +}; + +use bytes::Bytes; +use futures_core::{Stream, stream::FusedStream}; +use tokio::sync::mpsc; +use uuid::Uuid; + +use crate::{Error, LocalFuture, SandboxHandle}; + +mod fold; + +pub use fold::{ + ActivePhase, ActiveStep, FinishedPhase, FinishedStep, Measurement, OperationStatus, OutputLine, OutputLog, + Progress, ProgressCursor, Update, +}; + +const EVENT_CAPACITY: usize = 64; + +/// One stable phase of ensuring that a Sandbox is ready. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[non_exhaustive] +pub enum SandboxPhase { + /// Validate the backend-neutral request. + Validate, + /// Look up an existing Sandbox with the requested name. + Lookup, + /// Discover and verify required Backend Features. + FeatureDiscovery, + /// Resolve the immutable Image. + ImageResolve, + /// Export or import a prepared Image. + ImagePrepare, + /// Materialize the Sandbox around the resolved Image. + SandboxCreate, + /// Reconcile mutable Sandbox configuration. + SandboxUpdate, + /// Start or reconnect the independently selected Network Backend. + NetworkStart, + /// Start the Sandbox. + SandboxStart, + /// Inspect the resulting Sandbox state. + Inspect, +} + +impl SandboxPhase { + /// Returns the stable identifier and label reported for this phase. + #[must_use] + pub const fn phase(self) -> Phase { + let (id, label) = match self { + Self::Validate => ("validate", "Validate Sandbox request"), + Self::Lookup => ("lookup", "Look up Sandbox"), + Self::FeatureDiscovery => ("featureDiscovery", "Discover Sandbox Capabilities"), + Self::ImageResolve => ("imageResolve", "Resolve Sandbox Image"), + Self::ImagePrepare => ("imagePrepare", "Prepare Sandbox Image"), + Self::SandboxCreate => ("sandboxCreate", "Create Sandbox"), + Self::SandboxUpdate => ("sandboxUpdate", "Update Sandbox"), + Self::NetworkStart => ("networkStart", "Start Sandbox Network"), + Self::SandboxStart => ("sandboxStart", "Start Sandbox"), + Self::Inspect => ("inspect", "Inspect Sandbox"), + }; + Phase::new(id, label) + } +} + +impl fmt::Display for SandboxPhase { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.phase().label) + } +} + +/// Identity of one reported phase: a stable machine identifier and a label. +/// +/// Sandbox phases come from [`SandboxPhase`]; callers that extend an operation +/// with work of their own report it under identifiers of their own. +#[derive(Clone, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct Phase { + /// Stable identifier, such as `imageResolve`. + pub id: Cow<'static, str>, + /// Human-readable label. + pub label: Cow<'static, str>, +} + +impl Phase { + /// Creates a phase identity from static text. + #[must_use] + pub const fn new(id: &'static str, label: &'static str) -> Self { + Self { + id: Cow::Borrowed(id), + label: Cow::Borrowed(label), + } + } +} + +impl From for Phase { + fn from(phase: SandboxPhase) -> Self { + phase.phase() + } +} + +/// Correlates the events of one step occurrence independently of its name. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize)] +#[serde(transparent)] +pub struct StepId(Uuid); + +impl StepId { + /// Creates a new unique step identity. + #[must_use] + pub fn generate() -> Self { + Self(Uuid::new_v4()) + } +} + +impl fmt::Display for StepId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +/// How a phase or step ended. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub enum Outcome { + /// The work was performed. + Completed, + /// Existing materialized state already satisfied it. + Reused, + /// It did not finish: the operation failed or the work was abandoned. + Failed, +} + +/// Unit of a measured step's quantity. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub enum ProgressUnit { + /// A byte count. + Bytes, + /// A count of discrete items. + Items, +} + +/// Output stream of one step. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub enum OutputStream { + /// Normal diagnostic output. + Stdout, + /// Warning or error diagnostic output. + Stderr, +} + +/// One non-terminal event emitted while an operation is running. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ProgressEvent { + /// A phase started. + PhaseStarted { + /// Phase being performed. + phase: Phase, + }, + /// A phase ended. + PhaseEnded { + /// Phase that ended. + phase: Phase, + /// How it ended. + outcome: Outcome, + /// Time spent in the phase. + elapsed: Duration, + }, + /// An implementation-specific step started within the phase in progress. + StepStarted { + /// Correlation identity for this step occurrence. + id: StepId, + /// Human-readable step name. It is not a stable identifier. + name: String, + /// Unit of the step's quantity, for a measured step. + unit: Option, + /// Total quantity, when it is known at the start. + total: Option, + }, + /// The quantity of a measured step advanced. + StepProgress { + /// Step being measured. + id: StepId, + /// Work completed so far, in the step's unit. + completed: u64, + /// Total work, when it is known. + total: Option, + }, + /// Raw diagnostic output from a step. + StepOutput { + /// Step producing the output. + id: StepId, + /// Stream the output was produced on. + stream: OutputStream, + /// Raw output bytes; not necessarily whole lines. + bytes: Bytes, + }, + /// A step ended. + StepEnded { + /// Step that ended. + id: StepId, + /// How it ended. + outcome: Outcome, + /// Time spent in the step. + elapsed: Duration, + }, +} + +/// One item yielded by a [`PendingOperation`]. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum OperationEvent { + /// Non-terminal observable progress. + Progress(ProgressEvent), + /// Successful terminal result. + Ready(T), +} + +/// Reports the steps of the phase in progress. +/// +/// Sandbox and Image Backend implementations obtain one from +/// [`PendingOperation::run`]; a caller that extends an operation with work of +/// its own gets one from [`ProgressReporter::steps`]. +#[derive(Clone)] +pub struct SandboxProgress { + reporter: ProgressReporter, +} + +impl SandboxProgress { + /// Starts a step without a quantity and returns its reporting token. + pub async fn start_step(&self, name: impl Into) -> ProgressStep { + self.start(name.into(), None, None).await + } + + /// Starts a step that reports one quantity in `unit`. + pub async fn start_measured_step( + &self, + name: impl Into, + unit: ProgressUnit, + total: Option, + ) -> MeasuredStep { + MeasuredStep { + step: self.start(name.into(), Some(unit), total).await, + } + } + + async fn start(&self, name: String, unit: Option, total: Option) -> ProgressStep { + let id = StepId::generate(); + self.reporter + .emit(ProgressEvent::StepStarted { + id: id.clone(), + name, + unit, + total, + }) + .await; + ProgressStep { + reporter: self.reporter.clone(), + id, + started: Instant::now(), + } + } +} + +/// Reporting token for one in-flight step. +/// +/// A step that is never completed ends as failed when its operation ends. +#[must_use = "a step that is never completed ends as failed"] +pub struct ProgressStep { + reporter: ProgressReporter, + id: StepId, + started: Instant, +} + +impl ProgressStep { + /// Reports raw output from the step. + pub async fn output(&self, stream: OutputStream, bytes: impl Into) { + self.reporter + .emit(ProgressEvent::StepOutput { + id: self.id.clone(), + stream, + bytes: bytes.into(), + }) + .await; + } + + /// Ends the step as completed. + pub async fn complete(self) { + self.reporter + .emit(ProgressEvent::StepEnded { + id: self.id, + outcome: Outcome::Completed, + elapsed: self.started.elapsed(), + }) + .await; + } +} + +/// Reporting token for one in-flight step that measures a single quantity. +#[must_use = "a step that is never completed ends as failed"] +pub struct MeasuredStep { + step: ProgressStep, +} + +impl MeasuredStep { + /// Reports the quantity completed so far and the total, when known. + pub async fn report(&self, completed: u64, total: Option) { + self.step + .reporter + .emit(ProgressEvent::StepProgress { + id: self.step.id.clone(), + completed, + total, + }) + .await; + } + + /// Reports raw output from the step. + pub async fn output(&self, stream: OutputStream, bytes: impl Into) { + self.step.output(stream, bytes).await; + } + + /// Ends the step as completed. + pub async fn complete(self) { + self.step.complete().await; + } +} + +/// One started phase. A phase that is never ended ends as failed when its +/// operation ends. +#[must_use = "a phase that is never ended ends as failed"] +pub struct PhaseSpan { + reporter: ProgressReporter, + phase: Phase, + started: Instant, +} + +impl PhaseSpan { + /// Ends the phase with `outcome`. + pub async fn end(self, outcome: Outcome) { + self.reporter + .emit(ProgressEvent::PhaseEnded { + phase: self.phase, + outcome, + elapsed: self.started.elapsed(), + }) + .await; + } + + /// Ends the phase as completed. + pub async fn complete(self) { + self.end(Outcome::Completed).await; + } +} + +/// An observable operation that terminates with either one value or an Error. +/// +/// Polling this value as a [`Stream`] drives the operation and exposes its +/// progress. Awaiting it through [`IntoFuture`] drains progress and returns +/// only the terminal result. Dropping it cancels the in-flight future. +/// +/// Phases and steps still open when it terminates are ended by the fold of +/// its events; see [`Progress::fail`] and [`Progress::succeed`]. +pub struct PendingOperation<'a, T> { + events: mpsc::Receiver, + driver: Option>>, + result: Option>, + terminated: bool, +} + +/// An operation that terminates with a ready, operable Sandbox handle. +pub type PendingSandbox<'a> = PendingOperation<'a, SandboxHandle>; + +impl<'a, T> PendingOperation<'a, T> { + /// Creates an observable operation whose steps belong to the phase its + /// caller has in progress. + /// + /// The operation owns its progress reporter. Consumers only receive the + /// returned stream/future and cannot inject a sink. + pub fn run(operation: F) -> Self + where + F: FnOnce(SandboxProgress) -> LocalFuture<'a, Result>, + { + let (events, receiver) = ProgressReporter::channel(); + let progress = events.steps(); + drop(events); + Self::new(receiver, operation(progress)) + } + + pub(crate) fn with_events(operation: F) -> Self + where + F: FnOnce(ProgressReporter) -> LocalFuture<'a, Result>, + { + let (events, receiver) = ProgressReporter::channel(); + let driver = operation(events); + Self::new(receiver, driver) + } + + fn new(events: mpsc::Receiver, driver: LocalFuture<'a, Result>) -> Self { + Self { + events, + driver: Some(driver), + result: None, + terminated: false, + } + } + + /// Drives the operation to completion while discarding progress events. + /// + /// # Errors + /// + /// Returns the terminal operation Error, or an invariant error if the + /// operation ends without producing a value or Error. + pub async fn finish(mut self) -> Result { + while let Some(event) = poll_fn(|context| Pin::new(&mut self).poll_next(context)).await { + match event? { + OperationEvent::Ready(value) => return Ok(value), + OperationEvent::Progress(_) => {} + } + } + Err(Error::OperationStreamEnded) + } + + /// Drives the operation to completion while reporting its progress to `events`. + /// + /// # Errors + /// + /// Returns the terminal operation Error, or an invariant error if the + /// operation ends without producing a value or Error. + pub async fn forward(mut self, events: &ProgressReporter) -> Result { + while let Some(event) = poll_fn(|context| Pin::new(&mut self).poll_next(context)).await { + match event? { + OperationEvent::Progress(event) => events.emit(event).await, + OperationEvent::Ready(value) => return Ok(value), + } + } + Err(Error::OperationStreamEnded) + } +} + +impl Unpin for PendingOperation<'_, T> {} + +impl Stream for PendingOperation<'_, T> { + type Item = Result, Error>; + + fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { + if self.terminated { + return Poll::Ready(None); + } + + if let Poll::Ready(Some(event)) = self.events.poll_recv(context) { + return Poll::Ready(Some(Ok(OperationEvent::Progress(event)))); + } + + if self.result.is_none() + && let Some(driver) = self.driver.as_mut() + && let Poll::Ready(result) = driver.as_mut().poll(context) + { + self.driver = None; + self.result = Some(result); + } + + if let Poll::Ready(Some(event)) = self.events.poll_recv(context) { + return Poll::Ready(Some(Ok(OperationEvent::Progress(event)))); + } + + if let Some(result) = self.result.take() { + self.terminated = true; + return Poll::Ready(Some(result.map(OperationEvent::Ready))); + } + + Poll::Pending + } +} + +impl FusedStream for PendingOperation<'_, T> { + fn is_terminated(&self) -> bool { + self.terminated + } +} + +impl<'a, T: 'a> IntoFuture for PendingOperation<'a, T> { + type Output = Result; + type IntoFuture = LocalFuture<'a, Self::Output>; + + fn into_future(self) -> Self::IntoFuture { + Box::pin(self.finish()) + } +} + +/// Where progress events go: the stream of a [`PendingOperation`], or a +/// caller's callback. +#[derive(Clone)] +pub struct ProgressReporter { + sink: Sink, +} + +#[derive(Clone)] +enum Sink { + Operation(mpsc::Sender), + Callback(Rc), +} + +impl ProgressReporter { + /// Reports to `callback`. Whoever folds the events ends the work that is + /// still open when the operation ends, as [`Progress::succeed`] and + /// [`Progress::fail`] do. + pub fn from_callback(callback: impl Fn(ProgressEvent) + 'static) -> Self { + Self { + sink: Sink::Callback(Rc::new(callback)), + } + } + + fn channel() -> (Self, mpsc::Receiver) { + let (sender, receiver) = mpsc::channel(EVENT_CAPACITY); + ( + Self { + sink: Sink::Operation(sender), + }, + receiver, + ) + } + + /// Starts a phase. + pub async fn start_phase(&self, phase: impl Into) -> PhaseSpan { + let phase = phase.into(); + self.emit(ProgressEvent::PhaseStarted { phase: phase.clone() }).await; + PhaseSpan { + reporter: self.clone(), + phase, + started: Instant::now(), + } + } + + /// Returns the reporter for steps of the phase in progress. + #[must_use] + pub fn steps(&self) -> SandboxProgress { + SandboxProgress { reporter: self.clone() } + } + + async fn emit(&self, event: ProgressEvent) { + match &self.sink { + Sink::Operation(sender) => { + let _ = sender.send(event).await; + } + Sink::Callback(callback) => callback(event), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Drives an operation and folds its events, as a consumer does. + async fn fold(mut operation: PendingOperation<'_, T>) -> Progress { + let mut progress = Progress::new(); + while let Some(item) = poll_fn(|context| Pin::new(&mut operation).poll_next(context)).await { + match item { + Ok(OperationEvent::Progress(event)) => progress.apply(&event), + Ok(OperationEvent::Ready(_)) => { + progress.succeed(); + break; + } + Err(error) => { + progress.fail(error.to_string()); + break; + } + } + } + progress + } + + #[tokio::test] + async fn a_failed_operation_leaves_no_phase_or_step_open() { + let operation = PendingOperation::<()>::with_events(|events| { + Box::pin(async move { + let _span = events.start_phase(SandboxPhase::ImageResolve).await; + let step = events + .steps() + .start_measured_step("Pull", ProgressUnit::Bytes, Some(10)) + .await; + step.report(4, Some(10)).await; + Err(Error::Backend("registry unavailable".into())) + }) + }); + let progress = fold(operation).await; + assert!(progress.current().is_none()); + let phase = &progress.finished()[0]; + assert_eq!(phase.outcome, Outcome::Failed); + assert_eq!(phase.steps[0].outcome, Outcome::Failed); + assert_eq!( + phase.steps[0].measurement.map(|measurement| measurement.completed), + Some(4) + ); + } + + #[tokio::test] + async fn steps_belong_to_the_phase_in_progress_and_an_abandoned_one_fails() { + let operation = PendingOperation::<()>::with_events(|events| { + Box::pin(async move { + let span = events.start_phase(SandboxPhase::SandboxUpdate).await; + let backend = PendingOperation::run(|progress| { + Box::pin(async move { + drop(progress.start_step("Abandoned").await); + progress.start_step("Finished").await.complete().await; + Ok(()) + }) + }); + backend.forward(&events).await?; + span.end(Outcome::Reused).await; + Ok(()) + }) + }); + let progress = fold(operation).await; + let [phase] = progress.finished() else { + panic!("one phase: {progress:?}"); + }; + assert_eq!(phase.phase, SandboxPhase::SandboxUpdate.phase()); + assert_eq!(phase.outcome, Outcome::Reused); + let steps = phase + .steps + .iter() + .map(|step| (step.name.as_str(), step.outcome)) + .collect::>(); + assert_eq!( + steps, + [("Finished", Outcome::Completed), ("Abandoned", Outcome::Failed)] + ); + } +} diff --git a/sandbox/core/src/provider.rs b/sandbox/core/src/provider.rs new file mode 100644 index 0000000..636c80f --- /dev/null +++ b/sandbox/core/src/provider.rs @@ -0,0 +1,16 @@ +//! Cohesive Sandbox Backend and Image Backend composition. + +use crate::{backend::SandboxBackend, image}; + +/// Supplies the backend components that share one image materialization domain. +/// +/// A provider keeps Sandbox lifecycle paired with the Image Backend that owns +/// its shared image materialization domain. Consumers compose a provider with +/// an optional Network Backend instead of pairing these components independently. +pub trait SandboxProvider { + /// Returns the Sandbox Backend owned by this provider. + fn backend(&self) -> &dyn SandboxBackend; + + /// Returns the Image Backend whose materialized images the Sandbox Backend consumes. + fn image_backend(&self) -> &dyn image::ImageBackend; +} diff --git a/sandbox/core/src/resource.rs b/sandbox/core/src/resource.rs new file mode 100644 index 0000000..e2233e2 --- /dev/null +++ b/sandbox/core/src/resource.rs @@ -0,0 +1,461 @@ +//! Kubernetes-style quantities used by Sandbox resource assignments. + +use std::{fmt, num::NonZeroU64, str::FromStr}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; +use thiserror::Error; + +const MILLICPUS_PER_CPU: u64 = 1_000; +const MAX_QUANTITY: u128 = i64::MAX as u128; + +const BINARY_SUFFIXES: [(u64, &str); 6] = [ + (1_u64 << 60, "Ei"), + (1_u64 << 50, "Pi"), + (1_u64 << 40, "Ti"), + (1_u64 << 30, "Gi"), + (1_u64 << 20, "Mi"), + (1_u64 << 10, "Ki"), +]; + +const DECIMAL_SUFFIXES: [(u64, &str); 6] = [ + (1_000_000_000_000_000_000, "E"), + (1_000_000_000_000_000, "P"), + (1_000_000_000_000, "T"), + (1_000_000_000, "G"), + (1_000_000, "M"), + (1_000, "k"), +]; + +/// Failure to parse or construct a resource quantity. +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum ParseQuantityError { + /// The quantity contains no value. + #[error("resource quantity must not be empty")] + Empty, + /// The quantity does not follow the supported Kubernetes syntax. + #[error("invalid resource quantity")] + Invalid, + /// Sandbox resource assignments must be greater than zero. + #[error("resource quantity must be greater than zero")] + NonPositive, + /// The value cannot be represented exactly in the resource's base unit. + #[error("resource quantity has unsupported precision")] + Precision, + /// The value exceeds the Kubernetes quantity range. + #[error("resource quantity is too large")] + Overflow, +} + +/// A positive CPU quantity stored as an exact number of millicpus. +/// +/// The accepted syntax follows Kubernetes CPU conventions, including `0.5`, +/// `500m`, and whole CPU values such as `4`. Precision finer than one +/// millicpu is rejected. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct CpuQuantity(NonZeroU64); + +impl CpuQuantity { + /// Creates a quantity from a non-zero number of millicpus. + #[must_use] + pub const fn from_millicpus(millicpus: NonZeroU64) -> Self { + Self(millicpus) + } + + /// Creates a quantity from a positive number of whole CPUs. + /// + /// # Errors + /// + /// Returns an error for zero or when conversion to millicpus overflows. + pub fn from_cpus(cpus: u64) -> Result { + let millicpus = cpus + .checked_mul(MILLICPUS_PER_CPU) + .ok_or(ParseQuantityError::Overflow)?; + Self::try_from_millicpus(millicpus) + } + + /// Creates a quantity from a positive number of millicpus. + /// + /// # Errors + /// + /// Returns an error when the value is zero or exceeds the Kubernetes + /// quantity range. + pub fn try_from_millicpus(millicpus: u64) -> Result { + if u128::from(millicpus) > MAX_QUANTITY { + return Err(ParseQuantityError::Overflow); + } + NonZeroU64::new(millicpus) + .map(Self) + .ok_or(ParseQuantityError::NonPositive) + } + + /// Returns the normalized value in millicpus. + #[must_use] + pub const fn millicpus(self) -> u64 { + self.0.get() + } + + /// Returns the value as whole CPUs when it has no fractional CPU. + #[must_use] + pub const fn whole_cpus(self) -> Option { + let millicpus = self.millicpus(); + if millicpus.is_multiple_of(MILLICPUS_PER_CPU) { + Some(millicpus / MILLICPUS_PER_CPU) + } else { + None + } + } +} + +impl FromStr for CpuQuantity { + type Err = ParseQuantityError; + + fn from_str(value: &str) -> Result { + let (number, suffix) = split_number_and_suffix(value)?; + let decimal = parse_decimal(number)?; + let multiplier = match suffix { + "" => u128::from(MILLICPUS_PER_CPU), + "m" => 1, + _ => return Err(ParseQuantityError::Invalid), + }; + let millicpus = decimal.to_exact_integer(multiplier, 0)?; + let millicpus = u64::try_from(millicpus).map_err(|_| ParseQuantityError::Overflow)?; + Self::try_from_millicpus(millicpus) + } +} + +impl TryFrom<&str> for CpuQuantity { + type Error = ParseQuantityError; + + fn try_from(value: &str) -> Result { + value.parse() + } +} + +impl fmt::Display for CpuQuantity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let millicpus = self.millicpus(); + if let Some(cpus) = self.whole_cpus() { + write!(formatter, "{cpus}") + } else { + write!(formatter, "{millicpus}m") + } + } +} + +impl Serialize for CpuQuantity { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for CpuQuantity { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + String::deserialize(deserializer)?.parse().map_err(D::Error::custom) + } +} + +/// A positive byte quantity using Kubernetes binary and decimal SI syntax. +/// +/// Values such as `256Mi`, `2Gi`, `1.5Gi`, `2G`, and plain byte counts are +/// accepted when they resolve to an exact whole number of bytes. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct ByteQuantity(NonZeroU64); + +impl ByteQuantity { + /// Creates a quantity from a non-zero number of bytes. + #[must_use] + pub const fn from_bytes(bytes: NonZeroU64) -> Self { + Self(bytes) + } + + /// Creates a quantity from a positive number of bytes. + /// + /// # Errors + /// + /// Returns an error when the value is zero or exceeds the Kubernetes + /// quantity range. + pub fn try_from_bytes(bytes: u64) -> Result { + if u128::from(bytes) > MAX_QUANTITY { + return Err(ParseQuantityError::Overflow); + } + NonZeroU64::new(bytes).map(Self).ok_or(ParseQuantityError::NonPositive) + } + + /// Creates a quantity from a positive number of mebibytes. + /// + /// # Errors + /// + /// Returns an error for zero or when conversion to bytes overflows. + pub fn from_mebibytes(mebibytes: u64) -> Result { + Self::from_units(mebibytes, 1_u64 << 20) + } + + /// Creates a quantity from a positive number of gibibytes. + /// + /// # Errors + /// + /// Returns an error for zero or when conversion to bytes overflows. + pub fn from_gibibytes(gibibytes: u64) -> Result { + Self::from_units(gibibytes, 1_u64 << 30) + } + + fn from_units(value: u64, multiplier: u64) -> Result { + let bytes = value.checked_mul(multiplier).ok_or(ParseQuantityError::Overflow)?; + Self::try_from_bytes(bytes) + } + + /// Returns the normalized value in bytes. + #[must_use] + pub const fn bytes(self) -> u64 { + self.0.get() + } + + /// Returns the value in whole mebibytes when it is exactly representable. + #[must_use] + pub const fn whole_mebibytes(self) -> Option { + const MEBIBYTE: u64 = 1_u64 << 20; + let bytes = self.bytes(); + if bytes.is_multiple_of(MEBIBYTE) { + Some(bytes / MEBIBYTE) + } else { + None + } + } +} + +impl FromStr for ByteQuantity { + type Err = ParseQuantityError; + + fn from_str(value: &str) -> Result { + let (number, suffix) = split_number_and_suffix(value)?; + let decimal = parse_decimal(number)?; + let (multiplier, exponent) = byte_scale(suffix)?; + let bytes = decimal.to_exact_integer(multiplier, exponent)?; + if bytes > MAX_QUANTITY { + return Err(ParseQuantityError::Overflow); + } + let bytes = u64::try_from(bytes).map_err(|_| ParseQuantityError::Overflow)?; + Self::try_from_bytes(bytes) + } +} + +impl TryFrom<&str> for ByteQuantity { + type Error = ParseQuantityError; + + fn try_from(value: &str) -> Result { + value.parse() + } +} + +impl fmt::Display for ByteQuantity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let bytes = self.bytes(); + let mut canonical = bytes.to_string(); + for (multiplier, suffix) in BINARY_SUFFIXES.into_iter().chain(DECIMAL_SUFFIXES) { + if bytes.is_multiple_of(multiplier) { + let candidate = format!("{}{suffix}", bytes / multiplier); + if candidate.len() < canonical.len() { + canonical = candidate; + } + } + } + formatter.write_str(&canonical) + } +} + +impl Serialize for ByteQuantity { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for ByteQuantity { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + String::deserialize(deserializer)?.parse().map_err(D::Error::custom) + } +} + +#[derive(Clone, Copy)] +struct Decimal { + mantissa: u128, + scale: u32, +} + +impl Decimal { + fn to_exact_integer(self, multiplier: u128, exponent: i32) -> Result { + let mut numerator = self + .mantissa + .checked_mul(multiplier) + .ok_or(ParseQuantityError::Overflow)?; + let mut denominator = checked_power(10, self.scale)?; + if exponent >= 0 { + numerator = numerator + .checked_mul(checked_power(10, exponent.unsigned_abs())?) + .ok_or(ParseQuantityError::Overflow)?; + } else { + denominator = denominator + .checked_mul(checked_power(10, exponent.unsigned_abs())?) + .ok_or(ParseQuantityError::Overflow)?; + } + if !numerator.is_multiple_of(denominator) { + return Err(ParseQuantityError::Precision); + } + let value = numerator / denominator; + if value == 0 { + return Err(ParseQuantityError::NonPositive); + } + Ok(value) + } +} + +fn split_number_and_suffix(value: &str) -> Result<(&str, &str), ParseQuantityError> { + if value.is_empty() { + return Err(ParseQuantityError::Empty); + } + let mut number_end = 0; + for (index, character) in value.char_indices() { + let sign = index == 0 && matches!(character, '+' | '-'); + if sign || character.is_ascii_digit() || character == '.' { + number_end = index + character.len_utf8(); + } else { + break; + } + } + if number_end == 0 { + return Err(ParseQuantityError::Invalid); + } + Ok(value.split_at(number_end)) +} + +fn parse_decimal(value: &str) -> Result { + let value = match value.strip_prefix('+') { + Some(value) => value, + None if value.starts_with('-') => return Err(ParseQuantityError::NonPositive), + None => value, + }; + let mut parts = value.split('.'); + let integer = parts.next().ok_or(ParseQuantityError::Invalid)?; + let fraction = parts.next(); + if parts.next().is_some() || (integer.is_empty() && fraction.is_none_or(str::is_empty)) { + return Err(ParseQuantityError::Invalid); + } + if !integer.bytes().all(|byte| byte.is_ascii_digit()) + || fraction.is_some_and(|digits| !digits.bytes().all(|byte| byte.is_ascii_digit())) + { + return Err(ParseQuantityError::Invalid); + } + let fraction = fraction.unwrap_or_default(); + let scale = u32::try_from(fraction.len()).map_err(|_| ParseQuantityError::Overflow)?; + let mut digits = String::with_capacity(integer.len() + fraction.len()); + digits.push_str(integer); + digits.push_str(fraction); + if digits.is_empty() { + return Err(ParseQuantityError::Invalid); + } + let mantissa = digits.parse().map_err(|_| ParseQuantityError::Overflow)?; + Ok(Decimal { mantissa, scale }) +} + +fn byte_scale(suffix: &str) -> Result<(u128, i32), ParseQuantityError> { + let multiplier = match suffix { + "" => 1, + "Ki" => 1_u128 << 10, + "Mi" => 1_u128 << 20, + "Gi" => 1_u128 << 30, + "Ti" => 1_u128 << 40, + "Pi" => 1_u128 << 50, + "Ei" => 1_u128 << 60, + "k" => 1_000, + "M" => 1_000_000, + "G" => 1_000_000_000, + "T" => 1_000_000_000_000, + "P" => 1_000_000_000_000_000, + "E" => 1_000_000_000_000_000_000, + _ => return decimal_exponent(suffix).map(|exponent| (1, exponent)), + }; + Ok((multiplier, 0)) +} + +fn decimal_exponent(suffix: &str) -> Result { + let exponent = suffix + .strip_prefix('e') + .or_else(|| suffix.strip_prefix('E')) + .ok_or(ParseQuantityError::Invalid)?; + if exponent.is_empty() { + return Err(ParseQuantityError::Invalid); + } + exponent.parse().map_err(|_| ParseQuantityError::Invalid) +} + +fn checked_power(base: u128, exponent: u32) -> Result { + base.checked_pow(exponent).ok_or(ParseQuantityError::Overflow) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use super::{ByteQuantity, CpuQuantity, ParseQuantityError}; + + #[test] + fn cpu_quantities_normalize_to_millicpus() { + let decimal: CpuQuantity = "0.5".parse().expect("decimal CPU should parse"); + let milli: CpuQuantity = "500m".parse().expect("millicpu should parse"); + let whole: CpuQuantity = "2.0".parse().expect("whole CPU should parse"); + + assert_eq!(decimal, milli); + assert_eq!(decimal.millicpus(), 500); + assert_eq!(decimal.to_string(), "500m"); + assert_eq!(whole.whole_cpus(), Some(2)); + assert_eq!(whole.to_string(), "2"); + } + + #[test] + fn cpu_quantities_reject_sub_millicpu_precision() { + assert_eq!("0.0001".parse::(), Err(ParseQuantityError::Precision)); + assert_eq!("0.5m".parse::(), Err(ParseQuantityError::Precision)); + } + + #[test] + fn byte_quantities_accept_binary_decimal_and_exponent_forms() { + let binary: ByteQuantity = "1.5Gi".parse().expect("binary quantity should parse"); + let binary_canonical: ByteQuantity = "1536Mi".parse().expect("canonical binary quantity should parse"); + let decimal: ByteQuantity = "2G".parse().expect("decimal quantity should parse"); + let exponent: ByteQuantity = "2e9".parse().expect("exponent quantity should parse"); + + assert_eq!(binary, binary_canonical); + assert_eq!(binary.to_string(), "1536Mi"); + assert_eq!(decimal, exponent); + assert_eq!(decimal.to_string(), "2G"); + } + + #[test] + fn byte_quantities_require_whole_positive_bytes() { + assert_eq!("0".parse::(), Err(ParseQuantityError::NonPositive)); + assert_eq!("0.5".parse::(), Err(ParseQuantityError::Precision)); + assert_eq!("-1Gi".parse::(), Err(ParseQuantityError::NonPositive)); + } + + #[test] + fn quantities_serialize_as_strings() { + let cpu: CpuQuantity = serde_json::from_str(r#""0.5""#).expect("CPU should deserialize"); + let bytes: ByteQuantity = serde_json::from_str(r#""1.5Gi""#).expect("bytes should deserialize"); + + assert_eq!(serde_json::to_string(&cpu).expect("CPU should serialize"), r#""500m""#); + assert_eq!( + serde_json::to_string(&bytes).expect("bytes should serialize"), + r#""1536Mi""# + ); + } +} diff --git a/sandbox/core/src/root_filesystem.rs b/sandbox/core/src/root_filesystem.rs new file mode 100644 index 0000000..968e0d5 --- /dev/null +++ b/sandbox/core/src/root_filesystem.rs @@ -0,0 +1,93 @@ +//! Writable root filesystem configuration. + +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; + +use crate::ByteQuantity; + +/// How an Image is materialized as a writable Sandbox root filesystem. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +#[non_exhaustive] +pub enum RootFilesystemMode { + /// Share an immutable Image and record Sandbox writes in a private layer. + Layered, + /// Materialize the complete Image into a private writable filesystem. + Direct, +} + +/// Deterministic set of root filesystem materialization modes. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct RootFilesystemModeSet(BTreeSet); + +impl RootFilesystemModeSet { + /// Reports whether no root filesystem mode is supported. + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Reports whether a root filesystem mode is supported. + #[must_use] + pub fn contains(&self, mode: RootFilesystemMode) -> bool { + self.0.contains(&mode) + } + + /// Iterates over supported modes in stable order. + pub fn iter(&self) -> impl Iterator + '_ { + self.0.iter().copied() + } + + /// Returns the modes present in both sets. + #[must_use] + pub fn intersection(&self, other: &Self) -> Self { + Self(self.0.intersection(&other.0).copied().collect()) + } +} + +impl From<[RootFilesystemMode; N]> for RootFilesystemModeSet { + fn from(modes: [RootFilesystemMode; N]) -> Self { + Self(modes.into_iter().collect()) + } +} + +/// Desired capacity and immutable materialization mode of a Sandbox root filesystem. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RootFilesystem { + capacity: ByteQuantity, + mode: RootFilesystemMode, +} + +impl RootFilesystem { + /// Creates a root filesystem configuration. + #[must_use] + pub const fn new(capacity: ByteQuantity, mode: RootFilesystemMode) -> Self { + Self { capacity, mode } + } + + /// Creates a capacity-efficient layered root filesystem. + #[must_use] + pub const fn layered(capacity: ByteQuantity) -> Self { + Self::new(capacity, RootFilesystemMode::Layered) + } + + /// Creates a fully materialized private root filesystem. + #[must_use] + pub const fn direct(capacity: ByteQuantity) -> Self { + Self::new(capacity, RootFilesystemMode::Direct) + } + + /// Returns the desired writable capacity. + #[must_use] + pub const fn capacity(self) -> ByteQuantity { + self.capacity + } + + /// Returns the immutable materialization mode. + #[must_use] + pub const fn mode(self) -> RootFilesystemMode { + self.mode + } +} diff --git a/sandbox/core/src/secret_store.rs b/sandbox/core/src/secret_store.rs new file mode 100644 index 0000000..9fcc49a --- /dev/null +++ b/sandbox/core/src/secret_store.rs @@ -0,0 +1,56 @@ +//! Host-owned secret references and material used by trusted mediators. + +use crate::{Error, LocalFuture}; +use zeroize::Zeroizing; + +/// An opaque reference to secret material kept outside sandbox state. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct SecretReference(String); + +impl SecretReference { + /// Restores an opaque reference issued by a Secret Store implementation. + /// + /// Application code normally obtains references from [`SecretStore::set`]. + #[must_use] + pub fn from_opaque(value: impl Into) -> Self { + Self(value.into()) + } + + /// Returns the opaque reference value. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Secret bytes that are zeroed when their host-side owner releases them. +pub struct SecretMaterial(Zeroizing>); + +impl SecretMaterial { + /// Takes ownership of secret bytes. + #[must_use] + pub fn new(value: Vec) -> Self { + Self(Zeroizing::new(value)) + } + + /// Borrows the secret bytes without creating another copy. + #[must_use] + pub fn expose(&self) -> &[u8] { + self.0.as_slice() + } +} + +impl std::fmt::Debug for SecretMaterial { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("SecretMaterial([REDACTED])") + } +} + +/// Stores secret material separately from sandbox disks and networking. +pub trait SecretStore { + /// Creates or replaces a named value and returns its opaque reference. + fn set<'a>(&'a self, name: &'a str, value: &'a [u8]) -> LocalFuture<'a, Result>; + + /// Resolves current material for an already-authorized host-mediated use. + fn resolve<'a>(&'a self, reference: &'a SecretReference) -> LocalFuture<'a, Result>; +} diff --git a/sandbox/core/src/service.rs b/sandbox/core/src/service.rs new file mode 100644 index 0000000..e525d16 --- /dev/null +++ b/sandbox/core/src/service.rs @@ -0,0 +1,1278 @@ +use std::rc::Rc; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{ + Hostname, PendingOperation, Platform, Sandbox, SandboxCapabilities, SandboxFeature, SandboxFeatureSet, SandboxName, + SandboxPath, SandboxResources, SandboxState, + backend::{SandboxBackend, SandboxBackendCapabilities}, + execution, file_transfer, image, + init::InitSystem, + mount::{Mount, MountKind}, + network, + progress::{Outcome, PendingSandbox, ProgressReporter, SandboxPhase, SandboxProgress}, + provider::SandboxProvider, + terminal, volume, +}; + +/// Errors produced by the backend-neutral Sandbox SDK. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ResourceKind { + /// A Sandbox. + Sandbox, + /// An Execution. + Execution, + /// A Volume. + Volume, + /// An immutable Image or cache entry. + Image, + /// A regular file inside a Sandbox. + File, + /// A live Network Backend attachment. + Network, + /// Host-owned secret material. + Secret, +} + +impl std::fmt::Display for ResourceKind { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::Sandbox => "Sandbox", + Self::Execution => "Execution", + Self::Volume => "Volume", + Self::Image => "Image", + Self::File => "file", + Self::Network => "Sandbox Network", + Self::Secret => "secret", + }) + } +} + +/// Stable category for programmatic Sandbox error handling. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ErrorKind { + /// A requested resource does not exist. + NotFound, + /// Caller input violates a generic contract. + InvalidRequest, + /// An immutable field changed. + Immutable, + /// A provider cannot support the requested operation or value. + Unsupported, + /// Execution start or observation failed. + Execution, + /// Host I/O failed. + Io, + /// A provider-specific operation failed. + Backend, +} + +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum Error { + /// A requested object does not exist. + #[error("{resource} {id:?} not found")] + NotFound { + /// Resource category. + resource: ResourceKind, + /// Stable name or identifier used for lookup. + id: String, + }, + /// An immutable field differs from the materialized Sandbox. + #[error("immutable Sandbox field changed: {0}")] + Immutable(&'static str), + /// A request violates the generic Sandbox contract. + #[error("invalid Sandbox field {field}: {reason}")] + Invalid { + /// Stable field or parameter path. + field: &'static str, + /// Human-readable invariant that was violated. + reason: &'static str, + }, + /// A requested Sandbox Feature is unavailable. + #[error("unsupported Sandbox Feature: {0:?}")] + UnsupportedFeature(SandboxFeature), + /// A requested Mount form is unavailable. + #[error("unsupported Sandbox Mount kind: {0:?}")] + UnsupportedMountKind(MountKind), + /// A requested root filesystem mode is unavailable. + #[error("unsupported Sandbox root filesystem mode: {0:?}")] + UnsupportedRootFilesystemMode(crate::RootFilesystemMode), + /// An Image Backend operation is unavailable for the requested Platform. + #[error("unsupported Image operation: {0:?}")] + UnsupportedImageOperation(image::ImageOperation), + /// An Image Backend operation does not accept the requested OCI source form. + #[error("unsupported Image Source kind {source_kind:?} for operation {operation:?}")] + UnsupportedImageSourceKind { + /// Operation being requested. + operation: image::ImageOperation, + /// OCI source form rejected by the Image Backend. + source_kind: image::ImageSourceKind, + }, + /// An Image Backend operation cannot materialize the requested root mode. + #[error("unsupported root filesystem mode {mode:?} for Image operation {operation:?}")] + UnsupportedImageRootFilesystemMode { + /// Operation being requested. + operation: image::ImageOperation, + /// Root filesystem mode rejected by the Image Backend. + mode: crate::RootFilesystemMode, + }, + /// A Sandbox Backend cannot materialize the requested Platform. + #[error("unsupported Sandbox Platform: {0}")] + UnsupportedPlatform(Platform), + /// A Sandbox Backend cannot represent a resource value exactly. + #[error("unsupported Sandbox resource value for {resource}: {value} ({reason})")] + UnsupportedResourceValue { + /// Stable field name in [`SandboxResources`]. + resource: &'static str, + /// Requested Kubernetes-style quantity. + value: String, + /// Backend-specific representation constraint. + reason: &'static str, + }, + /// A Sandbox Backend cannot apply a requested resource transition. + #[error("unsupported Sandbox resource change for {resource}: {current} -> {requested} ({reason})")] + UnsupportedResourceChange { + /// Stable field name in [`SandboxResources`]. + resource: &'static str, + /// Currently materialized quantity. + current: String, + /// Requested quantity. + requested: String, + /// Backend-specific transition constraint. + reason: &'static str, + }, + /// A Sandbox Backend returned an identity different from the create request. + #[error("Sandbox Backend returned ID {actual}; expected lifecycle-assigned ID {expected}")] + SandboxIdMismatch { + /// ID assigned before the create call. + expected: crate::SandboxId, + /// ID returned by the Sandbox Backend. + actual: crate::SandboxId, + }, + /// The service does not contain the Network Backend attached to a Sandbox. + #[error("configured service cannot operate Sandbox Network Backend {0}")] + NetworkBackendUnavailable(network::NetworkBackendId), + /// The Network Backend cannot use any endpoint offered by the Sandbox Backend. + #[error("Network Backend {0} has no compatible Sandbox Network endpoint")] + NetworkEndpointUnavailable(network::NetworkBackendId), + /// A Network Backend selected an endpoint not offered by the Sandbox Backend. + #[error("Sandbox Backend does not support selected Network endpoint {0:?}")] + UnsupportedNetworkEndpoint(network::NetworkEndpointSelection), + /// A Sandbox Backend opened a different endpoint than the immutable attachment. + #[error("Sandbox Backend opened Network endpoint {actual:?}; expected {expected:?}")] + NetworkEndpointMismatch { + /// Endpoint recorded when the Sandbox was created. + expected: network::NetworkEndpointSelection, + /// Endpoint returned by the Sandbox Backend. + actual: network::NetworkEndpointSelection, + }, + /// A resolved Image does not support the requested Sandbox Platform. + #[error("Image Platform {actual} does not satisfy requested Sandbox Platform {requested}")] + ImagePlatformMismatch { + /// Platform requested by the Sandbox specification. + requested: Box, + /// Platform reported by the built Image. + actual: Box, + }, + /// An asynchronous file operation failed. + #[error("{operation}: {source}")] + Io { + /// The operation being performed. + operation: &'static str, + /// The underlying I/O error. + #[source] + source: std::io::Error, + }, + /// An Execution failed before it could produce an exit status. + #[error("Execution {id} failed: {message}")] + ExecutionFailed { + /// Identity assigned before dispatch. + id: execution::ExecutionId, + /// Backend-neutral failure description. + message: String, + }, + /// An Execution event stream ended without an exit status. + #[error("Execution {id} event stream ended before completion")] + ExecutionStreamEnded { + /// Identity assigned before dispatch. + id: execution::ExecutionId, + }, + /// An observable operation ended without a value or Error. + #[error("Sandbox operation stream ended before completion")] + OperationStreamEnded, + /// A Sandbox SDK component failed. + #[error("{operation}: {source}")] + Component { + /// The operation that failed. + operation: &'static str, + /// The component error. + #[source] + source: Box, + }, + /// An implementation-specific failure. + #[error("Sandbox implementation error: {0}")] + Backend(String), +} + +impl Error { + /// Creates a structured not-found error. + #[must_use] + pub fn not_found(resource: ResourceKind, id: &(impl ToString + ?Sized)) -> Self { + Self::NotFound { + resource, + id: id.to_string(), + } + } + + /// Creates a structured invalid-request error. + #[must_use] + pub const fn invalid(field: &'static str, reason: &'static str) -> Self { + Self::Invalid { field, reason } + } + + /// Returns a stable category suitable for control-flow decisions. + #[must_use] + pub const fn kind(&self) -> ErrorKind { + match self { + Self::NotFound { .. } => ErrorKind::NotFound, + Self::Immutable(_) | Self::SandboxIdMismatch { .. } | Self::NetworkEndpointMismatch { .. } => { + ErrorKind::Immutable + } + Self::Invalid { .. } | Self::ImagePlatformMismatch { .. } => ErrorKind::InvalidRequest, + Self::UnsupportedFeature(_) + | Self::UnsupportedMountKind(_) + | Self::UnsupportedRootFilesystemMode(_) + | Self::UnsupportedImageOperation(_) + | Self::UnsupportedImageSourceKind { .. } + | Self::UnsupportedImageRootFilesystemMode { .. } + | Self::UnsupportedPlatform(_) + | Self::UnsupportedResourceValue { .. } + | Self::UnsupportedResourceChange { .. } + | Self::NetworkBackendUnavailable(_) + | Self::NetworkEndpointUnavailable(_) + | Self::UnsupportedNetworkEndpoint(_) => ErrorKind::Unsupported, + Self::Io { .. } => ErrorKind::Io, + Self::ExecutionFailed { .. } | Self::ExecutionStreamEnded { .. } => ErrorKind::Execution, + Self::OperationStreamEnded | Self::Backend(_) => ErrorKind::Backend, + Self::Component { source, .. } => source.kind(), + } + } + + /// Reports whether this error or its component cause is not-found. + #[must_use] + pub const fn is_not_found(&self) -> bool { + matches!(self.kind(), ErrorKind::NotFound) + } + + /// Returns the missing resource and lookup value, including through component context. + #[must_use] + pub fn not_found_target(&self) -> Option<(ResourceKind, &str)> { + match self { + Self::NotFound { resource, id } => Some((*resource, id)), + Self::Component { source, .. } => source.not_found_target(), + _ => None, + } + } + + fn component(operation: &'static str, source: Self) -> Self { + Self::Component { + operation, + source: Box::new(source), + } + } +} + +/// Controls what an owner does with a released Sandbox. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub enum RetentionPolicy { + /// Stop the Sandbox and retain its storage for later re-adoption. + #[default] + Retain, + /// Stop and delete the Sandbox. + Delete, +} + +/// Backend-neutral Sandbox configuration used by higher layers. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SandboxSpec { + /// Source of the immutable Image. + pub image: image::ImageSource, + /// Desired Sandbox Platform. + pub platform: Platform, + /// Desired mutable compute and writable root filesystem resources. + pub resources: SandboxResources, + /// Process responsible for initializing the Sandbox after backend setup. + #[serde(default)] + pub init_system: InitSystem, + /// Whether an owner retains the Sandbox when releasing it. + #[serde(default)] + pub retention_policy: RetentionPolicy, +} + +impl SandboxSpec { + /// Validates fields interpreted by the generic Sandbox layer. + /// + /// # Errors + /// + /// Returns an error when the Image Source is incomplete. + pub fn validate(&self) -> Result<(), Error> { + self.image.validate()?; + self.platform.validate()?; + Ok(()) + } + + /// Resolves relative Image Source paths against a caller-supplied directory. + #[must_use] + pub fn resolve_from(&self, source_directory: &std::path::Path) -> Self { + Self { + image: self.image.resolve_from(source_directory), + ..self.clone() + } + } +} + +/// Desired materialization of one named Sandbox. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EnsureSandboxRequest { + /// Stable Sandbox name. + name: SandboxName, + /// Hostname overriding the Sandbox name inside the guest. + hostname: Option, + /// Desired backend-neutral configuration. + spec: SandboxSpec, + /// Attachments contributed by the caller or a higher platform layer. + mounts: Vec, + /// Non-secret environment contributed by the caller or a higher platform layer. + environment: std::collections::BTreeMap, + /// Optional functionality required from the selected Sandbox Backend. + required_features: SandboxFeatureSet, +} + +impl EnsureSandboxRequest { + /// Creates a request without mounts or optional feature requirements. + #[must_use] + pub const fn new(name: SandboxName, spec: SandboxSpec) -> Self { + Self { + name, + hostname: None, + spec, + mounts: Vec::new(), + environment: std::collections::BTreeMap::new(), + required_features: SandboxFeatureSet::new(), + } + } + + /// Reports a hostname other than the Sandbox name inside the guest. + /// + /// The hostname is applied when the Sandbox is created; an existing Sandbox + /// keeps the hostname it was created with. + #[must_use] + pub fn with_hostname(mut self, hostname: Hostname) -> Self { + self.hostname = Some(hostname); + self + } + + /// Adds filesystem attachments materialized with the Sandbox. + #[must_use] + pub fn with_mounts(mut self, mounts: impl IntoIterator) -> Self { + self.mounts = mounts.into_iter().collect(); + self + } + + /// Adds non-secret environment inherited by image init and Sandbox Executions. + #[must_use] + pub fn with_environment(mut self, environment: impl IntoIterator) -> Self { + self.environment.extend(environment); + self + } + + /// Adds optional Backend Features required by the caller. + #[must_use] + pub fn requiring_features(mut self, features: impl Into) -> Self { + self.required_features.extend(&features.into()); + self + } + + /// Returns the stable Sandbox name. + #[must_use] + pub const fn name(&self) -> &SandboxName { + &self.name + } + + /// Returns the hostname the guest reports, defaulting to the Sandbox name. + #[must_use] + pub fn hostname(&self) -> Hostname { + self.hostname.clone().unwrap_or_else(|| self.name.clone().into()) + } + + /// Returns the desired Sandbox configuration. + #[must_use] + pub const fn spec(&self) -> &SandboxSpec { + &self.spec + } + + /// Mutably borrows the desired Sandbox configuration before dispatch. + #[must_use] + pub const fn spec_mut(&mut self) -> &mut SandboxSpec { + &mut self.spec + } + + /// Returns the desired filesystem attachments. + #[must_use] + pub fn mounts(&self) -> &[Mount] { + &self.mounts + } + + /// Returns the desired non-secret Sandbox environment. + #[must_use] + pub const fn environment(&self) -> &std::collections::BTreeMap { + &self.environment + } + + /// Returns explicit optional Feature requirements. + #[must_use] + pub const fn required_features(&self) -> &SandboxFeatureSet { + &self.required_features + } + + fn effective_required_features(&self) -> SandboxFeatureSet { + let mut required = self.required_features.clone(); + for mount in &self.mounts { + match mount { + Mount::Volume { .. } => required.insert(SandboxFeature::PersistentVolumes), + Mount::Bind { .. } | Mount::Tmpfs { .. } => {} + } + } + if self.spec.init_system == InitSystem::Image { + required.insert(SandboxFeature::ImageInit); + } + required + } +} + +/// Coordinates backend-neutral Sandbox operations. +#[derive(Clone)] +pub struct SandboxService { + provider: Rc, + network_backend: Option>, +} + +impl SandboxService { + /// Creates a service from a coherently paired Sandbox provider. + #[must_use] + pub fn new(provider: Rc) -> Self { + Self { + provider, + network_backend: None, + } + } + + fn backend(&self) -> &dyn SandboxBackend { + self.provider.backend() + } + + /// Reports consumer-visible functionality for one Sandbox Platform. + /// + /// # Errors + /// + /// Returns an error when the Provider does not support the Platform or the + /// configured Network Backend cannot use any offered endpoint. + pub async fn capabilities(&self, platform: &Platform) -> Result { + let capabilities = self.backend_capabilities(platform).await?; + let image_capabilities = self.image_backend_capabilities(platform).await?; + let network_available = match &self.network_backend { + Some(network) => { + let backend_id = network.id(); + network + .select_endpoint(&capabilities.network) + .ok_or(Error::NetworkEndpointUnavailable(backend_id))?; + true + } + None => false, + }; + let root_filesystem_modes = if image_capabilities.resolve.is_available() { + capabilities + .root_filesystems + .intersection(&image_capabilities.resolve.root_filesystem_modes) + } else { + crate::RootFilesystemModeSet::default() + }; + Ok(SandboxCapabilities::new( + capabilities.features, + capabilities.mounts, + root_filesystem_modes, + image_capabilities.prepared_image_export, + image_capabilities.prepared_image_import, + network_available, + )) + } + + async fn backend_capabilities(&self, platform: &Platform) -> Result { + self.backend() + .capabilities(platform) + .await + .map_err(|error| Error::component("discover Sandbox capabilities", error)) + } + + async fn image_backend_capabilities(&self, platform: &Platform) -> Result { + self.provider + .image_backend() + .capabilities(platform) + .await + .map_err(|error| Error::component("discover Image capabilities", error)) + } + + /// Attaches every newly materialized Sandbox to this Network Backend. + /// + /// The selected Backend identity becomes immutable when the Sandbox is + /// created. The Network Backend remains independently replaceable when + /// composing a service for a different Sandbox. + #[must_use] + pub fn with_network_backend(mut self, network_backend: Rc) -> Self { + self.network_backend = Some(network_backend); + self + } + + /// Creates or re-adopts a named Sandbox and returns an operation that + /// yields progress until the Sandbox is ready or provisioning fails. + /// + /// Awaiting the returned value discards progress and returns the terminal + /// result. Polling it as a stream exposes every provisioning event. + #[must_use] + pub fn ensure<'a>(&'a self, request: &'a EnsureSandboxRequest) -> PendingSandbox<'a> { + PendingOperation::with_events(|events| Box::pin(async move { self.ensure_inner(request, &events).await })) + } + + /// Exports a prepared Image into an opaque Provider-owned artifact. + #[must_use] + pub fn export_prepared_image<'a>( + &'a self, + request: &'a image::ResolveRequest, + destination: &'a std::path::Path, + ) -> PendingOperation<'a, image::PreparedImage> { + PendingOperation::with_events(|events| { + Box::pin(async move { + request.validate()?; + self.require_image_operation(image::ImageOperation::PreparedImageExport, request) + .await?; + let span = events.start_phase(SandboxPhase::ImagePrepare).await; + let prepared = self + .provider + .image_backend() + .export_prepared_image(request, destination) + .forward(&events) + .await?; + prepared.validate_for(request)?; + span.complete().await; + Ok(prepared) + }) + }) + } + + /// Imports a prepared Image into this Provider's materialization domain. + #[must_use] + pub fn import_prepared_image<'a>( + &'a self, + request: &'a image::ResolveRequest, + source: &'a std::path::Path, + ) -> PendingOperation<'a, image::PreparedImage> { + PendingOperation::with_events(|events| { + Box::pin(async move { + request.validate()?; + self.require_image_operation(image::ImageOperation::PreparedImageImport, request) + .await?; + let span = events.start_phase(SandboxPhase::ImagePrepare).await; + let prepared = self + .provider + .image_backend() + .import_prepared_image(request, source) + .forward(&events) + .await?; + prepared.validate_for(request)?; + span.complete().await; + Ok(prepared) + }) + }) + } + + async fn ensure_inner( + &self, + request: &EnsureSandboxRequest, + events: &ProgressReporter, + ) -> Result { + let retention_policy = request.spec.retention_policy; + let sandbox = self.ensure_sandbox(request, events).await?; + Ok(SandboxHandle { + service: self.clone(), + sandbox, + retention_policy, + }) + } + + async fn ensure_sandbox( + &self, + request: &EnsureSandboxRequest, + events: &ProgressReporter, + ) -> Result { + let span = events.start_phase(SandboxPhase::Validate).await; + request.spec.validate()?; + validate_environment(&request.environment)?; + span.end(Outcome::Completed).await; + + let span = events.start_phase(SandboxPhase::Lookup).await; + match self.backend().find(&request.name).await { + Ok(sandbox) => { + if !sandbox.image.platform.satisfies(&request.spec.platform) { + return Err(Error::Immutable("platform")); + } + if sandbox.image.source != request.spec.image { + return Err(Error::Immutable("image")); + } + if sandbox.resources.root_filesystem().mode() != request.spec.resources.root_filesystem().mode() { + return Err(Error::Immutable("resources.rootFilesystem.mode")); + } + if sandbox.init_system != request.spec.init_system { + return Err(Error::Immutable("initSystem")); + } + if sandbox.mounts != request.mounts { + return Err(Error::Immutable("mounts")); + } + span.end(Outcome::Reused).await; + let network = self + .require_backend_features_observed(&sandbox.image.platform, request, events) + .await?; + if sandbox.network != network { + return Err(Error::Immutable("network")); + } + let sandbox = self.ensure_updates(sandbox, request, events).await?; + self.ensure_running(sandbox, events).await + } + Err(error) if error.is_not_found() => { + span.end(Outcome::Completed).await; + self.require_backend_features_observed(&request.spec.platform, request, events) + .await?; + let image = self.resolve_image(request, events).await?; + let network = self + .require_backend_features_observed(&image.platform, request, events) + .await?; + let span = events.start_phase(SandboxPhase::SandboxCreate).await; + let id = crate::SandboxId::generate(); + let sandbox = self + .backend() + .create(crate::backend::CreateSandboxRequest { + id: id.clone(), + name: request.name.clone(), + hostname: request.hostname(), + image, + resources: request.spec.resources, + init_system: request.spec.init_system, + mounts: request.mounts.clone(), + environment: request.environment.clone(), + network, + }) + .forward(events) + .await + .map_err(|error| Error::component("create Sandbox", error))?; + if sandbox.id != id { + return Err(Error::SandboxIdMismatch { + expected: id, + actual: sandbox.id, + }); + } + span.end(Outcome::Completed).await; + self.ensure_running(sandbox, events).await + } + Err(error) => Err(Error::component("find Sandbox", error)), + } + } + + async fn resolve_image( + &self, + request: &EnsureSandboxRequest, + events: &ProgressReporter, + ) -> Result { + let span = events.start_phase(SandboxPhase::ImageResolve).await; + let image = self + .provider + .image_backend() + .resolve(&image::ResolveRequest { + source: request.spec.image.clone(), + platform: request.spec.platform.clone(), + root_filesystem_mode: request.spec.resources.root_filesystem().mode(), + }) + .forward(events) + .await + .map_err(|error| Error::component("resolve Sandbox Image", error))?; + image.validate()?; + if !image.platform.satisfies(&request.spec.platform) { + return Err(Error::ImagePlatformMismatch { + requested: Box::new(request.spec.platform.clone()), + actual: Box::new(image.platform), + }); + } + span.end(Outcome::Completed).await; + Ok(image) + } + + /// Converges mutable Sandbox settings in one `SandboxUpdate` phase. + async fn ensure_updates( + &self, + sandbox: Sandbox, + request: &EnsureSandboxRequest, + events: &ProgressReporter, + ) -> Result { + let span = events.start_phase(SandboxPhase::SandboxUpdate).await; + let progress = events.steps(); + let (sandbox, environment) = self + .ensure_environment(sandbox, &request.environment, events, &progress) + .await?; + let (sandbox, resources) = self + .ensure_resources(sandbox, request.spec.resources, events, &progress) + .await?; + let outcome = if environment == Outcome::Reused && resources == Outcome::Reused { + Outcome::Reused + } else { + Outcome::Completed + }; + span.end(outcome).await; + Ok(sandbox) + } + + async fn ensure_resources( + &self, + sandbox: Sandbox, + resources: SandboxResources, + events: &ProgressReporter, + progress: &SandboxProgress, + ) -> Result<(Sandbox, Outcome), Error> { + if sandbox.resources == resources { + return Ok((sandbox, Outcome::Reused)); + } + let step = progress.start_step("Update Sandbox resources").await; + let sandbox = self + .backend() + .update_resources(&sandbox.id, resources) + .forward(events) + .await + .map_err(|error| Error::component("update Sandbox resources", error))?; + step.complete().await; + Ok((sandbox, Outcome::Completed)) + } + + async fn ensure_environment( + &self, + sandbox: Sandbox, + environment: &std::collections::BTreeMap, + events: &ProgressReporter, + progress: &SandboxProgress, + ) -> Result<(Sandbox, Outcome), Error> { + if &sandbox.environment == environment { + return Ok((sandbox, Outcome::Reused)); + } + let step = progress.start_step("Update Sandbox environment").await; + if sandbox.state != SandboxState::Stopped { + self.backend() + .stop(&sandbox.id) + .await + .map_err(|error| Error::component("stop Sandbox for environment update", error))?; + if let Some(network_backend) = self.network_backend_for(&sandbox)? { + network_backend + .stop(&sandbox.id) + .await + .map_err(|error| Error::component("stop Sandbox Network for environment update", error))?; + } + } + let sandbox = self + .backend() + .update_environment(&sandbox.id, environment.clone()) + .forward(events) + .await + .map_err(|error| Error::component("update Sandbox environment", error))?; + step.complete().await; + Ok((sandbox, Outcome::Completed)) + } + + async fn require_backend_features_observed( + &self, + platform: &Platform, + request: &EnsureSandboxRequest, + events: &ProgressReporter, + ) -> Result, Error> { + let span = events.start_phase(SandboxPhase::FeatureDiscovery).await; + let network = self.require_backend_features(platform, request).await?; + span.end(Outcome::Completed).await; + Ok(network) + } + + async fn require_backend_features( + &self, + platform: &Platform, + request: &EnsureSandboxRequest, + ) -> Result, Error> { + let capabilities = self.backend_capabilities(platform).await?; + for feature in request.effective_required_features().iter() { + if !capabilities.features.contains(feature) { + return Err(Error::UnsupportedFeature(feature)); + } + } + for mount in &request.mounts { + if !capabilities.mounts.contains(mount.kind()) { + return Err(Error::UnsupportedMountKind(mount.kind())); + } + } + let root_filesystem_mode = request.spec.resources.root_filesystem().mode(); + if !capabilities.root_filesystems.contains(root_filesystem_mode) { + return Err(Error::UnsupportedRootFilesystemMode(root_filesystem_mode)); + } + self.require_image_operation( + image::ImageOperation::Resolve, + &image::ResolveRequest { + source: request.spec.image.clone(), + platform: platform.clone(), + root_filesystem_mode, + }, + ) + .await?; + let Some(network_backend) = &self.network_backend else { + return Ok(None); + }; + let backend_id = network_backend.id(); + let selection = network_backend + .select_endpoint(&capabilities.network) + .ok_or_else(|| Error::NetworkEndpointUnavailable(backend_id.clone()))?; + if !capabilities.network.supports(&selection) { + return Err(Error::UnsupportedNetworkEndpoint(selection)); + } + Ok(Some(network::NetworkAttachment { + backend: backend_id, + endpoint: selection, + })) + } + + async fn require_image_operation( + &self, + operation: image::ImageOperation, + request: &image::ResolveRequest, + ) -> Result<(), Error> { + let capabilities = self.image_backend_capabilities(&request.platform).await?; + let operation_capabilities = match operation { + image::ImageOperation::Resolve => &capabilities.resolve, + image::ImageOperation::PreparedImageExport => &capabilities.prepared_image_export, + image::ImageOperation::PreparedImageImport => &capabilities.prepared_image_import, + }; + if !operation_capabilities.is_available() { + return Err(Error::UnsupportedImageOperation(operation)); + } + let source = request.source.kind(); + if !operation_capabilities.sources.contains(source) { + return Err(Error::UnsupportedImageSourceKind { + operation, + source_kind: source, + }); + } + if !operation_capabilities + .root_filesystem_modes + .contains(request.root_filesystem_mode) + { + return Err(Error::UnsupportedImageRootFilesystemMode { + operation, + mode: request.root_filesystem_mode, + }); + } + Ok(()) + } + + /// Returns the materialized Sandbox for a stable name. + /// + /// # Errors + /// + /// Returns an error when the Sandbox does not exist or the Backend fails. + pub async fn inspect(&self, name: &SandboxName) -> Result { + self.backend() + .find(name) + .await + .map_err(|error| Error::component("find Sandbox", error)) + } + + /// Opens an effect-free Handle for an already materialized Sandbox. + /// + /// This never creates, starts, updates, or reconnects the Sandbox. Callers + /// that own only in-Sandbox effects use it after a lifecycle owner has + /// persisted the exact Sandbox identity. + /// + /// # Errors + /// + /// Returns an error when the Sandbox does not exist or cannot be inspected. + pub async fn open(&self, id: &crate::SandboxId, retention_policy: RetentionPolicy) -> Result { + let sandbox = self + .backend() + .inspect(id) + .await + .map_err(|error| Error::component("inspect Sandbox", error))?; + Ok(SandboxHandle { + service: self.clone(), + sandbox, + retention_policy, + }) + } + + /// Stops and deletes a named Sandbox if it exists. + /// + /// # Errors + /// + /// Returns an error when a lifecycle component fails. + pub async fn delete(&self, name: &SandboxName) -> Result<(), Error> { + self.release(name, RetentionPolicy::Delete).await + } + + /// Stops a named Sandbox and its Network if it exists, keeping its identity, + /// root filesystem, Volumes and Network attachment for a later [`Self::ensure`]. + /// + /// # Errors + /// + /// Returns an error when a lifecycle component fails. + pub async fn stop(&self, name: &SandboxName) -> Result<(), Error> { + self.release(name, RetentionPolicy::Retain).await + } + + /// Stops a Sandbox and retains or deletes its materialized resources. + /// + /// # Errors + /// + /// Returns an error when a lifecycle component fails. + pub async fn release(&self, name: &SandboxName, retention: RetentionPolicy) -> Result<(), Error> { + let sandbox = match self.backend().find(name).await { + Ok(sandbox) => sandbox, + Err(error) if error.is_not_found() => return Ok(()), + Err(error) => return Err(Error::component("find Sandbox", error)), + }; + self.release_sandbox(sandbox, retention).await + } + + async fn release_by_id(&self, id: &crate::SandboxId, retention: RetentionPolicy) -> Result<(), Error> { + let sandbox = match self.backend().inspect(id).await { + Ok(sandbox) => sandbox, + Err(error) if error.is_not_found() => return Ok(()), + Err(error) => return Err(Error::component("inspect Sandbox", error)), + }; + self.release_sandbox(sandbox, retention).await + } + + async fn release_sandbox(&self, sandbox: Sandbox, retention: RetentionPolicy) -> Result<(), Error> { + let network_backend = self.network_backend_for(&sandbox)?; + + if sandbox.state != SandboxState::Stopped { + self.backend() + .stop(&sandbox.id) + .await + .map_err(|error| Error::component("stop Sandbox", error))?; + } + if let Some(network_backend) = network_backend { + network_backend + .stop(&sandbox.id) + .await + .map_err(|error| Error::component("stop Sandbox Network", error))?; + } + if retention == RetentionPolicy::Delete { + if let Some(network_backend) = network_backend { + network_backend + .delete(&sandbox.id) + .await + .map_err(|error| Error::component("delete Sandbox Network", error))?; + } + self.backend() + .delete(&sandbox.id) + .await + .map_err(|error| Error::component("delete Sandbox", error))?; + } + Ok(()) + } + + async fn ensure_running(&self, sandbox: Sandbox, events: &ProgressReporter) -> Result { + if let Some(network_backend) = self.network_backend_for(&sandbox)? + && !network_backend.is_running(&sandbox.id) + { + let span = events.start_phase(SandboxPhase::NetworkStart).await; + let endpoint = self + .backend() + .open_network_endpoint(&sandbox.id) + .await + .map_err(|error| Error::component("open Sandbox Network endpoint", error))?; + let expected = sandbox + .network + .as_ref() + .ok_or(Error::invalid("network", "attachment is missing"))? + .endpoint + .clone(); + let actual = endpoint.selection(); + if actual != expected { + return Err(Error::NetworkEndpointMismatch { expected, actual }); + } + network_backend + .start(network::StartNetworkRequest { + sandbox_id: sandbox.id.clone(), + sandbox_name: sandbox.name.clone(), + endpoint, + }) + .await + .map_err(|error| Error::component("start Sandbox Network", error))?; + span.end(Outcome::Completed).await; + } + let span = events.start_phase(SandboxPhase::SandboxStart).await; + if sandbox.state != SandboxState::Running { + self.backend() + .start(&sandbox.id) + .forward(events) + .await + .map_err(|error| Error::component("start Sandbox", error))?; + } + let outcome = if sandbox.state == SandboxState::Running { + Outcome::Reused + } else { + Outcome::Completed + }; + span.end(outcome).await; + let span = events.start_phase(SandboxPhase::Inspect).await; + let sandbox = self + .backend() + .inspect(&sandbox.id) + .await + .map_err(|error| Error::component("inspect Sandbox", error))?; + span.end(Outcome::Completed).await; + Ok(sandbox) + } + + fn network_backend_for(&self, sandbox: &Sandbox) -> Result>, Error> { + let Some(attachment) = &sandbox.network else { + return Ok(None); + }; + let backend = self + .network_backend + .as_ref() + .filter(|backend| backend.id() == attachment.backend) + .ok_or_else(|| Error::NetworkBackendUnavailable(attachment.backend.clone()))?; + Ok(Some(backend)) + } + + /// Creates or returns a named Volume. + /// + /// # Errors + /// + /// Returns an error when the provider cannot materialize the Volume. + pub async fn ensure_volume(&self, request: volume::EnsureVolumeRequest) -> Result { + self.backend().ensure_volume(request).await + } + + /// Finds a named Volume. + /// + /// # Errors + /// + /// Returns an error when the Volume does not exist or the provider fails. + pub async fn find_volume(&self, name: &volume::VolumeName) -> Result { + self.backend().find_volume(name).await + } + + /// Deletes a Volume by identifier. + /// + /// # Errors + /// + /// Returns an error when the Volume does not exist or the provider fails. + pub async fn delete_volume(&self, id: &volume::VolumeId) -> Result<(), Error> { + self.backend().delete_volume(id).await + } +} + +fn validate_environment(environment: &std::collections::BTreeMap) -> Result<(), Error> { + for (name, value) in environment { + let valid_name = !name.is_empty() + && name.bytes().enumerate().all(|(index, byte)| { + byte == b'_' || byte.is_ascii_alphabetic() || (index > 0 && byte.is_ascii_digit()) + }); + if !valid_name || value.contains('\0') { + return Err(Error::invalid( + "environment", + "names must be portable environment variables and values must not contain NUL", + )); + } + } + Ok(()) +} + +/// A ready Sandbox and the operations scoped to its immutable lifecycle ID. +/// +/// The Handle owns the retention policy selected during `ensure`, but dropping +/// it performs no asynchronous cleanup. Call [`Self::release`] or +/// [`Self::delete`] explicitly. Its observed Sandbox snapshot may become stale +/// and changes only when [`Self::refresh`] succeeds. +#[must_use = "release or delete the Sandbox Handle explicitly when its work is complete"] +pub struct SandboxHandle { + service: SandboxService, + sandbox: Sandbox, + retention_policy: RetentionPolicy, +} + +impl std::fmt::Debug for SandboxHandle { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SandboxHandle") + .field("sandbox", &self.sandbox) + .field("retention_policy", &self.retention_policy) + .finish_non_exhaustive() + } +} + +impl SandboxHandle { + /// Returns the immutable lifecycle identifier targeted by this Handle. + #[must_use] + pub const fn id(&self) -> &crate::SandboxId { + &self.sandbox.id + } + + /// Returns the stable lookup name of this Sandbox. + #[must_use] + pub const fn name(&self) -> &SandboxName { + &self.sandbox.name + } + + /// Returns the latest observed Sandbox snapshot cached by this Handle. + #[must_use] + pub const fn snapshot(&self) -> &Sandbox { + &self.sandbox + } + + /// Refreshes and returns the Sandbox snapshot. + /// + /// # Errors + /// + /// Returns an error when the provider cannot inspect the Sandbox. + pub async fn refresh(&mut self) -> Result<&Sandbox, Error> { + self.sandbox = self + .service + .backend() + .inspect(&self.sandbox.id) + .await + .map_err(|error| Error::component("inspect Sandbox", error))?; + Ok(&self.sandbox) + } + + /// Starts an addressable Execution and returns its live event stream. + /// + /// # Errors + /// + /// Returns an error when the provider cannot start the Execution. + pub async fn start_execution( + &self, + request: execution::StartExecutionRequest, + ) -> Result { + self.service.backend().start_execution(&self.sandbox.id, request).await + } + + /// Starts an addressable Execution and collects its output in memory. + /// + /// Use [`Self::start_execution`] to stream unbounded output. + /// + /// # Errors + /// + /// Returns an error when execution fails or its event stream ends unexpectedly. + pub async fn run_execution(&self, spec: execution::ExecutionSpec) -> Result { + let request = execution::StartExecutionRequest::new(spec); + self.start_execution(request).await?.collect().await + } + + /// Starts a terminal Execution with programmatic input and output. + /// + /// # Errors + /// + /// Returns an error when the provider cannot start the terminal Execution. + pub async fn start_terminal_execution( + &self, + request: terminal::StartTerminalExecutionRequest, + ) -> Result { + self.service + .backend() + .start_terminal_execution(&self.sandbox.id, request) + .await + } + + /// Attaches the caller's terminal to an interactive Execution. + /// + /// # Errors + /// + /// Returns an error when the provider cannot attach the terminal. + pub async fn attach_terminal( + &self, + request: terminal::AttachTerminalRequest, + ) -> Result { + self.service.backend().attach_terminal(&self.sandbox.id, request).await + } + + /// Requests graceful termination of a live Execution. + /// + /// # Errors + /// + /// Returns an error when the Execution cannot be found or terminated. + pub async fn terminate_execution(&self, id: &execution::ExecutionId) -> Result<(), Error> { + self.service.backend().terminate_execution(&self.sandbox.id, id).await + } + + /// Forces a live Execution to stop. + /// + /// # Errors + /// + /// Returns an error when the Execution cannot be found or killed. + pub async fn kill_execution(&self, id: &execution::ExecutionId) -> Result<(), Error> { + self.service.backend().kill_execution(&self.sandbox.id, id).await + } + + /// Opens a regular Sandbox file for streamed reading. + /// + /// # Errors + /// + /// Returns an error when the provider cannot open the file. + pub async fn read_file(&self, path: &SandboxPath) -> Result { + self.service.backend().read_file(&self.sandbox.id, path).await + } + + /// Creates or replaces a regular Sandbox file from a byte stream. + /// + /// The replacement is atomic: a process in the Sandbox reading the path concurrently sees + /// either the previous file or the complete new one. A replaced regular file keeps its mode + /// and ownership. + /// + /// # Errors + /// + /// Returns an error when the provider cannot write the file. + pub async fn write_file(&self, path: &SandboxPath, contents: file_transfer::ByteReader) -> Result<(), Error> { + self.service + .backend() + .write_file(&self.sandbox.id, path, contents) + .await + } + + /// Stops this Sandbox and applies the retention policy used to ensure it. + /// + /// # Errors + /// + /// Returns an error when the Sandbox or its Network cannot be released. + pub async fn release(self) -> Result<(), Error> { + self.service + .release_by_id(&self.sandbox.id, self.retention_policy) + .await + } + + /// Stops and deletes this Sandbox regardless of its retention policy. + /// + /// # Errors + /// + /// Returns an error when the Sandbox or its Network cannot be deleted. + pub async fn delete(self) -> Result<(), Error> { + self.service + .release_by_id(&self.sandbox.id, RetentionPolicy::Delete) + .await + } +} diff --git a/sandbox/core/src/terminal.rs b/sandbox/core/src/terminal.rs new file mode 100644 index 0000000..acedc1f --- /dev/null +++ b/sandbox/core/src/terminal.rs @@ -0,0 +1,218 @@ +//! Bidirectional terminal execution inside a running Sandbox. + +use std::{fmt, pin::Pin, rc::Rc}; + +use bytes::Bytes; +use futures_core::Stream; + +use crate::{Error, LocalFuture, execution}; + +/// Character-cell dimensions of a terminal. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TerminalSize { + rows: u16, + columns: u16, +} + +impl TerminalSize { + /// Conventional terminal size used when the host cannot report one. + pub const DEFAULT: Self = Self { rows: 24, columns: 80 }; + + /// Creates non-zero terminal dimensions. + /// + /// # Errors + /// + /// Returns an error when either dimension is zero. + pub const fn new(rows: u16, columns: u16) -> Result { + if rows == 0 || columns == 0 { + return Err(InvalidTerminalSize); + } + Ok(Self { rows, columns }) + } + + /// Returns the terminal height in character cells. + #[must_use] + pub const fn rows(self) -> u16 { + self.rows + } + + /// Returns the terminal width in character cells. + #[must_use] + pub const fn columns(self) -> u16 { + self.columns + } +} + +impl Default for TerminalSize { + fn default() -> Self { + Self::DEFAULT + } +} + +/// A terminal size contained a zero dimension. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct InvalidTerminalSize; + +impl fmt::Display for InvalidTerminalSize { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("terminal rows and columns must both be non-zero") + } +} + +impl std::error::Error for InvalidTerminalSize {} + +/// Starts a terminal-backed Execution in a running Sandbox. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StartTerminalExecutionRequest { + /// Backend-neutral identity assigned before dispatch. + id: execution::ExecutionId, + /// Desired command and process environment. + spec: execution::ExecutionSpec, + /// Dimensions assigned before the process starts. + initial_size: TerminalSize, +} + +impl StartTerminalExecutionRequest { + /// Creates a request with a freshly assigned Execution identifier. + #[must_use] + pub fn new(spec: execution::ExecutionSpec, initial_size: TerminalSize) -> Self { + Self { + id: execution::ExecutionId::generate(), + spec, + initial_size, + } + } + + /// Returns the assigned Execution identifier. + #[must_use] + pub const fn id(&self) -> &execution::ExecutionId { + &self.id + } + + /// Returns the desired command and process environment. + #[must_use] + pub const fn spec(&self) -> &execution::ExecutionSpec { + &self.spec + } + + /// Returns the terminal dimensions assigned before process start. + #[must_use] + pub const fn initial_size(&self) -> TerminalSize { + self.initial_size + } + + /// Decomposes the request for a Backend implementation. + #[must_use] + pub fn into_parts(self) -> (execution::ExecutionId, execution::ExecutionSpec, TerminalSize) { + (self.id, self.spec, self.initial_size) + } +} + +/// Attaches the caller's terminal to an interactive Execution in a running Sandbox. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AttachTerminalRequest { + spec: execution::ExecutionSpec, + detach_keys: Option, +} + +impl AttachTerminalRequest { + /// Creates a terminal attachment request. + #[must_use] + pub const fn new(spec: execution::ExecutionSpec) -> Self { + Self { + spec, + detach_keys: None, + } + } + + /// Returns the desired command and process environment. + #[must_use] + pub const fn spec(&self) -> &execution::ExecutionSpec { + &self.spec + } + + /// Overrides the Provider's default local detach key sequence. + /// + /// The value uses Docker-style syntax, such as `"ctrl-]"` or `"ctrl-b,d"`. + #[must_use] + pub fn with_detach_keys(mut self, keys: impl Into) -> Self { + self.detach_keys = Some(keys.into()); + self + } + + /// Returns an explicit local detach key sequence, when configured. + #[must_use] + pub fn detach_keys(&self) -> Option<&str> { + self.detach_keys.as_deref() + } + + /// Decomposes the request for a Backend implementation. + #[must_use] + pub fn into_parts(self) -> (execution::ExecutionSpec, Option) { + (self.spec, self.detach_keys) + } +} + +/// Terminal condition observed when an attachment ends. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum TerminalAttachOutcome { + /// The attached Execution exited. + Exited(execution::ExitStatus), + /// The caller detached before an Execution exit was observed. + Detached, +} + +/// Input and terminal controls tied to one live addressable terminal Execution. +pub trait TerminalControl { + /// Writes raw terminal input bytes, applying backend transport backpressure. + fn write_input(&self, bytes: Bytes) -> LocalFuture<'_, Result<(), Error>>; + + /// Closes the input stream and sends end-of-file to the process. + fn close_input(&self) -> LocalFuture<'_, Result<(), Error>>; + + /// Changes the terminal's character-cell dimensions. + fn resize(&self, size: TerminalSize) -> LocalFuture<'_, Result<(), Error>>; +} + +/// One event from a live terminal Execution. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum TerminalEvent { + /// The process started. + Started { + /// Backend-reported process identifier, when available. + process_id: Option, + }, + /// Raw terminal output bytes. A terminal combines standard output and error. + Output(Bytes), + /// The process exited. + Exited(execution::ExitStatus), + /// The process could not be started. + Failed { + /// Backend-neutral failure description. + message: String, + }, +} + +/// A non-`Send` stream of events from one live terminal Execution. +pub type TerminalEventStream = Pin>>>; + +/// A newly started, addressable terminal Execution. +pub struct StartedTerminalExecution { + /// Identifier accepted by the common Execution termination operations. + pub id: execution::ExecutionId, + /// Bidirectional controls for the transient terminal connection. + pub control: Rc, + /// Events emitted until the Execution exits or fails. + pub events: TerminalEventStream, +} + +impl fmt::Debug for StartedTerminalExecution { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("StartedTerminalExecution") + .field("id", &self.id) + .finish_non_exhaustive() + } +} diff --git a/sandbox/core/src/volume.rs b/sandbox/core/src/volume.rs new file mode 100644 index 0000000..9e84681 --- /dev/null +++ b/sandbox/core/src/volume.rs @@ -0,0 +1,135 @@ +//! Mutable storage with a lifecycle independent of a Sandbox image. + +use std::fmt; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{InvalidSandboxName, SandboxName}; + +/// Stable, portable caller-provided Volume name. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct VolumeName(SandboxName); + +impl VolumeName { + /// Creates a name accepted by local and Kubernetes-oriented providers. + /// + /// # Errors + /// + /// Returns an error unless the value is a lowercase DNS label. + pub fn new(value: impl Into) -> Result { + SandboxName::new(value).map(Self).map_err(InvalidVolumeName) + } + + /// Returns the name as text. + #[must_use] + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl AsRef for VolumeName { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for VolumeName { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl std::str::FromStr for VolumeName { + type Err = InvalidVolumeName; + + fn from_str(value: &str) -> Result { + Self::new(value) + } +} + +/// A Volume name was not a portable lowercase DNS label. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InvalidVolumeName(InvalidSandboxName); + +impl fmt::Display for InvalidVolumeName { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "invalid Volume name: {}", self.0) + } +} + +impl std::error::Error for InvalidVolumeName {} + +/// Identifies a Volume independently of backend-specific identifiers. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct VolumeId(Uuid); + +impl VolumeId { + fn generate() -> Self { + Self(Uuid::new_v4()) + } + + /// Returns the UUID representation. + #[must_use] + pub const fn as_uuid(&self) -> &Uuid { + &self.0 + } +} + +impl std::fmt::Display for VolumeId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(formatter) + } +} + +impl std::str::FromStr for VolumeId { + type Err = uuid::Error; + + fn from_str(value: &str) -> Result { + value.parse().map(Self) + } +} + +/// Backend-neutral view of a materialized Volume. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Volume { + /// Stable backend-neutral identifier. + pub id: VolumeId, + /// Stable caller-provided name. + pub name: VolumeName, +} + +/// Inputs used to ensure a Volume exists. +#[derive(Debug, Eq, PartialEq)] +pub struct EnsureVolumeRequest { + /// Backend-neutral identity used when a new Volume is materialized. + id: VolumeId, + /// Stable caller-provided name. + pub name: VolumeName, +} + +impl EnsureVolumeRequest { + /// Creates a request with a freshly assigned Volume identifier. + #[must_use] + pub fn new(name: VolumeName) -> Self { + Self { + id: VolumeId::generate(), + name, + } + } + + /// Returns the identifier to use if the Volume is newly materialized. + #[must_use] + pub const fn id(&self) -> &VolumeId { + &self.id + } + + /// Decomposes the request for a Backend implementation. + #[must_use] + pub fn into_parts(self) -> (VolumeId, VolumeName) { + (self.id, self.name) + } +} diff --git a/sandbox/core/tests/architecture.rs b/sandbox/core/tests/architecture.rs new file mode 100644 index 0000000..7acfa80 --- /dev/null +++ b/sandbox/core/tests/architecture.rs @@ -0,0 +1,13 @@ +#![allow(clippy::expect_used)] + +use std::{fs, path::Path}; + +#[test] +fn sandbox_crate_has_no_higher_layer_or_concrete_backend_dependencies() { + let manifest = fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")) + .expect("sandbox Cargo.toml should be readable"); + + assert!(!manifest.contains("agent =")); + assert!(!manifest.contains("agent-runtime =")); + assert!(!manifest.contains("sandbox-microsandbox =")); +} diff --git a/sandbox/core/tests/image.rs b/sandbox/core/tests/image.rs new file mode 100644 index 0000000..a4eae8b --- /dev/null +++ b/sandbox/core/tests/image.rs @@ -0,0 +1,137 @@ +#![allow(clippy::expect_used)] + +use std::path::{Path, PathBuf}; + +use sandbox::{ + Platform, RootFilesystemMode, RootFilesystemModeSet, + image::{ + ImageBackend as _, ImageOperationCapabilities, ImageSource, ImageSourceKind, ImageSourceKindSet, ResolveRequest, + }, + memory::MemoryImageBackend, +}; + +#[test] +fn build_source_resolves_only_its_context_from_the_manifest_directory() { + let source = ImageSource::Build { + context: PathBuf::from("image"), + dockerfile: PathBuf::from("containers/Agent.Dockerfile"), + target: Some("runtime".into()), + }; + + assert_eq!( + source.resolve_from(Path::new("/manifests/worker")), + ImageSource::Build { + context: PathBuf::from("/manifests/worker/image"), + dockerfile: PathBuf::from("containers/Agent.Dockerfile"), + target: Some("runtime".into()), + } + ); +} + +#[test] +fn reference_source_is_independent_of_the_manifest_directory() { + let source = ImageSource::Reference { + reference: "ghcr.io/example/agent@sha256:1234".to_string(), + }; + + assert_eq!(source.resolve_from(Path::new("/manifests/worker")), source); +} + +#[test] +fn image_source_has_an_explicit_serialized_variant() { + let build: ImageSource = serde_json::from_value(serde_json::json!({ + "type": "build", + "context": ".", + "dockerfile": "Dockerfile", + "target": "minimal" + })) + .expect("build source should decode"); + let reference: ImageSource = serde_json::from_value(serde_json::json!({ + "type": "reference", + "reference": "docker.io/library/alpine:3.22" + })) + .expect("reference source should decode"); + + assert!(matches!( + build, + ImageSource::Build { + target: Some(target), + .. + } if target == "minimal" + )); + assert!(matches!(reference, ImageSource::Reference { .. })); +} + +#[test] +fn build_source_rejects_an_empty_target() { + let source = ImageSource::Build { + context: PathBuf::from("."), + dockerfile: PathBuf::from("Dockerfile"), + target: Some(" ".into()), + }; + + assert!(source.validate().is_err()); +} + +#[test] +fn image_operation_requires_at_least_one_source_and_mode() { + assert!( + ImageOperationCapabilities::new([ImageSourceKind::Reference].into(), [RootFilesystemMode::Direct].into(),) + .is_available() + ); + assert!( + !ImageOperationCapabilities::new([ImageSourceKind::Reference].into(), RootFilesystemModeSet::default(),) + .is_available() + ); + assert!( + !ImageOperationCapabilities::new(ImageSourceKindSet::default(), [RootFilesystemMode::Direct].into(),) + .is_available() + ); +} + +#[tokio::test(flavor = "local")] +async fn memory_images_have_deterministic_sha256_manifest_digests() { + let backend = MemoryImageBackend; + let request = ResolveRequest { + source: ImageSource::Reference { + reference: "registry.example/worker:latest".to_string(), + }, + platform: Platform::new("linux", "amd64"), + root_filesystem_mode: RootFilesystemMode::Layered, + }; + + let first = backend.resolve(&request).await.expect("image should resolve"); + let second = backend.resolve(&request).await.expect("image should resolve again"); + let digest = first + .manifest_digest + .strip_prefix("sha256:") + .expect("manifest digest should use SHA-256"); + + assert_eq!(first.manifest_digest, second.manifest_digest); + assert_eq!(digest.len(), 64); + assert!( + digest + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + ); + + let different_source = backend + .resolve(&ResolveRequest { + source: ImageSource::Reference { + reference: "registry.example/other:latest".to_string(), + }, + platform: request.platform.clone(), + root_filesystem_mode: request.root_filesystem_mode, + }) + .await + .expect("another image should resolve"); + let different_platform = backend + .resolve(&ResolveRequest { + platform: Platform::new("linux", "arm64"), + ..request + }) + .await + .expect("image should resolve for another platform"); + assert_ne!(first.manifest_digest, different_source.manifest_digest); + assert_ne!(first.manifest_digest, different_platform.manifest_digest); +} diff --git a/sandbox/core/tests/name.rs b/sandbox/core/tests/name.rs new file mode 100644 index 0000000..434bd5a --- /dev/null +++ b/sandbox/core/tests/name.rs @@ -0,0 +1,70 @@ +#![allow(clippy::expect_used)] + +use sandbox::{Hostname, InvalidSandboxName, MAX_SANDBOX_NAME_BYTES, SandboxName}; + +#[test] +fn accepts_portable_dns_labels() { + for value in ["a", "0", "worker-1", "a1-b2"] { + let name = SandboxName::new(value).expect("portable name should be accepted"); + assert_eq!(name.as_str(), value); + } + + let maximum = "a".repeat(MAX_SANDBOX_NAME_BYTES); + assert_eq!( + SandboxName::new(&maximum) + .expect("maximum-length name should be accepted") + .as_str(), + maximum + ); +} + +#[test] +fn rejects_names_outside_the_portable_subset() { + assert_eq!(SandboxName::new(""), Err(InvalidSandboxName::Empty)); + assert_eq!( + SandboxName::new("a".repeat(MAX_SANDBOX_NAME_BYTES + 1)), + Err(InvalidSandboxName::TooLong { + length: MAX_SANDBOX_NAME_BYTES + 1 + }) + ); + + for value in ["Worker", "worker_name", "worker.name", "-worker", "worker-", "wørker"] { + assert_eq!( + SandboxName::new(value), + Err(InvalidSandboxName::InvalidSyntax), + "{value:?} should be rejected" + ); + } +} + +#[test] +fn deserialization_cannot_bypass_validation() { + let name: SandboxName = serde_json::from_str(r#""worker-1""#).expect("valid name should deserialize"); + assert_eq!(name.as_str(), "worker-1"); + assert!(serde_json::from_str::(r#""Worker_1""#).is_err()); + assert_eq!( + serde_json::to_string(&name).expect("name should serialize"), + r#""worker-1""# + ); +} + +#[test] +fn hostnames_share_the_portable_label_rules() { + let hostname = Hostname::new("agent-test").expect("portable hostname should be accepted"); + assert_eq!(hostname.as_str(), "agent-test"); + assert_eq!(hostname.to_string(), "agent-test"); + assert_eq!( + Hostname::from(SandboxName::new("worker").expect("valid name")).as_str(), + "worker" + ); + + for value in ["", "Worker", "worker_name", "worker.example.com"] { + let error = Hostname::new(value).expect_err("non-label hostname should be rejected"); + assert!( + error.to_string().starts_with("hostname is not a portable DNS label: "), + "{value:?}: {error}" + ); + } + assert!(Hostname::new("a".repeat(MAX_SANDBOX_NAME_BYTES + 1)).is_err()); + assert!(serde_json::from_str::(r#""Worker""#).is_err()); +} diff --git a/sandbox/core/tests/network.rs b/sandbox/core/tests/network.rs new file mode 100644 index 0000000..0cefdd1 --- /dev/null +++ b/sandbox/core/tests/network.rs @@ -0,0 +1,269 @@ +#![allow(clippy::expect_used)] + +use std::{ + future::poll_fn, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + num::{NonZeroU32, NonZeroUsize}, +}; + +use bytes::Bytes; +use sandbox::{ + memory, + network::{ + InterfaceAddress, MacAddress, NetworkControlMessage, NetworkEndpoint, NetworkEndpointError, + NetworkInterfaceConfiguration, NetworkPacket, NetworkPacketBatch, NetworkTransferProgress, PacketEndpoint, + PacketEndpointProperties, PacketMedium, + }, +}; + +const fn nonzero(value: usize) -> NonZeroUsize { + NonZeroUsize::new(value).expect("test value should be nonzero") +} + +fn properties() -> PacketEndpointProperties { + PacketEndpointProperties::new( + PacketMedium::Ethernet, + interface_configuration(), + NonZeroU32::new(1_514).expect("test frame length should be nonzero"), + ) +} + +fn interface_configuration() -> NetworkInterfaceConfiguration { + NetworkInterfaceConfiguration::new( + MacAddress::new([0x02, 0, 0, 0, 0, 2]), + NonZeroU32::new(1_500).expect("test MTU should be nonzero"), + vec![ + InterfaceAddress::new(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 2)), 24).expect("valid test IPv4 prefix"), + InterfaceAddress::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 128).expect("valid test IPv6 prefix"), + ], + vec![IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1))], + vec![IpAddr::V4(Ipv4Addr::new(192, 0, 2, 53))], + ) +} + +fn packet(payload: &'static [u8]) -> NetworkPacket { + let mut bytes = vec![0; 14]; + bytes.extend_from_slice(payload); + NetworkPacket::new(Bytes::from(bytes)) +} + +#[tokio::test(flavor = "local")] +async fn packet_endpoint_moves_bounded_batches_in_both_directions() { + let (endpoint, mut peer) = memory::packet_endpoint_pair(nonzero(4), properties()); + assert_eq!(endpoint.properties(), &properties()); + let mut parts = endpoint.into_parts(); + + peer.emit_from_sandbox(packet(b"sandbox-one")) + .await + .expect("emit first Sandbox packet"); + peer.emit_from_sandbox(packet(b"sandbox-two")) + .await + .expect("emit second Sandbox packet"); + peer.emit_from_sandbox(packet(b"sandbox-three")) + .await + .expect("emit third Sandbox packet"); + + let mut received = NetworkPacketBatch::new(nonzero(2)); + let progress = poll_fn(|context| parts.from_sandbox.as_mut().poll_receive(context, &mut received)) + .await + .expect("receive Sandbox packets"); + assert_eq!(progress, NetworkTransferProgress::Items(nonzero(2))); + assert_eq!(received.pop_front(), Some(packet(b"sandbox-one"))); + assert_eq!(received.pop_front(), Some(packet(b"sandbox-two"))); + let progress = poll_fn(|context| parts.from_sandbox.as_mut().poll_receive(context, &mut received)) + .await + .expect("receive remaining Sandbox packet"); + assert_eq!(progress, NetworkTransferProgress::Items(nonzero(1))); + assert_eq!(received.pop_front(), Some(packet(b"sandbox-three"))); + + let mut pending = NetworkPacketBatch::new(nonzero(4)); + pending.push_back(packet(b"network-one")).expect("queue first packet"); + pending.push_back(packet(b"network-two")).expect("queue second packet"); + let progress = poll_fn(|context| parts.to_sandbox.as_mut().poll_send(context, &mut pending)) + .await + .expect("send packets to Sandbox"); + assert_eq!(progress, NetworkTransferProgress::Items(nonzero(2))); + assert!(pending.is_empty()); + poll_fn(|context| parts.to_sandbox.as_mut().poll_flush(context)) + .await + .expect("flush packets to Sandbox"); + assert_eq!(peer.receive_for_sandbox().await, Some(packet(b"network-one"))); + assert_eq!(peer.receive_for_sandbox().await, Some(packet(b"network-two"))); +} + +#[tokio::test(flavor = "local")] +async fn receive_wakes_when_the_sandbox_emits_a_packet() { + let (endpoint, peer) = memory::packet_endpoint_pair(nonzero(1), properties()); + let mut receiver = endpoint.into_parts().from_sandbox; + let mut batch = NetworkPacketBatch::new(nonzero(1)); + + let receive = poll_fn(|context| receiver.as_mut().poll_receive(context, &mut batch)); + let emit = peer.emit_from_sandbox(packet(b"wake")); + let (progress, emitted) = tokio::join!(receive, emit); + + emitted.expect("emit packet after receiver registered its waker"); + assert_eq!( + progress.expect("receiver should wake after a packet arrives"), + NetworkTransferProgress::Items(nonzero(1)) + ); + assert_eq!(batch.pop_front(), Some(packet(b"wake"))); +} + +#[tokio::test(flavor = "local")] +async fn send_reports_partial_progress_and_wakes_after_backpressure() { + let (endpoint, mut peer) = memory::packet_endpoint_pair(nonzero(1), properties()); + let mut sender = endpoint.into_parts().to_sandbox; + let mut pending = NetworkPacketBatch::new(nonzero(2)); + pending.push_back(packet(b"first")).expect("queue first packet"); + pending.push_back(packet(b"second")).expect("queue second packet"); + + let first_progress = poll_fn(|context| sender.as_mut().poll_send(context, &mut pending)) + .await + .expect("send until bounded channel becomes full"); + assert_eq!(first_progress, NetworkTransferProgress::Items(nonzero(1))); + assert_eq!(pending.len(), 1); + + let send_after_capacity = poll_fn(|context| sender.as_mut().poll_send(context, &mut pending)); + let receive_first = peer.receive_for_sandbox(); + let (second_progress, first) = tokio::join!(send_after_capacity, receive_first); + + assert_eq!(first, Some(packet(b"first"))); + assert_eq!( + second_progress.expect("sender should wake after capacity becomes available"), + NetworkTransferProgress::Items(nonzero(1)) + ); + assert!(pending.is_empty()); + assert_eq!(peer.receive_for_sandbox().await, Some(packet(b"second"))); +} + +#[tokio::test(flavor = "local")] +async fn receive_drains_queued_packets_before_reporting_closure() { + let (endpoint, mut peer) = memory::packet_endpoint_pair(nonzero(2), properties()); + peer.emit_from_sandbox(packet(b"last")) + .await + .expect("emit final packet"); + peer.close_from_sandbox(); + let mut receiver = endpoint.into_parts().from_sandbox; + let mut batch = NetworkPacketBatch::new(nonzero(2)); + + let progress = poll_fn(|context| receiver.as_mut().poll_receive(context, &mut batch)) + .await + .expect("drain final packet"); + assert_eq!(progress, NetworkTransferProgress::Items(nonzero(1))); + assert_eq!(batch.pop_front(), Some(packet(b"last"))); + + let progress = poll_fn(|context| receiver.as_mut().poll_receive(context, &mut batch)) + .await + .expect("observe closure after draining"); + assert_eq!(progress, NetworkTransferProgress::Closed); +} + +#[tokio::test(flavor = "local")] +async fn closed_destination_retains_unaccepted_packets() { + let (endpoint, mut peer) = memory::packet_endpoint_pair(nonzero(1), properties()); + peer.close_to_sandbox(); + let mut sender = endpoint.into_parts().to_sandbox; + let mut pending = NetworkPacketBatch::new(nonzero(1)); + pending.push_back(packet(b"retained")).expect("queue packet"); + + let progress = poll_fn(|context| sender.as_mut().poll_send(context, &mut pending)) + .await + .expect("closed destination is a normal endpoint state"); + + assert_eq!(progress, NetworkTransferProgress::Closed); + assert_eq!(pending.pop_front(), Some(packet(b"retained"))); +} + +#[tokio::test(flavor = "local")] +async fn endpoint_enforces_its_maximum_packet_length() { + let short_properties = PacketEndpointProperties::new( + PacketMedium::Ethernet, + interface_configuration(), + NonZeroU32::new(14).expect("test frame length should be nonzero"), + ); + let (endpoint, peer) = memory::packet_endpoint_pair(nonzero(1), short_properties); + let oversized = packet(b"x"); + + let error = peer + .emit_from_sandbox(oversized.clone()) + .await + .expect_err("Sandbox packet should respect the endpoint maximum"); + assert!(matches!( + error, + NetworkEndpointError::PacketTooLarge { + actual: 15, + maximum: 14 + } + )); + + let mut sender = endpoint.into_parts().to_sandbox; + let mut pending = NetworkPacketBatch::new(nonzero(1)); + pending.push_back(oversized.clone()).expect("queue oversized packet"); + let error = poll_fn(|context| sender.as_mut().poll_send(context, &mut pending)) + .await + .expect_err("Network Backend packet should respect the endpoint maximum"); + assert!(matches!(error, NetworkEndpointError::PacketTooLarge { .. })); + assert_eq!(pending.pop_front(), Some(oversized)); +} + +#[tokio::test(flavor = "local")] +async fn sender_shutdown_is_idempotent_and_prevents_new_packets() { + let (endpoint, _peer) = memory::packet_endpoint_pair(nonzero(1), properties()); + let mut sender = endpoint.into_parts().to_sandbox; + + poll_fn(|context| sender.as_mut().poll_shutdown(context)) + .await + .expect("first shutdown"); + poll_fn(|context| sender.as_mut().poll_shutdown(context)) + .await + .expect("idempotent shutdown"); + + let mut pending = NetworkPacketBatch::new(nonzero(1)); + let unsent = packet(b"after-shutdown"); + pending.push_back(unsent.clone()).expect("queue packet after shutdown"); + let progress = poll_fn(|context| sender.as_mut().poll_send(context, &mut pending)) + .await + .expect("closed sender should report its state"); + assert_eq!(progress, NetworkTransferProgress::Closed); + assert_eq!(pending.pop_front(), Some(unsent)); +} + +#[test] +fn live_endpoint_can_move_to_a_dedicated_thread() { + fn assert_send() {} + + assert_send::(); + assert_send::(); +} + +#[test] +fn packet_properties_expose_complete_immutable_interface_configuration() { + let properties = properties(); + let interface = properties.interface(); + + assert_eq!(properties.medium(), PacketMedium::Ethernet); + assert_eq!(properties.maximum_frame_length().get(), 1_514); + assert_eq!(interface.mac_address().to_string(), "02:00:00:00:00:02"); + assert_eq!(interface.mtu().get(), 1_500); + assert_eq!(interface.addresses().len(), 2); + assert_eq!(interface.default_gateways(), [IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1))]); + assert_eq!(interface.dns_servers(), [IpAddr::V4(Ipv4Addr::new(192, 0, 2, 53))]); +} + +#[test] +fn interface_address_deserialization_rejects_invalid_prefixes() { + let error = serde_json::from_str::(r#"{"address":"192.0.2.2","prefixLength":33}"#) + .expect_err("IPv4 prefix above 32 should be rejected"); + + assert!(error.to_string().contains("prefix length 33 is invalid")); +} + +#[test] +fn opaque_control_message_debug_output_does_not_expose_payload() { + let message = NetworkControlMessage::new(b"must-not-appear"); + + let debug = format!("{message:?}"); + + assert!(!debug.contains("must-not-appear")); + assert!(debug.contains("15")); +} diff --git a/sandbox/core/tests/platform.rs b/sandbox/core/tests/platform.rs new file mode 100644 index 0000000..886e928 --- /dev/null +++ b/sandbox/core/tests/platform.rs @@ -0,0 +1,28 @@ +use std::collections::BTreeSet; + +use sandbox::Platform; + +#[test] +fn resolved_platform_may_refine_unspecified_oci_constraints() { + let requested = Platform::new("windows", "amd64"); + let mut resolved = requested.clone(); + resolved.os_version = Some("10.0.26100.0".into()); + resolved.os_features = BTreeSet::from(["win32k".into()]); + + assert!(resolved.satisfies(&requested)); + assert!(!requested.satisfies(&resolved)); +} + +#[test] +fn resolved_platform_must_preserve_requested_constraints() { + let mut requested = Platform::new("linux", "arm64"); + requested.variant = Some("v8".into()); + requested.os_features = BTreeSet::from(["feature-a".into()]); + let mut wrong_variant = requested.clone(); + wrong_variant.variant = Some("v9".into()); + let mut missing_feature = requested.clone(); + missing_feature.os_features.clear(); + + assert!(!wrong_variant.satisfies(&requested)); + assert!(!missing_feature.satisfies(&requested)); +} diff --git a/sandbox/core/tests/secret_store.rs b/sandbox/core/tests/secret_store.rs new file mode 100644 index 0000000..c81a436 --- /dev/null +++ b/sandbox/core/tests/secret_store.rs @@ -0,0 +1,54 @@ +#![allow(clippy::expect_used)] + +use sandbox::{ + Error, + memory::MemorySecretStore, + secret_store::{SecretMaterial, SecretStore as _}, +}; + +#[tokio::test(flavor = "local")] +async fn rotating_a_secret_preserves_its_reference() { + let store = MemorySecretStore::default(); + let reference = store.set("provider-token", b"first").await.expect("store secret"); + + assert_eq!( + store + .resolve(&reference) + .await + .expect("resolve initial secret") + .expose(), + b"first" + ); + + let rotated = store.set("provider-token", b"second").await.expect("rotate secret"); + + assert_eq!(rotated, reference); + assert_eq!( + store + .resolve(&reference) + .await + .expect("resolve rotated secret") + .expose(), + b"second" + ); +} + +#[tokio::test(flavor = "local")] +async fn resolving_an_unknown_reference_fails_closed() { + let store = MemorySecretStore::default(); + let reference = store.set("known", b"value").await.expect("store secret"); + let unknown = sandbox::secret_store::SecretReference::from_opaque("unknown"); + + assert!(matches!(store.resolve(&unknown).await, Err(Error::NotFound { .. }))); + assert_ne!(reference, unknown); +} + +#[test] +fn secret_material_debug_output_is_redacted() { + let material = SecretMaterial::new(b"must-not-appear".to_vec()); + + let debug = format!("{material:?}"); + + assert_eq!(debug, "SecretMaterial([REDACTED])"); + assert!(!debug.contains("must-not-appear")); +} diff --git a/sandbox/core/tests/service.rs b/sandbox/core/tests/service.rs new file mode 100644 index 0000000..3f27221 --- /dev/null +++ b/sandbox/core/tests/service.rs @@ -0,0 +1,925 @@ +#![allow(clippy::expect_used)] + +use std::{future::poll_fn, io::Cursor, path::PathBuf, pin::Pin, rc::Rc}; + +use bytes::Bytes; +use futures_core::Stream as _; +use sandbox::{ + ByteQuantity, CpuQuantity, EnsureSandboxRequest, Error, Hostname, OperationEvent, PendingOperation, Platform, + ProgressEvent, RetentionPolicy, RootFilesystem, RootFilesystemMode, SandboxFeature, SandboxName, SandboxPath, + SandboxPhase, SandboxResources, SandboxService, SandboxSpec, + execution::{ExecutionEvent, ExecutionSpec, ExitStatus, StartExecutionRequest}, + image::{self, ImageSource}, + memory, + network::{NetworkAttachment, NetworkBackend as _, NetworkBackendId, NetworkEndpointSelection, PacketMedium}, + terminal::{StartTerminalExecutionRequest, TerminalEvent, TerminalSize}, + volume::{EnsureVolumeRequest, VolumeName}, +}; +use tokio::io::AsyncReadExt as _; + +fn spec() -> SandboxSpec { + SandboxSpec { + image: ImageSource::Build { + context: PathBuf::from("."), + dockerfile: PathBuf::from("Dockerfile"), + target: None, + }, + platform: Platform::native("linux"), + resources: resources("2", "1Gi", "4Gi"), + init_system: sandbox::init::InitSystem::Backend, + retention_policy: RetentionPolicy::Retain, + } +} + +fn request() -> EnsureSandboxRequest { + EnsureSandboxRequest::new(sandbox_name(), spec()) +} + +fn sandbox_name() -> SandboxName { + SandboxName::new("worker").expect("test Sandbox name should be valid") +} + +fn resources(cpu: &str, memory: &str, root_filesystem: &str) -> SandboxResources { + SandboxResources::new( + cpu.parse::().expect("test CPU should be valid"), + memory.parse::().expect("test memory should be valid"), + RootFilesystem::layered( + root_filesystem + .parse::() + .expect("test root filesystem should be valid"), + ), + ) +} + +#[tokio::test(flavor = "local")] +async fn ensure_defaults_the_hostname_to_the_sandbox_name() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend); + let request = request(); + assert_eq!(request.hostname().as_str(), "worker"); + + let sandbox = service.ensure(&request).await.expect("ensure"); + assert_eq!(sandbox.snapshot().hostname, Hostname::from(sandbox_name())); +} + +#[tokio::test(flavor = "local")] +async fn ensure_creates_the_sandbox_with_an_explicit_hostname() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend); + let hostname = Hostname::new("agent-test").expect("test hostname should be valid"); + let request = request().with_hostname(hostname.clone()); + assert_eq!(request.hostname(), hostname); + + let sandbox = service.ensure(&request).await.expect("ensure"); + assert_eq!(sandbox.name(), &sandbox_name()); + assert_eq!(sandbox.snapshot().hostname, hostname); + assert_eq!( + service.inspect(request.name()).await.expect("inspect").hostname, + hostname + ); +} + +#[tokio::test(flavor = "local")] +async fn ensure_is_idempotent_and_runs_one_sandbox() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend.clone()); + let request = request(); + + let first = service.ensure(&request).await.expect("first ensure"); + let second = service.ensure(&request).await.expect("second ensure"); + + assert_eq!(first.snapshot(), second.snapshot()); + assert!(first.snapshot().network.is_none()); + assert_eq!(backend.count(), 1); +} + +#[tokio::test(flavor = "local")] +async fn component_errors_preserve_stable_kind_and_resource_identity() { + let service = SandboxService::new(Rc::new(memory::Provider::new())); + let name = SandboxName::new("missing").expect("test Sandbox name should be valid"); + + let error = service.inspect(&name).await.expect_err("Sandbox should be absent"); + + assert_eq!(error.kind(), sandbox::ErrorKind::NotFound); + assert_eq!( + error.not_found_target(), + Some((sandbox::ResourceKind::Sandbox, "missing")) + ); +} + +#[tokio::test(flavor = "local")] +async fn capabilities_describe_the_configured_consumer_surface() { + let platform = Platform::native("linux"); + let without_network = SandboxService::new(Rc::new(memory::Provider::new())) + .capabilities(&platform) + .await + .expect("Provider capabilities should be available"); + assert!(without_network.features().contains(SandboxFeature::Execution)); + assert!(without_network.prepared_image_export().sources.iter().next().is_none()); + assert!(without_network.prepared_image_import().sources.iter().next().is_none()); + assert!(!without_network.network_available()); + + let with_network = SandboxService::new(Rc::new(memory::Provider::new())).with_network_backend(Rc::new( + memory::NetworkBackend::for_endpoint("network-a", NetworkEndpointSelection::Packet(PacketMedium::Ethernet)), + )); + assert!( + with_network + .capabilities(&platform) + .await + .expect("compatible Network should be discoverable") + .network_available() + ); +} + +#[tokio::test(flavor = "local")] +async fn memory_provider_rejects_prepared_image_transport_with_typed_errors() { + let service = SandboxService::new(Rc::new(memory::Provider::new())); + let spec = spec(); + let request = image::ResolveRequest { + source: spec.image, + platform: spec.platform, + root_filesystem_mode: spec.resources.root_filesystem().mode(), + }; + + let export_error = service + .export_prepared_image(&request, std::path::Path::new("unused")) + .await + .expect_err("memory Image Backend should not export prepared images"); + assert!(matches!( + export_error, + Error::UnsupportedImageOperation(image::ImageOperation::PreparedImageExport) + )); + + let import_error = service + .import_prepared_image(&request, std::path::Path::new("unused")) + .await + .expect_err("memory Image Backend should not import prepared images"); + assert!(matches!( + import_error, + Error::UnsupportedImageOperation(image::ImageOperation::PreparedImageImport) + )); +} + +struct PreparedImageBackend { + prepared: image::PreparedImage, +} + +impl image::ImageBackend for PreparedImageBackend { + fn capabilities<'a>( + &'a self, + _platform: &'a Platform, + ) -> sandbox::LocalFuture<'a, Result> { + Box::pin(async { + let operation = image::ImageOperationCapabilities::new( + [image::ImageSourceKind::Build].into(), + [RootFilesystemMode::Layered].into(), + ); + Ok(image::ImageBackendCapabilities::new( + operation.clone(), + operation.clone(), + operation, + )) + }) + } + + fn resolve<'a>(&'a self, _request: &'a image::ResolveRequest) -> PendingOperation<'a, image::ResolvedImage> { + let image = self.prepared.image.clone(); + PendingOperation::run(move |_progress| Box::pin(async move { Ok(image) })) + } + + fn export_prepared_image<'a>( + &'a self, + _request: &'a image::ResolveRequest, + _destination: &'a std::path::Path, + ) -> PendingOperation<'a, image::PreparedImage> { + completed_prepared_image(self.prepared.clone()) + } + + fn import_prepared_image<'a>( + &'a self, + _request: &'a image::ResolveRequest, + _source: &'a std::path::Path, + ) -> PendingOperation<'a, image::PreparedImage> { + completed_prepared_image(self.prepared.clone()) + } +} + +fn completed_prepared_image(prepared: image::PreparedImage) -> PendingOperation<'static, image::PreparedImage> { + PendingOperation::run(move |_progress| Box::pin(async move { Ok(prepared) })) +} + +struct PreparedImageProvider { + sandbox_backend: memory::Provider, + image_backend: PreparedImageBackend, +} + +impl sandbox::provider::SandboxProvider for PreparedImageProvider { + fn backend(&self) -> &dyn sandbox::backend::SandboxBackend { + &self.sandbox_backend + } + + fn image_backend(&self) -> &dyn image::ImageBackend { + &self.image_backend + } +} + +#[tokio::test(flavor = "local")] +async fn service_accepts_prepared_image_metadata_coherent_with_the_request() { + let spec = spec(); + let request = image::ResolveRequest { + source: spec.image, + platform: spec.platform, + root_filesystem_mode: spec.resources.root_filesystem().mode(), + }; + let prepared = image::PreparedImage { + image: image::ResolvedImage { + source: request.source.clone(), + platform: request.platform.clone(), + manifest_digest: "sha256:resolved-oci-manifest".into(), + }, + root_filesystem_mode: request.root_filesystem_mode, + artifact_digest: "sha256:opaque-artifact".into(), + virtual_size_bytes: 4096, + }; + let service = SandboxService::new(Rc::new(PreparedImageProvider { + sandbox_backend: memory::Provider::new(), + image_backend: PreparedImageBackend { + prepared: prepared.clone(), + }, + })); + + let exported = service + .export_prepared_image(&request, std::path::Path::new("unused")) + .await + .expect("coherent exported metadata should be accepted"); + let imported = service + .import_prepared_image(&request, std::path::Path::new("unused")) + .await + .expect("coherent imported metadata should be accepted"); + + assert_eq!(exported, prepared); + assert_eq!(imported, prepared); +} + +#[tokio::test(flavor = "local")] +async fn ensure_stream_yields_progress_then_exactly_one_ready_sandbox() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend); + let request = request(); + let mut pending = service.ensure(&request); + let mut events = Vec::new(); + + loop { + let event = poll_fn(|context| Pin::new(&mut pending).poll_next(context)) + .await + .expect("provisioning should produce a terminal event") + .expect("provisioning should succeed"); + let ready = matches!(event, OperationEvent::Ready(_)); + events.push(event); + if ready { + break; + } + } + + assert!(matches!( + events.first(), + Some(OperationEvent::Progress(ProgressEvent::PhaseStarted { phase })) + if *phase == SandboxPhase::Validate.phase() + )); + assert!(events.iter().any(|event| { + matches!( + event, + OperationEvent::Progress(ProgressEvent::PhaseStarted { phase }) + if *phase == SandboxPhase::ImageResolve.phase() + ) + })); + let started = events + .iter() + .filter(|event| matches!(event, OperationEvent::Progress(ProgressEvent::PhaseStarted { .. }))) + .count(); + let ended = events + .iter() + .filter(|event| matches!(event, OperationEvent::Progress(ProgressEvent::PhaseEnded { .. }))) + .count(); + assert_eq!(started, ended, "every started phase ends"); + assert!(matches!(events.last(), Some(OperationEvent::Ready(_)))); + assert!( + poll_fn(|context| Pin::new(&mut pending).poll_next(context)) + .await + .is_none() + ); +} + +#[tokio::test(flavor = "local")] +async fn ensure_stream_yields_exactly_one_terminal_error() { + let backend = Rc::new(memory::Provider::with_platforms(Platform::new("linux", "amd64"), [])); + let service = SandboxService::new(backend); + let mut request = request(); + request.spec_mut().platform = Platform::new("windows", "amd64"); + let mut pending = service.ensure(&request); + + let error = loop { + let event = poll_fn(|context| Pin::new(&mut pending).poll_next(context)) + .await + .expect("provisioning should produce a terminal event"); + if let Err(error) = event { + break error; + } + }; + + assert!(matches!( + error, + Error::Component { source, .. } if matches!(*source, Error::UnsupportedPlatform(_)) + )); + assert!( + poll_fn(|context| Pin::new(&mut pending).poll_next(context)) + .await + .is_none() + ); +} + +#[tokio::test(flavor = "local")] +async fn independent_network_backend_follows_the_sandbox_lifecycle() { + let sandbox_backend = Rc::new(memory::Provider::new()); + let network_backend = Rc::new(memory::NetworkBackend::for_endpoint( + "network-a", + NetworkEndpointSelection::Packet(PacketMedium::Ethernet), + )); + let service = SandboxService::new(sandbox_backend.clone()).with_network_backend(network_backend.clone()); + let request = request(); + service + .stop(request.name()) + .await + .expect("stopping a missing Sandbox has no effect"); + + let sandbox = service.ensure(&request).await.expect("create attached Sandbox"); + assert_eq!( + sandbox.snapshot().network, + Some(NetworkAttachment { + backend: NetworkBackendId::new("network-a"), + endpoint: NetworkEndpointSelection::Packet(PacketMedium::Ethernet), + }) + ); + assert!(network_backend.is_attached(sandbox.id())); + assert!(network_backend.is_running(sandbox.id())); + + service.stop(request.name()).await.expect("stop attached Sandbox"); + service.stop(request.name()).await.expect("repeated stop"); + let stopped = service.inspect(request.name()).await.expect("inspect stopped Sandbox"); + assert_eq!(stopped.state, sandbox::SandboxState::Stopped); + assert!(network_backend.is_attached(sandbox.id())); + assert!(!network_backend.is_running(sandbox.id())); + + let adopted = service.ensure(&request).await.expect("reconnect retained Network"); + assert_eq!(adopted.id(), sandbox.id()); + assert!(network_backend.is_running(sandbox.id())); + + let other_network = Rc::new(memory::NetworkBackend::for_endpoint( + "network-b", + NetworkEndpointSelection::Packet(PacketMedium::Ethernet), + )); + let incompatible_service = SandboxService::new(sandbox_backend).with_network_backend(other_network); + let error = incompatible_service + .ensure(&request) + .await + .expect_err("Network Backend attachment should be immutable"); + assert!(matches!(error, Error::Immutable("network"))); + + service.delete(request.name()).await.expect("delete attached Sandbox"); + assert!(!network_backend.is_attached(sandbox.id())); + assert!(!network_backend.is_running(sandbox.id())); +} + +#[tokio::test(flavor = "local")] +async fn endpoint_negotiation_rejects_an_incompatible_network_backend() { + let sandbox_backend = Rc::new(memory::Provider::new()); + let network_backend = Rc::new(memory::NetworkBackend::for_endpoint( + "intercepted", + NetworkEndpointSelection::Intercepted, + )); + let service = SandboxService::new(sandbox_backend).with_network_backend(network_backend); + + let error = service + .ensure(&request()) + .await + .expect_err("memory Sandbox Backend offers only packet endpoints"); + + assert!(matches!(error, Error::NetworkEndpointUnavailable(_))); +} + +#[tokio::test(flavor = "local")] +async fn endpoint_negotiation_is_not_coupled_to_ethernet() { + let sandbox_backend = Rc::new(memory::Provider::new()); + let network_backend = Rc::new(memory::NetworkBackend::for_endpoint( + "ip-network", + NetworkEndpointSelection::Packet(PacketMedium::Ip), + )); + let service = SandboxService::new(sandbox_backend).with_network_backend(network_backend); + + let sandbox = service + .ensure(&request()) + .await + .expect("IP endpoint should be compatible"); + + assert_eq!( + sandbox.snapshot().network, + Some(NetworkAttachment { + backend: NetworkBackendId::new("ip-network"), + endpoint: NetworkEndpointSelection::Packet(PacketMedium::Ip), + }) + ); +} + +#[tokio::test(flavor = "local")] +async fn image_is_immutable_after_materialization() { + let service = SandboxService::new(Rc::new(memory::Provider::new())); + let mut request = request(); + let _ = service.ensure(&request).await.expect("first ensure"); + request.spec_mut().image = ImageSource::Reference { + reference: "example.test/other:latest".to_string(), + }; + + let error = service.ensure(&request).await.expect_err("image change should fail"); + assert!(matches!(error, Error::Immutable("image"))); +} + +#[tokio::test(flavor = "local")] +async fn ensure_reconciles_mutable_resources() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend); + let mut request = request(); + let first = service.ensure(&request).await.expect("first ensure"); + request.spec_mut().resources = resources("4", "2Gi", "8Gi"); + + let updated = service.ensure(&request).await.expect("resource update"); + + assert_eq!(updated.id(), first.id()); + assert_eq!(updated.snapshot().resources, request.spec().resources); +} + +#[tokio::test(flavor = "local")] +async fn ensure_reconciles_environment_by_restarting_the_sandbox_and_network() { + let backend = Rc::new(memory::Provider::new()); + let network = Rc::new(memory::NetworkBackend::for_endpoint( + "network-a", + NetworkEndpointSelection::Packet(PacketMedium::Ethernet), + )); + let service = SandboxService::new(backend).with_network_backend(network.clone()); + let first_request = request().with_environment([("API_TOKEN".into(), "placeholder-one".into())]); + let first = service.ensure(&first_request).await.expect("first ensure"); + + let second_request = request().with_environment([("API_TOKEN".into(), "placeholder-two".into())]); + let updated = service.ensure(&second_request).await.expect("environment update"); + + assert_eq!(updated.id(), first.id()); + assert_eq!(updated.snapshot().state, sandbox::SandboxState::Running); + assert_eq!(updated.snapshot().environment, second_request.environment().clone()); + assert!(network.is_running(updated.id())); +} + +#[tokio::test(flavor = "local")] +async fn ensure_rejects_invalid_environment_before_materialization() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend.clone()); + let request = request().with_environment([("NOT-AN-ENV".into(), "placeholder".into())]); + + let error = service + .ensure(&request) + .await + .expect_err("invalid environment should fail at the SDK boundary"); + + assert!(matches!( + error, + Error::Invalid { + field: "environment", + .. + } + )); + assert_eq!(backend.count(), 0); +} + +#[tokio::test(flavor = "local")] +async fn root_filesystem_mode_is_immutable() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend); + let mut request = request(); + let _ = service.ensure(&request).await.expect("first ensure"); + request.spec_mut().resources = SandboxResources::new( + request.spec().resources.cpu(), + request.spec().resources.memory(), + sandbox::RootFilesystem::direct(request.spec().resources.root_filesystem().capacity()), + ); + + let error = service + .ensure(&request) + .await + .expect_err("root filesystem mode change should fail"); + + assert!(matches!(error, Error::Immutable("resources.rootFilesystem.mode"))); +} + +#[tokio::test(flavor = "local")] +async fn release_retains_by_default_and_delete_is_idempotent() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend.clone()); + let request = request(); + let first = service.ensure(&request).await.expect("ensure"); + + service + .release(request.name(), RetentionPolicy::Retain) + .await + .expect("retain"); + let adopted = service.ensure(&request).await.expect("re-adopt"); + assert_eq!(first.id(), adopted.id()); + + service.delete(request.name()).await.expect("delete"); + service.delete(request.name()).await.expect("idempotent delete"); + assert_eq!(backend.count(), 0); +} + +#[tokio::test(flavor = "local")] +async fn recreating_a_name_assigns_a_new_backend_neutral_id() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend); + let request = request(); + let first = service.ensure(&request).await.expect("first materialization"); + + service + .delete(request.name()) + .await + .expect("delete first materialization"); + let second = service.ensure(&request).await.expect("second materialization"); + + assert_ne!(first.id(), second.id()); + assert_eq!(first.name(), second.name()); + assert_eq!(first.id().as_uuid().get_version_num(), 4); + assert_eq!(second.id().as_uuid().get_version_num(), 4); +} + +#[tokio::test(flavor = "local")] +async fn a_stale_handle_cannot_delete_a_new_materialization_with_the_same_name() { + let service = SandboxService::new(Rc::new(memory::Provider::new())); + let request = request(); + let stale = service.ensure(&request).await.expect("first materialization"); + + service + .delete(request.name()) + .await + .expect("delete first materialization"); + let current = service.ensure(&request).await.expect("second materialization"); + stale.delete().await.expect("stale deletion should be idempotent"); + + assert_eq!( + service + .inspect(request.name()) + .await + .expect("current Sandbox should remain") + .id, + current.id().clone() + ); +} + +#[tokio::test(flavor = "local")] +async fn service_and_handle_expose_execution_and_volume_operations() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend.clone()); + let sandbox = service.ensure(&request()).await.expect("ensure"); + + let volume_name = VolumeName::new("home").expect("valid Volume name"); + let volume_request = EnsureVolumeRequest::new(volume_name.clone()); + let expected_volume_id = volume_request.id().clone(); + let volume = service.ensure_volume(volume_request).await.expect("create Volume"); + assert_eq!(volume.id, expected_volume_id); + assert_eq!(volume.id.as_uuid().get_version_num(), 4); + assert_eq!(service.find_volume(&volume_name).await.expect("find Volume"), volume); + + backend.queue_execution_events(vec![ + ExecutionEvent::Started { process_id: Some(42) }, + ExecutionEvent::Stdout(Bytes::from_static(b"output")), + ExecutionEvent::Stderr(Bytes::from_static(b"warning")), + ExecutionEvent::Exited(ExitStatus { code: 7 }), + ]); + let output = sandbox + .run_execution(ExecutionSpec::command( + SandboxPath::new("/usr/bin/example"), + ["--check".into()], + )) + .await + .expect("run Execution"); + + assert_eq!(output.status.code, 7); + assert!(!output.status.success()); + assert_eq!(output.stdout, Bytes::from_static(b"output")); + assert_eq!(output.stderr, Bytes::from_static(b"warning")); + assert_eq!(backend.execution_specs().len(), 1); + assert_eq!( + backend.execution_specs()[0].program(), + &sandbox::execution::Program::Command { + executable: SandboxPath::new("/usr/bin/example"), + args: vec!["--check".into()], + } + ); + + let execution = sandbox + .start_execution(StartExecutionRequest::new(ExecutionSpec::image_entrypoint())) + .await + .expect("start addressable Execution"); + assert_eq!(execution.id.as_uuid().get_version_num(), 4); + sandbox + .terminate_execution(&execution.id) + .await + .expect("terminate Execution"); + + backend.queue_terminal_events(vec![ + TerminalEvent::Started { process_id: Some(43) }, + TerminalEvent::Output(Bytes::from_static(b"terminal output")), + TerminalEvent::Exited(ExitStatus { code: 0 }), + ]); + let mut terminal = sandbox + .start_terminal_execution(StartTerminalExecutionRequest::new( + ExecutionSpec::image_entrypoint(), + TerminalSize::new(40, 120).expect("valid terminal size"), + )) + .await + .expect("start terminal Execution"); + assert_eq!(terminal.id.as_uuid().get_version_num(), 4); + terminal + .control + .write_input(Bytes::from_static(b"input")) + .await + .expect("write terminal input"); + terminal + .control + .resize(TerminalSize::new(50, 140).expect("valid terminal size")) + .await + .expect("resize terminal"); + assert!(matches!( + poll_fn(|context| terminal.events.as_mut().poll_next(context)).await, + Some(Ok(TerminalEvent::Started { process_id: Some(43) })) + )); +} + +#[tokio::test(flavor = "local")] +async fn memory_provider_matches_execution_responses_without_fifo_coupling() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend.clone()); + let sandbox = service.ensure(&request()).await.expect("ensure"); + backend.queue_execution_events_matching( + |spec| { + matches!( + spec.program(), + sandbox::execution::Program::Command { executable, .. } + if executable.as_str() == "/usr/bin/matched" + ) + }, + vec![ + ExecutionEvent::Started { process_id: None }, + ExecutionEvent::Exited(ExitStatus { code: 23 }), + ], + ); + + let unrelated = sandbox + .run_execution(ExecutionSpec::command( + SandboxPath::new("/usr/bin/unrelated"), + Vec::::new(), + )) + .await + .expect("unrelated Execution"); + let matched = sandbox + .run_execution(ExecutionSpec::command( + SandboxPath::new("/usr/bin/matched"), + Vec::::new(), + )) + .await + .expect("matched Execution"); + + assert!(unrelated.status.success()); + assert_eq!(matched.status.code, 23); + assert_eq!(backend.execution_specs().len(), 2); +} + +#[tokio::test(flavor = "local")] +async fn sandbox_backend_streams_files_in_both_directions() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend.clone()); + let sandbox = service.ensure(&request()).await.expect("ensure"); + let path = SandboxPath::new("/home/sandbox/code/input.bin"); + + sandbox + .write_file(&path, Box::pin(Cursor::new(vec![0, 1, 2, 0xff]))) + .await + .expect("write Sandbox file"); + let mut reader = sandbox.read_file(&path).await.expect("read Sandbox file"); + let mut contents = Vec::new(); + reader + .read_to_end(&mut contents) + .await + .expect("consume Sandbox file stream"); + + assert_eq!(contents, vec![0, 1, 2, 0xff]); + assert!( + service + .capabilities(&sandbox.snapshot().image.platform) + .await + .expect("Platform capabilities") + .features() + .contains(SandboxFeature::FileTransfer) + ); +} + +#[tokio::test(flavor = "local")] +async fn ensure_rejects_an_unsupported_platform() { + let backend = Rc::new(memory::Provider::with_platforms(Platform::new("linux", "amd64"), [])); + let service = SandboxService::new(backend.clone()); + let mut request = request(); + request.spec_mut().platform = Platform::new("windows", "amd64"); + + let error = service + .ensure(&request) + .await + .expect_err("Platform should be unsupported"); + + assert!(matches!( + error, + Error::Component { source, .. } if matches!(*source, Error::UnsupportedPlatform(_)) + )); + assert_eq!(backend.count(), 0); +} + +struct IncompatibleImageBackend; + +impl image::ImageBackend for IncompatibleImageBackend { + fn capabilities<'a>( + &'a self, + _platform: &'a Platform, + ) -> sandbox::LocalFuture<'a, Result> { + Box::pin(async { + Ok(image::ImageBackendCapabilities::new( + image::ImageOperationCapabilities::new( + [image::ImageSourceKind::Build, image::ImageSourceKind::Reference].into(), + [RootFilesystemMode::Layered].into(), + ), + image::ImageOperationCapabilities::default(), + image::ImageOperationCapabilities::default(), + )) + }) + } + + fn resolve<'a>(&'a self, request: &'a image::ResolveRequest) -> PendingOperation<'a, image::ResolvedImage> { + PendingOperation::run(move |_progress| { + Box::pin(async move { + Ok(image::ResolvedImage { + source: request.source.clone(), + platform: Platform::new("windows", "amd64"), + manifest_digest: "sha256:incompatible".into(), + }) + }) + }) + } + + fn export_prepared_image<'a>( + &'a self, + _request: &'a image::ResolveRequest, + _destination: &'a std::path::Path, + ) -> PendingOperation<'a, image::PreparedImage> { + unsupported_prepared_image(image::ImageOperation::PreparedImageExport) + } + + fn import_prepared_image<'a>( + &'a self, + _request: &'a image::ResolveRequest, + _source: &'a std::path::Path, + ) -> PendingOperation<'a, image::PreparedImage> { + unsupported_prepared_image(image::ImageOperation::PreparedImageImport) + } +} + +fn unsupported_prepared_image<'a>(operation: image::ImageOperation) -> PendingOperation<'a, image::PreparedImage> { + PendingOperation::run(move |_progress| Box::pin(async move { Err(Error::UnsupportedImageOperation(operation)) })) +} + +struct IncompatibleProvider { + backend: memory::Provider, + image_backend: IncompatibleImageBackend, +} + +impl sandbox::provider::SandboxProvider for IncompatibleProvider { + fn backend(&self) -> &dyn sandbox::backend::SandboxBackend { + &self.backend + } + + fn image_backend(&self) -> &dyn image::ImageBackend { + &self.image_backend + } +} + +#[tokio::test(flavor = "local")] +async fn ensure_rejects_an_image_that_does_not_satisfy_the_requested_platform() { + let provider = Rc::new(IncompatibleProvider { + backend: memory::Provider::with_platforms(Platform::native("linux"), [Platform::new("windows", "amd64")]), + image_backend: IncompatibleImageBackend, + }); + let service = SandboxService::new(provider.clone()); + + let error = service + .ensure(&request()) + .await + .expect_err("Image Platform should be incompatible"); + + assert!(matches!(error, Error::ImagePlatformMismatch { .. })); + assert_eq!(provider.backend.count(), 0); +} + +#[tokio::test(flavor = "local")] +async fn ensure_rejects_a_root_mode_the_image_backend_cannot_materialize() { + let provider = Rc::new(IncompatibleProvider { + backend: memory::Provider::new(), + image_backend: IncompatibleImageBackend, + }); + let service = SandboxService::new(provider.clone()); + let mut request = request(); + request.spec_mut().resources = SandboxResources::new( + "2".parse::().expect("test CPU should be valid"), + "1Gi".parse::().expect("test memory should be valid"), + RootFilesystem::direct( + "4Gi" + .parse::() + .expect("test root filesystem should be valid"), + ), + ); + + let capabilities = service + .capabilities(&request.spec().platform) + .await + .expect("Provider capabilities should be available"); + assert!( + capabilities + .root_filesystem_modes() + .contains(RootFilesystemMode::Layered) + ); + assert!( + !capabilities + .root_filesystem_modes() + .contains(RootFilesystemMode::Direct) + ); + + let error = service + .ensure(&request) + .await + .expect_err("Image Backend should reject direct materialization"); + + assert!(matches!( + error, + Error::UnsupportedImageRootFilesystemMode { + operation: image::ImageOperation::Resolve, + mode: RootFilesystemMode::Direct, + } + )); + assert_eq!(provider.backend.count(), 0); +} + +#[tokio::test(flavor = "local")] +async fn platform_is_immutable_after_materialization() { + let linux = Platform::native("linux"); + let windows = Platform::native("windows"); + let backend = Rc::new(memory::Provider::with_platforms(linux.clone(), [windows.clone()])); + let service = SandboxService::new(backend); + let linux_request = request(); + let _ = service.ensure(&linux_request).await.expect("first ensure"); + let windows_request = EnsureSandboxRequest::new( + linux_request.name().clone(), + SandboxSpec { + platform: windows, + ..linux_request.spec().clone() + }, + ); + + let error = service + .ensure(&windows_request) + .await + .expect_err("Platform change should fail"); + + assert!(matches!(error, Error::Immutable("platform"))); +} + +#[tokio::test(flavor = "local")] +async fn init_system_is_immutable_after_materialization() { + let backend = Rc::new(memory::Provider::new()); + let service = SandboxService::new(backend); + let backend_init = request(); + let _ = service.ensure(&backend_init).await.expect("first ensure"); + let image_init = EnsureSandboxRequest::new( + backend_init.name().clone(), + SandboxSpec { + init_system: sandbox::init::InitSystem::Image, + ..backend_init.spec().clone() + }, + ); + + let error = service + .ensure(&image_init) + .await + .expect_err("init system change should fail"); + + assert!(matches!(error, Error::Immutable("initSystem"))); +} diff --git a/sandbox/microsandbox/Cargo.toml b/sandbox/microsandbox/Cargo.toml new file mode 100644 index 0000000..7d0579a --- /dev/null +++ b/sandbox/microsandbox/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "sandbox-microsandbox" +description = "Microsandbox implementations of Sandbox SDK and enforcement contracts" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +bollard.workspace = true +bytes.workspace = true +futures-util.workspace = true +ignore.workspace = true +microsandbox.workspace = true +microsandbox-image.workspace = true +microsandbox-network.workspace = true +sandbox = { path = "../core" } +sandbox-authorization = { path = "../authorization" } +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tar.workspace = true +tempfile.workspace = true +tokio.workspace = true +tokio-util.workspace = true +tracing.workspace = true +uuid.workspace = true +zeroize.workspace = true + +[lints] +workspace = true diff --git a/sandbox/microsandbox/src/backend.rs b/sandbox/microsandbox/src/backend.rs new file mode 100644 index 0000000..bf94791 --- /dev/null +++ b/sandbox/microsandbox/src/backend.rs @@ -0,0 +1,1052 @@ +use std::{ + cell::RefCell, + collections::{BTreeMap, HashMap}, + path::{Path, PathBuf}, + rc::Rc, +}; + +use microsandbox::sandbox::{PullPolicy, SandboxStatus}; +use sandbox::progress::SandboxProgress; +use sandbox::{ + Error, LocalFuture, PendingOperation, Platform, ResourceKind, RootFilesystemMode, RootFilesystemModeSet, Sandbox, + SandboxFeature, SandboxId, SandboxName, SandboxResources, SandboxState, + backend::{CreateSandboxRequest, SandboxBackend, SandboxBackendCapabilities}, + execution, file_transfer, + mount::{Mount, MountKind, MountKindSet}, + network, + provider::SandboxProvider, + terminal, volume, +}; + +use crate::{ + client::{Client, RuntimeResources}, + error, + execution::ExecutionControls, + heartbeat, + image::MicrosandboxImageBackend, + image_cache::ImageCache, + network_endpoint, platform, + state::{SandboxRecord, StateStore}, +}; + +const RECORD_SANDBOX: &str = "Record Sandbox state"; +const INSTALL_RUNTIME: &str = "Install Microsandbox runtime"; +const RESOLVE_RUNTIME_INPUTS: &str = "Resolve Microsandbox runtime inputs"; +const MATERIALIZE_DIRECT_ROOT_IMAGE: &str = "Materialize direct root image"; +const CREATE_RUNTIME: &str = "Create Microsandbox VM"; +const START_RUNTIME: &str = "Start Microsandbox VM"; +const UPDATE_RUNTIME_RESOURCES: &str = "Update Microsandbox VM resources"; +const UPDATE_RUNTIME_ENVIRONMENT: &str = "Update Microsandbox environment"; + +/// How long a stopping runtime may take to shut its guest down before it is +/// killed. Microsandbox's own `stop` waits indefinitely, so a wedged guest +/// would otherwise block stopping and deleting the Sandbox. +const STOP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +/// Microsandbox Provider pairing its Sandbox Backend with its Image Backend. +pub struct MicrosandboxProvider { + pub(crate) client: Client, + images: ImageCache, + image_backend: MicrosandboxImageBackend, + pub(crate) state: StateStore, + pub(crate) executions: ExecutionControls, +} + +/// Configures the host storage used by a [`MicrosandboxProvider`]. +pub struct MicrosandboxProviderBuilder { + home: PathBuf, + cache_directory: Option, + unused_image_retention: Option, + registry_authentication: Option, + runtime_bundle: Option, +} + +#[derive(Clone)] +pub(crate) struct RuntimeBundle { + pub(crate) path: PathBuf, + pub(crate) sha256: String, +} + +impl MicrosandboxProvider { + /// Configures a Microsandbox Provider below its private data directory. + #[must_use] + pub fn builder(home: impl Into) -> MicrosandboxProviderBuilder { + MicrosandboxProviderBuilder { + home: home.into(), + cache_directory: None, + unused_image_retention: None, + registry_authentication: None, + runtime_bundle: None, + } + } + + /// Opens an isolated Microsandbox Provider below its data directory. + /// + /// # Errors + /// + /// Returns an error when the home cannot be initialized or Microsandbox + /// cannot open its local runtime. + pub async fn open(home: impl AsRef) -> Result { + Self::builder(home.as_ref().to_path_buf()).open().await + } + + async fn open_configured( + home: PathBuf, + cache_directory: Option, + unused_image_retention: Option, + registry_authentication: Option, + runtime_bundle: Option, + ) -> Result { + if home.as_os_str().is_empty() { + return Err(Error::invalid("provider.home", "must not be empty")); + } + if cache_directory.as_ref().is_some_and(|path| path.as_os_str().is_empty()) { + return Err(Error::invalid("provider.cacheDirectory", "must not be empty")); + } + if let Some(retention) = unused_image_retention { + if cache_directory.is_some() { + return Err(Error::invalid( + "provider.unusedImageRetention", + "cannot be combined with a cache directory, which other Providers may share", + )); + } + if retention < crate::image_cache::MINIMUM_RETENTION { + return Err(Error::invalid( + "provider.unusedImageRetention", + "must be at least an hour, to cover the time between resolving an image and creating its Sandbox", + )); + } + } + if let Some(bundle) = &runtime_bundle { + if !bundle.path.is_file() { + return Err(Error::invalid( + "provider.runtimeBundle.path", + "must identify a regular file", + )); + } + if bundle.sha256.len() != 64 || !bundle.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(Error::invalid( + "provider.runtimeBundle.sha256", + "must be a 64-character hexadecimal SHA-256 digest", + )); + } + } + let state = StateStore::open(home.join("state")).await?; + let client = Client::open(home.join("runtime"), cache_directory, runtime_bundle).await?; + let images = ImageCache::new(client.clone(), state.clone(), unused_image_retention); + let image_backend = MicrosandboxImageBackend::new(client.clone(), images.clone(), registry_authentication); + let provider = Self { + client, + images, + image_backend, + state, + executions: Rc::new(RefCell::new(HashMap::new())), + }; + if unused_image_retention.is_some() { + if let Err(error) = Box::pin(provider.migrate_images()).await { + tracing::warn!(%error, "failed to migrate the Microsandbox image catalog; retrying when the Provider next opens"); + } + Box::pin(provider.images.remove_unused()).await; + } + Ok(provider) + } + + /// Migrates a catalog recorded before Sandboxes held their images, while the Provider opens + /// and before anything else runs. Every Sandbox holds its image; one with a runtime whose + /// image the old catalog lost fetches it again by digest. Image versions nothing holds are + /// then removed, but only once every Sandbox's image is protected from that removal, which + /// a Sandbox that never started, or whose first start was interrupted, is not. Until then + /// each open tries again. + async fn migrate_images(&self) -> Result<(), Error> { + if !self.images.migration_pending().await { + return Ok(()); + } + let mut every_image_pinned = true; + for record in self.state.sandbox_records().await? { + let held = if self.runtime_handle(&record.runtime_name).await?.is_some() { + match self.hold_image(&record).await { + Ok(_) => true, + Err(error) => { + tracing::warn!(sandbox = %record.id, %error, "failed to hold a Sandbox's image"); + false + } + } + } else { + self.images.hold(&record).await?.is_some() + }; + every_image_pinned &= held && self.images.is_pinned(&record).await?; + } + if !every_image_pinned { + tracing::info!("keeping image versions from before this release until every Sandbox's image is protected"); + return Ok(()); + } + self.images.finish_migration().await + } + + /// Makes a Sandbox hold its image and returns the name to create its runtime from. An image + /// no longer in the cache is fetched again from its registry by digest. + pub(crate) async fn hold_image(&self, record: &SandboxRecord) -> Result { + use sandbox::image::ImageBackend as _; + + if let Some(entry) = self.images.hold(record).await? { + return Ok(entry); + } + let manifest_digest = &record.image.manifest_digest; + if let sandbox::image::ImageSource::Reference { reference } = &record.image.source { + let reference = reference + .parse::() + .map_err(error::backend)?; + let pinned = microsandbox_image::Reference::with_digest( + reference.registry().to_string(), + reference.repository().to_string(), + manifest_digest.clone(), + ); + self.image_backend + .resolve(&sandbox::image::ResolveRequest { + source: sandbox::image::ImageSource::Reference { + reference: pinned.to_string(), + }, + platform: record.image.platform.clone(), + root_filesystem_mode: record.resources.root_filesystem().mode(), + }) + .await?; + if let Some(entry) = self.images.hold(record).await? { + return Ok(entry); + } + } + Err(Error::Backend(format!( + "image manifest digest {manifest_digest} is not present in this Microsandbox cache" + ))) + } + + /// Removes unused images now, as the Provider also does when it opens, after each image is + /// resolved or imported and after each Sandbox is deleted. Only a Provider opened with + /// [`MicrosandboxProviderBuilder::remove_unused_images_after`] removes any. + pub async fn remove_unused_images(&self) { + self.images.remove_unused().await; + } + + #[cfg(test)] + pub(crate) const fn images(&self) -> &ImageCache { + &self.images + } + + async fn create_record(&self, request: CreateSandboxRequest) -> Result { + platform::require_supported(&request.image.platform)?; + RuntimeResources::try_from(request.resources)?; + RuntimeNetwork::for_attachment(request.network.as_ref())?; + match self.state.sandbox_by_name(&request.name).await { + Ok(_) => return Err(Error::Backend(format!("Sandbox '{}' already exists", request.name))), + Err(error) if error.is_not_found() => {} + Err(error) => return Err(error), + } + let record = SandboxRecord::new(request); + self.state.save_sandbox(&record).await?; + // The record comes first, so an image entry without a record is always a deleted + // Sandbox's, which removal passes clean up. + if let Err(error) = self.hold_image(&record).await { + if let Err(cleanup) = self.state.remove_sandbox(&record).await { + tracing::warn!(sandbox = %record.id, error = %cleanup, "failed to remove the record of a Sandbox without its image"); + } + return Err(error); + } + Ok(record.to_sandbox(SandboxState::Stopped)) + } + + async fn inspect_record(&self, record: &SandboxRecord) -> Result { + let Some(handle) = self.runtime_handle(&record.runtime_name).await? else { + return Ok(record.to_sandbox(SandboxState::Stopped)); + }; + let status = handle.status_snapshot(); + // A starting, draining or paused guest is not expected to beat, so its + // stale heartbeat is no evidence either way. + let guest_heartbeat = if status == SandboxStatus::Running { + heartbeat::read(&self.runtime_directory(&record.runtime_name)).await + } else { + None + }; + Ok(Sandbox { + guest_heartbeat, + ..record.to_sandbox(map_state(status)) + }) + } + + /// Host-side directory the runtime shares with its guest. + fn runtime_directory(&self, runtime_name: &str) -> PathBuf { + self.client.local().sandboxes_dir().join(runtime_name).join("runtime") + } + + async fn update_sandbox_resources( + &self, + id: &SandboxId, + resources: SandboxResources, + progress: &SandboxProgress, + ) -> Result { + let mut record = self.state.sandbox_by_id(id).await?; + if record.resources == resources { + return self.inspect_record(&record).await; + } + if record.resources.root_filesystem().mode() != resources.root_filesystem().mode() { + return Err(Error::Immutable("resources.rootFilesystem.mode")); + } + + let desired = RuntimeResources::try_from(resources)?; + + if let Some(handle) = self.runtime_handle(&record.runtime_name).await? { + let config = handle.config().map_err(error::microsandbox)?; + let recorded_root_filesystem_mib = RuntimeResources::try_from(record.resources)?.root_filesystem_mib; + let current_root_filesystem_mib = config + .spec + .image + .oci_root_disk() + .and_then(microsandbox::sandbox::RootDisk::size_mib) + .unwrap_or(recorded_root_filesystem_mib); + if desired.root_filesystem_mib < current_root_filesystem_mib { + return Err(Error::UnsupportedResourceChange { + resource: "rootFilesystem", + current: format!("{current_root_filesystem_mib}Mi"), + requested: resources.root_filesystem().capacity().to_string(), + reason: "Microsandbox layered and direct root filesystems can only grow", + }); + } + + let mut modification = handle.modify(); + let mut runtime_change = config.spec.resources.cpus != desired.cpus; + if runtime_change { + modification = modification + .cpus(desired.cpus) + .max_cpus(config.spec.resources.max_cpus.max(desired.cpus)); + } + if config.spec.resources.memory_mib != desired.memory_mib { + modification = modification + .memory(desired.memory_mib) + .max_memory(config.spec.resources.max_memory_mib.max(desired.memory_mib)); + runtime_change = true; + } + if current_root_filesystem_mib < desired.root_filesystem_mib { + modification = modification.root_disk_size(desired.root_filesystem_mib); + runtime_change = true; + } + if runtime_change { + self.prepare_runtime_network(&record)?; + let step = progress.start_step(UPDATE_RUNTIME_RESOURCES).await; + // A running VM is restarted here rather than by Microsandbox, + // whose restart stops without a deadline and relaunches with + // whatever runtime the home holds. The change is persisted for + // the next start first, so a rejected change leaves the VM + // running, and the root disk grows before that start boots. + // The runtime is installed first, since a resource change can + // come before the first start after an upgrade. + let running = map_state(handle.status_snapshot()) == SandboxState::Running; + if running { + self.client.ensure_installed().await?; + modification = modification.next_start(); + } + modification.apply().await.map_err(error::microsandbox)?; + if running { + stop_runtime(&handle, &record.runtime_name).await?; + self.runtime_handle(&record.runtime_name) + .await? + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, &record.id))? + .start_detached() + .await + .map_err(error::microsandbox)?; + } + step.complete().await; + } + } + + record.resources = resources; + self.state.update_sandbox(&record).await?; + self.inspect_record(&record).await + } + + async fn update_sandbox_environment( + &self, + id: &SandboxId, + environment: BTreeMap, + progress: &SandboxProgress, + ) -> Result { + let mut record = self.state.sandbox_by_id(id).await?; + if record.environment == environment { + return self.inspect_record(&record).await; + } + let sandbox = self.inspect_record(&record).await?; + if sandbox.state != SandboxState::Stopped { + return Err(Error::invalid("sandbox.state", "must be stopped")); + } + + if let Some(handle) = self.runtime_handle(&record.runtime_name).await? { + let mut modification = handle.modify().next_start(); + for name in record.environment.keys() { + if !environment.contains_key(name) { + modification = modification.remove_env(name); + } + } + for (name, value) in &environment { + modification = modification.env(name, value); + } + let step = progress.start_step(UPDATE_RUNTIME_ENVIRONMENT).await; + modification.apply().await.map_err(error::microsandbox)?; + step.complete().await; + } + + record.environment = environment; + self.state.update_sandbox(&record).await?; + self.inspect_record(&record).await + } + + async fn start_sandbox(&self, id: &SandboxId, progress: &SandboxProgress) -> Result<(), Error> { + let record = self.state.sandbox_by_id(id).await?; + self.prepare_runtime_network(&record)?; + let step = progress.start_step(INSTALL_RUNTIME).await; + self.client.ensure_installed().await?; + step.complete().await; + let _running = match self.runtime_handle(&record.runtime_name).await? { + Some(handle) if map_state(handle.status_snapshot()) == SandboxState::Running => return Ok(()), + Some(handle) => { + let step = progress.start_step(START_RUNTIME).await; + let running = handle.start_detached().await.map_err(error::microsandbox)?; + step.complete().await; + running + } + None => Box::pin(self.create_runtime(&record, progress)).await?, + }; + Ok(()) + } + + async fn stop_sandbox(&self, id: &SandboxId) -> Result<(), Error> { + let record = self.state.sandbox_by_id(id).await?; + if let Some(handle) = self.runtime_handle(&record.runtime_name).await? + && map_state(handle.status_snapshot()) == SandboxState::Running + { + stop_runtime(&handle, &record.runtime_name).await?; + } + self.executions + .borrow_mut() + .retain(|(sandbox_id, _), _| sandbox_id != id); + Ok(()) + } + + async fn delete_sandbox(&self, id: &SandboxId) -> Result<(), Error> { + let record = self.state.sandbox_by_id(id).await?; + self.stop_sandbox(id).await?; + if let Some(handle) = self.runtime_handle(&record.runtime_name).await? { + handle.remove().await.map_err(error::microsandbox)?; + } + self.client.local().set_network_controlled(&record.runtime_name, false); + // Releasing first keeps a removal pass from taking the image as left behind before its + // cache entry is refreshed. + self.images.release(&record).await; + self.state.remove_sandbox(&record).await?; + self.images.remove_unused().await; + Ok(()) + } + + /// Tells Microsandbox whether this runtime must start under host network + /// control. Call it before every operation that can start the runtime: the + /// setting lives only in this process, while a runtime can outlive the + /// process that started it. + fn prepare_runtime_network(&self, record: &SandboxRecord) -> Result { + let network = RuntimeNetwork::for_attachment(record.network.as_ref())?; + self.client + .local() + .set_network_controlled(&record.runtime_name, network == RuntimeNetwork::Controlled); + Ok(network) + } + + async fn runtime_handle(&self, name: &str) -> Result, Error> { + match self.client.scope(microsandbox::Sandbox::get(name)).await { + Ok(handle) => Ok(Some(handle)), + Err(microsandbox::MicrosandboxError::SandboxNotFound(_)) => Ok(None), + Err(error) => Err(error::microsandbox(error)), + } + } + + pub(crate) async fn connect_running(&self, record: &SandboxRecord) -> Result { + let handle = self + .runtime_handle(&record.runtime_name) + .await? + .ok_or_else(|| Error::not_found(ResourceKind::Sandbox, &record.id))?; + handle.connect().await.map_err(error::microsandbox) + } + + async fn create_runtime( + &self, + record: &SandboxRecord, + progress: &SandboxProgress, + ) -> Result { + // Applying the attachment here, not trusting a caller's decision, keeps + // a runtime from being created with the controlled network policy but + // without host network control. + let network = self.prepare_runtime_network(record)?; + let step = progress.start_step(RESOLVE_RUNTIME_INPUTS).await; + let mounts = self.resolve_mounts(&record.mounts).await?; + // Holding the image again also covers a record saved before it held its image, and an + // image the cache no longer has. + let image = self.hold_image(record).await?; + step.complete().await; + if record.resources.root_filesystem().mode() == RootFilesystemMode::Direct { + let step = progress.start_step(MATERIALIZE_DIRECT_ROOT_IMAGE).await; + self.materialize_direct_root_image(&image).await?; + step.complete().await; + } + let mut builder = Client::sandbox_builder(&record.runtime_name, image, record.resources)? + .pull_policy(PullPolicy::Never) + .hostname(record.hostname().as_str()); + builder = builder.envs(record.environment.clone()); + if network == RuntimeNetwork::Controlled { + builder = + builder.network(|network| network.policy(microsandbox::NetworkPolicy::allow_all()).tls(|tls| tls)); + } + if record.init_system == sandbox::init::InitSystem::Image { + builder = builder.init("auto"); + } + for mount in mounts { + builder = mount.apply(builder); + } + let step = progress.start_step(CREATE_RUNTIME).await; + let runtime = Box::pin(self.client.scope(builder.create_detached())) + .await + .map_err(error::microsandbox)?; + step.complete().await; + Ok(runtime) + } + + // Image resolution prepares Microsandbox's layered cache, while a direct root + // filesystem requires a cached flat ext4 artifact. PullPolicy::Never will only + // consume that artifact during sandbox creation, so materialize it here first; + // Microsandbox then clones it into the sandbox-owned root disk. + async fn materialize_direct_root_image(&self, reference: &str) -> Result<(), Error> { + let reference = reference + .parse::() + .map_err(error::backend)?; + let cache = microsandbox_image::GlobalCache::new(&self.client.local().cache_dir()).map_err(error::backend)?; + let metadata = cache + .read_image_metadata(&reference) + .map_err(error::backend)? + .ok_or_else(|| Error::Backend(format!("Microsandbox image metadata is missing for {reference}")))?; + let manifest_digest = metadata.manifest_digest.parse().map_err(error::backend)?; + let layer_diff_ids = metadata + .layers + .iter() + .map(|layer| layer.diff_id.parse().map_err(error::backend)) + .collect::, _>>()?; + let registry = microsandbox_image::Registry::new(microsandbox_image::Platform::host_linux(), cache) + .map_err(error::backend)?; + registry + .materialize_flat_rootfs(&manifest_digest, &layer_diff_ids, false) + .await + .map_err(error::backend)?; + Ok(()) + } + + async fn resolve_mounts(&self, mounts: &[Mount]) -> Result, Error> { + let mut resolved = Vec::with_capacity(mounts.len()); + for mount in mounts { + resolved.push(match mount { + Mount::Volume { id, target, read_only } => { + let volume = self.state.volume_by_id(id).await?; + self.ensure_volume_runtime(&volume).await?; + RuntimeMount::Volume { + name: volume.runtime_name, + target: target.as_str().to_string(), + read_only: *read_only, + } + } + Mount::Bind { + source, + target, + read_only, + } => RuntimeMount::Bind { + source: source.clone(), + target: target.as_str().to_string(), + read_only: *read_only, + }, + Mount::Tmpfs { target, capacity } => RuntimeMount::Tmpfs { + target: target.as_str().to_string(), + capacity_mib: crate::client::exact_mib("mount.tmpfs.capacity", *capacity)?, + }, + }); + } + Ok(resolved) + } +} + +impl MicrosandboxProviderBuilder { + /// Places reusable Microsandbox cache artifacts in this directory. + /// + /// Separate Provider instances may share this directory. Sandbox state, + /// writable roots and other mutable runtime data remain below the private + /// Provider home. + #[must_use] + pub fn cache_directory(mut self, path: impl Into) -> Self { + self.cache_directory = Some(path.into()); + self + } + + /// Removes cached images no Sandbox uses once `retention`, at least an hour, has passed + /// since each was last resolved, imported or released by a deleted Sandbox. A Sandbox keeps + /// its image until it is deleted, running or not. + /// + /// Enable this only for the Provider that owns its home. It cannot be combined with + /// [`Self::cache_directory`], since another Provider may use a shared cache. + #[must_use] + pub const fn remove_unused_images_after(mut self, retention: std::time::Duration) -> Self { + self.unused_image_retention = Some(retention); + self + } + + /// Supplies transient credentials used to resolve OCI registry references. + #[must_use] + pub fn registry_authentication(mut self, authentication: sandbox::image::RegistryAuthentication) -> Self { + self.registry_authentication = Some(authentication); + self + } + + /// Installs the Microsandbox host runtime from a verified local release bundle. + /// + /// The path must identify a platform-compatible Microsandbox `tar.gz` + /// runtime bundle. The expected digest is checked before extraction. + #[must_use] + pub fn runtime_bundle(mut self, path: impl Into, sha256: impl Into) -> Self { + self.runtime_bundle = Some(RuntimeBundle { + path: path.into(), + sha256: sha256.into(), + }); + self + } + + /// Opens the configured Microsandbox Provider. + /// + /// # Errors + /// + /// Returns an error when a configured path is empty or cannot be + /// initialized by the Microsandbox runtime. + pub async fn open(self) -> Result { + MicrosandboxProvider::open_configured( + self.home, + self.cache_directory, + self.unused_image_retention, + self.registry_authentication, + self.runtime_bundle, + ) + .await + } +} + +impl SandboxProvider for MicrosandboxProvider { + fn backend(&self) -> &dyn SandboxBackend { + self + } + + fn image_backend(&self) -> &dyn sandbox::image::ImageBackend { + &self.image_backend + } +} + +impl SandboxBackend for MicrosandboxProvider { + fn capabilities<'a>( + &'a self, + platform: &'a Platform, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + platform::require_supported(platform)?; + Ok(SandboxBackendCapabilities::new( + [ + SandboxFeature::Execution, + SandboxFeature::TerminalExecution, + SandboxFeature::TerminalAttach, + SandboxFeature::FileTransfer, + SandboxFeature::PersistentVolumes, + SandboxFeature::NestedContainers, + SandboxFeature::ImageInit, + ] + .into(), + MountKindSet::from([MountKind::Volume, MountKind::Bind, MountKind::Tmpfs]), + RootFilesystemModeSet::from([RootFilesystemMode::Layered, RootFilesystemMode::Direct]), + network::NetworkEndpointCapabilities::new().with_control_protocol( + network::NetworkControlProtocolId::new(microsandbox_network::control::NETWORK_CONTROL_PROTOCOL), + ), + )) + }) + } + + fn create(&self, request: CreateSandboxRequest) -> PendingOperation<'_, Sandbox> { + PendingOperation::run(move |progress| { + Box::pin(async move { + let step = progress.start_step(RECORD_SANDBOX).await; + let sandbox = self.create_record(request).await?; + step.complete().await; + Ok(sandbox) + }) + }) + } + + fn update_resources<'a>(&'a self, id: &'a SandboxId, resources: SandboxResources) -> PendingOperation<'a, Sandbox> { + PendingOperation::run(move |progress| { + Box::pin(async move { self.update_sandbox_resources(id, resources, &progress).await }) + }) + } + + fn update_environment<'a>( + &'a self, + id: &'a SandboxId, + environment: BTreeMap, + ) -> PendingOperation<'a, Sandbox> { + PendingOperation::run(move |progress| { + Box::pin(async move { self.update_sandbox_environment(id, environment, &progress).await }) + }) + } + + fn find<'a>(&'a self, name: &'a SandboxName) -> LocalFuture<'a, Result> { + Box::pin(async move { + let record = self.state.sandbox_by_name(name).await?; + self.inspect_record(&record).await + }) + } + + fn inspect<'a>(&'a self, id: &'a SandboxId) -> LocalFuture<'a, Result> { + Box::pin(async move { + let record = self.state.sandbox_by_id(id).await?; + self.inspect_record(&record).await + }) + } + + fn start<'a>(&'a self, id: &'a SandboxId) -> PendingOperation<'a, ()> { + PendingOperation::run(move |progress| Box::pin(async move { self.start_sandbox(id, &progress).await })) + } + + fn stop<'a>(&'a self, id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(self.stop_sandbox(id)) + } + + fn delete<'a>(&'a self, id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(self.delete_sandbox(id)) + } + + fn open_network_endpoint<'a>( + &'a self, + id: &'a SandboxId, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + let record = self.state.sandbox_by_id(id).await?; + match self.prepare_runtime_network(&record)? { + RuntimeNetwork::Controlled => { + let controller = self.client.bind_network_controller(&record.runtime_name).await?; + network_endpoint::open(controller).map(network::NetworkEndpoint::Control) + } + RuntimeNetwork::Unattached => Err(Error::invalid("network", "Sandbox has no attachment")), + } + }) + } + + fn start_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + request: execution::StartExecutionRequest, + ) -> LocalFuture<'a, Result> { + Box::pin(self.start_execution_stream(sandbox_id, request)) + } + + fn start_terminal_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + request: terminal::StartTerminalExecutionRequest, + ) -> LocalFuture<'a, Result> { + Box::pin(self.start_terminal_execution_stream(sandbox_id, request)) + } + + fn attach_terminal<'a>( + &'a self, + sandbox_id: &'a SandboxId, + request: terminal::AttachTerminalRequest, + ) -> LocalFuture<'a, Result> { + Box::pin(self.attach_terminal_to_runtime(sandbox_id, request)) + } + fn terminate_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + execution_id: &'a execution::ExecutionId, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(self.control_execution(sandbox_id, execution_id, false)) + } + + fn kill_execution<'a>( + &'a self, + sandbox_id: &'a SandboxId, + execution_id: &'a execution::ExecutionId, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(self.control_execution(sandbox_id, execution_id, true)) + } + + fn read_file<'a>( + &'a self, + sandbox_id: &'a SandboxId, + path: &'a sandbox::SandboxPath, + ) -> LocalFuture<'a, Result> { + Box::pin(self.read_file_stream(sandbox_id, path)) + } + + fn write_file<'a>( + &'a self, + sandbox_id: &'a SandboxId, + path: &'a sandbox::SandboxPath, + contents: file_transfer::ByteReader, + ) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(self.write_file_stream(sandbox_id, path, contents)) + } + + fn ensure_volume(&self, request: volume::EnsureVolumeRequest) -> LocalFuture<'_, Result> { + Box::pin(self.ensure_volume_record(request)) + } + + fn find_volume<'a>(&'a self, name: &'a volume::VolumeName) -> LocalFuture<'a, Result> { + Box::pin(async move { Ok(self.state.volume_by_name(name).await?.to_volume()) }) + } + + fn delete_volume<'a>(&'a self, id: &'a volume::VolumeId) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(self.delete_volume_record(id)) + } +} + +impl SandboxRecord { + fn to_sandbox(&self, state: SandboxState) -> Sandbox { + Sandbox { + image: self.image.clone(), + init_system: self.init_system, + id: self.id.clone(), + name: self.name.clone(), + hostname: self.hostname(), + resources: self.resources, + state, + guest_heartbeat: None, + mounts: self.mounts.clone(), + environment: self.environment.clone(), + network: self.network.clone(), + } + } +} + +enum RuntimeMount { + Volume { + name: String, + target: String, + read_only: bool, + }, + Bind { + source: std::path::PathBuf, + target: String, + read_only: bool, + }, + Tmpfs { + target: String, + capacity_mib: u32, + }, +} + +impl RuntimeMount { + fn apply(self, builder: microsandbox::sandbox::SandboxBuilder) -> microsandbox::sandbox::SandboxBuilder { + match self { + Self::Volume { + name, + target, + read_only, + } => builder.volume(target, |mount| { + let mount = mount.named(name); + if read_only { mount.readonly() } else { mount } + }), + Self::Bind { + source, + target, + read_only, + } => builder.volume(target, |mount| { + let mount = mount.bind(source); + if read_only { mount.readonly() } else { mount } + }), + Self::Tmpfs { target, capacity_mib } => builder.volume(target, |mount| mount.tmpfs().size(capacity_mib)), + } + } +} + +/// Stops a running VM gracefully within [`STOP_TIMEOUT`], and kills it when +/// that fails. A guest that stopped responding, a halted guest and a paused VM +/// cannot take the shutdown request, so the stop still ends with the VM gone. +async fn stop_runtime(handle: µsandbox::sandbox::SandboxHandle, name: &str) -> Result<(), Error> { + match handle.stop_with_timeout(STOP_TIMEOUT).await { + Ok(()) => Ok(()), + Err(graceful) => { + tracing::warn!(sandbox = %name, error = %graceful, "Microsandbox VM did not stop gracefully; killing it"); + handle + .kill() + .await + .map_err(|kill| error::backend(format!("{kill}, after a graceful stop failed: {graceful}"))) + } + } +} + +const fn map_state(status: SandboxStatus) -> SandboxState { + match status { + SandboxStatus::Starting | SandboxStatus::Running | SandboxStatus::Draining | SandboxStatus::Paused => { + SandboxState::Running + } + SandboxStatus::Created | SandboxStatus::Stopped | SandboxStatus::Crashed => SandboxState::Stopped, + } +} + +/// How a runtime's network is wired, decided once from the Sandbox's immutable +/// Network attachment. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RuntimeNetwork { + /// No Network Backend is attached; the runtime keeps Microsandbox's own network. + Unattached, + /// The attached Network Backend authorizes traffic through the control + /// protocol this build implements. + Controlled, +} + +impl RuntimeNetwork { + /// Refuses every attachment this build cannot enforce. Microsandbox only + /// offers its own control protocol, so any other recorded endpoint, such as + /// a control protocol from a different version, would otherwise start + /// without host network control. + fn for_attachment(attachment: Option<&network::NetworkAttachment>) -> Result { + match attachment.map(|attachment| &attachment.endpoint) { + None => Ok(Self::Unattached), + Some(network::NetworkEndpointSelection::Control(protocol)) + if protocol.as_str() == microsandbox_network::control::NETWORK_CONTROL_PROTOCOL => + { + Ok(Self::Controlled) + } + Some(endpoint) => Err(Error::UnsupportedNetworkEndpoint(endpoint.clone())), + } + } +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use std::{collections::BTreeMap, path::PathBuf}; + + use microsandbox::sandbox::VolumeMount; + use sandbox::{ + ByteQuantity, CpuQuantity, Error, Hostname, Platform, RootFilesystem, SandboxId, SandboxName, SandboxResources, + backend::{CreateSandboxRequest, SandboxBackend as _}, + image, + init::InitSystem, + network::{ + NetworkAttachment, NetworkBackendId, NetworkControlProtocolId, NetworkEndpointSelection, PacketMedium, + }, + }; + + use super::{MicrosandboxProvider, RuntimeMount, RuntimeNetwork}; + use crate::state::SandboxRecord; + + fn record_with_network(id: &str, endpoint: NetworkEndpointSelection) -> SandboxRecord { + SandboxRecord::new(CreateSandboxRequest { + id: id.parse::().expect("test Sandbox ID should be a UUID"), + name: SandboxName::new("worker").expect("test Sandbox name should be valid"), + hostname: Hostname::new("worker").expect("test hostname should be valid"), + image: image::ResolvedImage { + source: image::ImageSource::Reference { + reference: "docker.io/library/alpine:3.22".to_string(), + }, + platform: Platform::new("linux", "amd64"), + manifest_digest: "sha256:1234".to_string(), + }, + resources: SandboxResources::new( + "1".parse::().expect("test CPU should be valid"), + "512Mi".parse::().expect("test memory should be valid"), + RootFilesystem::layered( + "4Gi" + .parse::() + .expect("test root filesystem should be valid"), + ), + ), + init_system: InitSystem::Backend, + mounts: Vec::new(), + environment: BTreeMap::new(), + network: Some(NetworkAttachment { + backend: NetworkBackendId::new("microsandbox"), + endpoint, + }), + }) + } + + #[test] + fn only_an_absent_attachment_or_this_builds_control_protocol_is_accepted() { + assert_eq!( + RuntimeNetwork::for_attachment(None).ok(), + Some(RuntimeNetwork::Unattached) + ); + let controlled = record_with_network( + "00000000-0000-4000-8000-000000000010", + NetworkEndpointSelection::Control(NetworkControlProtocolId::new( + microsandbox_network::control::NETWORK_CONTROL_PROTOCOL, + )), + ); + assert_eq!( + RuntimeNetwork::for_attachment(controlled.network.as_ref()).ok(), + Some(RuntimeNetwork::Controlled) + ); + } + + // A persisted attachment that this build cannot enforce must never reach + // the runtime, where it would start without host network control. + #[tokio::test(flavor = "local")] + async fn start_refuses_a_recorded_endpoint_this_build_cannot_control() { + let home = tempfile::tempdir().expect("temporary home should be created"); + let provider = MicrosandboxProvider::open(PathBuf::from(home.path()).join("microsandbox")) + .await + .expect("Provider should open without starting a VM"); + let endpoints = [ + NetworkEndpointSelection::Control(NetworkControlProtocolId::new("microsandbox.network-control.v0")), + NetworkEndpointSelection::Packet(PacketMedium::Ethernet), + NetworkEndpointSelection::Intercepted, + ]; + for (index, endpoint) in endpoints.into_iter().enumerate() { + let mut record = + record_with_network(&format!("00000000-0000-4000-8000-00000000000{}", index + 1), endpoint); + record.name = SandboxName::new(format!("worker-{index}")).expect("test Sandbox name should be valid"); + provider + .state + .save_sandbox(&record) + .await + .expect("record should be saved"); + + let result = provider.start(&record.id).await; + + let expected = &record + .network + .as_ref() + .expect("record should have an attachment") + .endpoint; + assert!( + matches!(&result, Err(Error::UnsupportedNetworkEndpoint(actual)) if actual == expected), + "starting a Sandbox recorded with {expected:?} should be refused, got {result:?}" + ); + } + drop(provider); + } + + #[tokio::test(flavor = "local")] + async fn tmpfs_capacity_maps_to_microsandbox() { + let config = Box::pin(crate::client::build_in_client_scope( + RuntimeMount::Tmpfs { + target: "/tmp".to_string(), + capacity_mib: 4096, + } + .apply(microsandbox::sandbox::SandboxBuilder::new("sandbox").image("alpine")), + )) + .await; + + assert!(matches!( + config.spec.mounts.as_slice(), + [VolumeMount::Tmpfs { + guest, + size_mib: Some(4096), + .. + }] if guest == "/tmp" + )); + } +} diff --git a/sandbox/microsandbox/src/client.rs b/sandbox/microsandbox/src/client.rs new file mode 100644 index 0000000..2f65a2d --- /dev/null +++ b/sandbox/microsandbox/src/client.rs @@ -0,0 +1,467 @@ +#[cfg(unix)] +use std::{ + fmt::Write as _, + fs, + os::unix::ffi::OsStrExt, + os::unix::fs::{DirBuilderExt, MetadataExt}, +}; +use std::{ + future::Future, + path::{Path, PathBuf}, + rc::Rc, + sync::Arc, +}; + +use microsandbox::LocalBackend; +use sandbox::Error; +#[cfg(unix)] +use sha2::{Digest, Sha256}; +use tokio::sync::OnceCell; + +use crate::{backend::RuntimeBundle, error}; + +// Published runtime bundle digests for Microsandbox 0.7.4-digdir.3. Update these +// together with the pinned Microsandbox revisions in the workspace manifest. +const LINUX_X86_64_RUNTIME_SHA256: &str = "0fe0304ea066b991982d56e715bb6fd8655489f0b373d18845b8ea5025496725"; +const LINUX_AARCH64_RUNTIME_SHA256: &str = "1969a9a01c1689ec358c05e1a2f40350f3f86579aaea247ba1a2724a6a5f78c0"; +const MACOS_AARCH64_RUNTIME_SHA256: &str = "51f7ac53078bd92b4564ac0464ae390711a9dba05ae36af4fae764f1f09f83cd"; +const WINDOWS_X86_64_RUNTIME_SHA256: &str = "7f2569658c32f546356d1071fda823ee8a8fcdcccd698d8eb8cfb85b6d73a5ff"; +const WINDOWS_AARCH64_RUNTIME_SHA256: &str = "a87f5cab0e0e6643c65111bf2b043e522c20ce2511748422627e6f7505c544a8"; + +/// Keeps Microsandbox's thread-safe ownership model at the SDK boundary. +#[derive(Clone)] +pub(crate) struct Client { + backend: Arc, + runtime_bundle: Option, + installation: Rc>, +} + +#[derive(Clone, Copy)] +pub(crate) struct RuntimeResources { + pub(crate) cpus: u8, + pub(crate) memory_mib: u32, + pub(crate) root_filesystem_mib: u32, +} + +impl TryFrom for RuntimeResources { + type Error = Error; + + fn try_from(resources: sandbox::SandboxResources) -> Result { + let cpus = resources.cpu().whole_cpus().ok_or_else(|| { + unsupported_resource( + "cpu", + resources.cpu(), + "Microsandbox requires a whole number of virtual CPUs", + ) + })?; + let cpus = u8::try_from(cpus).map_err(|_| { + unsupported_resource( + "cpu", + resources.cpu(), + "Microsandbox virtual CPU count must fit in an unsigned 8-bit integer", + ) + })?; + let memory_mib = exact_mib("memory", resources.memory())?; + let root_filesystem_mib = exact_mib("rootFilesystem.capacity", resources.root_filesystem().capacity())?; + Ok(Self { + cpus, + memory_mib, + root_filesystem_mib, + }) + } +} + +pub(crate) fn exact_mib(resource: &'static str, quantity: sandbox::ByteQuantity) -> Result { + let mebibytes = quantity.whole_mebibytes().ok_or_else(|| { + unsupported_resource( + resource, + quantity, + "Microsandbox requires an exact whole number of mebibytes", + ) + })?; + u32::try_from(mebibytes).map_err(|_| { + unsupported_resource( + resource, + quantity, + "Microsandbox mebibyte value must fit in an unsigned 32-bit integer", + ) + }) +} + +fn unsupported_resource(resource: &'static str, value: impl std::fmt::Display, reason: &'static str) -> Error { + Error::UnsupportedResourceValue { + resource, + value: value.to_string(), + reason, + } +} + +impl Client { + pub(crate) async fn open( + microsandbox_home: PathBuf, + cache_directory: Option, + runtime_bundle: Option, + ) -> Result { + if let Some(cache_directory) = &cache_directory { + tokio::fs::create_dir_all(cache_directory) + .await + .map_err(|source| error::io("create Microsandbox cache directory", source))?; + } + #[cfg(unix)] + let run_directory = run_directory(µsandbox_home)?; + #[cfg(not(unix))] + let run_directory = microsandbox_home.join("run"); + // The Client owns this home, so its user configuration lives there + // rather than in the process user's Microsandbox configuration. + let mut builder = LocalBackend::builder() + .config_path(microsandbox_home.join("config.json")) + .home(µsandbox_home) + .run_dir(run_directory) + .disable_metrics_sample(true) + .deployment_profile(microsandbox::sandbox::DeploymentProfile::SingleTenant); + if let Some(cache_directory) = cache_directory { + builder = builder.cache_dir(cache_directory); + } + let backend = builder.build().await.map_err(error::microsandbox)?; + Ok(Self { + backend: Arc::new(backend), + runtime_bundle, + installation: Rc::new(OnceCell::new()), + }) + } + + pub(crate) fn local(&self) -> &LocalBackend { + &self.backend + } + + pub(crate) async fn bind_network_controller( + &self, + name: &str, + ) -> Result { + self.backend + .bind_network_controller(name) + .await + .map_err(error::microsandbox) + } + + /// Installs the pinned host runtime into the Client's home, replacing any + /// other version the SDK would refuse to launch. Runtime path overrides + /// would bypass the pinned digest or embedded guest agent and are refused. + pub(crate) async fn ensure_installed(&self) -> Result<(), Error> { + self.installation + .get_or_try_init(|| async { + let config = self.backend.config(); + let paths = &config.paths; + if let Some(path) = [&paths.msb, &paths.libkrunfw, &paths.agentd] + .into_iter() + .flatten() + .next() + { + return Err(Error::Backend(format!( + "Microsandbox runtime override {} is not supported", + path.display() + ))); + } + if let Ok(runtime) = microsandbox::setup::resolve_runtime(config) + && is_pinned_runtime(&runtime.msb_path) + { + return Ok(()); + } + let (source, sha256) = match &self.runtime_bundle { + Some(bundle) => ( + microsandbox::setup::InstallSource::Archive(bundle.path.clone()), + bundle.sha256.clone(), + ), + None => ( + microsandbox::setup::InstallSource::ReleaseDownload, + released_runtime_sha256() + .ok_or_else(|| Error::UnsupportedPlatform(sandbox::Platform::native("linux")))? + .to_owned(), + ), + }; + microsandbox::setup::install_runtime( + config, + microsandbox::setup::InstallOptions { + source, + force: true, + expected_archive_sha256: Some(sha256), + ..Default::default() + }, + ) + .await + .map(drop) + .map_err(error::microsandbox) + }) + .await?; + Ok(()) + } + + /// Builds a sandbox whose unset settings come from this Client's backend + /// when it is created inside [`Self::scope`], never from the process + /// user's Microsandbox configuration. + pub(crate) fn sandbox_builder( + name: impl Into, + image: impl Into, + resources: sandbox::SandboxResources, + ) -> Result { + let root_filesystem_mode = resources.root_filesystem().mode(); + let resources = RuntimeResources::try_from(resources)?; + let builder = microsandbox::sandbox::SandboxBuilder::new(name) + .image(image.into()) + .cpus(resources.cpus) + .memory(resources.memory_mib); + Ok(match root_filesystem_mode { + sandbox::RootFilesystemMode::Layered => builder.root_disk(resources.root_filesystem_mib), + sandbox::RootFilesystemMode::Direct => { + builder.root_disk_with(|disk| disk.flat().size(resources.root_filesystem_mib)) + } + mode => return Err(Error::UnsupportedRootFilesystemMode(mode)), + }) + } + + pub(crate) async fn scope(&self, future: F) -> T + where + F: Future, + { + let backend: Arc = self.backend.clone(); + microsandbox::with_backend(backend, future).await + } +} + +/// Builds within a Client whose home is private to the test, so neither the +/// host user's Microsandbox configuration nor another test leaks in. +#[cfg(test)] +#[allow(clippy::expect_used)] +pub(crate) async fn build_in_client_scope( + builder: microsandbox::sandbox::SandboxBuilder, +) -> microsandbox::sandbox::SandboxConfig { + let home = tempfile::tempdir().expect("temporary home should be created"); + let client = Client::open(home.path().join("microsandbox"), None, None) + .await + .expect("Client should open"); + Box::pin(client.scope(builder.build())) + .await + .expect("Sandbox configuration should build") +} + +#[cfg(unix)] +fn run_directory(home: &Path) -> Result { + let default = home.join("run"); + if microsandbox::runtime::run_directory_fits(&default) { + return Ok(default); + } + let digest = Sha256::digest(home.as_os_str().as_bytes()); + let mut id = String::with_capacity(32); + for byte in &digest[..16] { + let _ = write!(&mut id, "{byte:02x}"); + } + let path = PathBuf::from(format!("/tmp/microsandbox-{id}")); + if !microsandbox::runtime::run_directory_fits(&path) { + return Err(error::io( + "select private Microsandbox runtime directory", + std::io::Error::new(std::io::ErrorKind::InvalidInput, path.display().to_string()), + )); + } + let home_uid = fs::metadata(home.parent().unwrap_or(home)) + .map_err(|source| error::io("inspect Microsandbox home", source))? + .uid(); + match fs::symlink_metadata(&path) { + Ok(metadata) + if !metadata.file_type().is_dir() || metadata.uid() != home_uid || metadata.mode() & 0o077 != 0 => + { + return Err(error::io( + "validate private Microsandbox runtime directory", + std::io::Error::new(std::io::ErrorKind::PermissionDenied, path.display().to_string()), + )); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::DirBuilder::new() + .mode(0o700) + .create(&path) + .map_err(|source| error::io("create private Microsandbox runtime directory", source))?; + } + Err(source) => return Err(error::io("inspect private Microsandbox runtime directory", source)), + } + Ok(path) +} + +/// Whether an installed `msb` is the runtime this SDK build launches. +fn is_pinned_runtime(msb: &Path) -> bool { + matches!( + microsandbox::setup::resolve_runtime_version(msb), + Ok(Some(version)) if version.to_string() == microsandbox::setup::InstallOptions::default().version + ) +} + +fn released_runtime_sha256() -> Option<&'static str> { + runtime_sha256(std::env::consts::OS, std::env::consts::ARCH) +} + +pub(crate) fn runtime_sha256(os: &str, architecture: &str) -> Option<&'static str> { + match (os, architecture) { + ("linux", "x86_64") => Some(LINUX_X86_64_RUNTIME_SHA256), + ("linux", "aarch64") => Some(LINUX_AARCH64_RUNTIME_SHA256), + ("macos", "aarch64") => Some(MACOS_AARCH64_RUNTIME_SHA256), + ("windows", "x86_64") => Some(WINDOWS_X86_64_RUNTIME_SHA256), + ("windows", "aarch64") => Some(WINDOWS_AARCH64_RUNTIME_SHA256), + _ => None, + } +} + +#[cfg(test)] +// Test Clients live for the whole test; tightening their drop adds nothing. +#[allow(clippy::expect_used, clippy::significant_drop_tightening)] +mod tests { + use sandbox::{ByteQuantity, CpuQuantity, RootFilesystem, SandboxResources}; + #[cfg(unix)] + use std::fmt::Write as _; + #[cfg(unix)] + use std::os::unix::ffi::OsStrExt; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + #[cfg(unix)] + use std::path::PathBuf; + + #[cfg(unix)] + use super::Sha256; + #[cfg(unix)] + use sha2::Digest; + + use crate::client::Client; + + #[cfg(unix)] + #[test] + fn run_directory_preserves_short_homes_and_shortens_long_socket_paths() { + let normal = PathBuf::from("/Users/alice/.agent/runtime"); + assert_eq!( + super::run_directory(&normal).expect("run directory"), + normal.join("run") + ); + + let root = tempfile::tempdir().expect("temporary root"); + let long = root.path().join("username".repeat(20)); + std::fs::create_dir_all(long.parent().expect("long home parent")).expect("provider home"); + let run = super::run_directory(&long).expect("run directory"); + assert!(microsandbox::runtime::run_directory_fits(&run)); + std::fs::remove_dir(&run).expect("remove fallback directory"); + assert_ne!(run, long.join("run")); + } + + #[cfg(unix)] + #[test] + fn run_directory_rejects_unsafe_existing_fallbacks() { + let root = tempfile::tempdir().expect("temporary root"); + let home = root.path().join("home").join("longusername".repeat(20)).join("runtime"); + std::fs::create_dir_all(&home).expect("runtime home"); + let digest = Sha256::digest(home.as_os_str().as_bytes()); + let mut id = String::with_capacity(32); + for byte in &digest[..16] { + write!(&mut id, "{byte:02x}").expect("writing to String cannot fail"); + } + let fallback = PathBuf::from(format!("/tmp/microsandbox-{id}")); + + std::os::unix::fs::symlink(root.path(), &fallback).expect("fallback symlink"); + assert!(super::run_directory(&home).is_err()); + std::fs::remove_file(&fallback).expect("remove fallback symlink"); + + std::fs::write(&fallback, b"not a directory").expect("fallback file"); + assert!(super::run_directory(&home).is_err()); + std::fs::remove_file(&fallback).expect("remove fallback file"); + + std::fs::create_dir(&fallback).expect("fallback directory"); + std::fs::set_permissions(&fallback, std::fs::Permissions::from_mode(0o755)).expect("fallback permissions"); + assert!(super::run_directory(&home).is_err()); + std::fs::remove_dir(&fallback).expect("remove fallback directory"); + } + + #[test] + fn every_supported_host_runtime_download_is_digest_pinned() { + for (os, architecture) in [ + ("linux", "x86_64"), + ("linux", "aarch64"), + ("macos", "aarch64"), + ("windows", "x86_64"), + ("windows", "aarch64"), + ] { + let digest = super::runtime_sha256(os, architecture).expect("supported host digest"); + assert_eq!(digest.len(), 64); + assert!(digest.bytes().all(|byte| byte.is_ascii_hexdigit())); + } + } + + #[tokio::test(flavor = "local")] + async fn runtime_paths_outside_the_home_are_refused() { + let home = tempfile::tempdir().expect("temporary home should be created"); + let microsandbox_home = home.path().join("microsandbox"); + std::fs::create_dir_all(µsandbox_home).expect("home should be created"); + std::fs::write( + microsandbox_home.join("config.json"), + br#"{"paths":{"agentd":"/opt/other/agentd"}}"#, + ) + .expect("configuration should be written"); + let client = Client::open(microsandbox_home, None, None) + .await + .expect("Client should open"); + + let error = client + .ensure_installed() + .await + .expect_err("a configured guest agent should be refused"); + + assert!(error.to_string().contains("/opt/other/agentd"), "{error}"); + } + + #[tokio::test(flavor = "local")] + async fn sandbox_builders_use_explicit_resources_without_ambient_defaults() { + let resources = SandboxResources::new( + "2".parse::().expect("CPU should parse"), + "768Mi".parse::().expect("memory should parse"), + RootFilesystem::layered("4Gi".parse::().expect("root filesystem should parse")), + ); + let config = Box::pin(super::build_in_client_scope( + Client::sandbox_builder("sandbox", "alpine", resources).expect("resources should map to Microsandbox"), + )) + .await; + + assert_eq!(config.spec.resources.cpus, 2); + assert_eq!(config.spec.resources.memory_mib, 768); + assert_eq!(config.spec.image.oci_managed_root_disk_size_mib(), Some(4 * 1024)); + assert_eq!(config.spec.runtime.workdir, None); + } + + #[tokio::test(flavor = "local")] + async fn direct_root_filesystems_map_to_flat_microsandbox_disks() { + let resources = SandboxResources::new( + "2".parse::().expect("CPU should parse"), + "768Mi".parse::().expect("memory should parse"), + RootFilesystem::direct("4Gi".parse::().expect("root filesystem should parse")), + ); + let config = Box::pin(super::build_in_client_scope( + Client::sandbox_builder("sandbox", "alpine", resources).expect("resources should map to Microsandbox"), + )) + .await; + + assert_eq!( + config.spec.image.oci_root_disk(), + Some(µsandbox::sandbox::RootDisk::flat(4 * 1024)) + ); + } + + #[test] + fn resource_conversion_rejects_values_microsandbox_cannot_represent_exactly() { + let fractional_cpu = SandboxResources::new( + "500m".parse::().expect("CPU should parse"), + "768Mi".parse::().expect("memory should parse"), + RootFilesystem::layered("4Gi".parse::().expect("root filesystem should parse")), + ); + let decimal_memory = SandboxResources::new( + "2".parse::().expect("CPU should parse"), + "1G".parse::().expect("memory should parse"), + RootFilesystem::layered("4Gi".parse::().expect("root filesystem should parse")), + ); + + assert!(Client::sandbox_builder("sandbox", "alpine", fractional_cpu).is_err()); + assert!(Client::sandbox_builder("sandbox", "alpine", decimal_memory).is_err()); + } +} diff --git a/sandbox/microsandbox/src/encoding.rs b/sandbox/microsandbox/src/encoding.rs new file mode 100644 index 0000000..715904a --- /dev/null +++ b/sandbox/microsandbox/src/encoding.rs @@ -0,0 +1,20 @@ +pub(crate) fn lower_hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + + let mut encoded = String::with_capacity(bytes.len() * 2); + for &byte in bytes { + encoded.push(char::from(DIGITS[usize::from(byte >> 4)])); + encoded.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + encoded +} + +#[cfg(test)] +mod tests { + use super::lower_hex; + + #[test] + fn encodes_lowercase_hex() { + assert_eq!(lower_hex(&[0x00, 0x1f, 0xa5, 0xff]), "001fa5ff"); + } +} diff --git a/sandbox/microsandbox/src/error.rs b/sandbox/microsandbox/src/error.rs new file mode 100644 index 0000000..56dae69 --- /dev/null +++ b/sandbox/microsandbox/src/error.rs @@ -0,0 +1,21 @@ +use sandbox::{Error, ResourceKind}; + +pub(crate) fn microsandbox(error: microsandbox::MicrosandboxError) -> Error { + match error { + microsandbox::MicrosandboxError::SandboxNotFound(name) => Error::not_found(ResourceKind::Sandbox, &name), + microsandbox::MicrosandboxError::ImageNotFound(reference) => Error::not_found(ResourceKind::Image, &reference), + microsandbox::MicrosandboxError::VolumeNotFound(name) => Error::not_found(ResourceKind::Volume, &name), + microsandbox::MicrosandboxError::ExecFailed(failure) => { + Error::Backend(format!("Microsandbox execution failed: {}", failure.message)) + } + error => Error::Backend(error.to_string()), + } +} + +pub(crate) fn backend(error: impl std::fmt::Display) -> Error { + Error::Backend(error.to_string()) +} + +pub(crate) const fn io(operation: &'static str, source: std::io::Error) -> Error { + Error::Io { operation, source } +} diff --git a/sandbox/microsandbox/src/execution.rs b/sandbox/microsandbox/src/execution.rs new file mode 100644 index 0000000..d9cfb4f --- /dev/null +++ b/sandbox/microsandbox/src/execution.rs @@ -0,0 +1,301 @@ +use std::{cell::RefCell, collections::HashMap, rc::Rc}; + +use futures_util::stream; +use microsandbox::sandbox::{AttachOptionsBuilder, ExecOptionsBuilder}; +use microsandbox::{ExecControl, ExecEvent}; +use sandbox::{Error, LocalFuture, ResourceKind, SandboxId, execution, terminal}; + +use crate::{backend::MicrosandboxProvider, error}; + +type ExecutionKey = (SandboxId, execution::ExecutionId); +pub(crate) type ExecutionControls = Rc>>; + +// Microsandbox uses -1 when attachment ends before receiving a process exit. +const DETACHED_EXIT_CODE: i32 = -1; + +impl MicrosandboxProvider { + pub(crate) async fn start_execution_stream( + &self, + sandbox_id: &SandboxId, + request: execution::StartExecutionRequest, + ) -> Result { + let (execution_id, spec) = request.into_parts(); + let sandbox = self.state.sandbox_by_id(sandbox_id).await?; + let runtime = self.connect_running(&sandbox).await?; + let handle = start_runtime_execution(&runtime, &spec) + .await + .map_err(error::microsandbox)?; + let key = (sandbox_id.clone(), execution_id.clone()); + self.executions.borrow_mut().insert(key.clone(), handle.control()); + let guard = ExecutionGuard { + controls: Rc::clone(&self.executions), + key, + }; + let events = stream::unfold((handle, guard), |(mut handle, guard)| async move { + handle.recv().await.map(|event| { + let event = map_event(event); + (event, (handle, guard)) + }) + }); + + Ok(execution::StartedExecution { + id: execution_id, + events: Box::pin(events), + }) + } + + pub(crate) async fn start_terminal_execution_stream( + &self, + sandbox_id: &SandboxId, + request: terminal::StartTerminalExecutionRequest, + ) -> Result { + let (execution_id, spec, initial_size) = request.into_parts(); + let sandbox = self.state.sandbox_by_id(sandbox_id).await?; + let runtime = self.connect_running(&sandbox).await?; + let mut handle = start_runtime_terminal_execution(&runtime, &spec, initial_size) + .await + .map_err(error::microsandbox)?; + let input = handle + .take_stdin() + .ok_or_else(|| Error::Backend("Microsandbox terminal Execution did not provide stdin".to_string()))?; + let runtime_control = handle.control(); + let key = (sandbox_id.clone(), execution_id.clone()); + self.executions + .borrow_mut() + .insert(key.clone(), runtime_control.clone()); + let guard = ExecutionGuard { + controls: Rc::clone(&self.executions), + key, + }; + let events = stream::unfold((handle, guard), |(mut handle, guard)| async move { + handle.recv().await.map(|event| { + let event = map_terminal_event(event); + (event, (handle, guard)) + }) + }); + + Ok(terminal::StartedTerminalExecution { + id: execution_id, + control: Rc::new(MicrosandboxTerminalControl { input, runtime_control }), + events: Box::pin(events), + }) + } + + pub(crate) async fn attach_terminal_to_runtime( + &self, + sandbox_id: &SandboxId, + request: terminal::AttachTerminalRequest, + ) -> Result { + let sandbox = self.state.sandbox_by_id(sandbox_id).await?; + let runtime = self.connect_running(&sandbox).await?; + let (spec, detach_keys) = request.into_parts(); + let exit_code = match spec.program() { + execution::Program::ImageEntrypoint => { + runtime + .attach_default_with(|options| apply_attach_options(options, &spec, detach_keys.as_deref())) + .await + } + execution::Program::Command { executable, args } => { + runtime + .attach_with(executable.as_str(), |options| { + apply_attach_options(options.args(args.iter().cloned()), &spec, detach_keys.as_deref()) + }) + .await + } + } + .map_err(error::microsandbox)?; + + Ok(if exit_code == DETACHED_EXIT_CODE { + terminal::TerminalAttachOutcome::Detached + } else { + terminal::TerminalAttachOutcome::Exited(execution::ExitStatus { code: exit_code }) + }) + } + + pub(crate) async fn control_execution( + &self, + sandbox_id: &SandboxId, + execution_id: &execution::ExecutionId, + force: bool, + ) -> Result<(), Error> { + let key = (sandbox_id.clone(), execution_id.clone()); + let control = self + .executions + .borrow() + .get(&key) + .cloned() + .ok_or_else(|| Error::not_found(ResourceKind::Execution, execution_id))?; + if force { + control.kill().await + } else { + control.signal(15).await + } + .map_err(error::microsandbox) + } +} + +fn apply_attach_options( + mut options: AttachOptionsBuilder, + spec: &execution::ExecutionSpec, + detach_keys: Option<&str>, +) -> AttachOptionsBuilder { + if let Some(working_directory) = spec.working_directory() { + options = options.cwd(working_directory.as_str()); + } + if let Some(detach_keys) = detach_keys { + options = options.detach_keys(detach_keys); + } + options.envs( + spec.environment() + .iter() + .map(|(key, value)| (key.clone(), value.clone())), + ) +} + +async fn start_runtime_execution( + runtime: µsandbox::Sandbox, + spec: &execution::ExecutionSpec, +) -> microsandbox::MicrosandboxResult { + let working_directory = spec.working_directory().map(|path| path.as_str().to_string()); + let environment = spec.environment().clone(); + match spec.program() { + execution::Program::ImageEntrypoint => { + runtime + .exec_default_stream_with(move |options| configure(options, working_directory, environment)) + .await + } + execution::Program::Command { executable, args } => { + let args = args.clone(); + runtime + .exec_stream_with(executable.as_str(), move |options| { + configure(options.args(args), working_directory, environment) + }) + .await + } + } +} + +async fn start_runtime_terminal_execution( + runtime: µsandbox::Sandbox, + spec: &execution::ExecutionSpec, + size: terminal::TerminalSize, +) -> microsandbox::MicrosandboxResult { + let working_directory = spec.working_directory().map(|path| path.as_str().to_string()); + let environment = spec.environment().clone(); + match spec.program() { + execution::Program::ImageEntrypoint => { + runtime + .exec_default_stream_with(move |options| { + configure( + options + .stdin_pipe() + .tty(true) + .terminal_size(size.rows(), size.columns()), + working_directory, + environment, + ) + }) + .await + } + execution::Program::Command { executable, args } => { + let args = args.clone(); + runtime + .exec_stream_with(executable.as_str(), move |options| { + configure( + options + .args(args) + .stdin_pipe() + .tty(true) + .terminal_size(size.rows(), size.columns()), + working_directory, + environment, + ) + }) + .await + } + } +} + +fn configure( + mut options: ExecOptionsBuilder, + working_directory: Option, + environment: std::collections::BTreeMap, +) -> ExecOptionsBuilder { + if let Some(directory) = working_directory { + options = options.cwd(directory); + } + options.envs(environment) +} + +fn map_event(event: ExecEvent) -> Result { + Ok(match event { + ExecEvent::Started { pid } => execution::ExecutionEvent::Started { process_id: Some(pid) }, + ExecEvent::Stdout(bytes) => execution::ExecutionEvent::Stdout(bytes), + ExecEvent::Stderr(bytes) => execution::ExecutionEvent::Stderr(bytes), + ExecEvent::Exited { code } => execution::ExecutionEvent::Exited(execution::ExitStatus { code }), + ExecEvent::Failed(failure) => execution::ExecutionEvent::Failed { + message: failure.message, + }, + ExecEvent::StdinError(failure) => { + return Err(Error::Backend(format!( + "Microsandbox Execution stdin failed: {failure:?}" + ))); + } + }) +} + +fn map_terminal_event(event: ExecEvent) -> Result { + Ok(match event { + ExecEvent::Started { pid } => terminal::TerminalEvent::Started { process_id: Some(pid) }, + ExecEvent::Stdout(bytes) | ExecEvent::Stderr(bytes) => terminal::TerminalEvent::Output(bytes), + ExecEvent::Exited { code } => terminal::TerminalEvent::Exited(execution::ExitStatus { code }), + ExecEvent::Failed(failure) => terminal::TerminalEvent::Failed { + message: failure.message, + }, + ExecEvent::StdinError(failure) => { + return Err(Error::Backend(format!( + "Microsandbox terminal Execution stdin failed: {failure:?}" + ))); + } + }) +} + +struct MicrosandboxTerminalControl { + input: microsandbox::sandbox::exec::ExecSink, + runtime_control: ExecControl, +} + +impl terminal::TerminalControl for MicrosandboxTerminalControl { + fn write_input(&self, bytes: bytes::Bytes) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + if bytes.is_empty() { + return Ok(()); + } + self.input.write(bytes).await.map_err(error::microsandbox) + }) + } + + fn close_input(&self) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { self.input.close().await.map_err(error::microsandbox) }) + } + + fn resize(&self, size: terminal::TerminalSize) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + self.runtime_control + .resize(size.rows(), size.columns()) + .await + .map_err(error::microsandbox) + }) + } +} + +struct ExecutionGuard { + controls: ExecutionControls, + key: ExecutionKey, +} + +impl Drop for ExecutionGuard { + fn drop(&mut self) { + self.controls.borrow_mut().remove(&self.key); + } +} diff --git a/sandbox/microsandbox/src/files.rs b/sandbox/microsandbox/src/files.rs new file mode 100644 index 0000000..aab9fe9 --- /dev/null +++ b/sandbox/microsandbox/src/files.rs @@ -0,0 +1,163 @@ +use futures_util::stream; +use microsandbox::sandbox::{FsEntryKind, FsHandle, FsMetadata, FsOpenOptions, FsSetAttrs, SandboxFsOps}; +use sandbox::{Error, SandboxId, SandboxPath, file_transfer::ByteReader}; +use tokio::io::AsyncReadExt as _; +use tokio_util::io::StreamReader; + +use crate::backend::MicrosandboxProvider; + +impl MicrosandboxProvider { + pub(crate) async fn read_file_stream( + &self, + sandbox_id: &SandboxId, + path: &SandboxPath, + ) -> Result { + let record = self.state.sandbox_by_id(sandbox_id).await?; + let runtime = self.connect_running(&record).await?; + let source = runtime + .fs() + .read_stream(path.as_str()) + .await + .map_err(crate::error::microsandbox)?; + let chunks = stream::try_unfold(source, |mut source| async move { + source + .recv() + .await + .map(|chunk| chunk.map(|bytes| (bytes, source))) + .map_err(std::io::Error::other) + }); + Ok(Box::pin(StreamReader::new(chunks))) + } + + /// Replaces the file atomically: the contents stream into a hidden sibling, which is renamed + /// over the destination once complete, so a concurrent reader sees the old or the new file + /// and never a truncated or partially written one. The sibling is created readable by the + /// guest supervisor only, so the contents are not exposed while they stream. A replaced + /// regular file keeps its mode and owner; a new file gets the guest's default mode once + /// complete, as an in-place write would have. + pub(crate) async fn write_file_stream( + &self, + sandbox_id: &SandboxId, + path: &SandboxPath, + contents: ByteReader, + ) -> Result<(), Error> { + let record = self.state.sandbox_by_id(sandbox_id).await?; + let runtime = self.connect_running(&record).await?; + let fs = runtime.fs(); + let target = path.as_str(); + let existing = existing_regular_file(&fs, target).await?; + let staging = staging_path(target); + let replaced = match stage(&fs, &staging, existing.as_ref(), contents).await { + Ok(()) => fs.rename(&staging, target).await.map_err(crate::error::microsandbox), + Err(error) => Err(error), + }; + if replaced.is_err() { + // Best effort: the staging file is garbage once the write or rename failed, and the + // original error is what the caller needs to see. + let _ = fs.remove(&staging).await; + } + replaced + } +} + +async fn existing_regular_file(fs: &SandboxFsOps<'_>, path: &str) -> Result, Error> { + if !fs.exists(path).await.map_err(crate::error::microsandbox)? { + return Ok(None); + } + let metadata = fs.stat(path).await.map_err(crate::error::microsandbox)?; + Ok(matches!(metadata.kind, FsEntryKind::File).then_some(metadata)) +} + +/// Owner read and write only, for the sibling while its contents stream. +const STAGING_MODE: u32 = 0o600; +/// What the guest supervisor's default file creation yields; a new file ends up with this. +const DEFAULT_MODE: u32 = 0o644; + +async fn stage( + fs: &SandboxFsOps<'_>, + staging: &str, + existing: Option<&FsMetadata>, + contents: ByteReader, +) -> Result<(), Error> { + // The mode applies at creation, before any byte is written; a plain streamed write would + // create the sibling with the guest's default, world-readable mode. + let options = FsOpenOptions { + write: true, + create_new: true, + mode: Some(STAGING_MODE), + ..FsOpenOptions::default() + }; + let handle = fs + .open_file(staging, options) + .await + .map_err(crate::error::microsandbox)?; + let streamed = stream_into(fs, handle, contents).await; + let closed = fs.close_handle(handle).await.map_err(crate::error::microsandbox); + streamed?; + closed?; + let mode = if let Some(existing) = existing { + // Ownership first: chown clears set-user-ID and set-group-ID bits, so the mode must be + // applied afterwards for the replacement to keep them. + let ownership = FsSetAttrs { + uid: Some(existing.uid), + gid: Some(existing.gid), + ..FsSetAttrs::default() + }; + fs.set_stat(staging, false, ownership) + .await + .map_err(crate::error::microsandbox)?; + existing.mode + } else { + DEFAULT_MODE + }; + let attributes = FsSetAttrs { + mode: Some(mode), + ..FsSetAttrs::default() + }; + fs.set_stat(staging, false, attributes) + .await + .map_err(crate::error::microsandbox) +} + +async fn stream_into(fs: &SandboxFsOps<'_>, handle: FsHandle, mut contents: ByteReader) -> Result<(), Error> { + let destination = fs + .write_handle_stream(handle, 0, None) + .await + .map_err(crate::error::microsandbox)?; + let mut buffer = vec![0_u8; 64 * 1024].into_boxed_slice(); + loop { + let read = contents + .read(&mut buffer) + .await + .map_err(|source| crate::error::io("read runtime file-transfer input", source))?; + if read == 0 { + break; + } + destination + .write(&buffer[..read]) + .await + .map_err(crate::error::microsandbox)?; + } + destination.close().await.map_err(crate::error::microsandbox) +} + +/// A hidden sibling in the destination's directory, so the final rename stays on one file system +/// and directory listings that skip dotfiles never show the staging file. +fn staging_path(target: &str) -> String { + let (directory, name) = target.rsplit_once('/').unwrap_or(("", target)); + format!("{directory}/.{name}.agent-{}.tmp", uuid::Uuid::new_v4()) +} + +#[cfg(test)] +mod tests { + use super::staging_path; + + #[test] + fn staging_path_is_a_hidden_sibling() { + let staging = staging_path("/home/agent/.claude/skills/evidence/SKILL.md"); + let (directory, name) = staging.rsplit_once('/').expect("directory"); + assert_eq!(directory, "/home/agent/.claude/skills/evidence"); + assert!(name.starts_with(".SKILL.md.agent-")); + assert_eq!(std::path::Path::new(name).extension(), Some("tmp".as_ref())); + } +} diff --git a/sandbox/microsandbox/src/guest_tcp.rs b/sandbox/microsandbox/src/guest_tcp.rs new file mode 100644 index 0000000..32ac828 --- /dev/null +++ b/sandbox/microsandbox/src/guest_tcp.rs @@ -0,0 +1,367 @@ +//! Host-dialed TCP streams into a running Microsandbox guest. +//! +//! The Microsandbox agent relay multiplexes `TcpConnect` streams over the +//! Sandbox's host socket, so a host process can open TCP connections that are +//! dialed from inside the guest network namespace without any configuration +//! on the Sandbox itself. + +use std::{cell::Cell, sync::Arc}; + +use microsandbox::{ + agent::AgentClient, + protocol::{ + message::{Message, MessageType}, + tcp::{TcpClose, TcpConnect, TcpConnected, TcpData, TcpEof, TcpFailed}, + }, +}; +use sandbox::{Error, SandboxId}; +use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _}; + +use crate::{MicrosandboxProvider, error}; + +const RELAY_READ_BUFFER_BYTES: usize = 32 * 1024; + +/// Dials TCP connections from inside one running Sandbox. +/// +/// The dialer keeps a single multiplexed agent connection, so opening many +/// concurrent streams through one dialer is cheap. It stops working when the +/// Sandbox runtime restarts; create a replacement through +/// [`MicrosandboxProvider::guest_tcp_dialer`] when a connect fails. +pub struct GuestTcpDialer { + client: Arc, + // Keeps the connected runtime handle (and its relay session) alive. + _sandbox: microsandbox::Sandbox, +} + +/// One open TCP stream dialed from inside the guest. +pub struct GuestTcpStream { + id: u32, + client: Arc, + receiver: tokio::sync::mpsc::Receiver, +} + +impl MicrosandboxProvider { + /// Connects a TCP dialer to one running Sandbox. + /// + /// # Errors + /// + /// Returns an error when the Sandbox is unknown, not running, or its + /// runtime predates agent-relay TCP support. + pub async fn guest_tcp_dialer(&self, id: &SandboxId) -> Result { + let record = self.state.sandbox_by_id(id).await?; + let sandbox = self.connect_running(&record).await?; + let client = sandbox.client_arc(); + if !client.supports(MessageType::TcpConnect) { + return Err(Error::Backend( + "Sandbox runtime does not support agent-relay TCP forwarding; restart the Sandbox".into(), + )); + } + Ok(GuestTcpDialer { + client, + _sandbox: sandbox, + }) + } +} + +impl GuestTcpDialer { + /// Opens one TCP connection dialed from inside the guest. + /// + /// # Errors + /// + /// Returns an error when the relay stream cannot be opened or the guest + /// dial is rejected. + pub async fn connect(&self, host: &str, port: u16) -> Result { + GuestTcpStream::open(Arc::clone(&self.client), host, port).await + } +} + +impl GuestTcpStream { + async fn open(client: Arc, host: &str, port: u16) -> Result { + let request = TcpConnect { + host: host.to_owned(), + port, + bulk: None, + }; + let (id, mut receiver) = client + .stream(MessageType::TcpConnect, &request) + .await + .map_err(error::backend)?; + let Some(first) = receiver.recv().await else { + return Err(Error::Backend( + "Sandbox agent closed the TCP stream before replying to connect".into(), + )); + }; + match first.t { + MessageType::TcpConnected => { + let _: TcpConnected = first.payload().map_err(error::backend)?; + Ok(Self { id, client, receiver }) + } + MessageType::TcpFailed => { + let failed: TcpFailed = first.payload().map_err(error::backend)?; + Err(Error::Backend(format!( + "guest TCP connect to {host}:{port} failed: {}", + failed.error + ))) + } + other => Err(Error::Backend(format!( + "unexpected Sandbox agent reply {:?} to guest TCP connect", + other.as_str() + ))), + } + } +} + +impl Drop for GuestTcpStream { + /// Releases the guest socket and its agent session on every path — normal + /// completion, an error, and a cancelled relay task alike. + fn drop(&mut self) { + let client = Arc::clone(&self.client); + let id = self.id; + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + let _ = client.send(id, MessageType::TcpClose, &TcpClose {}).await; + }); + } + } +} + +impl GuestTcpStream { + /// Pipes bytes between a host socket and the guest connection until both + /// directions have closed; dropping the stream releases the guest session. + /// + /// # Errors + /// + /// Returns an error when a host socket read or write fails, or a relay + /// message cannot be sent or decoded. + pub async fn relay(self, stream: tokio::net::TcpStream) -> Result<(), Error> { + let (host_reader, host_writer) = stream.into_split(); + self.relay_io(host_reader, host_writer).await + } + + /// Pipes bytes between an arbitrary host byte stream pair, such as a + /// process's standard input and output, and the guest connection until + /// both directions have closed; dropping the stream releases the guest + /// session. + /// + /// # Errors + /// + /// Returns an error when a host read or write fails, or a relay message + /// cannot be sent or decoded. + pub async fn relay_io(mut self, mut host_reader: R, mut host_writer: W) -> Result<(), Error> + where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, + { + let client = Arc::clone(&self.client); + let id = self.id; + let host_closed = Cell::new(false); + let guest_closed = Cell::new(false); + + let host_to_guest = async { + let mut buffer = vec![0u8; RELAY_READ_BUFFER_BYTES]; + loop { + let read = host_reader + .read(&mut buffer) + .await + .map_err(|source| error::io("read forwarded host connection", source))?; + if read == 0 { + client + .send(id, MessageType::TcpEof, &TcpEof {}) + .await + .map_err(error::backend)?; + host_closed.set(true); + return Ok::<(), Error>(()); + } + let data = TcpData { + data: buffer[..read].to_vec(), + }; + client + .send(id, MessageType::TcpData, &data) + .await + .map_err(error::backend)?; + } + }; + + let guest_to_host = async { + while let Some(message) = self.receiver.recv().await { + match message.t { + MessageType::TcpData => { + let data: TcpData = message.payload().map_err(error::backend)?; + host_writer + .write_all(&data.data) + .await + .map_err(|source| error::io("write forwarded host connection", source))?; + // A buffered writer such as `tokio::io::stdout()`, used by `ssh-proxy`, + // holds bytes until the buffer fills; an interactive protocol stalls + // waiting for a reply that is sitting unflushed. Push whatever has + // arrived out once the guest has nothing more queued, which keeps a + // bulk transfer batched while never stranding an idle response. + if self.receiver.is_empty() { + host_writer + .flush() + .await + .map_err(|source| error::io("flush forwarded host connection", source))?; + } + } + MessageType::TcpEof => { + host_writer + .shutdown() + .await + .map_err(|source| error::io("shut down forwarded host connection", source))?; + guest_closed.set(true); + if host_closed.get() { + return Ok(()); + } + } + MessageType::TcpClosed => return Ok::<(), Error>(()), + other => { + return Err(Error::Backend(format!( + "unexpected Sandbox agent message {:?} on a guest TCP stream", + other.as_str() + ))); + } + } + } + Ok(()) + }; + + // A host-side EOF is a half-close: the guest may still be writing its + // response, so the relay ends when the guest side has closed too. + // Neither end sends a terminal frame after a mutual half-close, so + // waiting for one here would pin the closed socket forever. + tokio::pin!(guest_to_host); + tokio::select! { + result = &mut guest_to_host => result, + result = host_to_guest => match result { + Ok(()) if guest_closed.get() => Ok(()), + Ok(()) => guest_to_host.await, + Err(error) => Err(error), + }, + } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, clippy::panic)] + + use std::{sync::Arc, time::Duration}; + + use microsandbox::{ + agent::AgentClient, + protocol::{ + codec, + core::Ready, + message::{Message, MessageType}, + tcp::{TcpConnected, TcpEof}, + }, + }; + use tokio::{ + io::{AsyncReadExt as _, AsyncWriteExt as _, DuplexStream}, + net::{TcpListener, TcpStream}, + }; + + use super::GuestTcpStream; + + const RELAY_ID_MIN: u32 = 1; + const RELAY_ID_MAX: u32 = 1 << 20; + + /// The guest end of the agent relay, driven by the test one frame at a time. + struct FakeGuestAgent { + wire: DuplexStream, + pending: Vec, + } + + impl FakeGuestAgent { + async fn handshake() -> (Arc, Self) { + let (host, guest) = tokio::io::duplex(64 * 1024); + let mut agent = Self { + wire: guest, + pending: Vec::new(), + }; + agent + .wire + .write_all(&RELAY_ID_MIN.to_be_bytes()) + .await + .expect("write relay id range start"); + agent + .wire + .write_all(&RELAY_ID_MAX.to_be_bytes()) + .await + .expect("write relay id range end"); + agent + .write(&Message::with_payload(MessageType::Ready, 0, &Ready::default()).expect("ready frame")) + .await; + let client = AgentClient::connect_stream_with_timeout(host, Duration::from_secs(5)) + .await + .expect("relay handshake"); + (Arc::new(client), agent) + } + + async fn write(&mut self, message: &Message) { + let mut frame = Vec::new(); + codec::encode_to_buf(message, &mut frame).expect("encode frame"); + self.wire.write_all(&frame).await.expect("write frame"); + } + + async fn expect(&mut self, expected: MessageType) -> Message { + loop { + if let Some(message) = codec::try_decode_from_buf(&mut self.pending).expect("decode frame") { + assert_eq!(message.t, expected, "unexpected frame from the host relay"); + return message; + } + let mut chunk = [0u8; 4096]; + let read = self.wire.read(&mut chunk).await.expect("read frame"); + assert!(read > 0, "host closed the relay before sending {expected:?}"); + self.pending.extend_from_slice(&chunk[..read]); + } + } + } + + #[tokio::test(flavor = "local")] + async fn relay_ends_once_both_directions_have_closed() { + let (client, mut agent) = FakeGuestAgent::handshake().await; + let (opened, ()) = tokio::join!(GuestTcpStream::open(client, "127.0.0.1", 80), async { + let connect = agent.expect(MessageType::TcpConnect).await; + agent + .write( + &Message::with_payload(MessageType::TcpConnected, connect.id, &TcpConnected {}) + .expect("connected frame"), + ) + .await; + }); + let stream = opened.expect("guest connect should succeed"); + let session = stream.id; + + let listener = TcpListener::bind(("127.0.0.1", 0)).await.expect("bind host listener"); + let mut browser = TcpStream::connect(listener.local_addr().expect("listener address")) + .await + .expect("connect browser side"); + let (forwarded, _) = listener.accept().await.expect("accept forwarded connection"); + let relay = tokio::task::spawn_local(stream.relay(forwarded)); + + browser.shutdown().await.expect("half-close the browser side"); + agent.expect(MessageType::TcpEof).await; + + agent + .write(&Message::with_payload(MessageType::TcpEof, session, &TcpEof {}).expect("eof frame")) + .await; + let mut sink = [0u8; 1]; + let read = browser + .read(&mut sink) + .await + .expect("read guest EOF on the browser side"); + assert_eq!(read, 0, "guest EOF should half-close the browser side"); + + tokio::time::timeout(Duration::from_secs(2), relay) + .await + .expect("relay should finish once both directions have closed") + .expect("relay task should not panic") + .expect("relay should end cleanly"); + let close = agent.expect(MessageType::TcpClose).await; + assert_eq!( + close.id, session, + "dropping the finished relay should release the guest session" + ); + } +} diff --git a/sandbox/microsandbox/src/heartbeat.rs b/sandbox/microsandbox/src/heartbeat.rs new file mode 100644 index 0000000..c8fc25b --- /dev/null +++ b/sandbox/microsandbox/src/heartbeat.rs @@ -0,0 +1,140 @@ +//! Guest heartbeats read from a running runtime's host-side directory. +//! +//! Microsandbox's guest agent replaces `heartbeat.json` in the runtime +//! directory about once a second from a dedicated thread, and the runtime +//! removes it before every boot. Reading the file needs no round trip to the +//! guest, so a guest that stopped responding keeps reporting the sequence it +//! last wrote. Only the sequence is read: the guest-written timestamps follow +//! the guest clock, which falls behind the host's while the guest is stalled. +//! +//! The guest writes the file through a directory it shares with the host, so +//! anything in the guest can replace it, with up to the runtime directory's +//! quota (16 MiB). It is read only as a small regular file, within a time +//! bound, whatever the shared directory makes of a guest's special files. + +use std::{io, path::Path, time::Duration}; + +use sandbox::GuestHeartbeat; +use serde::Deserialize; +use tokio::io::AsyncReadExt as _; + +/// Heartbeat file in a runtime directory, written by the guest agent. +const HEARTBEAT_FILE: &str = "heartbeat.json"; +/// Largest heartbeat file read; the guest agent writes a few hundred bytes. +const MAX_HEARTBEAT_BYTES: u64 = 4096; +/// Longest one read of the guest-controlled file may take. +const READ_TIMEOUT: Duration = Duration::from_secs(1); + +#[derive(Deserialize)] +struct HeartbeatFile { + heartbeat_seq: u64, +} + +/// Reads the latest guest heartbeat, or none before the guest's first one. +/// +/// A missing or unreadable file reports no heartbeat rather than failing +/// inspection: it is evidence about the guest, not about the Sandbox. +pub(crate) async fn read(runtime_directory: &Path) -> Option { + let path = runtime_directory.join(HEARTBEAT_FILE); + let read = tokio::time::timeout(READ_TIMEOUT, read_bounded(&path)) + .await + .unwrap_or_else(|_| Err(io::Error::new(io::ErrorKind::TimedOut, "reading timed out"))); + let contents = match read { + Ok(contents) => contents, + Err(error) if error.kind() == io::ErrorKind::NotFound => return None, + Err(error) => { + tracing::debug!(%error, path = %path.display(), "could not read Microsandbox guest heartbeat"); + return None; + } + }; + match serde_json::from_slice::(&contents) { + Ok(file) => Some(GuestHeartbeat::new(file.heartbeat_seq)), + Err(error) => { + tracing::debug!(%error, path = %path.display(), "could not parse Microsandbox guest heartbeat"); + None + } + } +} + +/// Reads at most [`MAX_HEARTBEAT_BYTES`] of a regular file, without following +/// a symlink to it. The file is checked before it is opened, so a FIFO is not +/// opened, and again once open, in case it was replaced in between. +async fn read_bounded(path: &Path) -> io::Result> { + let not_heartbeat = || io::Error::new(io::ErrorKind::InvalidData, "not a small regular file"); + let metadata = tokio::fs::symlink_metadata(path).await?; + if !metadata.is_file() || metadata.len() > MAX_HEARTBEAT_BYTES { + return Err(not_heartbeat()); + } + let file = tokio::fs::File::open(path).await?; + if !file.metadata().await?.is_file() { + return Err(not_heartbeat()); + } + let mut contents = Vec::new(); + file.take(MAX_HEARTBEAT_BYTES).read_to_end(&mut contents).await?; + Ok(contents) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use sandbox::GuestHeartbeat; + + use super::{HEARTBEAT_FILE, read}; + + #[tokio::test(flavor = "local")] + async fn reads_the_sequence_of_the_guest_agents_heartbeat() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + std::fs::write( + directory.path().join(HEARTBEAT_FILE), + br#"{"heartbeat_seq":20,"activity_seq":217,"timestamp":"2026-09-30T13:41:12.662733257Z", +"last_activity":"2026-09-30T13:40:59.618201064Z","active_exec_sessions":0,"active_fs_streams":0, +"active_tcp_streams":0,"activity_counters":{"host_messages":134,"guest_messages":83, +"exec_output_bytes":0,"fs_bytes":4096,"tcp_bytes":0}}"#, + ) + .expect("heartbeat should be written"); + + assert_eq!(read(directory.path()).await, Some(GuestHeartbeat::new(20))); + } + + #[tokio::test(flavor = "local")] + async fn a_missing_or_malformed_heartbeat_is_not_evidence() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + assert_eq!(read(directory.path()).await, None); + + std::fs::write(directory.path().join(HEARTBEAT_FILE), b"{\"heartbeat_seq\":") + .expect("heartbeat should be written"); + assert_eq!(read(directory.path()).await, None); + } + + #[tokio::test(flavor = "local")] + async fn a_large_heartbeat_file_is_not_read() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let mut large = br#"{"heartbeat_seq":20,"padding":""#.to_vec(); + large.resize(64 * 1024, b' '); + large.extend_from_slice(br#""}"#); + std::fs::write(directory.path().join(HEARTBEAT_FILE), large).expect("heartbeat should be written"); + + assert_eq!(read(directory.path()).await, None); + } + + #[cfg(unix)] + #[tokio::test(flavor = "local")] + async fn a_heartbeat_that_is_not_a_regular_file_is_not_read() { + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let target = directory.path().join("elsewhere.json"); + std::fs::write(&target, br#"{"heartbeat_seq":20}"#).expect("target should be written"); + std::os::unix::fs::symlink(&target, directory.path().join(HEARTBEAT_FILE)).expect("symlink"); + assert_eq!(read(directory.path()).await, None, "a symlink is not followed"); + + std::fs::remove_file(directory.path().join(HEARTBEAT_FILE)).expect("symlink should be removed"); + let made = std::process::Command::new("mkfifo") + .arg(directory.path().join(HEARTBEAT_FILE)) + .status() + .expect("mkfifo should run"); + assert!(made.success()); + let read = tokio::time::timeout(std::time::Duration::from_secs(5), read(directory.path())) + .await + .expect("a FIFO must not block the read"); + assert_eq!(read, None); + } +} diff --git a/sandbox/microsandbox/src/image.rs b/sandbox/microsandbox/src/image.rs new file mode 100644 index 0000000..6605183 --- /dev/null +++ b/sandbox/microsandbox/src/image.rs @@ -0,0 +1,1202 @@ +use std::{ + collections::HashSet, + fs::File, + future::Future, + path::{Path, PathBuf}, +}; + +use bollard::{ + Docker, + query_parameters::{BuildImageOptionsBuilder, BuilderVersion, TagImageOptionsBuilder}, +}; +use futures_util::StreamExt as _; +use ignore::gitignore::{Gitignore, GitignoreBuilder}; +use sandbox::progress::{MeasuredStep, ProgressStep, SandboxProgress}; +use sandbox::{Error, LocalFuture, OutputStream, PendingOperation, ProgressUnit, RootFilesystemMode, image}; +use sha2::{Digest as _, Sha256}; +use tokio::io::AsyncWriteExt as _; +use tokio_util::codec::{BytesCodec, FramedRead}; +use uuid::Uuid; + +use crate::{ + client::Client, + encoding::lower_hex, + error, + image_cache::{self, ImageCache}, + platform, +}; + +const CHECK_DOCKER: &str = "Check Docker Engine"; +const PREPARE_CONTEXT: &str = "Prepare Docker build context"; +const BUILD_IMAGE: &str = "Build Docker image"; +const PULL_IMAGE: &str = "Pull OCI image"; +const LOOKUP_IMPORTED_IMAGE: &str = "Look up imported Microsandbox image"; +const EXPORT_IMAGE: &str = "Export Docker image"; +const IMPORT_IMAGE: &str = "Import Microsandbox image"; +const DOWNLOAD_LAYERS: &str = "Download image layers"; +const MATERIALIZE_LAYERS: &str = "Materialize image layers"; +const ASSEMBLE_ROOT_DISK: &str = "Assemble root disk"; +const RETAIN_BUILD_CACHE: &str = "Retain Docker build cache"; +const REMOVE_TEMPORARY_IMAGE: &str = "Remove temporary Docker image"; +const EXPORT_PREPARED_ROOT: &str = "Export prepared root"; +const IMPORT_PREPARED_ROOT: &str = "Import prepared root"; +const EXPORT_PROGRESS_INTERVAL: u64 = 32 * 1024 * 1024; +const CACHE_REPOSITORY: &str = "sandbox-microsandbox-cache"; +const IMPORT_CACHE_REPOSITORY: &str = "sandbox-microsandbox-import"; + +/// Resolves Dockerfile builds and OCI references into the Microsandbox cache +/// used by its paired Backend. +pub(crate) struct MicrosandboxImageBackend { + client: Client, + images: ImageCache, + docker: Result, + registry_authentication: Option, +} + +impl MicrosandboxImageBackend { + pub(crate) fn new( + client: Client, + images: ImageCache, + registry_authentication: Option, + ) -> Self { + Self { + client, + images, + docker: Docker::connect_with_defaults().map_err(|failure| failure.to_string()), + registry_authentication, + } + } + + fn docker(&self) -> Result<&Docker, Error> { + self.docker.as_ref().map_err(|failure| Error::Backend(failure.clone())) + } + + /// Scratch directory for image and build-context archives, inside the Microsandbox cache. + /// + /// The system temporary directory is often a small tmpfs (a Sandbox guest gives `/tmp` + /// 512 MiB), while an exported image archive is as large as the image itself. + async fn scratch_dir(&self) -> Result { + let scratch = self.images.scratch_directory(); + tokio::fs::create_dir_all(&scratch) + .await + .map_err(|source| error::io("create image scratch directory", source))?; + Ok(scratch) + } + + async fn build_image( + &self, + request: &image::ResolveRequest, + context: &Path, + dockerfile: &Path, + target: Option<&str>, + progress: &SandboxProgress, + ) -> Result { + let platform = platform::require_supported(&request.platform)?; + self.check_docker(progress).await?; + let prepared = self + .prepare_context(context, dockerfile, target, &request.platform, progress) + .await?; + let build_id = Uuid::new_v4().simple().to_string(); + let temporary_tag = format!("sandbox-microsandbox-build:{build_id}"); + self.build_docker_image(&prepared, &temporary_tag, &build_id, &platform, progress) + .await?; + let resolution = self + .resolve_built_image(&temporary_tag, &prepared.cache_tag, progress) + .await; + let cleanup = self.remove_temporary_image(&temporary_tag, progress).await; + let metadata = resolution?; + cleanup?; + Ok(metadata) + } + + async fn check_docker(&self, progress: &SandboxProgress) -> Result<(), Error> { + let step = progress.start_step(CHECK_DOCKER).await; + self.docker()?.ping().await.map_err(error::backend)?; + step.complete().await; + Ok(()) + } + + async fn prepare_context( + &self, + source_context: &Path, + source_dockerfile: &Path, + target: Option<&str>, + platform: &sandbox::Platform, + progress: &SandboxProgress, + ) -> Result { + let step = progress.start_step(PREPARE_CONTEXT).await; + let context = tokio::fs::canonicalize(source_context) + .await + .map_err(|source| error::io("resolve Docker build context", source))?; + let dockerfile = tokio::fs::canonicalize(context.join(source_dockerfile)) + .await + .map_err(|source| error::io("resolve Dockerfile", source))?; + let relative_dockerfile = dockerfile + .strip_prefix(&context) + .map_err(|_| Error::invalid("image.dockerfile", "must stay within image.context"))? + .to_path_buf(); + let dockerfile_parameter = archive_path(&relative_dockerfile)?; + + let cache_tag = cache_tag(&context, &dockerfile_parameter, target, platform); + let archive = create_context_archive(self.scratch_dir().await?, context, relative_dockerfile).await?; + step.complete().await; + Ok(PreparedBuild { + archive, + dockerfile: dockerfile_parameter, + target: target.map(str::to_owned), + cache_tag, + }) + } + + async fn build_docker_image( + &self, + prepared: &PreparedBuild, + temporary_tag: &str, + build_id: &str, + platform: &sandbox::Platform, + progress: &SandboxProgress, + ) -> Result<(), Error> { + let context_file = tokio::fs::File::open(&prepared.archive) + .await + .map_err(|source| error::io("open Docker build context archive", source))?; + let context_stream = FramedRead::new(context_file, BytesCodec::new()) + .map(|result| result.map(tokio_util::bytes::BytesMut::freeze)); + let options = BuildImageOptionsBuilder::default() + .dockerfile(&prepared.dockerfile) + .t(temporary_tag) + .platform(&platform.to_string()) + .version(BuilderVersion::BuilderBuildKit) + .session(build_id) + .rm(true) + .forcerm(true); + let options = if let Some(target) = &prepared.target { + options.target(target) + } else { + options + }; + let options = options.build(); + + let step = progress + .start_measured_step(BUILD_IMAGE, ProgressUnit::Bytes, None) + .await; + let mut completed_vertices = HashSet::new(); + let mut transfers = Transfers::default(); + let mut responses = self + .docker()? + .build_image(options, None, Some(bollard::body_try_stream(context_stream))); + while let Some(response) = responses.next().await { + let response = response.map_err(error::backend)?; + if let Some(detail) = response.error_detail { + return Err(Error::Backend( + detail + .message + .unwrap_or_else(|| "Docker image build failed".to_string()), + )); + } + if let Some(stream) = response.stream { + step.output(OutputStream::Stdout, stream).await; + } + if let Some(status) = response.status { + let output = response.id.as_ref().map_or_else( + || format!("{status}\n"), + |identifier| format!("{identifier}: {status}\n"), + ); + step.output(OutputStream::Stdout, output).await; + } + if let Some(detail) = response.progress_detail + && let Some(completed) = detail.current.and_then(|value| u64::try_from(value).ok()) + { + let total = detail.total.and_then(|value| u64::try_from(value).ok()); + let key = response.id.clone().unwrap_or_default(); + let (completed, total) = transfers.record(key, completed, total); + step.report(completed, total).await; + } + if let Some(aux) = response.aux { + report_buildkit_status(&step, &mut completed_vertices, aux).await?; + } + } + step.complete().await; + Ok(()) + } + + async fn resolve_built_image( + &self, + temporary_tag: &str, + cache_tag: &str, + progress: &SandboxProgress, + ) -> Result { + let step = progress.start_step(RETAIN_BUILD_CACHE).await; + self.retain_build_cache(temporary_tag, cache_tag).await?; + step.complete().await; + + let import_reference = self.import_cache_reference(temporary_tag).await?; + if let Some(metadata) = self.cached_import(&import_reference, progress).await? { + return Ok(metadata); + } + + let image_archive = self.export_image_observed(temporary_tag, progress).await?; + self.import_image(&image_archive, &import_reference, progress).await + } + + async fn export_image_observed( + &self, + temporary_tag: &str, + progress: &SandboxProgress, + ) -> Result { + let step = progress + .start_measured_step(EXPORT_IMAGE, ProgressUnit::Bytes, None) + .await; + let archive = self.export_image(temporary_tag, &step).await?; + step.complete().await; + Ok(archive) + } + + async fn remove_temporary_image(&self, temporary_tag: &str, progress: &SandboxProgress) -> Result<(), Error> { + let step = progress.start_step(REMOVE_TEMPORARY_IMAGE).await; + self.docker()? + .remove_image( + temporary_tag, + None::, + None, + ) + .await + .map_err(error::backend)?; + step.complete().await; + Ok(()) + } + + async fn retain_build_cache(&self, image: &str, cache_tag: &str) -> Result<(), Error> { + let (repository, tag) = cache_tag + .split_once(':') + .ok_or(Error::invalid("image.cacheTag", "must contain a repository and tag"))?; + self.docker()? + .tag_image( + image, + Some(TagImageOptionsBuilder::default().repo(repository).tag(tag).build()), + ) + .await + .map_err(error::backend) + } + + async fn import_cache_reference(&self, image: &str) -> Result { + let image_id = self + .docker()? + .inspect_image(image) + .await + .map_err(error::backend)? + .id + .ok_or_else(|| Error::Backend("Docker did not report the built image ID".to_string()))?; + Ok(format!( + "{IMPORT_CACHE_REPOSITORY}:docker-{}", + lower_hex(&Sha256::digest(image_id.as_bytes())) + )) + } + + /// Returns the metadata of an image imported from the same Docker image before, while the + /// cache still has it. + async fn cached_import( + &self, + reference: &str, + progress: &SandboxProgress, + ) -> Result, Error> { + let step = progress.start_step(LOOKUP_IMPORTED_IMAGE).await; + let reference = reference.parse().map_err(error::backend)?; + let cache = microsandbox_image::GlobalCache::new(&self.client.local().cache_dir()).map_err(error::backend)?; + let Some(metadata) = cache.read_image_metadata(&reference).map_err(error::backend)? else { + return Ok(None); + }; + let entry = image_cache::cache_entry(&metadata.manifest_digest); + let cached = match microsandbox::Image::get_local(self.client.local(), &entry).await { + Ok(_) => true, + Err(microsandbox::MicrosandboxError::ImageNotFound(_)) => false, + Err(failure) => return Err(error::microsandbox(failure)), + }; + step.complete().await; + Ok(cached.then_some(metadata)) + } + + async fn import_image( + &self, + image_archive: &Path, + import_reference: &str, + progress: &SandboxProgress, + ) -> Result { + let step = progress.start_step(IMPORT_IMAGE).await; + let (mut import_events, import_progress) = microsandbox_image::progress_channel(); + let cache_dir = self.client.local().cache_dir(); + let load = microsandbox_image::load_archive( + &cache_dir, + image_archive, + microsandbox_image::ImageLoadOptions { + tags: vec![import_reference.to_string()], + progress: Some(import_progress), + }, + ); + let report = async { + let mut pull = PullReport::default(); + while let Some(event) = import_events.recv().await { + pull.report(progress, event).await; + } + pull + }; + let (loaded, pull) = tokio::join!(load, report); + let loaded = loaded.map_err(error::backend)?; + // The channel also closes when the import fails; its steps then stay + // open and end as failed with the operation. + pull.finish().await; + let image = loaded + .into_iter() + .find(|image| image.reference == import_reference) + .ok_or_else(|| Error::Backend(format!("Microsandbox did not return imported image {import_reference}")))?; + step.complete().await; + Ok(image.metadata) + } + + async fn export_image(&self, reference: &str, step: &MeasuredStep) -> Result { + let archive = tempfile::NamedTempFile::new_in(self.scratch_dir().await?) + .map_err(|source| error::io("create Docker image archive", source))? + .into_temp_path(); + let mut file = tokio::fs::File::create(&archive) + .await + .map_err(|source| error::io("open Docker image archive", source))?; + let mut chunks = self.docker()?.export_image(reference); + let mut written = 0_u64; + let mut reported = 0_u64; + while let Some(chunk) = chunks.next().await { + let chunk = chunk.map_err(error::backend)?; + let chunk_length = u64::try_from(chunk.len()) + .map_err(|_| Error::Backend("Docker image export exceeded the supported size".to_string()))?; + file.write_all(&chunk) + .await + .map_err(|source| error::io("write Docker image archive", source))?; + written = written.saturating_add(chunk_length); + if written.saturating_sub(reported) >= EXPORT_PROGRESS_INTERVAL { + step.report(written, None).await; + reported = written; + } + } + step.report(written, Some(written)).await; + file.sync_all() + .await + .map_err(|source| error::io("sync Docker image archive", source))?; + Ok(archive) + } + + async fn pull_reference( + &self, + request: &image::ResolveRequest, + reference: &str, + progress: &SandboxProgress, + ) -> Result { + platform::require_supported(&request.platform)?; + let parsed: microsandbox_image::Reference = reference + .parse() + .map_err(|failure| Error::Backend(format!("invalid OCI image reference '{reference}': {failure}")))?; + let step = progress.start_step(PULL_IMAGE).await; + let cache = microsandbox_image::GlobalCache::new(&self.client.local().cache_dir()).map_err(error::backend)?; + let options = microsandbox_image::PullOptions { + pull_policy: reference_pull_policy(&parsed), + force: false, + materialization: match request.root_filesystem_mode { + RootFilesystemMode::Layered => microsandbox_image::RootfsMaterialization::Layered, + RootFilesystemMode::Direct => microsandbox_image::RootfsMaterialization::Flat, + mode => { + return Err(Error::UnsupportedImageRootFilesystemMode { + operation: image::ImageOperation::Resolve, + mode, + }); + } + }, + }; + + let metadata = if let Some((_, metadata)) = + microsandbox_image::Registry::pull_cached(&cache, &parsed, &options).map_err(error::backend)? + { + metadata + } else { + let config = self.client.local().config(); + let authentication = match &self.registry_authentication { + Some(sandbox::image::RegistryAuthentication::Anonymous) => microsandbox_image::RegistryAuth::Anonymous, + Some(sandbox::image::RegistryAuthentication::Basic { username, password }) => { + microsandbox_image::RegistryAuth::Basic { + username: username.clone(), + password: password.clone(), + } + } + None => config + .resolve_registry_auth(parsed.registry()) + .map_err(error::microsandbox)?, + }; + let registry = + microsandbox_image::Registry::builder(microsandbox_image::Platform::host_linux(), cache.clone()) + .auth(authentication) + .extra_ca_certs(config.resolve_ca_certs().await.map_err(error::microsandbox)?) + .add_insecure_registries(config.insecure_registries()) + .build() + .map_err(error::backend)?; + let (mut events, sender) = microsandbox_image::progress_channel(); + let pull = registry.pull_with_sender(&parsed, &options, sender); + let report = async { + let mut pull = PullReport::default(); + while let Some(event) = events.recv().await { + pull.report(progress, event).await; + } + pull + }; + let (result, report) = tokio::join!(pull, report); + result.map_err(error::backend)?.map_err(error::backend)?; + // The channel also closes when the pull fails; its steps then stay + // open and end as failed with the operation. + report.finish().await; + cache + .read_image_metadata(&parsed) + .map_err(error::backend)? + .ok_or_else(|| Error::Backend("Microsandbox did not retain pulled image metadata".to_string()))? + }; + + step.complete().await; + Ok(metadata) + } + + /// Records the image a fetch returns in the catalog and describes it as resolved for the + /// request. + async fn record_resolved( + &self, + request: &image::ResolveRequest, + fetch: impl Future>, + ) -> Result { + let fallback = platform::require_supported(&request.platform)?; + let (entry, ()) = self.images.record(async { Ok((fetch.await?, ())) }).await?; + let handle = microsandbox::Image::get_local(self.client.local(), &entry) + .await + .map_err(error::microsandbox)?; + let (manifest_digest, actual) = resolve_image_handle(&handle, &request.platform, &fallback)?; + Ok(image::ResolvedImage { + source: request.source.clone(), + platform: actual, + manifest_digest, + }) + } + + async fn export_prepared_root( + &self, + request: &image::ResolveRequest, + destination: &Path, + progress: &SandboxProgress, + ) -> Result { + let operation = image::ImageOperation::PreparedImageExport; + require_direct_prepared_root(request, operation)?; + let (_, reference) = prepared_root_reference(request, operation)?; + let resolved = self + .record_resolved(request, self.pull_reference(request, &reference.to_string(), progress)) + .await?; + let cache = microsandbox_image::GlobalCache::new(&self.client.local().cache_dir()).map_err(error::backend)?; + let step = progress.start_step(EXPORT_PREPARED_ROOT).await; + let prepared = microsandbox_image::export_prepared_root( + &cache, + &reference, + µsandbox_image::Platform::host_linux(), + destination, + ) + .await + .map_err(error::backend)?; + step.complete().await; + Ok(prepared_root(resolved, &prepared)) + } + + async fn import_prepared_root( + &self, + request: &image::ResolveRequest, + source: &Path, + progress: &SandboxProgress, + ) -> Result { + let operation = image::ImageOperation::PreparedImageImport; + require_direct_prepared_root(request, operation)?; + let actual = platform::require_supported(&request.platform)?; + let (_, reference) = prepared_root_reference(request, operation)?; + let cache = microsandbox_image::GlobalCache::new(&self.client.local().cache_dir()).map_err(error::backend)?; + let step = progress.start_step(IMPORT_PREPARED_ROOT).await; + // Record the imported root like a pulled image, so it is listed locally + // and removing it reclaims its flat artifacts. + let (_, prepared) = self + .images + .record(async { + let prepared = microsandbox_image::import_prepared_root( + &cache, + &reference, + µsandbox_image::Platform::host_linux(), + source, + ) + .await + .map_err(error::backend)?; + Ok((prepared.image.clone(), prepared)) + }) + .await?; + step.complete().await; + Ok(prepared_root( + image::ResolvedImage { + source: request.source.clone(), + platform: actual, + manifest_digest: prepared.image.manifest_digest.clone(), + }, + &prepared, + )) + } +} + +fn require_direct_prepared_root( + request: &image::ResolveRequest, + operation: image::ImageOperation, +) -> Result<(), Error> { + if request.root_filesystem_mode == RootFilesystemMode::Direct { + Ok(()) + } else { + Err(Error::UnsupportedImageRootFilesystemMode { + operation, + mode: request.root_filesystem_mode, + }) + } +} + +fn reference_pull_policy(reference: µsandbox_image::Reference) -> microsandbox_image::PullPolicy { + if reference.digest().is_some() { + microsandbox_image::PullPolicy::IfMissing + } else { + // A tag is mutable. Refresh its manifest when creating a Sandbox while + // retaining content-addressed layers and rootfs artifacts in the cache. + microsandbox_image::PullPolicy::Always + } +} + +/// The prepared root's reference as given, used as its catalog name like a +/// pulled image's, and parsed. +fn prepared_root_reference( + request: &image::ResolveRequest, + operation: image::ImageOperation, +) -> Result<(&str, microsandbox_image::Reference), Error> { + let image::ImageSource::Reference { reference } = &request.source else { + return Err(Error::UnsupportedImageSourceKind { + operation, + source_kind: request.source.kind(), + }); + }; + let parsed = reference + .parse::() + .map_err(error::backend)?; + if parsed.digest().is_none() { + return Err(Error::invalid( + "image.reference", + "prepared roots require an immutable digest-pinned OCI reference", + )); + } + Ok((reference, parsed)) +} + +fn prepared_root( + image: image::ResolvedImage, + prepared: µsandbox_image::PreparedRootMetadata, +) -> image::PreparedImage { + image::PreparedImage { + image, + root_filesystem_mode: RootFilesystemMode::Direct, + artifact_digest: prepared.root.artifact_digest.clone(), + virtual_size_bytes: prepared.root.virtual_size_bytes, + } +} + +struct PreparedBuild { + archive: tempfile::TempPath, + dockerfile: String, + target: Option, + cache_tag: String, +} + +fn cache_tag(context: &Path, dockerfile: &str, target: Option<&str>, platform: &sandbox::Platform) -> String { + let mut digest = Sha256::new(); + let platform = platform.to_string(); + for component in [ + context.as_os_str().as_encoded_bytes(), + dockerfile.as_bytes(), + target.unwrap_or_default().as_bytes(), + platform.as_bytes(), + ] { + digest.update(component); + digest.update([0]); + } + format!("{CACHE_REPOSITORY}:{}", lower_hex(&digest.finalize())) +} + +fn resolve_image_handle( + handle: µsandbox::ImageHandle, + requested: &sandbox::Platform, + fallback: &sandbox::Platform, +) -> Result<(String, sandbox::Platform), Error> { + let manifest_digest = handle + .manifest_digest() + .ok_or_else(|| Error::Backend("Microsandbox did not report the image manifest digest".to_string()))? + .to_string(); + let actual = sandbox::Platform::new( + handle.os().unwrap_or(fallback.os.as_str()), + handle.architecture().unwrap_or(fallback.architecture.as_str()), + ); + if !actual.satisfies(requested) { + return Err(Error::ImagePlatformMismatch { + requested: Box::new(requested.clone()), + actual: Box::new(actual), + }); + } + Ok((manifest_digest, actual)) +} + +async fn report_buildkit_status( + step: &MeasuredStep, + completed_vertices: &mut HashSet, + aux: bollard::models::BuildInfoAux, +) -> Result<(), Error> { + let bollard::models::BuildInfoAux::BuildKit(status) = aux else { + return Ok(()); + }; + for vertex in status.vertexes { + if !vertex.error.is_empty() { + return Err(Error::Backend(vertex.error)); + } + if vertex.completed.is_some() && completed_vertices.insert(vertex.digest) { + let outcome = if vertex.cached { "CACHED" } else { "DONE" }; + step.output(OutputStream::Stdout, format!("{outcome} {}\n", vertex.name)) + .await; + } + } + for log in status.logs { + let stream = if log.stream == 2 { + OutputStream::Stderr + } else { + OutputStream::Stdout + }; + step.output(stream, log.msg).await; + } + for warning in status.warnings { + let mut message = warning.short; + for detail in warning.detail { + message.extend_from_slice(b"\n"); + message.extend(detail); + } + message.extend_from_slice(b"\n"); + step.output(OutputStream::Stderr, message).await; + } + Ok(()) +} + +/// Sums concurrent transfers, such as layer downloads, into one quantity. +/// +/// The total is known once every transfer seen so far has announced its size. +#[derive(Default)] +struct Transfers { + transfers: std::collections::BTreeMap)>, +} + +impl Transfers { + fn record(&mut self, key: String, completed: u64, total: Option) -> (u64, Option) { + self.transfers.insert(key, (completed, total)); + self.totals(None) + } + + fn totals(&self, announced: Option) -> (u64, Option) { + let completed = self.transfers.values().map(|(completed, _)| completed).sum(); + let total = announced.or_else(|| { + self.transfers + .values() + .map(|(_, total)| *total) + .sum::>() + .filter(|_| !self.transfers.is_empty()) + }); + (completed, total) + } +} + +/// Translates registry pull events into the steps of one image pull or import. +/// +/// Layers download and materialize concurrently, so each activity is its own +/// measured step with one aggregated byte count, started when its first event +/// arrives. Assembling the root disk has no byte progress and is named through +/// its output so a long write is visibly in progress rather than silent. +#[derive(Default)] +struct PullReport { + layers: Option, + /// Total download size announced by the registry, when known up front. + total_download_bytes: Option, + downloads: Transfers, + materializations: Transfers, + download: Option, + materialize: Option, + assemble: Option, +} + +impl PullReport { + async fn report(&mut self, progress: &SandboxProgress, event: microsandbox_image::PullProgress) { + use microsandbox_image::PullProgress; + match event { + PullProgress::Resolved { + layer_count, + total_download_bytes, + .. + } => { + self.layers = u64::try_from(layer_count).ok(); + self.total_download_bytes = total_download_bytes; + } + PullProgress::LayerDownloadProgress { + layer_index, + downloaded_bytes, + total_bytes, + .. + } => { + self.downloads + .record(layer_index.to_string(), downloaded_bytes, total_bytes); + self.report_download(progress).await; + } + PullProgress::LayerDownloadComplete { + layer_index, + downloaded_bytes, + .. + } => { + self.downloads + .record(layer_index.to_string(), downloaded_bytes, Some(downloaded_bytes)); + self.report_download(progress).await; + } + PullProgress::LayerMaterializeStarted { layer_index, .. } => { + let line = self.layer_line("Materializing layer", layer_index); + if let Some(step) = self.materialize_step(progress).await { + step.output(OutputStream::Stdout, line).await; + } + } + PullProgress::LayerMaterializeProgress { + layer_index, + bytes_read, + total_bytes, + } => { + let (completed, total) = + self.materializations + .record(layer_index.to_string(), bytes_read, Some(total_bytes)); + if let Some(step) = self.materialize_step(progress).await { + step.report(completed, total).await; + } + } + PullProgress::StitchMergingTrees { layer_count } => { + self.assemble(progress, format!("Merging {layer_count} layer trees\n")) + .await; + } + PullProgress::StitchWritingFsmeta => { + self.assemble(progress, "Writing filesystem metadata\n".into()).await; + } + PullProgress::StitchWritingVmdk => { + self.assemble(progress, "Writing root disk image\n".into()).await; + } + PullProgress::Resolving { .. } + | PullProgress::LayerDownloadVerifying { .. } + | PullProgress::LayerMaterializeWriting { .. } + | PullProgress::LayerMaterializeComplete { .. } + | PullProgress::Complete { .. } + | PullProgress::StitchComplete => {} + } + } + + /// Completes the steps still running once the pull or import succeeded. + async fn finish(self) { + for step in [self.download, self.materialize].into_iter().flatten() { + step.complete().await; + } + if let Some(step) = self.assemble { + step.complete().await; + } + } + + async fn report_download(&mut self, progress: &SandboxProgress) { + let (completed, total) = self.downloads.totals(self.total_download_bytes); + if self.download.is_none() { + self.download = Some( + progress + .start_measured_step(DOWNLOAD_LAYERS, ProgressUnit::Bytes, total) + .await, + ); + } + if let Some(step) = &self.download { + step.report(completed, total).await; + } + } + + async fn materialize_step(&mut self, progress: &SandboxProgress) -> Option<&MeasuredStep> { + if self.materialize.is_none() { + self.materialize = Some( + progress + .start_measured_step(MATERIALIZE_LAYERS, ProgressUnit::Bytes, None) + .await, + ); + } + self.materialize.as_ref() + } + + /// Reports root-disk assembly, which starts once every layer is in place. + async fn assemble(&mut self, progress: &SandboxProgress, line: String) { + if self.assemble.is_none() { + for step in [self.download.take(), self.materialize.take()].into_iter().flatten() { + step.complete().await; + } + self.assemble = Some(progress.start_step(ASSEMBLE_ROOT_DISK).await); + } + if let Some(step) = &self.assemble { + step.output(OutputStream::Stdout, line).await; + } + } + + fn layer_line(&self, activity: &str, layer_index: usize) -> String { + let ordinal = layer_index.saturating_add(1); + self.layers.map_or_else( + || format!("{activity} {ordinal}\n"), + |total| format!("{activity} {ordinal}/{total}\n"), + ) + } +} + +impl image::ImageBackend for MicrosandboxImageBackend { + fn capabilities<'a>( + &'a self, + platform: &'a sandbox::Platform, + ) -> LocalFuture<'a, Result> { + Box::pin(async move { + platform::require_supported(platform)?; + // TODO: Add prepared-image transport for Microsandbox's layered + // EROFS/VMDK representation. The current artifact format packages + // only the flat ext4 representation used by direct roots. + let prepared = image::ImageOperationCapabilities::new( + [image::ImageSourceKind::Reference].into(), + [RootFilesystemMode::Direct].into(), + ); + Ok(image::ImageBackendCapabilities::new( + image::ImageOperationCapabilities::new( + [image::ImageSourceKind::Build, image::ImageSourceKind::Reference].into(), + [RootFilesystemMode::Layered, RootFilesystemMode::Direct].into(), + ), + prepared.clone(), + prepared, + )) + }) + } + + fn resolve<'a>(&'a self, request: &'a image::ResolveRequest) -> PendingOperation<'a, image::ResolvedImage> { + PendingOperation::run(move |progress| { + Box::pin(async move { + let fetch = async { + match &request.source { + image::ImageSource::Build { + context, + dockerfile, + target, + } => { + self.build_image(request, context, dockerfile, target.as_deref(), &progress) + .await + } + image::ImageSource::Reference { reference } => { + self.pull_reference(request, reference, &progress).await + } + } + }; + let resolved = Box::pin(self.record_resolved(request, fetch)).await?; + self.images.remove_unused().await; + Ok(resolved) + }) + }) + } + + fn export_prepared_image<'a>( + &'a self, + request: &'a image::ResolveRequest, + destination: &'a Path, + ) -> PendingOperation<'a, image::PreparedImage> { + PendingOperation::run(move |progress| { + Box::pin(async move { Box::pin(self.export_prepared_root(request, destination, &progress)).await }) + }) + } + + fn import_prepared_image<'a>( + &'a self, + request: &'a image::ResolveRequest, + source: &'a Path, + ) -> PendingOperation<'a, image::PreparedImage> { + PendingOperation::run(move |progress| { + Box::pin(async move { + let prepared = self.import_prepared_root(request, source, &progress).await?; + self.images.remove_unused().await; + Ok(prepared) + }) + }) + } +} + +async fn create_context_archive( + scratch: PathBuf, + context: PathBuf, + dockerfile: PathBuf, +) -> Result { + tokio::task::spawn_blocking(move || { + let archive = tempfile::NamedTempFile::new_in(scratch)?; + let path = archive.into_temp_path(); + let file = File::create(&path)?; + let ignore = dockerignore(&context)?; + let mut builder = tar::Builder::new(file); + append_directory(&mut builder, &context, &context, &dockerfile, &ignore)?; + builder.finish()?; + Ok::<_, std::io::Error>(path) + }) + .await + .map_err(error::backend)? + .map_err(|source| error::io("create Docker build context archive", source)) +} + +fn dockerignore(context: &Path) -> Result { + let mut builder = GitignoreBuilder::new(context); + let path = context.join(".dockerignore"); + if path.is_file() + && let Some(source) = builder.add(path) + { + return Err(std::io::Error::other(source)); + } + builder.build().map_err(std::io::Error::other) +} + +fn append_directory( + archive: &mut tar::Builder, + context: &Path, + directory: &Path, + dockerfile: &Path, + ignore: &Gitignore, +) -> Result<(), std::io::Error> { + let mut entries = std::fs::read_dir(directory)?.collect::, _>>()?; + entries.sort_by_key(std::fs::DirEntry::file_name); + + for entry in entries { + let path = entry.path(); + let relative = path.strip_prefix(context).map_err(std::io::Error::other)?; + let metadata = std::fs::symlink_metadata(&path)?; + let is_directory = metadata.is_dir(); + let forced = relative == dockerfile || relative == Path::new(".dockerignore"); + let excluded = ignore.matched_path_or_any_parents(relative, is_directory).is_ignore(); + + if is_directory { + if !excluded { + archive.append_dir(relative, &path)?; + } + append_directory(archive, context, &path, dockerfile, ignore)?; + } else if forced || !excluded { + archive.append_path_with_name(&path, relative)?; + } + } + Ok(()) +} + +fn archive_path(path: &Path) -> Result { + path.to_str() + .map(|value| value.replace('\\', "/")) + .ok_or(Error::invalid("image.dockerfile", "must be valid Unicode")) +} + +#[cfg(test)] +// Test Clients live for the whole test; tightening their drop adds nothing. +#[allow(clippy::expect_used, clippy::significant_drop_tightening)] +mod tests { + use std::{fs, path::Path}; + + async fn image_backend(client: crate::client::Client, state: &Path) -> super::MicrosandboxImageBackend { + let state = crate::state::StateStore::open(state.to_path_buf()) + .await + .expect("state store should open"); + let images = crate::image_cache::ImageCache::new(client.clone(), state, None); + super::MicrosandboxImageBackend::new(client, images, None) + } + + #[test] + fn mutable_references_refresh_registry_metadata_while_digest_pins_reuse_the_cache() { + let tagged = "ghcr.io/altinn/agent:latest" + .parse() + .expect("tagged reference should parse"); + let pinned = format!("ghcr.io/altinn/agent@sha256:{}", "0".repeat(64)) + .parse() + .expect("digest-pinned reference should parse"); + + assert_eq!( + super::reference_pull_policy(&tagged), + microsandbox_image::PullPolicy::Always + ); + assert_eq!( + super::reference_pull_policy(&pinned), + microsandbox_image::PullPolicy::IfMissing + ); + } + + #[tokio::test(flavor = "local")] + #[ignore = "requires Internet access"] + async fn imported_prepared_roots_are_recorded_in_the_image_catalog() { + use sandbox::image::ImageBackend as _; + + // Prepared roots pin the native platform manifest of Alpine 3.22. + let (architecture, manifest_digest) = match std::env::consts::ARCH { + "x86_64" => ( + "amd64", + "sha256:7c8cb692ae09657cbc4a3f3cbd0e8d5a2690ba38386aaaf252dbb060bf5eb2e6", + ), + "aarch64" => ( + "arm64", + "sha256:2c9d26f410d032d5b1525aa8a873e238b05b90c4ae8618743d4311f0cc827e37", + ), + _ => return, + }; + let temporary = tempfile::tempdir().expect("temporary directory should be created"); + let reference = format!("docker.io/library/alpine@{manifest_digest}"); + let request = sandbox::image::ResolveRequest { + source: sandbox::image::ImageSource::Reference { + reference: reference.clone(), + }, + platform: sandbox::Platform::new("linux", architecture), + root_filesystem_mode: sandbox::RootFilesystemMode::Direct, + }; + let exporter = image_backend( + crate::client::Client::open(temporary.path().join("exporter"), None, None) + .await + .expect("exporting Client should open"), + &temporary.path().join("exporter-state"), + ) + .await; + let bundle = temporary.path().join("prepared"); + exporter + .export_prepared_image(&request, &bundle) + .await + .expect("prepared root should export"); + + let client = crate::client::Client::open(temporary.path().join("importer"), None, None) + .await + .expect("importing Client should open"); + let importer = image_backend(client.clone(), &temporary.path().join("importer-state")).await; + importer + .import_prepared_image(&request, &bundle) + .await + .expect("prepared root should import"); + importer + .import_prepared_image(&request, &bundle) + .await + .expect("importing the same root again should succeed"); + + let images = microsandbox::Image::list_local(client.local()) + .await + .expect("local images should list"); + let entry = crate::image_cache::cache_entry(manifest_digest); + assert!( + images.iter().any(|image| image.reference() == entry), + "the imported root should be recorded in the cache; found {:?}", + images + .iter() + .map(microsandbox::ImageHandle::reference) + .collect::>() + ); + } + + #[tokio::test(flavor = "local")] + #[ignore = "requires Internet access"] + async fn a_tagged_image_is_recorded_by_its_manifest_digest() { + use sandbox::image::ImageBackend as _; + + let temporary = tempfile::tempdir().expect("temporary directory should be created"); + let client = crate::client::Client::open(temporary.path().join("runtime"), None, None) + .await + .expect("Client should open"); + let backend = image_backend(client.clone(), &temporary.path().join("state")).await; + let request = sandbox::image::ResolveRequest { + source: sandbox::image::ImageSource::Reference { + reference: "docker.io/library/alpine:3.22".to_string(), + }, + platform: sandbox::Platform::native("linux"), + root_filesystem_mode: sandbox::RootFilesystemMode::Layered, + }; + + let resolved = backend.resolve(&request).await.expect("tag should resolve"); + + let references: Vec = microsandbox::Image::list_local(client.local()) + .await + .expect("local images should list") + .iter() + .map(|image| image.reference().to_string()) + .collect(); + assert_eq!( + references, + [crate::image_cache::cache_entry(&resolved.manifest_digest)], + "a tag moves to newer versions, so only the resolved version's digest names it" + ); + } + + #[tokio::test(flavor = "local")] + async fn context_archive_applies_dockerignore_and_keeps_build_inputs() { + let context = tempfile::tempdir().expect("temporary context should be created"); + fs::create_dir(context.path().join("nested")).expect("nested directory should be created"); + fs::write(context.path().join("Dockerfile"), "FROM scratch\n").expect("Dockerfile should be written"); + fs::write( + context.path().join(".dockerignore"), + "Dockerfile\nignored.txt\nnested/*\n!nested/included.txt\n", + ) + .expect("Dockerignore should be written"); + fs::write(context.path().join("included.txt"), "included").expect("included file should be written"); + fs::write(context.path().join("ignored.txt"), "ignored").expect("ignored file should be written"); + fs::write(context.path().join("nested/included.txt"), "included").expect("re-included file should be written"); + fs::write(context.path().join("nested/ignored.txt"), "ignored").expect("nested ignored file should be written"); + + let archive = super::create_context_archive( + std::env::temp_dir(), + context.path().to_path_buf(), + Path::new("Dockerfile").to_path_buf(), + ) + .await + .expect("context archive should be created"); + let file = fs::File::open(archive).expect("context archive should open"); + let entries = tar::Archive::new(file) + .entries() + .expect("archive entries should be readable") + .map(|entry| { + entry + .expect("archive entry should be readable") + .path() + .expect("archive path should be readable") + .into_owned() + }) + .collect::>(); + + assert!(entries.contains(&Path::new("Dockerfile").to_path_buf())); + assert!(entries.contains(&Path::new(".dockerignore").to_path_buf())); + assert!(entries.contains(&Path::new("included.txt").to_path_buf())); + assert!(entries.contains(&Path::new("nested/included.txt").to_path_buf())); + assert!(!entries.contains(&Path::new("ignored.txt").to_path_buf())); + assert!(!entries.contains(&Path::new("nested/ignored.txt").to_path_buf())); + } + + #[test] + fn dockerfile_paths_use_archive_separators() { + assert_eq!( + super::archive_path(Path::new("nested\\Dockerfile")).expect("path should be valid"), + "nested/Dockerfile" + ); + } + + #[test] + fn docker_cache_tags_are_stable_per_source_and_platform() { + let context = Path::new("/workspace/project"); + let platform = sandbox::Platform::new("linux", "amd64"); + let tag = super::cache_tag(context, "Dockerfile", None, &platform); + + assert_eq!(tag, super::cache_tag(context, "Dockerfile", None, &platform)); + assert_ne!( + tag, + super::cache_tag(Path::new("/workspace/other"), "Dockerfile", None, &platform) + ); + assert_ne!(tag, super::cache_tag(context, "nested/Dockerfile", None, &platform)); + assert_ne!(tag, super::cache_tag(context, "Dockerfile", Some("minimal"), &platform)); + assert_ne!( + tag, + super::cache_tag(context, "Dockerfile", None, &sandbox::Platform::new("linux", "arm64")) + ); + } +} diff --git a/sandbox/microsandbox/src/image_cache.rs b/sandbox/microsandbox/src/image_cache.rs new file mode 100644 index 0000000..cb39625 --- /dev/null +++ b/sandbox/microsandbox/src/image_cache.rs @@ -0,0 +1,974 @@ +//! The Microsandbox image cache of one Provider home. +//! +//! Microsandbox keeps an image version while any catalog entry names it, and +//! `Image::remove_local` removes the version with its last entry: its manifest, the layers no +//! other version shares and its root filesystem artifacts. It refuses while a runtime uses the +//! image. Everything that holds an image is therefore a catalog entry: +//! +//! - A Sandbox entry per Sandbox record, added when the record is created and removed when the +//! Sandbox is deleted, so an image stays while a Sandbox uses it, with or without a runtime. +//! - A cache entry per image version, named by its digest, which resolving or importing the +//! image refreshes, and which deleting a Sandbox that used it refreshes too. A removal pass +//! removes it once it has not been refreshed for the retention period. +//! +//! Microsandbox applies each removal atomically, so removing an entry never needs to know what +//! else holds its image. Only removal passes remove the last entry of a version, and so delete +//! files; they run exclusively of image fetches, which may reuse those files. + +use std::{ + collections::HashSet, + future::Future, + path::PathBuf, + rc::Rc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use microsandbox_image::{CachedImageMetadata, GlobalCache, Reference}; +use sandbox::{Error, SandboxId}; +use tokio::sync::RwLock; + +use crate::{ + client::Client, + error, + state::{SandboxRecord, StateStore}, +}; + +/// The shortest retention period. A cache entry must outlive the time between resolving an +/// image and creating the Sandbox that holds it. +pub(crate) const MINIMUM_RETENTION: Duration = Duration::from_hours(1); + +/// Directory below the Microsandbox cache for this crate's image and build-context archives. +/// Microsandbox stages its own downloads in the parent directory. +const SCRATCH_DIRECTORY: &str = "tmp/sandbox-microsandbox"; + +/// Repository of Sandbox entries, tagged with the Sandbox ID. Nothing pulls it: runtimes are +/// created from the cache only. +const SANDBOX_REPOSITORY: &str = "sandbox-microsandbox-sandbox"; + +/// Repository of cache entries, pinned to the image version's manifest digest. +const CACHE_REPOSITORY: &str = "sandbox-microsandbox-cache"; + +/// Repository of the temporary entry that tells whether a runtime records using an image. +const PROBE_REPOSITORY: &str = "sandbox-microsandbox-probe"; + +/// Marks a home whose catalog, from before Sandbox entries, has been migrated. +const MIGRATED_MARKER: &str = "image-catalog-v2"; + +/// The Microsandbox image cache of one Provider home. +#[derive(Clone)] +pub(crate) struct ImageCache { + client: Client, + state: StateStore, + /// Removes unused images when set. Unset leaves the cache to its owner. + retention: Option, + /// Shared while an image is fetched and recorded, held exclusively by a removal pass. A + /// fetch reuses cached layers that the catalog does not yet attribute to the new version. + catalog: Rc>, +} + +impl ImageCache { + pub(crate) fn new(client: Client, state: StateStore, retention: Option) -> Self { + Self { + client, + state, + retention, + catalog: Rc::new(RwLock::new(())), + } + } + + /// Fetches an image and records it in its cache entry, which marks it used, and returns the + /// entry's name. Removal passes wait for the fetch, which may reuse cached layers that no + /// entry attributes to the new version yet. + pub(crate) async fn record( + &self, + fetch: impl Future>, + ) -> Result<(String, T), Error> { + let _recording = self.catalog.read().await; + let (metadata, fetched) = fetch.await?; + let name = cache_entry(&metadata.manifest_digest); + self.write_entry(&name, metadata).await?; + Ok((name, fetched)) + } + + /// Adds the entry that keeps a Sandbox's image while the Sandbox exists, and returns the + /// name to create its runtime from, or `None` when the image is not in the cache. Adding it + /// again changes nothing. Without a retention period nothing removes images, so no entry is + /// added and any entry naming the image is returned. + pub(crate) async fn hold(&self, record: &SandboxRecord) -> Result, Error> { + let name = sandbox_entry(&record.id); + let manifest_digest = record.image.manifest_digest.as_str(); + let _recording = self.catalog.read().await; + let mut cached = None; + for entry in microsandbox::Image::list_local(self.client.local()) + .await + .map_err(error::microsandbox)? + { + if entry.manifest_digest() != Some(manifest_digest) { + continue; + } + if entry.reference() == name { + return Ok(Some(name)); + } + // A tag recorded before images were recorded by digest may have moved on. + if cached.is_none() + && let Some(metadata) = self.metadata(entry.reference())? + && metadata.manifest_digest == manifest_digest + { + cached = Some((entry.reference().to_string(), metadata)); + } + } + let Some((cached_name, metadata)) = cached else { + return Ok(None); + }; + if self.retention.is_none() { + return Ok(Some(cached_name)); + } + self.write_entry(&name, metadata).await?; + Ok(Some(name)) + } + + /// Releases a deleted Sandbox's image. Its cache entry is refreshed before the Sandbox's + /// entry goes, so the image stays for the retention period after the deletion, and never + /// loses its last entry here. An entry this leaves behind goes with the next pass. + pub(crate) async fn release(&self, record: &SandboxRecord) { + if self.retention.is_none() { + return; + } + let name = sandbox_entry(&record.id); + let taken_over = { + let _recording = self.catalog.read().await; + match self.metadata(&name) { + Ok(Some(metadata)) => { + self.write_entry(&cache_entry(&record.image.manifest_digest), metadata) + .await + } + other => other.map(drop), + } + }; + if let Err(error) = taken_over { + tracing::warn!(sandbox = %record.id, %error, "failed to keep a deleted Sandbox's image"); + return; + } + match microsandbox::Image::remove_local(self.client.local(), &name, false).await { + // Another Sandbox's runtime still uses the image. + Ok(()) + | Err(microsandbox::MicrosandboxError::ImageNotFound(_) | microsandbox::MicrosandboxError::ImageInUse(_)) => + {} + Err(failure) => tracing::warn!(entry = name, error = %failure, "failed to release a Sandbox's image"), + } + } + + /// Removes cache entries not used for the retention period, and entries of Sandboxes + /// whose record is gone. + /// + /// Removal is best-effort and never fails the caller. A pass is skipped while an image is + /// fetched or another pass runs; later operations run passes of their own. + pub(crate) async fn remove_unused(&self) { + let Some(retention) = self.retention else { + return; + }; + let Ok(_exclusive) = self.catalog.try_write() else { + return; + }; + if let Err(error) = self.remove_unused_at(SystemTime::now(), retention).await { + tracing::warn!(%error, "failed to remove unused Microsandbox images"); + } + self.remove_stale_scratch(retention).await; + } + + async fn remove_unused_at(&self, now: SystemTime, retention: Duration) -> Result<(), Error> { + // A Sandbox's image is kept while its record exists, whether or not the Sandbox holds it + // yet, as in a home from before Sandboxes held their images. + let records = self.state.sandbox_records().await?; + let sandboxes: HashSet = records.iter().map(|record| entry_tag(&record.id)).collect(); + let used: HashSet<&str> = records + .iter() + .map(|record| record.image.manifest_digest.as_str()) + .collect(); + let cutoff = unix_millis(now).saturating_sub(i64::try_from(retention.as_millis()).unwrap_or(i64::MAX)); + for image in microsandbox::Image::list_local(self.client.local()) + .await + .map_err(error::microsandbox)? + { + let last_used = image + .last_used_at() + .or_else(|| image.created_at()) + .map(|time| time.timestamp_millis()); + let unused = sandbox_entry_tag(image.reference()).map_or_else( + || is_expired(last_used, cutoff) && image.manifest_digest().is_none_or(|digest| !used.contains(digest)), + |tag| !sandboxes.contains(tag), + ); + if !unused { + continue; + } + match microsandbox::Image::remove_local(self.client.local(), image.reference(), false).await { + Ok(()) => tracing::info!(reference = image.reference(), "removed unused Microsandbox image entry"), + // A runtime still uses the image, or the entry is already gone. + Err( + microsandbox::MicrosandboxError::ImageInUse(_) | microsandbox::MicrosandboxError::ImageNotFound(_), + ) => {} + Err(failure) => tracing::warn!( + reference = image.reference(), + error = %failure, + "failed to remove unused Microsandbox image entry" + ), + } + } + Ok(()) + } + + /// Reports whether this home's catalog is from before Sandboxes held their images. + pub(crate) async fn migration_pending(&self) -> bool { + !tokio::fs::try_exists(self.state.marker(MIGRATED_MARKER)) + .await + .unwrap_or(false) + } + + /// Reports whether a runtime records that it uses a held Sandbox image, which is what + /// protects an image from Microsandbox's prune. Microsandbox refuses to remove an entry of an + /// image a runtime records using, so a temporary entry that can be removed shows that none + /// does. The Sandbox's own entry keeps the image meanwhile. + pub(crate) async fn is_pinned(&self, record: &SandboxRecord) -> Result { + let Some(metadata) = self.metadata(&sandbox_entry(&record.id))? else { + return Ok(false); + }; + let probe = format!("{PROBE_REPOSITORY}:{}", entry_tag(&record.id)); + self.write_entry(&probe, metadata).await?; + match microsandbox::Image::remove_local(self.client.local(), &probe, false).await { + Ok(()) => Ok(false), + Err(microsandbox::MicrosandboxError::ImageInUse(_)) => { + microsandbox::Image::remove_local(self.client.local(), &probe, true) + .await + .map_err(error::microsandbox)?; + Ok(true) + } + Err(failure) => Err(error::microsandbox(failure)), + } + } + + /// Completes the migration of a catalog from before Sandboxes held their images. Image + /// versions no entry names, which a moved tag left behind, are reachable only through + /// Microsandbox's prune, which also removes every entry of an image no runtime records + /// using, so it runs only once every Sandbox's image [is pinned](Self::is_pinned), while the + /// Provider opens and before anything else runs. + pub(crate) async fn finish_migration(&self) -> Result<(), Error> { + let report = microsandbox::Image::prune_local(self.client.local()) + .await + .map_err(error::microsandbox)?; + tracing::info!( + manifests = report.manifests_removed, + layers = report.layers_removed, + "migrated the Microsandbox image catalog" + ); + // Earlier releases staged archives in the parent of the scratch directory. + if let Some(legacy_scratch) = self.scratch_directory().parent() + && let Ok(mut entries) = tokio::fs::read_dir(legacy_scratch).await + { + while let Ok(Some(entry)) = entries.next_entry().await { + let archive = entry.file_name().to_string_lossy().starts_with(".tmp"); + if archive && entry.file_type().await.is_ok_and(|kind| kind.is_file()) { + let _ = tokio::fs::remove_file(entry.path()).await; + } + } + } + tokio::fs::write(self.state.marker(MIGRATED_MARKER), b"") + .await + .map_err(|source| error::io("record the Microsandbox image catalog migration", source)) + } + + /// Returns the metadata cached for a catalog entry. + fn metadata(&self, name: &str) -> Result, Error> { + let reference = name.parse::().map_err(error::backend)?; + self.global_cache()? + .read_image_metadata(&reference) + .map_err(error::backend) + } + + async fn write_entry(&self, name: &str, metadata: CachedImageMetadata) -> Result<(), Error> { + let reference = name.parse::().map_err(error::backend)?; + self.global_cache()? + .write_image_metadata_async(&reference, &metadata) + .await + .map_err(error::backend)?; + microsandbox::Image::persist(self.client.local(), name, metadata) + .await + .map_err(error::microsandbox)?; + Ok(()) + } + + fn global_cache(&self) -> Result { + GlobalCache::new(&self.client.local().cache_dir()).map_err(error::backend) + } + + /// Removes archives an interrupted image build left in this crate's scratch directory. + async fn remove_stale_scratch(&self, retention: Duration) { + let scratch = self.scratch_directory(); + let Ok(mut entries) = tokio::fs::read_dir(&scratch).await else { + return; + }; + let now = SystemTime::now(); + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(metadata) = entry.metadata().await else { + continue; + }; + let stale = metadata + .modified() + .ok() + .and_then(|modified| now.duration_since(modified).ok()) + .is_some_and(|age| age >= retention); + if metadata.is_file() + && stale + && let Err(error) = tokio::fs::remove_file(entry.path()).await + { + tracing::warn!(path = %entry.path().display(), %error, "failed to remove stale image archive"); + } + } + } + + pub(crate) fn scratch_directory(&self) -> PathBuf { + self.client.local().cache_dir().join(SCRATCH_DIRECTORY) + } +} + +fn entry_tag(id: &SandboxId) -> String { + id.as_uuid().simple().to_string() +} + +/// Returns the catalog name of a Sandbox's entry. +fn sandbox_entry(id: &SandboxId) -> String { + format!("{SANDBOX_REPOSITORY}:{}", entry_tag(id)) +} + +/// Returns the catalog name of an image version's cache entry. +pub(crate) fn cache_entry(manifest_digest: &str) -> String { + format!("{CACHE_REPOSITORY}@{manifest_digest}") +} + +/// Returns the Sandbox ID tag of a Sandbox entry, or `None` for any other catalog entry. +fn sandbox_entry_tag(reference: &str) -> Option<&str> { + let (repository, tag) = reference.rsplit_once(':')?; + (repository == SANDBOX_REPOSITORY).then_some(tag) +} + +/// Reports whether a cache entry was last used at or before the cutoff. An entry without a +/// recorded use has expired. +fn is_expired(last_used_millis: Option, cutoff_millis: i64) -> bool { + last_used_millis.is_none_or(|used| used <= cutoff_millis) +} + +fn unix_millis(time: SystemTime) -> i64 { + time.duration_since(UNIX_EPOCH) + .ok() + .and_then(|elapsed| i64::try_from(elapsed.as_millis()).ok()) + .unwrap_or_default() +} + +#[cfg(test)] +// Test Clients live for the whole test; tightening their drop adds nothing. +#[allow(clippy::expect_used, clippy::significant_drop_tightening)] +mod tests { + use std::{ + collections::BTreeMap, + path::PathBuf, + time::{Duration, SystemTime}, + }; + + use sandbox::{ByteQuantity, CpuQuantity, Hostname, Platform, RootFilesystem, SandboxName, SandboxResources}; + + use super::{ImageCache, MIGRATED_MARKER, cache_entry, is_expired, sandbox_entry}; + use crate::{ + client::Client, + state::{SandboxRecord, StateStore}, + }; + + const DAY: Duration = Duration::from_hours(24); + + struct Home { + directory: tempfile::TempDir, + client: Client, + state: StateStore, + } + + impl Home { + async fn open() -> Self { + let directory = tempfile::tempdir().expect("temporary home should be created"); + let client = Client::open(directory.path().join("runtime"), None, None) + .await + .expect("Client should open"); + let state = StateStore::open(directory.path().join("state")) + .await + .expect("state store should open"); + Self { + directory, + client, + state, + } + } + + fn images(&self) -> ImageCache { + ImageCache::new(self.client.clone(), self.state.clone(), Some(DAY)) + } + + fn path(&self) -> &std::path::Path { + self.directory.path() + } + + /// Records an image the way resolving it does. + async fn resolve(&self, images: &ImageCache, manifest_digest: &str) { + images + .record(async { Ok((metadata(manifest_digest), ())) }) + .await + .expect("image should be recorded"); + } + + /// Records a catalog entry the way releases before Sandbox entries did. + async fn record_legacy(&self, name: &str, manifest_digest: &str) { + microsandbox::Image::persist(self.client.local(), name, metadata(manifest_digest)) + .await + .expect("image should be recorded"); + microsandbox_image::GlobalCache::new(&self.client.local().cache_dir()) + .expect("image cache should open") + .write_image_metadata_async(&name.parse().expect("reference"), &metadata(manifest_digest)) + .await + .expect("image metadata should be written"); + } + + /// Stands in for the files Microsandbox materializes for an image version. + fn materialize(&self, manifest_digest: &str) -> PathBuf { + let path = microsandbox_image::GlobalCache::new(&self.client.local().cache_dir()) + .expect("image cache should open") + .fsmeta_erofs_path(&manifest_digest.parse().expect("digest should parse")); + std::fs::create_dir_all(path.parent().expect("fsmeta directory")).expect("fsmeta directory"); + std::fs::write(&path, b"fsmeta").expect("fsmeta file"); + path + } + + async fn sandbox_needing(&self, manifest_digest: &str) -> SandboxRecord { + let record = sandbox_record("00000000-0000-4000-8000-000000000001", manifest_digest); + self.state + .save_sandbox(&record) + .await + .expect("Sandbox record should be saved"); + record + } + + async fn references(&self) -> Vec { + let mut references: Vec = microsandbox::Image::list_local(self.client.local()) + .await + .expect("images should list") + .iter() + .map(|image| image.reference().to_string()) + .collect(); + references.sort(); + references + } + } + + fn metadata(manifest_digest: &str) -> microsandbox_image::CachedImageMetadata { + microsandbox_image::CachedImageMetadata { + manifest_digest: manifest_digest.to_string(), + config_digest: digest('c'), + raw_manifest_json: "{}".to_string(), + raw_config_json: "{}".to_string(), + config: microsandbox_image::ImageConfig::default(), + layers: Vec::new(), + } + } + + fn sandbox_record(id: &str, manifest_digest: &str) -> SandboxRecord { + SandboxRecord::new(sandbox::backend::CreateSandboxRequest { + id: id.parse().expect("Sandbox ID"), + name: SandboxName::new("worker").expect("Sandbox name"), + hostname: Hostname::new("worker").expect("hostname"), + image: sandbox::image::ResolvedImage { + source: sandbox::image::ImageSource::Reference { + reference: "example.com/app:latest".to_string(), + }, + platform: Platform::new("linux", "amd64"), + manifest_digest: manifest_digest.to_string(), + }, + resources: SandboxResources::new( + "1".parse::().expect("CPU"), + "512Mi".parse::().expect("memory"), + RootFilesystem::layered("1Gi".parse::().expect("root filesystem")), + ), + init_system: sandbox::init::InitSystem::Backend, + mounts: Vec::new(), + environment: BTreeMap::new(), + network: None, + }) + } + + fn digest(fill: char) -> String { + format!("sha256:{}", fill.to_string().repeat(64)) + } + + #[test] + fn cache_entries_expire_once_last_used_before_the_cutoff() { + assert!(!is_expired(Some(1001), 1000)); + assert!(is_expired(Some(1000), 1000)); + assert!(is_expired(None, 1000)); + } + + #[tokio::test(flavor = "local")] + async fn a_deleted_sandboxs_image_stays_for_the_retention_period_after_the_deletion() { + let home = Home::open().await; + let images = home.images(); + let image = digest('a'); + home.resolve(&images, &image).await; + let files = home.materialize(&image); + let record = home.sandbox_needing(&image).await; + let entry = images + .hold(&record) + .await + .expect("hold") + .expect("the image should be cached"); + assert_eq!(images.hold(&record).await.expect("hold again"), Some(entry.clone())); + + // Nothing is removed while the Sandbox uses the image, however long ago it was pulled. + images + .remove_unused_at(SystemTime::now() + 2 * DAY, DAY) + .await + .expect("pass"); + assert_eq!(home.references().await, [cache_entry(&image), entry]); + + home.state.remove_sandbox(&record).await.expect("record removed"); + images.release(&record).await; + assert_eq!(home.references().await, [cache_entry(&image)]); + images + .remove_unused_at(SystemTime::now() + Duration::from_hours(23), DAY) + .await + .expect("pass"); + assert!( + files.exists(), + "the image stays for the retention period after the deletion" + ); + images + .remove_unused_at(SystemTime::now() + Duration::from_hours(25), DAY) + .await + .expect("pass"); + assert!(home.references().await.is_empty()); + assert!(!files.exists(), "the image goes with its last entry"); + } + + #[tokio::test(flavor = "local")] + async fn an_entry_whose_sandbox_record_is_gone_is_removed() { + let home = Home::open().await; + let images = home.images(); + let image = digest('a'); + home.resolve(&images, &image).await; + let record = home.sandbox_needing(&image).await; + let entry = images + .hold(&record) + .await + .expect("hold") + .expect("the image should be cached"); + home.state.remove_sandbox(&record).await.expect("record removed"); + + images.remove_unused_at(SystemTime::now(), DAY).await.expect("pass"); + assert!(!home.references().await.contains(&entry)); + } + + #[tokio::test(flavor = "local")] + async fn an_image_that_is_not_cached_cannot_be_held() { + let home = Home::open().await; + let images = home.images(); + let record = home.sandbox_needing(&digest('a')).await; + assert_eq!(images.hold(&record).await.expect("hold"), None); + } + + /// Records a tag the way releases before Sandbox entries did, moved from a previous version + /// to a current one, which leaves the previous version without an entry. + async fn record_moved_tag(home: &Home) -> PathBuf { + let (previous, current) = (digest('a'), digest('b')); + home.record_legacy("example.com/app:latest", &previous).await; + home.record_legacy("example.com/app:latest", ¤t).await; + home.materialize(&previous) + } + + #[tokio::test(flavor = "local")] + async fn opening_a_provider_migrates_a_home_from_before_sandboxes_held_their_images() { + let home = Home::open().await; + let previous_files = record_moved_tag(&home).await; + let legacy_archive = home.client.local().cache_dir().join("tmp/.tmpArchive"); + std::fs::create_dir_all(legacy_archive.parent().expect("legacy scratch")).expect("legacy scratch"); + std::fs::write(&legacy_archive, b"archive").expect("legacy archive"); + + provider(home.path()).await; + assert!(!previous_files.exists(), "what nothing holds is removed"); + assert!(!legacy_archive.exists()); + assert!(home.state.marker(MIGRATED_MARKER).exists()); + } + + /// Records a Sandbox built from a Dockerfile before this release, whose image only its + /// import entry names, and which never started. + async fn record_unstarted_built_sandbox(home: &Home) -> (SandboxRecord, PathBuf) { + let image = digest('e'); + home.record_legacy("sandbox-microsandbox-import:docker-1234", &image) + .await; + let mut record = sandbox_record("00000000-0000-4000-8000-000000000009", &image); + record.image.source = sandbox::image::ImageSource::Build { + context: PathBuf::from("context"), + dockerfile: PathBuf::from("Dockerfile"), + target: None, + }; + home.state.save_sandbox(&record).await.expect("record saved"); + (record, home.materialize(&image)) + } + + #[tokio::test(flavor = "local")] + async fn migration_waits_for_a_sandbox_that_never_started_and_completes_once_it_is_deleted() { + use sandbox::backend::SandboxBackend as _; + + let home = Home::open().await; + let previous_files = record_moved_tag(&home).await; + let (record, image_files) = record_unstarted_built_sandbox(&home).await; + + let provider = provider(home.path()).await; + provider + .images() + .remove_unused_at(SystemTime::now() + 10 * DAY, DAY) + .await + .expect("pass"); + assert!( + image_files.exists(), + "the Sandbox keeps its image through the migration and later passes" + ); + assert!( + previous_files.exists(), + "nothing is pruned while a Sandbox's image is unprotected" + ); + assert!(!home.state.marker(MIGRATED_MARKER).exists()); + + provider.delete(&record.id).await.expect("Sandbox deleted"); + drop(provider); + crate::MicrosandboxProvider::builder(home.path()) + .remove_unused_images_after(DAY) + .open() + .await + .expect("Provider should open"); + assert!(!previous_files.exists()); + assert!(home.state.marker(MIGRATED_MARKER).exists()); + } + + #[tokio::test(flavor = "local")] + #[ignore = "seeds the Microsandbox database with python3"] + async fn migration_keeps_the_image_of_a_sandbox_whose_first_start_was_interrupted() { + let home = Home::open().await; + let previous_files = record_moved_tag(&home).await; + let (record, image_files) = record_unstarted_built_sandbox(&home).await; + // A runtime whose creation stopped before Microsandbox recorded that it uses its image. + let builder = Client::sandbox_builder( + &record.runtime_name, + "sandbox-microsandbox-import:docker-1234", + record.resources, + ) + .expect("runtime builder"); + let config = Box::pin(home.client.scope(builder.build())) + .await + .expect("runtime config"); + let seeded = std::process::Command::new("python3") + .arg("-c") + .arg( + "import pathlib, sqlite3, sys; db = next(pathlib.Path(sys.argv[1]).rglob('msb.db')); \ + connection = sqlite3.connect(db); connection.execute('INSERT INTO sandbox (name, config, status, ephemeral) \ + VALUES (?, ?, ?, ?)', (sys.argv[2], sys.argv[3], 'Stopped', 0)); connection.commit()", + ) + .arg(home.path()) + .arg(&record.runtime_name) + .arg(serde_json::to_string(&config).expect("runtime config serializes")) + .output() + .expect("python3 should run"); + assert!(seeded.status.success(), "{}", String::from_utf8_lossy(&seeded.stderr)); + assert!( + home.client + .scope(microsandbox::Sandbox::get(&record.runtime_name)) + .await + .is_ok(), + "the runtime should exist" + ); + + let provider = provider(home.path()).await; + provider + .images() + .remove_unused_at(SystemTime::now() + 10 * DAY, DAY) + .await + .expect("pass"); + assert!(image_files.exists()); + assert!(previous_files.exists()); + assert!(!home.state.marker(MIGRATED_MARKER).exists()); + } + + #[tokio::test(flavor = "local")] + async fn an_image_recorded_before_cache_entries_is_held() { + let home = Home::open().await; + let image = digest('a'); + home.record_legacy("sandbox-microsandbox-import:docker-1234", &image) + .await; + let record = home.sandbox_needing(&image).await; + + assert_eq!( + home.images().hold(&record).await.expect("hold"), + Some(sandbox_entry(&record.id)) + ); + let unmanaged = ImageCache::new(home.client.clone(), home.state.clone(), None); + let other = sandbox_record("00000000-0000-4000-8000-000000000004", &image); + let name = unmanaged + .hold(&other) + .await + .expect("hold") + .expect("the image is cached"); + assert_ne!(name, sandbox_entry(&other.id)); + assert!( + !home.references().await.contains(&sandbox_entry(&other.id)), + "without a retention period nothing removes images, so no entry is added" + ); + } + + #[tokio::test(flavor = "local")] + async fn an_unreadable_sandbox_record_removes_nothing() { + let home = Home::open().await; + let images = home.images(); + let (held, unused) = (digest('a'), digest('b')); + home.resolve(&images, &held).await; + let record = home.sandbox_needing(&held).await; + let entry = images + .hold(&record) + .await + .expect("hold") + .expect("the image should be cached"); + home.state.remove_sandbox(&record).await.expect("record removed"); + home.resolve(&images, &unused).await; + std::fs::write(home.path().join("state/sandboxes/unreadable.json"), b"{").expect("unreadable record"); + + assert!(images.remove_unused_at(SystemTime::now() + 2 * DAY, DAY).await.is_err()); + let references = home.references().await; + assert!(references.contains(&entry) && references.contains(&cache_entry(&unused))); + } + + #[tokio::test(flavor = "local")] + #[ignore = "requires Internet access"] + async fn a_sandbox_fetches_an_image_the_cache_lost_by_digest() { + let manifest_digest = match std::env::consts::ARCH { + "x86_64" => "sha256:7c8cb692ae09657cbc4a3f3cbd0e8d5a2690ba38386aaaf252dbb060bf5eb2e6", + "aarch64" => "sha256:2c9d26f410d032d5b1525aa8a873e238b05b90c4ae8618743d4311f0cc827e37", + _ => return, + }; + let home = Home::open().await; + let provider = provider(home.path()).await; + let mut record = sandbox_record("00000000-0000-4000-8000-000000000003", manifest_digest); + record.image.source = sandbox::image::ImageSource::Reference { + reference: "docker.io/library/alpine:3.22".to_string(), + }; + record.image.platform = native_platform(); + home.state.save_sandbox(&record).await.expect("record saved"); + + let entry = provider.hold_image(&record).await.expect("the image should be fetched"); + assert_eq!(entry, sandbox_entry(&record.id)); + assert!(home.references().await.contains(&cache_entry(manifest_digest))); + } + + #[tokio::test(flavor = "local")] + async fn without_a_retention_period_the_cache_is_left_alone() { + let home = Home::open().await; + let image = digest('a'); + home.record_legacy(&format!("example.com/app@{image}"), &image).await; + + ImageCache::new(home.client.clone(), home.state.clone(), None) + .remove_unused() + .await; + assert_eq!(home.references().await.len(), 1); + } + + fn native_platform() -> Platform { + Platform::native("linux") + } + + fn vm_resources(mode: sandbox::RootFilesystemMode) -> SandboxResources { + let capacity = "1Gi".parse::().expect("root filesystem"); + SandboxResources::new( + "1".parse::().expect("CPU"), + "512Mi".parse::().expect("memory"), + match mode { + sandbox::RootFilesystemMode::Direct => RootFilesystem::direct(capacity), + _ => RootFilesystem::layered(capacity), + }, + ) + } + + fn ensure_request(name: &str, reference: &str, mode: sandbox::RootFilesystemMode) -> sandbox::EnsureSandboxRequest { + sandbox::EnsureSandboxRequest::new( + SandboxName::new(name).expect("Sandbox name"), + sandbox::SandboxSpec { + image: sandbox::image::ImageSource::Reference { + reference: reference.to_string(), + }, + platform: native_platform(), + resources: vm_resources(mode), + init_system: sandbox::init::InitSystem::Backend, + retention_policy: sandbox::RetentionPolicy::Retain, + }, + ) + } + + /// Creates a Sandbox without starting it, as when its first start fails. + async fn create_unstarted(provider: &crate::MicrosandboxProvider, id: &str, reference: &str) -> sandbox::Sandbox { + use sandbox::{backend::SandboxBackend as _, provider::SandboxProvider as _}; + + let mode = sandbox::RootFilesystemMode::Direct; + let image = provider + .image_backend() + .resolve(&sandbox::image::ResolveRequest { + source: sandbox::image::ImageSource::Reference { + reference: reference.to_string(), + }, + platform: native_platform(), + root_filesystem_mode: mode, + }) + .await + .expect("image should resolve"); + provider + .create(sandbox::backend::CreateSandboxRequest { + id: id.parse().expect("Sandbox ID"), + image, + name: SandboxName::new("unstarted").expect("Sandbox name"), + hostname: Hostname::new("unstarted").expect("hostname"), + resources: vm_resources(mode), + init_system: sandbox::init::InitSystem::Backend, + mounts: Vec::new(), + environment: BTreeMap::new(), + network: None, + }) + .await + .expect("Sandbox should be created") + } + + /// Lists the cached artifacts in a directory, ignoring lock files. + fn cached_files(directory: &std::path::Path) -> Vec { + std::fs::read_dir(directory).map_or_else( + |_| Vec::new(), + |entries| { + entries + .map(|entry| entry.expect("cache entry should be readable").path()) + .filter(|path| path.extension().is_none_or(|extension| extension != "lock")) + .collect() + }, + ) + } + + async fn provider(home: &std::path::Path) -> std::rc::Rc { + std::rc::Rc::new( + crate::MicrosandboxProvider::builder(home) + .remove_unused_images_after(DAY) + .open() + .await + .expect("Provider should open"), + ) + } + + #[tokio::test(flavor = "local")] + #[ignore = "requires a Microsandbox host runtime, hardware virtualization and registry access"] + async fn sandboxes_keep_their_images_until_they_are_deleted() { + use sandbox::{ + RootFilesystemMode::{Direct, Layered}, + backend::SandboxBackend as _, + }; + + let home = tempfile::tempdir().expect("temporary home should be created"); + let provider = provider(home.path()).await; + let service = sandbox::SandboxService::new(provider.clone()); + let later = || SystemTime::now() + 2 * DAY; + + let stopped = service + .ensure(&ensure_request("stopped", "docker.io/library/alpine:3.21", Layered)) + .await + .expect("layered Sandbox should start") + .snapshot() + .clone(); + provider.stop(&stopped.id).await.expect("Sandbox should stop"); + let unstarted = create_unstarted( + &provider, + "00000000-0000-4000-8000-0000000020d4", + "docker.io/library/alpine:3.20", + ) + .await; + let deleted = service + .ensure(&ensure_request("deleted", "docker.io/library/alpine:3.22", Direct)) + .await + .expect("direct Sandbox should start") + .snapshot() + .clone(); + service.delete(&deleted.name).await.expect("Sandbox should be deleted"); + provider.images().remove_unused_at(later(), DAY).await.expect("pass"); + + let cache = home.path().join("runtime/cache"); + let flat_ref = |manifest_digest: &str| { + cache + .join("flat/refs") + .join(format!("{}.json", manifest_digest.replace(':', "_"))) + }; + assert!( + !flat_ref(&deleted.image.manifest_digest).exists(), + "the deleted Sandbox's image should be removed once unused for the retention period" + ); + assert!(flat_ref(&unstarted.image.manifest_digest).exists()); + + provider + .start(&stopped.id) + .await + .expect("the stopped Sandbox should keep its image and restart"); + provider + .start(&unstarted.id) + .await + .expect("the Sandbox without a runtime should keep its image and start"); + for sandbox in [&stopped, &unstarted] { + service.delete(&sandbox.name).await.expect("Sandbox should be deleted"); + } + provider.images().remove_unused_at(later(), DAY).await.expect("pass"); + for directory in ["flat/blobs", "flat/refs", "layers", "fsmeta", "vmdk"] { + assert_eq!( + cached_files(&cache.join(directory)), + Vec::::new(), + "no image should remain in {directory} once no Sandbox needs one" + ); + } + } + + #[tokio::test(flavor = "local")] + #[ignore = "requires a Microsandbox host runtime, hardware virtualization and registry access"] + async fn migration_prunes_once_every_sandbox_image_is_pinned() { + use sandbox::backend::SandboxBackend as _; + + let home = Home::open().await; + let first = provider(home.path()).await; + let service = sandbox::SandboxService::new(first.clone()); + let sandbox = service + .ensure(&ensure_request( + "running", + "docker.io/library/alpine:3.21", + sandbox::RootFilesystemMode::Layered, + )) + .await + .expect("Sandbox should start") + .snapshot() + .clone(); + drop(service); + drop(first); + // Turn the home back into one from before this release, with a version a moved tag left. + let previous_files = record_moved_tag(&home).await; + std::fs::remove_file(home.state.marker(MIGRATED_MARKER)).expect("marker removed"); + + let reopened = provider(home.path()).await; + assert!( + !previous_files.exists(), + "every Sandbox's image is pinned, so the migration prunes" + ); + assert!(home.state.marker(MIGRATED_MARKER).exists()); + reopened.stop(&sandbox.id).await.expect("Sandbox should stop"); + reopened + .start(&sandbox.id) + .await + .expect("the Sandbox should keep its image and restart"); + reopened.delete(&sandbox.id).await.expect("Sandbox should be deleted"); + } +} diff --git a/sandbox/microsandbox/src/lib.rs b/sandbox/microsandbox/src/lib.rs new file mode 100644 index 0000000..224b072 --- /dev/null +++ b/sandbox/microsandbox/src/lib.rs @@ -0,0 +1,30 @@ +//! Microsandbox implementation of the backend-neutral Sandbox SDK. +//! +//! The integration deliberately contains no Agent automation. It uses an +//! explicit Microsandbox home, materializes stopped Sandboxes in adapter-owned +//! state, and boots the underlying microVM only when the generic Backend is +//! started. + +mod backend; +mod client; +mod encoding; +mod error; +mod execution; +mod files; +mod guest_tcp; +mod heartbeat; +mod image; +mod image_cache; +mod network_backend; +mod network_endpoint; +mod platform; +mod state; +mod volumes; + +pub use backend::{MicrosandboxProvider, MicrosandboxProviderBuilder}; + +/// `RUST_LOG` directives that keep the Microsandbox runtime's helper processes quiet at the +/// default level. The runtime's agent client logs every relay connection at INFO. +pub const LOG_DIRECTIVES: &str = "microsandbox_agent_client=warn"; +pub use guest_tcp::{GuestTcpDialer, GuestTcpStream}; +pub use network_backend::{MicrosandboxNetworkBackend, SecretBinding}; diff --git a/sandbox/microsandbox/src/network_backend.rs b/sandbox/microsandbox/src/network_backend.rs new file mode 100644 index 0000000..4167867 --- /dev/null +++ b/sandbox/microsandbox/src/network_backend.rs @@ -0,0 +1,1258 @@ +use std::{ + cell::RefCell, + collections::{HashMap, HashSet}, + future::poll_fn, + num::NonZeroUsize, + rc::Rc, +}; + +use microsandbox_network::control::{ + AuthorizationDecision as RuntimeDecision, ControllerMessage, NETWORK_CONTROL_PROTOCOL, NetworkOperation, + RuntimeMessage, SecretMaterial as RuntimeSecretMaterial, +}; +use microsandbox_network::secrets::config::{ + HostPattern, SecretEntry, SecretSource, SecretSubstitution, SecretViolationAction, SecretsConfig, +}; +use sandbox::{ + Error, LocalFuture, ResourceKind, SandboxId, SandboxName, + network::{ + NetworkBackend, NetworkBackendId, NetworkBatch, NetworkControlEndpointParts, NetworkControlMessage, + NetworkEndpoint, NetworkEndpointSelection, NetworkTransferProgress, StartNetworkRequest, + }, + secret_store::{SecretReference, SecretStore}, +}; +use sandbox_authorization::{ + Action, AuthorizationContext, AuthorizationDecision, AuthorizationRequest, PolicyEngine, Principal, Resource, + vocabulary::{action, context, principal_kind, resource_kind}, +}; +use tokio::{sync::mpsc, task::JoinHandle}; +use zeroize::Zeroizing; + +const BACKEND_ID: &str = "microsandbox"; +const RECEIVE_BATCH_SIZE: usize = 16; + +/// Microsandbox Network Backend using the trusted runtime's protocol engine. +pub struct MicrosandboxNetworkBackend { + policy: Rc, + secret_store: Option>, + secret_bindings: RefCell>>, + drivers: RefCell>, +} + +/// Host-owned mapping from a non-secret placeholder to current secret material. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SecretBinding { + environment: String, + placeholder: String, + reference: SecretReference, +} + +struct Driver { + commands: mpsc::Sender, + task: JoinHandle<()>, +} + +#[derive(Clone, Copy)] +struct SandboxSubject<'a> { + id: &'a SandboxId, + name: &'a SandboxName, +} + +enum DriverCommand { + RevokeAll, +} + +impl MicrosandboxNetworkBackend { + /// Creates a Network Backend that evaluates every runtime request with `policy`. + #[must_use] + pub fn new(policy: Rc) -> Self { + Self { + policy, + secret_store: None, + secret_bindings: RefCell::new(HashMap::new()), + drivers: RefCell::new(HashMap::new()), + } + } + + /// Configures the host Secret Store used by mediated requests. + #[must_use] + pub fn with_secret_store(mut self, store: Rc) -> Self { + self.secret_store = Some(store); + self + } + + /// Replaces the secret bindings applied when this named Sandbox's Network starts. + /// + /// Returns whether the binding definition changed. Existing connections + /// keep their handshake configuration until the caller restarts the Network. + /// Secret material itself is resolved from the store for every authorized + /// use, so rotating a value behind an unchanged binding needs no restart. + /// + /// # Errors + /// + /// Returns an error when a binding is empty, duplicated, ambiguous, or + /// unsafe for the Microsandbox control protocol. + pub fn set_secret_bindings(&self, sandbox_name: SandboxName, bindings: Vec) -> Result { + validate_secret_bindings(&bindings)?; + let changed = self.secret_bindings.borrow().get(&sandbox_name) != Some(&bindings); + self.secret_bindings.borrow_mut().insert(sandbox_name, bindings); + Ok(changed) + } + + /// Removes the configured bindings for a named Sandbox. + pub fn remove_secret_bindings(&self, sandbox_name: &SandboxName) { + self.secret_bindings.borrow_mut().remove(sandbox_name); + } + + /// Revokes all currently allowed flows for one running Sandbox. + /// + /// New operations continue to use the current Policy Engine state. + /// + /// # Errors + /// + /// Returns [`Error::NotFound`] when this process does not drive the + /// Sandbox's Network endpoint. + pub async fn revoke_all(&self, sandbox_id: &SandboxId) -> Result<(), Error> { + let commands = self + .drivers + .borrow() + .get(sandbox_id) + .filter(|driver| !driver.task.is_finished()) + .map(|driver| driver.commands.clone()) + .ok_or_else(|| Error::not_found(ResourceKind::Network, sandbox_id))?; + commands + .send(DriverCommand::RevokeAll) + .await + .map_err(|_| Error::Backend("Microsandbox Network controller stopped".into())) + } + + async fn stop_driver(&self, sandbox_id: &SandboxId) { + let driver = self.drivers.borrow_mut().remove(sandbox_id); + if let Some(driver) = driver { + driver.task.abort(); + let _ = driver.task.await; + } + } +} + +impl NetworkBackend for MicrosandboxNetworkBackend { + fn id(&self) -> NetworkBackendId { + NetworkBackendId::new(BACKEND_ID) + } + + fn is_running(&self, sandbox_id: &SandboxId) -> bool { + self.drivers + .borrow() + .get(sandbox_id) + .is_some_and(|driver| !driver.task.is_finished()) + } + + fn select_endpoint( + &self, + available: &sandbox::network::NetworkEndpointCapabilities, + ) -> Option { + let selection = NetworkEndpointSelection::Control(sandbox::network::NetworkControlProtocolId::new( + NETWORK_CONTROL_PROTOCOL, + )); + available.supports(&selection).then_some(selection) + } + + fn start(&self, request: StartNetworkRequest) -> LocalFuture<'_, Result<(), Error>> { + Box::pin(async move { + if self.is_running(&request.sandbox_id) { + return Ok(()); + } + self.stop_driver(&request.sandbox_id).await; + let NetworkEndpoint::Control(endpoint) = request.endpoint else { + return Err(Error::UnsupportedNetworkEndpoint(request.endpoint.selection())); + }; + if endpoint.properties().protocol().as_str() != NETWORK_CONTROL_PROTOCOL { + return Err(Error::UnsupportedNetworkEndpoint(NetworkEndpointSelection::Control( + endpoint.properties().protocol().clone(), + ))); + } + let (commands, command_rx) = mpsc::channel(16); + let policy = self.policy.clone(); + let secret_store = self.secret_store.clone(); + let secret_bindings = self + .secret_bindings + .borrow() + .get(&request.sandbox_name) + .cloned() + .unwrap_or_default(); + let sandbox_id = request.sandbox_id.clone(); + let log_sandbox_id = sandbox_id.clone(); + let log_sandbox_name = request.sandbox_name.clone(); + let task = tokio::task::spawn_local(async move { + if let Err(error) = drive( + sandbox_id, + request.sandbox_name, + endpoint.into_parts(), + policy, + secret_store, + secret_bindings, + command_rx, + ) + .await + { + tracing::warn!( + %error, + sandbox = %log_sandbox_id, + sandbox_name = %log_sandbox_name, + "Microsandbox Network data plane stopped" + ); + } + }); + self.drivers + .borrow_mut() + .insert(request.sandbox_id, Driver { commands, task }); + Ok(()) + }) + } + + fn stop<'a>(&'a self, sandbox_id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>> { + Box::pin(async move { + self.stop_driver(sandbox_id).await; + Ok(()) + }) + } + + fn delete<'a>(&'a self, sandbox_id: &'a SandboxId) -> LocalFuture<'a, Result<(), Error>> { + self.stop(sandbox_id) + } +} + +impl SecretBinding { + /// Creates a host-owned secret binding. + /// + /// # Errors + /// + /// Returns an error when the environment-variable name is invalid. + pub fn new(environment: impl Into, reference: SecretReference) -> Result { + let environment = environment.into(); + let placeholder = format!("$MSB_{environment}"); + Self::with_placeholder(environment, placeholder, reference) + } + + /// Creates a host-owned secret binding with a caller-selected placeholder. + /// + /// # Errors + /// + /// Returns an error when the environment-variable name or placeholder is invalid. + pub fn with_placeholder( + environment: impl Into, + placeholder: impl Into, + reference: SecretReference, + ) -> Result { + let binding = Self { + environment: environment.into(), + placeholder: placeholder.into(), + reference, + }; + if !valid_secret_binding(&binding) { + return Err(Error::invalid( + "secretBinding", + "environment-variable name or placeholder is invalid", + )); + } + Ok(binding) + } + + /// Returns the non-secret environment assignment exposed inside the Sandbox. + #[must_use] + pub fn guest_environment(&self) -> (&str, &str) { + (&self.environment, &self.placeholder) + } + + fn runtime_entry(&self) -> SecretEntry { + SecretEntry { + env_var: self.environment.clone(), + value: Zeroizing::new(String::new()), + source: Some(SecretSource::Store { + reference: self.environment.clone(), + }), + placeholder: self.placeholder.clone(), + // The host controller decides per request where the secret is + // substituted (`SecretUse`), so every host is eligible here. + allowed_hosts: vec![HostPattern::Any], + substitution: SecretSubstitution::default(), + // The placeholder is inert text that also reaches requests the + // secret is not substituted into, such as a conversation history + // sent in a model request body. Forwarding it unchanged exposes no + // secret; blocking it would fail every later request in the session. + passthrough_hosts: vec![HostPattern::Any], + violation_action: Some(SecretViolationAction::Block), + require_tls_identity: true, + } + } +} + +async fn drive( + sandbox_id: SandboxId, + sandbox_name: SandboxName, + mut endpoint: NetworkControlEndpointParts, + policy: Rc, + secret_store: Option>, + secret_bindings: Vec, + mut commands: mpsc::Receiver, +) -> Result<(), Error> { + let batch_size = NonZeroUsize::new(RECEIVE_BATCH_SIZE) + .ok_or_else(|| Error::Backend("invalid Microsandbox Network receive batch size".into()))?; + let mut received = NetworkBatch::new(batch_size); + let mut state = DriverState::default(); + + loop { + tokio::select! { + progress = poll_fn(|context| endpoint.from_sandbox.as_mut().poll_receive(context, &mut received)) => { + match progress.map_err(|error| Error::Backend(error.to_string()))? { + NetworkTransferProgress::Items(_) => { + while let Some(message) = received.pop_front() { + let runtime_message = serde_json::from_slice(message.as_bytes()).map_err(protocol_error)?; + let response = handle_runtime_message( + SandboxSubject { + id: &sandbox_id, + name: &sandbox_name, + }, + runtime_message, + policy.as_ref(), + secret_store.as_deref(), + &secret_bindings, + &mut state, + ).await?; + if let Some(response) = response { + send(&mut endpoint, response).await?; + } + } + } + NetworkTransferProgress::Closed => return Ok(()), + } + } + command = commands.recv() => { + let Some(DriverCommand::RevokeAll) = command else { + return Ok(()); + }; + for flow_id in state.flows.drain() { + send(&mut endpoint, ControllerMessage::Revoke { flow_id }).await?; + } + } + } + } +} + +async fn handle_runtime_message( + subject: SandboxSubject<'_>, + message: RuntimeMessage, + policy: &dyn PolicyEngine, + secret_store: Option<&dyn SecretStore>, + secret_bindings: &[SecretBinding], + state: &mut DriverState, +) -> Result, Error> { + match message { + RuntimeMessage::Hello { protocol } if protocol == NETWORK_CONTROL_PROTOCOL => { + state.handshake_complete = true; + state.flows.clear(); + Ok(Some(ControllerMessage::HelloAccepted { + protocol, + secrets: SecretsConfig { + secrets: secret_bindings.iter().map(SecretBinding::runtime_entry).collect(), + violation_action: SecretViolationAction::Block, + passthrough_hosts: None, + }, + })) + } + RuntimeMessage::Hello { .. } => Err(Error::Backend("invalid Microsandbox Network control handshake".into())), + RuntimeMessage::AuthorizationRequest { + request_id, + flow_id, + operation, + } if state.handshake_complete => { + let authorization = authorize_operation(subject, &operation, policy, secret_store, secret_bindings).await; + if authorization.is_none() { + log_authorization_denial(subject, &operation, &mut state.denials); + } + let (decision, secret_material) = authorization.map_or_else( + || (RuntimeDecision::Deny, None), + |secret_material| { + state.flows.insert(flow_id); + (RuntimeDecision::Allow, secret_material) + }, + ); + Ok(Some(ControllerMessage::AuthorizationDecision { + request_id, + decision, + secret_material, + })) + } + RuntimeMessage::FlowClosed { flow_id } if state.handshake_complete => { + state.flows.remove(&flow_id); + Ok(None) + } + RuntimeMessage::AuthorizationRequest { .. } | RuntimeMessage::FlowClosed { .. } => Err(Error::Backend( + "Microsandbox Network request arrived before the protocol handshake".into(), + )), + } +} + +/// Mutable state of one Network driver: the handshake, live flows, and denial logging. +#[derive(Default)] +struct DriverState { + handshake_complete: bool, + flows: HashSet, + denials: DenialLog, +} + +/// Per-driver bound on denial logging. +/// +/// A Sandbox can be denied once per request it makes, so unbounded logging would +/// let it grow the host log at will. Each distinct `(action, hostname)` is logged +/// on its first denial and then once per [`DenialLog::REPEAT_EVERY`] repeats. A +/// Sandbox rotating hostnames would still log one line per new key, so the total +/// number of lines per driver is capped at [`DenialLog::MAX_LINES`]; beyond it only +/// every [`DenialLog::SUMMARY_EVERY`]th denial is logged, with the running total. +#[derive(Default)] +struct DenialLog { + counts: HashMap<(&'static str, Option), u64>, + total: u64, + lines: u64, +} + +impl DenialLog { + const REPEAT_EVERY: u64 = 100; + const MAX_KEYS: usize = 1_024; + const MAX_LINES: u64 = 1_000; + const SUMMARY_EVERY: u64 = 10_000; + + /// Records one denial and returns the count to log when a line is warranted. + fn record(&mut self, action: &'static str, hostname: Option<&str>) -> Option { + self.total += 1; + if self.lines >= Self::MAX_LINES { + return self.total.is_multiple_of(Self::SUMMARY_EVERY).then_some(self.total); + } + let key = (action, hostname.map(str::to_owned)); + if !self.counts.contains_key(&key) && self.counts.len() >= Self::MAX_KEYS { + self.counts.clear(); + } + let count = self.counts.entry(key).or_insert(0); + *count += 1; + let log = *count == 1 || count.is_multiple_of(Self::REPEAT_EVERY); + if log { + self.lines += 1; + } + log.then_some(*count) + } +} + +fn log_authorization_denial(subject: SandboxSubject<'_>, operation: &NetworkOperation, denials: &mut DenialLog) { + let (action, hostname, destination) = operation_log_fields(operation); + if let Some(denied) = denials.record(action, hostname) { + tracing::warn!( + action, + hostname, + destination = %destination, + sandbox = %subject.id, + sandbox_name = %subject.name, + denied, + "Microsandbox Network authorization denied" + ); + } +} + +fn operation_log_fields(operation: &NetworkOperation) -> (&'static str, Option<&str>, String) { + match operation { + NetworkOperation::Connect { + destination, hostname, .. + } => (action::NETWORK_CONNECT, hostname.as_deref(), destination.to_string()), + NetworkOperation::DnsQuery { name, resolver, .. } => ( + action::DNS_QUERY, + Some(name), + resolver.as_ref().map_or_else(|| "none".into(), ToString::to_string), + ), + NetworkOperation::HttpRequest { + destination, authority, .. + } => (action::HTTP_REQUEST, Some(authority), destination.to_string()), + NetworkOperation::SecretUse { + destination, authority, .. + } => (action::SECRET_USE, Some(authority), destination.to_string()), + } +} + +async fn authorize_operation( + subject: SandboxSubject<'_>, + operation: &NetworkOperation, + policy: &dyn PolicyEngine, + secret_store: Option<&dyn SecretStore>, + bindings: &[SecretBinding], +) -> Option> { + if let NetworkOperation::SecretUse { secret, locations, .. } = operation + && (locations.is_empty() + || locations.iter().collect::>().len() != locations.len() + || !bindings.iter().any(|binding| binding.environment == *secret)) + { + return None; + } + if !matches!( + policy.evaluate(authorization_request(subject, operation)).await, + Ok(AuthorizationDecision::Allow) + ) { + return None; + } + let NetworkOperation::SecretUse { secret, .. } = operation else { + return Some(None); + }; + let binding = bindings.iter().find(|binding| binding.environment == *secret)?; + let store = secret_store?; + let resolved = store.resolve(&binding.reference).await.ok()?; + let value = std::str::from_utf8(resolved.expose()).ok()?; + if value.is_empty() || value.bytes().any(|byte| matches!(byte, 0 | b'\r' | b'\n')) { + return None; + } + Some(Some(RuntimeSecretMaterial::new(value.to_owned()))) +} + +fn authorization_request(subject: SandboxSubject<'_>, operation: &NetworkOperation) -> AuthorizationRequest { + let principal = Principal::new(principal_kind::SANDBOX, subject.id.to_string()); + match operation { + NetworkOperation::Connect { + source, + destination, + transport, + hostname, + destination_is_host, + } => { + let mut authorization_context = sandbox_context(subject.name) + .with_attribute(context::NETWORK_DESTINATION_ADDRESS, destination.to_string()) + .with_attribute(context::NETWORK_DESTINATION_IS_HOST, *destination_is_host) + .with_attribute(context::NETWORK_TRANSPORT, transport_name(*transport)); + if let Some(source) = source { + authorization_context.insert(context::NETWORK_SOURCE_ADDRESS, source.to_string()); + } + let resource_id = hostname.as_ref().map_or_else( + || destination.to_string(), + |hostname| format!("{hostname}:{}", destination.port()), + ); + if let Some(hostname) = hostname { + authorization_context.insert(context::NETWORK_HOSTNAME, hostname.clone()); + } + AuthorizationRequest { + principal, + action: Action::new(action::NETWORK_CONNECT), + resource: Resource::new(resource_kind::EXTERNAL_SERVICE, resource_id), + context: authorization_context, + } + } + NetworkOperation::DnsQuery { + name, + record_type, + resolver, + transport, + } => { + let mut authorization_context = sandbox_context(subject.name) + .with_attribute(context::DNS_RECORD_TYPE, record_type.clone()) + .with_attribute(context::NETWORK_TRANSPORT, transport_name(*transport)); + if let Some(resolver) = resolver { + authorization_context.insert(context::DNS_RESOLVER, resolver.to_string()); + } + AuthorizationRequest { + principal, + action: Action::new(action::DNS_QUERY), + resource: Resource::new(resource_kind::DOMAIN, name.clone()), + context: authorization_context, + } + } + NetworkOperation::HttpRequest { + destination, + scheme, + authority, + method, + path, + version, + stream_id, + } => AuthorizationRequest { + principal, + action: Action::new(action::HTTP_REQUEST), + resource: Resource::new(resource_kind::EXTERNAL_SERVICE, authority.clone()), + context: http_context(*destination, *scheme, authority, method, path, *version, *stream_id) + .with_attribute(context::SANDBOX_NAME, subject.name.as_str()), + }, + NetworkOperation::SecretUse { + destination, + scheme, + authority, + method, + path, + version, + stream_id, + secret, + locations, + } => { + let mut authorization_context = + http_context(*destination, *scheme, authority, method, path, *version, *stream_id) + .with_attribute(context::SANDBOX_NAME, subject.name.as_str()); + authorization_context.insert( + context::SECRET_LOCATIONS, + locations + .iter() + .map(|location| secret_location_name(*location)) + .map(str::to_string) + .collect::>(), + ); + AuthorizationRequest { + principal, + action: Action::new(action::SECRET_USE), + resource: Resource::new(resource_kind::SECRET, secret.clone()), + context: authorization_context, + } + } + } +} + +fn http_context( + destination: std::net::SocketAddr, + scheme: microsandbox_network::control::HttpScheme, + authority: &str, + method: &str, + path: &str, + version: microsandbox_network::control::HttpVersion, + stream_id: Option, +) -> AuthorizationContext { + let mut authorization_context = AuthorizationContext::new() + .with_attribute(context::NETWORK_DESTINATION_ADDRESS, destination.to_string()) + .with_attribute(context::HTTP_SCHEME, http_scheme_name(scheme)) + .with_attribute(context::HTTP_AUTHORITY, authority) + .with_attribute(context::HTTP_METHOD, method) + .with_attribute(context::HTTP_PATH, path) + .with_attribute(context::HTTP_VERSION, http_version_name(version)); + if let Some(stream_id) = stream_id { + authorization_context.insert(context::HTTP_STREAM_ID, stream_id); + } + authorization_context +} + +fn sandbox_context(sandbox_name: &SandboxName) -> AuthorizationContext { + AuthorizationContext::new().with_attribute(context::SANDBOX_NAME, sandbox_name.as_str()) +} + +fn validate_secret_bindings(bindings: &[SecretBinding]) -> Result<(), Error> { + if bindings.iter().any(|binding| !valid_secret_binding(binding)) { + return Err(Error::invalid("secretBindings", "contains an invalid binding")); + } + let environments = bindings + .iter() + .map(|binding| &binding.environment) + .collect::>(); + let placeholders = bindings + .iter() + .map(|binding| &binding.placeholder) + .collect::>(); + let unambiguous = bindings.iter().enumerate().all(|(index, binding)| { + bindings + .iter() + .enumerate() + .all(|(other_index, other)| index == other_index || !binding.placeholder.contains(&other.placeholder)) + }); + if environments.len() != bindings.len() || placeholders.len() != bindings.len() || !unambiguous { + return Err(Error::invalid( + "secretBindings", + "environment variables and placeholders must be unique and placeholders must be unambiguous", + )); + } + Ok(()) +} + +fn valid_secret_binding(binding: &SecretBinding) -> bool { + valid_environment_variable(&binding.environment) + && !binding.placeholder.is_empty() + && binding.placeholder.len() <= microsandbox_network::secrets::config::MAX_SECRET_PLACEHOLDER_BYTES + && !binding + .placeholder + .bytes() + .any(|byte| matches!(byte, 0 | b'\r' | b'\n')) +} + +fn valid_environment_variable(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .enumerate() + .all(|(index, byte)| byte == b'_' || byte.is_ascii_alphabetic() || (index > 0 && byte.is_ascii_digit())) +} + +const fn http_scheme_name(scheme: microsandbox_network::control::HttpScheme) -> &'static str { + match scheme { + microsandbox_network::control::HttpScheme::Http => "http", + microsandbox_network::control::HttpScheme::Https => "https", + } +} + +const fn http_version_name(version: microsandbox_network::control::HttpVersion) -> &'static str { + match version { + microsandbox_network::control::HttpVersion::Http1 => "http1", + microsandbox_network::control::HttpVersion::Http2 => "http2", + } +} + +const fn secret_location_name(location: microsandbox_network::control::SecretLocation) -> &'static str { + match location { + microsandbox_network::control::SecretLocation::Header => "header", + microsandbox_network::control::SecretLocation::BasicAuth => "basicAuth", + microsandbox_network::control::SecretLocation::Query => "query", + } +} + +const fn transport_name(transport: microsandbox_network::control::TransportProtocol) -> &'static str { + match transport { + microsandbox_network::control::TransportProtocol::Tcp => "tcp", + microsandbox_network::control::TransportProtocol::Udp => "udp", + microsandbox_network::control::TransportProtocol::Icmpv4 => "icmpv4", + microsandbox_network::control::TransportProtocol::Icmpv6 => "icmpv6", + } +} + +async fn send(endpoint: &mut NetworkControlEndpointParts, response: ControllerMessage) -> Result<(), Error> { + let bytes = Zeroizing::new(serde_json::to_vec(&response).map_err(protocol_error)?); + let one = NonZeroUsize::new(1).ok_or_else(|| Error::Backend("invalid Network control send batch size".into()))?; + let mut pending = NetworkBatch::new(one); + pending + .push_back(NetworkControlMessage::from(bytes)) + .map_err(|_| Error::Backend("failed to queue Microsandbox Network response".into()))?; + match poll_fn(|context| endpoint.to_sandbox.as_mut().poll_send(context, &mut pending)) + .await + .map_err(|error| Error::Backend(error.to_string()))? + { + NetworkTransferProgress::Items(_) if pending.is_empty() => Ok(()), + NetworkTransferProgress::Items(_) => Err(Error::Backend( + "Microsandbox Network response was only partially accepted".into(), + )), + NetworkTransferProgress::Closed => { + Err(Error::Backend("Microsandbox Network control endpoint is closed".into())) + } + } +} + +fn protocol_error(error: impl std::fmt::Display) -> Error { + Error::Backend(format!("invalid Microsandbox Network control message: {error}")) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use std::time::Duration; + + #[test] + fn denial_logging_is_bounded_per_key() { + let mut denials = super::DenialLog::default(); + assert_eq!(denials.record("network.connect", Some("example.com")), Some(1)); + for _ in 1..super::DenialLog::REPEAT_EVERY - 1 { + assert_eq!(denials.record("network.connect", Some("example.com")), None); + } + assert_eq!( + denials.record("network.connect", Some("example.com")), + Some(super::DenialLog::REPEAT_EVERY) + ); + assert_eq!( + denials.record("network.connect", None), + Some(1), + "a new key logs immediately" + ); + + let mut rotating = super::DenialLog::default(); + let hostnames: Vec = (0..super::DenialLog::MAX_LINES + 50) + .map(|i| format!("h{i}.example")) + .collect(); + let logged = hostnames + .iter() + .filter(|hostname| rotating.record("dns.query", Some(hostname)).is_some()) + .count(); + assert_eq!( + u64::try_from(logged).expect("count"), + super::DenialLog::MAX_LINES, + "rotating hostnames hit the line cap" + ); + } + use std::{ + cell::{Cell, RefCell}, + collections::BTreeMap, + rc::Rc, + }; + + use microsandbox_network::control::{ + AuthorizationError, HttpScheme, HttpVersion, NetworkControlClient, NetworkOperation, TransportProtocol, + }; + use sandbox::{ + LocalFuture, SandboxName, + memory::MemorySecretStore, + network::{NetworkBackend as _, NetworkEndpoint, StartNetworkRequest}, + secret_store::{SecretMaterial, SecretReference, SecretStore}, + }; + use sandbox_authorization::{AuthorizationDecision, AuthorizationRequest, PolicyEngine, StaticPolicy}; + + use super::{MicrosandboxNetworkBackend, SecretBinding}; + + struct TestControlEndpoint { + #[cfg(unix)] + _directory: tempfile::TempDir, + path: std::path::PathBuf, + } + + impl TestControlEndpoint { + fn new() -> Self { + #[cfg(unix)] + { + let directory = tempfile::tempdir().expect("temporary endpoint directory"); + let path = directory.path().join("network.sock"); + Self { + _directory: directory, + path, + } + } + + #[cfg(windows)] + { + Self { + path: format!(r"\\.\pipe\agent-network-test-{}", uuid::Uuid::new_v4()).into(), + } + } + } + } + + struct RecordingPolicy { + decisions: RefCell>, + requests: RefCell>, + } + + struct RecordingSecretStore { + inner: MemorySecretStore, + resolutions: Cell, + } + + impl RecordingPolicy { + fn allow_all() -> Self { + Self { + decisions: RefCell::new(BTreeMap::new()), + requests: RefCell::new(Vec::new()), + } + } + + fn decide(&self, action: &str, decision: AuthorizationDecision) { + self.decisions.borrow_mut().insert(action.to_string(), decision); + } + } + + impl PolicyEngine for RecordingPolicy { + fn evaluate( + &self, + request: AuthorizationRequest, + ) -> sandbox_authorization::LocalFuture<'_, Result> + { + self.requests.borrow_mut().push(request.clone()); + let decision = self + .decisions + .borrow() + .get(request.action.as_str()) + .copied() + .unwrap_or(AuthorizationDecision::Allow); + Box::pin(async move { Ok(decision) }) + } + } + + impl RecordingSecretStore { + fn new() -> Self { + Self { + inner: MemorySecretStore::default(), + resolutions: Cell::new(0), + } + } + } + + impl SecretStore for RecordingSecretStore { + fn set<'a>( + &'a self, + name: &'a str, + value: &'a [u8], + ) -> LocalFuture<'a, Result> { + self.inner.set(name, value) + } + + fn resolve<'a>( + &'a self, + reference: &'a SecretReference, + ) -> LocalFuture<'a, Result> { + self.resolutions.set(self.resolutions.get() + 1); + self.inner.resolve(reference) + } + } + + fn sandbox_id() -> sandbox::SandboxId { + "d727d8a4-1345-4b31-b99d-59e04c9e476c" + .parse() + .expect("valid test Sandbox ID") + } + + fn sandbox_name() -> SandboxName { + SandboxName::new("worker").expect("valid test Sandbox name") + } + + fn operation() -> NetworkOperation { + NetworkOperation::Connect { + source: Some("192.0.2.2:40000".parse().expect("valid test source")), + destination: "198.51.100.10:443".parse().expect("valid test destination"), + transport: TransportProtocol::Tcp, + hostname: Some("example.com".to_string()), + destination_is_host: false, + } + } + + fn dns_operation() -> NetworkOperation { + NetworkOperation::DnsQuery { + name: "example.com".to_string(), + record_type: "A".to_string(), + resolver: Some("192.0.2.53:53".parse().expect("valid test resolver")), + transport: TransportProtocol::Udp, + } + } + + fn http_operation() -> NetworkOperation { + NetworkOperation::HttpRequest { + destination: "198.51.100.10:443".parse().expect("valid test destination"), + scheme: HttpScheme::Https, + authority: "example.com".to_string(), + method: "POST".to_string(), + path: "/items".to_string(), + version: HttpVersion::Http2, + stream_id: Some(3), + } + } + + fn secret_use_operation() -> NetworkOperation { + NetworkOperation::SecretUse { + destination: "198.51.100.10:443".parse().expect("valid test destination"), + scheme: HttpScheme::Https, + authority: "example.com".to_string(), + method: "POST".to_string(), + path: "/items".to_string(), + version: HttpVersion::Http1, + stream_id: None, + secret: "PROVIDER_TOKEN".to_string(), + locations: vec![microsandbox_network::control::SecretLocation::Header], + } + } + + #[test] + fn maps_http_request_to_authorization_contract() { + let sandbox_id = sandbox_id(); + let sandbox_name = sandbox_name(); + let request = super::authorization_request( + super::SandboxSubject { + id: &sandbox_id, + name: &sandbox_name, + }, + &http_operation(), + ); + + assert_eq!(request.action.as_str(), "http.request"); + assert_eq!(request.resource.kind, "externalService"); + assert_eq!(request.resource.id, "example.com"); + assert_eq!( + request.context.attributes["network.destinationAddress"].as_str(), + Some("198.51.100.10:443") + ); + assert_eq!(request.context.attributes["http.scheme"].as_str(), Some("https")); + assert_eq!(request.context.attributes["http.method"].as_str(), Some("POST")); + assert_eq!(request.context.attributes["http.path"].as_str(), Some("/items")); + assert_eq!(request.context.attributes["http.version"].as_str(), Some("http2")); + assert_eq!(request.context.attributes["http.streamId"].as_integer(), Some(3)); + } + + #[test] + fn secret_bindings_are_scoped_by_sandbox_name() { + let backend = MicrosandboxNetworkBackend::new(Rc::new(StaticPolicy::allow_all())); + let first = SandboxName::new("first").expect("valid Sandbox name"); + let second = SandboxName::new("second").expect("valid Sandbox name"); + backend + .set_secret_bindings( + first.clone(), + vec![ + SecretBinding::with_placeholder( + "FIRST_TOKEN", + "$FIRST_TOKEN", + SecretReference::from_opaque("first"), + ) + .expect("valid first binding"), + ], + ) + .expect("configure first Sandbox"); + backend + .set_secret_bindings( + second.clone(), + vec![ + SecretBinding::with_placeholder( + "SECOND_TOKEN", + "$SECOND_TOKEN", + SecretReference::from_opaque("second"), + ) + .expect("valid second binding"), + ], + ) + .expect("configure second Sandbox"); + + let bindings = backend.secret_bindings.borrow(); + assert_eq!(bindings[&first][0].environment, "FIRST_TOKEN"); + assert_eq!(bindings[&second][0].environment, "SECOND_TOKEN"); + } + + #[test] + fn default_secret_binding_exposes_an_inert_environment_placeholder() { + let binding = + SecretBinding::new("API_TOKEN", SecretReference::from_opaque("stored-token")).expect("valid binding"); + let runtime = binding.runtime_entry(); + + assert_eq!(runtime.env_var, "API_TOKEN"); + assert_eq!(runtime.placeholder, "$MSB_API_TOKEN"); + } + + #[test] + fn secret_binding_rejects_an_invalid_environment_variable() { + let error = SecretBinding::new("NOT-AN-ENV", SecretReference::from_opaque("stored-token")) + .expect_err("invalid environment-variable names must fail closed"); + + assert!(error.to_string().contains("environment-variable name")); + } + + #[test] + fn replacing_secret_bindings_reports_definition_changes() { + let backend = MicrosandboxNetworkBackend::new(Rc::new(StaticPolicy::allow_all())); + let sandbox = sandbox_name(); + let binding = || SecretBinding::new("TOKEN", SecretReference::from_opaque("token")).expect("valid binding"); + + assert!( + backend + .set_secret_bindings(sandbox.clone(), vec![binding()]) + .expect("initial binding") + ); + assert!( + !backend + .set_secret_bindings(sandbox.clone(), vec![binding()]) + .expect("unchanged binding") + ); + assert!( + backend + .set_secret_bindings(sandbox, Vec::new()) + .expect("removed binding") + ); + } + + #[tokio::test(flavor = "local")] + async fn controller_allows_and_revokes_a_live_flow() { + let control = TestControlEndpoint::new(); + let path = control.path.clone(); + let controller = microsandbox_network::control::NetworkControlHost::bind(path.clone()) + .await + .expect("bind Network control endpoint"); + let endpoint = crate::network_endpoint::open(controller).expect("open Network control endpoint"); + let backend = MicrosandboxNetworkBackend::new(Rc::new(StaticPolicy::allow_all())); + let sandbox_id = sandbox_id(); + backend + .start(StartNetworkRequest { + sandbox_id: sandbox_id.clone(), + sandbox_name: sandbox_name(), + endpoint: NetworkEndpoint::Control(endpoint), + }) + .await + .expect("start Network Backend"); + let client = NetworkControlClient::new(path.clone(), &tokio::runtime::Handle::current()); + + let mut grant = client + .authorize(operation()) + .await + .expect("operation should be allowed"); + let dns_grant = client + .authorize(dns_operation()) + .await + .expect("DNS query should be allowed"); + let http_grant = client + .authorize(http_operation()) + .await + .expect("HTTP request should be allowed"); + backend.revoke_all(&sandbox_id).await.expect("revoke live flows"); + tokio::time::timeout(Duration::from_secs(1), grant.revoked()) + .await + .expect("revocation should reach runtime client"); + + drop(grant); + drop(dns_grant); + drop(http_grant); + drop(client); + let reconnected = NetworkControlClient::new(path, &tokio::runtime::Handle::current()); + let _grant = reconnected + .authorize(operation()) + .await + .expect("a restarted runtime should establish a new control session"); + + backend.stop(&sandbox_id).await.expect("stop Network Backend"); + } + + #[tokio::test(flavor = "local")] + async fn controller_fails_closed_on_policy_denial_and_disconnect() { + let control = TestControlEndpoint::new(); + let path = control.path.clone(); + let controller = microsandbox_network::control::NetworkControlHost::bind(path.clone()) + .await + .expect("bind Network control endpoint"); + let endpoint = crate::network_endpoint::open(controller).expect("open Network control endpoint"); + let backend = MicrosandboxNetworkBackend::new(Rc::new(StaticPolicy::deny_all())); + let sandbox_id = sandbox_id(); + backend + .start(StartNetworkRequest { + sandbox_id: sandbox_id.clone(), + sandbox_name: sandbox_name(), + endpoint: NetworkEndpoint::Control(endpoint), + }) + .await + .expect("start Network Backend"); + let client = NetworkControlClient::new(path, &tokio::runtime::Handle::current()); + + let error = client + .authorize(operation()) + .await + .err() + .expect("deny-all policy should refuse the operation"); + assert!(matches!(error, AuthorizationError::Denied)); + backend.stop(&sandbox_id).await.expect("stop Network Backend"); + let error = client + .authorize(operation()) + .await + .err() + .expect("stopped controller should fail closed"); + assert!(matches!( + error, + AuthorizationError::Denied | AuthorizationError::Unavailable + )); + } + + #[tokio::test(flavor = "local")] + async fn controller_resolves_secrets_after_both_authorizations_and_observes_rotation() { + let control = TestControlEndpoint::new(); + let path = control.path.clone(); + let controller = microsandbox_network::control::NetworkControlHost::bind(path.clone()) + .await + .expect("bind Network control endpoint"); + let endpoint = crate::network_endpoint::open(controller).expect("open Network control endpoint"); + let policy = Rc::new(RecordingPolicy::allow_all()); + let store = Rc::new(RecordingSecretStore::new()); + let reference = store + .set("provider-token", b"first") + .await + .expect("store initial secret"); + let backend = MicrosandboxNetworkBackend::new(policy.clone()).with_secret_store(store.clone()); + backend + .set_secret_bindings( + sandbox_name(), + vec![SecretBinding::with_placeholder("PROVIDER_TOKEN", "$TOKEN", reference).expect("valid binding")], + ) + .expect("configure secret mediation"); + let sandbox_id = sandbox_id(); + backend + .start(StartNetworkRequest { + sandbox_id: sandbox_id.clone(), + sandbox_name: sandbox_name(), + endpoint: NetworkEndpoint::Control(endpoint), + }) + .await + .expect("start Network Backend"); + let client = NetworkControlClient::new(path, &tokio::runtime::Handle::current()); + + let http_grant = client + .authorize(NetworkOperation::HttpRequest { + destination: "198.51.100.10:443".parse().expect("valid test destination"), + scheme: HttpScheme::Https, + authority: "example.com".to_string(), + method: "POST".to_string(), + path: "/items".to_string(), + version: HttpVersion::Http1, + stream_id: None, + }) + .await + .expect("HTTP request authorization"); + drop(http_grant); + let material = client + .authorize_secret_use(secret_use_operation()) + .await + .expect("authorized secret use"); + assert_eq!(material.expose(), "first"); + assert_eq!(store.resolutions.get(), 1); + { + let requests = policy.requests.borrow(); + assert_eq!(requests[0].action.as_str(), "http.request"); + assert_eq!(requests[1].action.as_str(), "secret.use"); + assert_eq!(requests[1].resource.kind, "secret"); + assert_eq!(requests[1].resource.id, "PROVIDER_TOKEN"); + assert_eq!( + requests[1].context.attributes["http.authority"].as_str(), + Some("example.com") + ); + assert_eq!( + requests[1].context.attributes["secret.locations"] + .as_strings() + .expect("locations should be a list"), + ["header"] + ); + } + + store.set("provider-token", b"second").await.expect("rotate secret"); + let material = client + .authorize_secret_use(secret_use_operation()) + .await + .expect("authorized request after rotation"); + assert_eq!(material.expose(), "second"); + assert_eq!(store.resolutions.get(), 2); + + policy.decide("secret.use", AuthorizationDecision::Deny); + assert!(matches!( + client.authorize_secret_use(secret_use_operation()).await, + Err(AuthorizationError::Denied) + )); + assert_eq!(store.resolutions.get(), 2); + + policy.decide("secret.use", AuthorizationDecision::Allow); + policy.decide("http.request", AuthorizationDecision::Deny); + assert!(matches!( + client.authorize(http_operation()).await, + Err(AuthorizationError::Denied) + )); + assert_eq!(store.resolutions.get(), 2); + + backend.stop(&sandbox_id).await.expect("stop Network Backend"); + } + + #[tokio::test(flavor = "local")] + async fn unknown_bindings_and_secret_store_failures_are_denied() { + let policy = StaticPolicy::allow_all(); + let store = MemorySecretStore::default(); + let sandbox_id = sandbox_id(); + let sandbox_name = sandbox_name(); + let subject = super::SandboxSubject { + id: &sandbox_id, + name: &sandbox_name, + }; + let missing = + SecretBinding::with_placeholder("PROVIDER_TOKEN", "$TOKEN", SecretReference::from_opaque("missing")) + .expect("valid binding"); + + assert!( + super::authorize_operation( + subject, + &secret_use_operation(), + &policy, + Some(&store), + std::slice::from_ref(&missing), + ) + .await + .is_none() + ); + assert!( + super::authorize_operation(subject, &secret_use_operation(), &policy, Some(&store), &[],) + .await + .is_none() + ); + } +} diff --git a/sandbox/microsandbox/src/network_endpoint.rs b/sandbox/microsandbox/src/network_endpoint.rs new file mode 100644 index 0000000..46f71e6 --- /dev/null +++ b/sandbox/microsandbox/src/network_endpoint.rs @@ -0,0 +1,151 @@ +use std::{ + num::NonZeroUsize, + pin::Pin, + task::{Context, Poll}, +}; + +use microsandbox_network::control::{NetworkControlHost, NetworkControlIncoming}; +use sandbox::network::{ + BatchReceiver, BatchSender, NetworkBatch, NetworkControlEndpoint, NetworkControlEndpointProperties, + NetworkControlMessage, NetworkControlProtocolId, NetworkEndpointError, NetworkTransferProgress, +}; +use tokio_util::sync::PollSender; + +pub(crate) fn open(controller: NetworkControlHost) -> Result { + let maximum_message_length = NonZeroUsize::new(microsandbox_network::control::MAX_CONTROL_MESSAGE_LENGTH) + .ok_or_else(|| sandbox::Error::Backend("invalid Network control message length".into()))?; + let parts = controller.into_parts(); + + Ok(NetworkControlEndpoint::new( + NetworkControlEndpointProperties::new( + NetworkControlProtocolId::new(microsandbox_network::control::NETWORK_CONTROL_PROTOCOL), + maximum_message_length, + ), + ControlReceiver { + receiver: parts.incoming, + }, + ControlSender { + sender: PollSender::new(parts.outgoing), + maximum_message_length: maximum_message_length.get(), + }, + )) +} + +struct ControlReceiver { + receiver: NetworkControlIncoming, +} + +impl BatchReceiver for ControlReceiver { + fn poll_receive( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + output: &mut NetworkBatch, + ) -> Poll> { + if output.is_full() { + return Poll::Ready(Err(NetworkEndpointError::FullReceiveBatch)); + } + + let mut received = 0; + while !output.is_full() { + match self.receiver.poll_recv(context) { + Poll::Ready(Some(message)) => { + if output.push_back(NetworkControlMessage::from(message)).is_err() { + return Poll::Ready(Err(NetworkEndpointError::FullReceiveBatch)); + } + received += 1; + } + Poll::Ready(None) => return transfer_or_closed(received), + Poll::Pending => return transfer_or_pending(received), + } + } + transfer_or_full(received) + } +} + +struct ControlSender { + sender: PollSender>>, + maximum_message_length: usize, +} + +impl BatchSender for ControlSender { + fn poll_send( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + pending: &mut NetworkBatch, + ) -> Poll> { + if pending.is_empty() { + return Poll::Ready(Err(NetworkEndpointError::EmptySendBatch)); + } + + let mut sent = 0; + while let Some(message) = pending.front() { + if message.len() > self.maximum_message_length { + return transfer_or_error( + sent, + NetworkEndpointError::ControlMessageTooLarge { + actual: message.len(), + maximum: self.maximum_message_length, + }, + ); + } + match self.sender.poll_reserve(context) { + Poll::Ready(Ok(())) => { + let Some(message) = pending.pop_front() else { + return Poll::Ready(Err(NetworkEndpointError::EmptySendBatch)); + }; + if let Err(rejected) = self.sender.send_item(message.into_bytes()) { + if let Some(message) = rejected.into_inner() + && pending.push_front(NetworkControlMessage::from(message)).is_err() + { + return Poll::Ready(Err(NetworkEndpointError::Backend( + "failed to restore an unaccepted Network control message".into(), + ))); + } + return transfer_or_closed(sent); + } + sent += 1; + } + Poll::Ready(Err(_)) => return transfer_or_closed(sent), + Poll::Pending => return transfer_or_pending(sent), + } + } + transfer_or_error(sent, NetworkEndpointError::EmptySendBatch) + } + + fn poll_flush(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll> { + self.sender.abort_send(); + self.sender.close(); + Poll::Ready(Ok(())) + } +} + +fn transfer_or_closed(count: usize) -> Poll> { + NonZeroUsize::new(count).map_or(Poll::Ready(Ok(NetworkTransferProgress::Closed)), |count| { + Poll::Ready(Ok(NetworkTransferProgress::Items(count))) + }) +} + +fn transfer_or_pending(count: usize) -> Poll> { + NonZeroUsize::new(count).map_or(Poll::Pending, |count| { + Poll::Ready(Ok(NetworkTransferProgress::Items(count))) + }) +} + +fn transfer_or_full(count: usize) -> Poll> { + NonZeroUsize::new(count).map_or(Poll::Ready(Err(NetworkEndpointError::FullReceiveBatch)), |count| { + Poll::Ready(Ok(NetworkTransferProgress::Items(count))) + }) +} + +fn transfer_or_error( + count: usize, + error: NetworkEndpointError, +) -> Poll> { + NonZeroUsize::new(count).map_or(Poll::Ready(Err(error)), |count| { + Poll::Ready(Ok(NetworkTransferProgress::Items(count))) + }) +} diff --git a/sandbox/microsandbox/src/platform.rs b/sandbox/microsandbox/src/platform.rs new file mode 100644 index 0000000..b1abeee --- /dev/null +++ b/sandbox/microsandbox/src/platform.rs @@ -0,0 +1,55 @@ +use sandbox::{Error, Platform}; + +pub(crate) fn require_supported(requested: &Platform) -> Result { + let actual = Platform::native("linux"); + let host_supported = host_supported(std::env::consts::OS, std::env::consts::ARCH); + let unconstrained = + requested.variant.is_none() && requested.os_version.is_none() && requested.os_features.is_empty(); + + if host_supported && actual.satisfies(requested) && unconstrained { + Ok(actual) + } else { + Err(Error::UnsupportedPlatform(requested.clone())) + } +} + +pub(crate) fn host_supported(os: &str, architecture: &str) -> bool { + crate::client::runtime_sha256(os, architecture).is_some() +} + +#[cfg(test)] +mod tests { + use sandbox::Platform; + + #[test] + fn supports_only_the_native_unconstrained_linux_platform() { + let native = Platform::native("linux"); + let expected_support = super::host_supported(std::env::consts::OS, std::env::consts::ARCH); + assert_eq!(super::require_supported(&native).is_ok(), expected_support); + + assert!(super::require_supported(&Platform::native("windows")).is_err()); + assert!(super::require_supported(&Platform::new("linux", "different-architecture")).is_err()); + } + + #[test] + fn supports_only_hosts_with_a_pinned_runtime_release() { + for (os, architecture) in [ + ("linux", "x86_64"), + ("linux", "aarch64"), + ("macos", "aarch64"), + ("windows", "x86_64"), + ("windows", "aarch64"), + ] { + assert!(super::host_supported(os, architecture)); + } + + for (os, architecture) in [ + ("linux", "riscv64"), + ("macos", "x86_64"), + ("windows", "x86"), + ("freebsd", "x86_64"), + ] { + assert!(!super::host_supported(os, architecture)); + } + } +} diff --git a/sandbox/microsandbox/src/state.rs b/sandbox/microsandbox/src/state.rs new file mode 100644 index 0000000..c5d7749 --- /dev/null +++ b/sandbox/microsandbox/src/state.rs @@ -0,0 +1,474 @@ +use std::{collections::BTreeMap, io::Write as _, path::PathBuf}; + +use sandbox::{ + Hostname, SandboxId, SandboxName, SandboxResources, backend::CreateSandboxRequest, image::ResolvedImage, + init::InitSystem, mount::Mount, network::NetworkAttachment, volume::VolumeId, +}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use sha2::{Digest as _, Sha256}; + +use crate::{encoding::lower_hex, error}; + +const SANDBOX_SCHEMA_VERSION: u32 = 4; +const VOLUME_SCHEMA_VERSION: u32 = 1; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct SandboxRecord { + schema_version: u32, + pub(crate) id: SandboxId, + pub(crate) runtime_name: String, + pub(crate) name: SandboxName, + /// Absent in records written before hostnames were persisted; those + /// Sandboxes report their name once their runtime is recreated. + #[serde(default, skip_serializing_if = "Option::is_none")] + hostname: Option, + pub(crate) image: ResolvedImage, + pub(crate) resources: SandboxResources, + #[serde(default)] + pub(crate) init_system: InitSystem, + pub(crate) mounts: Vec, + pub(crate) environment: BTreeMap, + pub(crate) network: Option, +} + +impl SandboxRecord { + pub(crate) fn new(request: CreateSandboxRequest) -> Self { + let runtime_name = format!("sandbox-{}", request.id.as_uuid().simple()); + Self { + schema_version: SANDBOX_SCHEMA_VERSION, + runtime_name, + id: request.id, + name: request.name, + hostname: Some(request.hostname), + image: request.image, + resources: request.resources, + init_system: request.init_system, + mounts: request.mounts, + environment: request.environment, + network: request.network, + } + } + + /// Returns the hostname the guest reports, defaulting to the Sandbox name. + pub(crate) fn hostname(&self) -> Hostname { + self.hostname.clone().unwrap_or_else(|| self.name.clone().into()) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct VolumeRecord { + schema_version: u32, + pub(crate) id: VolumeId, + pub(crate) runtime_name: String, + pub(crate) name: sandbox::volume::VolumeName, +} + +impl VolumeRecord { + pub(crate) fn new(id: VolumeId, name: sandbox::volume::VolumeName) -> Self { + let runtime_name = format!("volume-{}", id.as_uuid().simple()); + Self { + schema_version: VOLUME_SCHEMA_VERSION, + runtime_name, + id, + name, + } + } +} + +#[derive(Clone)] +pub(crate) struct StateStore { + home: PathBuf, + sandboxes: PathBuf, + volumes: PathBuf, +} + +impl StateStore { + pub(crate) async fn open(home: PathBuf) -> Result { + let store = Self { + sandboxes: home.join("sandboxes"), + volumes: home.join("volumes"), + home, + }; + for directory in [&store.sandboxes, &store.volumes] { + tokio::fs::create_dir_all(directory) + .await + .map_err(|source| error::io("create Microsandbox state directory", source))?; + } + Ok(store) + } + + pub(crate) async fn save_sandbox(&self, record: &SandboxRecord) -> Result<(), sandbox::Error> { + write_new(self.sandbox_path(&record.name), record).await + } + + pub(crate) async fn update_sandbox(&self, record: &SandboxRecord) -> Result<(), sandbox::Error> { + write_replace(self.sandbox_path(&record.name), record).await + } + + pub(crate) async fn sandbox_by_name(&self, name: &SandboxName) -> Result { + let record: SandboxRecord = read_record( + self.sandbox_path(name), + "read Microsandbox Sandbox state", + sandbox::ResourceKind::Sandbox, + name.to_string(), + ) + .await?; + validate_schema(record.schema_version, SANDBOX_SCHEMA_VERSION)?; + Ok(record) + } + + pub(crate) async fn sandbox_by_id(&self, id: &SandboxId) -> Result { + let record = scan_records( + &self.sandboxes, + sandbox::ResourceKind::Sandbox, + id.to_string(), + |record: &SandboxRecord| &record.id == id, + ) + .await?; + validate_schema(record.schema_version, SANDBOX_SCHEMA_VERSION)?; + Ok(record) + } + + /// Returns every Sandbox record, whether or not its runtime exists. + pub(crate) async fn sandbox_records(&self) -> Result, sandbox::Error> { + let records: Vec = read_records(&self.sandboxes, sandbox::ResourceKind::Sandbox).await?; + for record in &records { + validate_schema(record.schema_version, SANDBOX_SCHEMA_VERSION)?; + } + Ok(records) + } + + pub(crate) async fn remove_sandbox(&self, record: &SandboxRecord) -> Result<(), sandbox::Error> { + remove_file(self.sandbox_path(&record.name), "remove Microsandbox Sandbox state").await + } + + pub(crate) async fn save_volume(&self, record: &VolumeRecord) -> Result<(), sandbox::Error> { + write_new(self.volume_path(&record.name), record).await + } + + pub(crate) async fn volume_by_name( + &self, + name: &sandbox::volume::VolumeName, + ) -> Result { + let record: VolumeRecord = read_record( + self.volume_path(name), + "read Microsandbox Volume state", + sandbox::ResourceKind::Volume, + name.to_string(), + ) + .await?; + validate_schema(record.schema_version, VOLUME_SCHEMA_VERSION)?; + Ok(record) + } + + pub(crate) async fn volume_by_id(&self, id: &VolumeId) -> Result { + let record = scan_records( + &self.volumes, + sandbox::ResourceKind::Volume, + id.to_string(), + |record: &VolumeRecord| record.id == *id, + ) + .await?; + validate_schema(record.schema_version, VOLUME_SCHEMA_VERSION)?; + Ok(record) + } + + pub(crate) async fn remove_volume(&self, record: &VolumeRecord) -> Result<(), sandbox::Error> { + remove_file(self.volume_path(&record.name), "remove Microsandbox Volume state").await + } + + /// Path of a Provider-wide marker file beside the records. + pub(crate) fn marker(&self, name: &str) -> PathBuf { + self.home.join(name) + } + + fn sandbox_path(&self, name: &SandboxName) -> PathBuf { + self.sandboxes.join(record_filename(name)) + } + + fn volume_path(&self, name: &sandbox::volume::VolumeName) -> PathBuf { + self.volumes.join(record_filename(name)) + } +} + +fn record_filename(name: impl AsRef) -> String { + format!("{}.json", lower_hex(&Sha256::digest(name.as_ref().as_bytes()))) +} + +fn validate_schema(version: u32, expected: u32) -> Result<(), sandbox::Error> { + if version == expected { + Ok(()) + } else { + Err(sandbox::Error::Backend(format!( + "unsupported Microsandbox state schema version {version}" + ))) + } +} + +async fn write_new(path: PathBuf, value: &T) -> Result<(), sandbox::Error> +where + T: Serialize + Send + Sync + 'static, +{ + write_record(path, value, false).await +} + +async fn write_replace(path: PathBuf, value: &T) -> Result<(), sandbox::Error> +where + T: Serialize + Send + Sync + 'static, +{ + write_record(path, value, true).await +} + +async fn write_record(path: PathBuf, value: &T, replace: bool) -> Result<(), sandbox::Error> +where + T: Serialize + Send + Sync + 'static, +{ + let serialized = serde_json::to_vec_pretty(value).map_err(error::backend)?; + tokio::task::spawn_blocking(move || { + let parent = path + .parent() + .ok_or_else(|| std::io::Error::other("state record has no parent directory"))?; + let mut temporary = tempfile::NamedTempFile::new_in(parent)?; + temporary.write_all(&serialized)?; + temporary.as_file().sync_all()?; + if replace { + temporary.persist(path).map_err(|failure| failure.error)?; + } else { + temporary.persist_noclobber(path).map_err(|failure| failure.error)?; + } + Ok::<(), std::io::Error>(()) + }) + .await + .map_err(error::backend)? + .map_err(|source| error::io("persist Microsandbox state", source)) +} + +async fn read_record( + path: PathBuf, + operation: &'static str, + resource: sandbox::ResourceKind, + id: String, +) -> Result +where + T: DeserializeOwned, +{ + let contents = tokio::fs::read(path).await.map_err(|source| { + if source.kind() == std::io::ErrorKind::NotFound { + sandbox::Error::not_found(resource, &id) + } else { + error::io(operation, source) + } + })?; + serde_json::from_slice(&contents).map_err(error::backend) +} + +async fn scan_records( + directory: &PathBuf, + resource: sandbox::ResourceKind, + id: String, + predicate: impl Fn(&T) -> bool, +) -> Result +where + T: DeserializeOwned, +{ + for record in read_records(directory, resource).await? { + if predicate(&record) { + return Ok(record); + } + } + Err(sandbox::Error::not_found(resource, &id)) +} + +async fn read_records(directory: &PathBuf, resource: sandbox::ResourceKind) -> Result, sandbox::Error> +where + T: DeserializeOwned, +{ + let mut entries = tokio::fs::read_dir(directory) + .await + .map_err(|source| error::io("list Microsandbox state", source))?; + let mut records = Vec::new(); + while let Some(entry) = entries + .next_entry() + .await + .map_err(|source| error::io("read Microsandbox state entry", source))? + { + let path = entry.path(); + // Writes stage records in temporary files beside the committed ones. + if path.extension().is_none_or(|extension| extension != "json") { + continue; + } + match read_record( + path.clone(), + "read Microsandbox state entry", + resource, + path.display().to_string(), + ) + .await + { + Ok(record) => records.push(record), + // Removed since the directory was read. + Err(error) if error.is_not_found() => {} + Err(error) => return Err(error), + } + } + Ok(records) +} + +async fn remove_file(path: PathBuf, operation: &'static str) -> Result<(), sandbox::Error> { + tokio::fs::remove_file(path) + .await + .map_err(|source| error::io(operation, source)) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use std::{collections::BTreeMap, path::PathBuf}; + + use sandbox::{ + ByteQuantity, CpuQuantity, Hostname, Platform, RootFilesystem, SandboxName, SandboxResources, + backend::CreateSandboxRequest, image, init::InitSystem, + }; + + use super::{SandboxRecord, StateStore, VolumeRecord}; + + fn sandbox_id(value: &str) -> sandbox::SandboxId { + value.parse().expect("test Sandbox ID should be a UUID") + } + + fn image() -> image::ResolvedImage { + image::ResolvedImage { + source: image::ImageSource::Build { + context: PathBuf::from("context"), + dockerfile: PathBuf::from("Dockerfile"), + target: None, + }, + platform: Platform::new("linux", "amd64"), + manifest_digest: "sha256:1234".to_string(), + } + } + + fn sandbox_name() -> SandboxName { + SandboxName::new("worker").expect("test Sandbox name should be valid") + } + + fn resources() -> SandboxResources { + SandboxResources::new( + "1".parse::().expect("test CPU should be valid"), + "512Mi".parse::().expect("test memory should be valid"), + RootFilesystem::layered( + "4Gi" + .parse::() + .expect("test root filesystem should be valid"), + ), + ) + } + + fn sandbox_record(id: &str) -> SandboxRecord { + SandboxRecord::new(CreateSandboxRequest { + id: sandbox_id(id), + name: sandbox_name(), + hostname: Hostname::new("worker-host").expect("test hostname should be valid"), + image: image(), + resources: resources(), + init_system: InitSystem::Backend, + mounts: Vec::new(), + environment: BTreeMap::new(), + network: None, + }) + } + + #[tokio::test(flavor = "local")] + async fn sandbox_records_survive_reopening_the_store() { + let home = tempfile::tempdir().expect("temporary state home should be created"); + let store = StateStore::open(home.path().to_path_buf()) + .await + .expect("state store should open"); + let record = sandbox_record("00000000-0000-4000-8000-000000000001"); + store.save_sandbox(&record).await.expect("record should be saved"); + + let reopened = StateStore::open(home.path().to_path_buf()) + .await + .expect("state store should reopen"); + assert_eq!( + reopened + .sandbox_by_name(&sandbox_name()) + .await + .expect("record should be found by name"), + record + ); + assert_eq!( + reopened + .sandbox_by_id(&record.id) + .await + .expect("record should be found by identifier"), + record + ); + } + + #[tokio::test(flavor = "local")] + async fn records_without_a_persisted_hostname_report_the_sandbox_name() { + let mut record = sandbox_record("00000000-0000-4000-8000-000000000005"); + assert_eq!(record.hostname().as_str(), "worker-host"); + + let mut serialized = serde_json::to_value(&record).expect("record should serialize"); + let fields = serialized.as_object_mut().expect("record should be an object"); + assert!(fields.remove("hostname").is_some(), "hostname should be persisted"); + let legacy: SandboxRecord = serde_json::from_value(serialized).expect("legacy record should deserialize"); + assert_eq!(legacy.hostname().as_str(), "worker"); + + record.hostname = None; + assert_eq!(legacy, record); + } + + #[tokio::test(flavor = "local")] + async fn saving_a_duplicate_name_does_not_replace_immutable_state() { + let home = tempfile::tempdir().expect("temporary state home should be created"); + let store = StateStore::open(home.path().to_path_buf()) + .await + .expect("state store should open"); + let original = sandbox_record("00000000-0000-4000-8000-000000000002"); + let replacement = sandbox_record("00000000-0000-4000-8000-000000000003"); + store.save_sandbox(&original).await.expect("record should be saved"); + + assert!(store.save_sandbox(&replacement).await.is_err()); + assert_eq!( + store + .sandbox_by_name(&sandbox_name()) + .await + .expect("original record should remain"), + original + ); + } + + #[tokio::test(flavor = "local")] + async fn volume_records_are_addressable_by_name_and_identifier() { + let home = tempfile::tempdir().expect("temporary state home should be created"); + let store = StateStore::open(home.path().to_path_buf()) + .await + .expect("state store should open"); + let id = "00000000-0000-4000-8000-000000000004" + .parse() + .expect("test Volume identifier should be valid"); + let name = sandbox::volume::VolumeName::new("home").expect("test Volume name should be valid"); + let record = VolumeRecord::new(id, name.clone()); + store.save_volume(&record).await.expect("record should be saved"); + + assert_eq!( + store + .volume_by_name(&name) + .await + .expect("record should be found by name"), + record + ); + assert_eq!( + store + .volume_by_id(&record.id) + .await + .expect("record should be found by identifier"), + record + ); + } +} diff --git a/sandbox/microsandbox/src/volumes.rs b/sandbox/microsandbox/src/volumes.rs new file mode 100644 index 0000000..b3a53cd --- /dev/null +++ b/sandbox/microsandbox/src/volumes.rs @@ -0,0 +1,55 @@ +use sandbox::{Error, volume}; + +use crate::{backend::MicrosandboxProvider, error, state::VolumeRecord}; + +impl MicrosandboxProvider { + pub(crate) async fn ensure_volume_record( + &self, + request: volume::EnsureVolumeRequest, + ) -> Result { + let (id, name) = request.into_parts(); + let record = match self.state.volume_by_name(&name).await { + Ok(record) => record, + Err(error) if error.is_not_found() => { + let record = VolumeRecord::new(id, name); + self.state.save_volume(&record).await?; + record + } + Err(error) => return Err(error), + }; + self.ensure_volume_runtime(&record).await?; + Ok(record.to_volume()) + } + + pub(crate) async fn delete_volume_record(&self, id: &volume::VolumeId) -> Result<(), Error> { + let record = self.state.volume_by_id(id).await?; + match self.client.scope(microsandbox::Volume::get(&record.runtime_name)).await { + Ok(handle) => handle.remove().await.map_err(error::microsandbox)?, + Err(microsandbox::MicrosandboxError::VolumeNotFound(_)) => {} + Err(failure) => return Err(error::microsandbox(failure)), + } + self.state.remove_volume(&record).await + } + + pub(crate) async fn ensure_volume_runtime(&self, record: &VolumeRecord) -> Result<(), Error> { + match self.client.scope(microsandbox::Volume::get(&record.runtime_name)).await { + Ok(_) => return Ok(()), + Err(microsandbox::MicrosandboxError::VolumeNotFound(_)) => {} + Err(failure) => return Err(error::microsandbox(failure)), + } + self.client + .scope(microsandbox::Volume::builder(&record.runtime_name).directory().create()) + .await + .map(|_| ()) + .map_err(error::microsandbox) + } +} + +impl VolumeRecord { + pub(crate) fn to_volume(&self) -> volume::Volume { + volume::Volume { + id: self.id.clone(), + name: self.name.clone(), + } + } +} diff --git a/sandbox/microsandbox/tests/architecture.rs b/sandbox/microsandbox/tests/architecture.rs new file mode 100644 index 0000000..f553f9e --- /dev/null +++ b/sandbox/microsandbox/tests/architecture.rs @@ -0,0 +1,13 @@ +#![allow(clippy::expect_used)] + +use std::{fs, path::Path}; + +#[test] +fn microsandbox_integration_does_not_depend_on_agent_automation() { + let manifest = fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")) + .expect("Microsandbox integration Cargo.toml should be readable"); + + assert!(manifest.contains("sandbox =")); + assert!(!manifest.contains("agent =")); + assert!(!manifest.contains("agent-runtime =")); +} diff --git a/sandbox/microsandbox/tests/backend.rs b/sandbox/microsandbox/tests/backend.rs new file mode 100644 index 0000000..cc7469a --- /dev/null +++ b/sandbox/microsandbox/tests/backend.rs @@ -0,0 +1,235 @@ +// A Provider handle lives for the whole test; tightening its drop adds nothing. +#![allow(clippy::expect_used, clippy::significant_drop_tightening)] + +use microsandbox_network::control::NETWORK_CONTROL_PROTOCOL; +use sandbox::{ + Platform, RootFilesystemMode, SandboxFeature, + backend::SandboxBackend as _, + image::{ImageSource, ImageSourceKind, ResolveRequest}, + network::{NetworkControlProtocolId, NetworkEndpointSelection}, + provider::SandboxProvider as _, +}; +use sandbox_microsandbox::MicrosandboxProvider; + +const ALPINE_3_22_INDEX_DIGEST: &str = "sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce"; + +#[tokio::test(flavor = "local")] +async fn backend_state_and_runtime_are_rooted_in_the_explicit_home() { + let home = tempfile::tempdir().expect("temporary home should be created"); + let backend_home = home.path().join("microsandbox"); + let backend = MicrosandboxProvider::open(&backend_home) + .await + .expect("Backend should open without starting a VM"); + + assert!(backend_home.join("state/sandboxes").is_dir()); + assert!(backend_home.join("state/volumes").is_dir()); + assert!(backend_home.join("runtime").is_dir()); + + let architecture = match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + architecture => architecture, + }; + let platform = Platform::new("linux", architecture); + let host_supported = matches!(std::env::consts::OS, "linux" | "windows") + || (std::env::consts::OS == "macos" && std::env::consts::ARCH == "aarch64"); + let result = backend.capabilities(&platform).await; + if !host_supported { + assert!(result.is_err()); + return; + } + let capabilities = result.expect("native Linux capabilities should be reported"); + assert!(capabilities.features.contains(SandboxFeature::Execution)); + assert!(capabilities.features.contains(SandboxFeature::TerminalExecution)); + assert!(capabilities.features.contains(SandboxFeature::TerminalAttach)); + assert!(capabilities.features.contains(SandboxFeature::FileTransfer)); + assert!(capabilities.features.contains(SandboxFeature::PersistentVolumes)); + assert!(capabilities.features.contains(SandboxFeature::NestedContainers)); + assert!( + capabilities + .network + .supports(&NetworkEndpointSelection::Control(NetworkControlProtocolId::new( + NETWORK_CONTROL_PROTOCOL + ))) + ); + + let image_capabilities = backend + .image_backend() + .capabilities(&platform) + .await + .expect("native Image Backend capabilities should be reported"); + assert!(image_capabilities.resolve.sources.contains(ImageSourceKind::Build)); + assert!(image_capabilities.resolve.sources.contains(ImageSourceKind::Reference)); + assert!( + image_capabilities + .resolve + .root_filesystem_modes + .contains(RootFilesystemMode::Layered) + ); + assert!( + image_capabilities + .resolve + .root_filesystem_modes + .contains(RootFilesystemMode::Direct) + ); + for prepared in [ + &image_capabilities.prepared_image_export, + &image_capabilities.prepared_image_import, + ] { + assert!(prepared.sources.contains(ImageSourceKind::Reference)); + assert!(!prepared.sources.contains(ImageSourceKind::Build)); + assert!(prepared.root_filesystem_modes.contains(RootFilesystemMode::Direct)); + assert!(!prepared.root_filesystem_modes.contains(RootFilesystemMode::Layered)); + } +} + +#[tokio::test(flavor = "local")] +async fn cache_directory_can_be_shared_without_sharing_provider_state() { + let temporary = tempfile::tempdir().expect("temporary home should be created"); + let shared_cache = temporary.path().join("shared-cache"); + let first_home = temporary.path().join("first-provider"); + let second_home = temporary.path().join("second-provider"); + + MicrosandboxProvider::builder(&first_home) + .cache_directory(&shared_cache) + .open() + .await + .expect("first Provider should open with the shared cache"); + MicrosandboxProvider::builder(&second_home) + .cache_directory(&shared_cache) + .open() + .await + .expect("second Provider should open with the shared cache"); + + assert!(shared_cache.is_dir()); + assert!(first_home.join("state/sandboxes").is_dir()); + assert!(second_home.join("state/sandboxes").is_dir()); +} + +#[tokio::test(flavor = "local")] +#[ignore = "requires access to the public Docker registry"] +async fn multi_platform_index_resolves_to_the_native_image_manifest() { + let (architecture, expected_manifest_digest) = match std::env::consts::ARCH { + "x86_64" => ( + "amd64", + "sha256:7c8cb692ae09657cbc4a3f3cbd0e8d5a2690ba38386aaaf252dbb060bf5eb2e6", + ), + "aarch64" => ( + "arm64", + "sha256:2c9d26f410d032d5b1525aa8a873e238b05b90c4ae8618743d4311f0cc827e37", + ), + _ => return, + }; + let temporary = tempfile::tempdir().expect("temporary home should be created"); + let provider = MicrosandboxProvider::open(temporary.path().join("provider")) + .await + .expect("Provider should open"); + let request = ResolveRequest { + source: ImageSource::Reference { + reference: format!("docker.io/library/alpine@{ALPINE_3_22_INDEX_DIGEST}"), + }, + platform: Platform::new("linux", architecture), + root_filesystem_mode: RootFilesystemMode::Layered, + }; + + let resolved = provider + .image_backend() + .resolve(&request) + .await + .expect("multi-platform index should resolve"); + + assert_eq!(resolved.manifest_digest, expected_manifest_digest); + assert_ne!(resolved.manifest_digest, ALPINE_3_22_INDEX_DIGEST); +} + +#[tokio::test(flavor = "local")] +async fn cache_directory_must_not_be_empty() { + let result = MicrosandboxProvider::builder("private-provider") + .cache_directory("") + .open() + .await; + + assert!(matches!( + result, + Err(sandbox::Error::Invalid { + field: "provider.cacheDirectory", + .. + }) + )); +} + +#[tokio::test(flavor = "local")] +async fn unused_images_are_never_removed_from_a_cache_other_providers_may_share() { + let temporary = tempfile::tempdir().expect("temporary home should be created"); + let result = MicrosandboxProvider::builder(temporary.path().join("provider")) + .cache_directory(temporary.path().join("shared-cache")) + .remove_unused_images_after(std::time::Duration::from_mins(1)) + .open() + .await; + + assert!(matches!( + result, + Err(sandbox::Error::Invalid { + field: "provider.unusedImageRetention", + .. + }) + )); +} + +#[tokio::test(flavor = "local")] +async fn unused_images_are_kept_for_at_least_an_hour() { + let temporary = tempfile::tempdir().expect("temporary home should be created"); + let result = MicrosandboxProvider::builder(temporary.path().join("provider")) + .remove_unused_images_after(std::time::Duration::from_mins(59)) + .open() + .await; + + assert!(matches!( + result, + Err(sandbox::Error::Invalid { + field: "provider.unusedImageRetention", + .. + }) + )); + MicrosandboxProvider::builder(temporary.path().join("provider")) + .remove_unused_images_after(std::time::Duration::from_hours(1)) + .open() + .await + .expect("an hour should be accepted"); +} + +#[tokio::test(flavor = "local")] +async fn runtime_bundle_must_be_a_regular_file() { + let temporary = tempfile::tempdir().expect("temporary home should be created"); + let result = MicrosandboxProvider::builder(temporary.path().join("provider")) + .runtime_bundle(temporary.path().join("missing.tar.gz"), "0".repeat(64)) + .open() + .await; + + assert!(matches!( + result, + Err(sandbox::Error::Invalid { + field: "provider.runtimeBundle.path", + .. + }) + )); +} + +#[tokio::test(flavor = "local")] +async fn runtime_bundle_digest_must_be_sha256() { + let temporary = tempfile::tempdir().expect("temporary home should be created"); + let bundle = temporary.path().join("runtime.tar.gz"); + std::fs::write(&bundle, []).expect("placeholder runtime bundle should be written"); + let result = MicrosandboxProvider::builder(temporary.path().join("provider")) + .runtime_bundle(bundle, "not-a-sha256") + .open() + .await; + + assert!(matches!( + result, + Err(sandbox::Error::Invalid { + field: "provider.runtimeBundle.sha256", + .. + }) + )); +} diff --git a/sandbox/microsandbox/tests/fixtures/runtime-image/Dockerfile b/sandbox/microsandbox/tests/fixtures/runtime-image/Dockerfile new file mode 100644 index 0000000..b797eae --- /dev/null +++ b/sandbox/microsandbox/tests/fixtures/runtime-image/Dockerfile @@ -0,0 +1,8 @@ +# syntax=docker/dockerfile:1 +FROM alpine:3.22 + +RUN apk add --no-cache iptables nftables + +RUN --mount=type=cache,target=/var/cache true + +CMD ["/bin/echo", "default-entrypoint"] diff --git a/sandbox/microsandbox/tests/network_runtime.rs b/sandbox/microsandbox/tests/network_runtime.rs new file mode 100644 index 0000000..b1cb637 --- /dev/null +++ b/sandbox/microsandbox/tests/network_runtime.rs @@ -0,0 +1,314 @@ +// A Provider handle lives for the whole test; tightening its drop adds nothing. +#![allow(clippy::expect_used, clippy::significant_drop_tightening)] + +use std::{cell::RefCell, panic::AssertUnwindSafe, rc::Rc}; + +use futures_util::FutureExt as _; +use sandbox::{ + ByteQuantity, CpuQuantity, EnsureSandboxRequest, Platform, RetentionPolicy, RootFilesystem, SandboxHandle, + SandboxName, SandboxResources, SandboxService, SandboxSpec, backend::SandboxBackend as _, execution::ExecutionSpec, + image::ImageSource, memory::MemorySecretStore, secret_store::SecretStore as _, +}; +use sandbox_authorization::{AuthorizationDecision, AuthorizationRequest, LocalFuture, PolicyEngine}; +use sandbox_microsandbox::{MicrosandboxNetworkBackend, MicrosandboxProvider, SecretBinding}; + +struct RecordingPolicy { + denied_action: RefCell>, + requests: RefCell>, +} + +impl RecordingPolicy { + const fn allow_all() -> Self { + Self { + denied_action: RefCell::new(None), + requests: RefCell::new(Vec::new()), + } + } + + fn deny(&self, action: &str) { + self.denied_action.replace(Some(action.to_string())); + } +} + +impl PolicyEngine for RecordingPolicy { + fn evaluate( + &self, + request: AuthorizationRequest, + ) -> LocalFuture<'_, Result> { + let decision = if self.denied_action.borrow().as_deref() == Some(request.action.as_str()) { + AuthorizationDecision::Deny + } else { + AuthorizationDecision::Allow + }; + self.requests.borrow_mut().push(request); + Box::pin(async move { Ok(decision) }) + } +} + +#[tokio::test(flavor = "local")] +#[ignore = "requires Internet access, a Docker Engine API, Microsandbox host runtime and hardware virtualization"] +async fn controlled_network_authorizes_dns_tcp_and_http_and_fails_closed() { + let temporary = tempfile::tempdir().expect("temporary integration home should be created"); + let backend = Rc::new( + MicrosandboxProvider::open(temporary.path().join("control-plane")) + .await + .expect("Backend should open"), + ); + let policy = Rc::new(RecordingPolicy::allow_all()); + let secrets = Rc::new(MemorySecretStore::default()); + let token = secrets + .set("provider-token", b"integration-secret") + .await + .expect("integration secret should be stored"); + let network = Rc::new(MicrosandboxNetworkBackend::new(policy.clone()).with_secret_store(secrets.clone())); + let sandbox_name = SandboxName::new("controlled-network").expect("test Sandbox name should be valid"); + network + .set_secret_bindings( + sandbox_name.clone(), + vec![ + SecretBinding::with_placeholder("PROVIDER_TOKEN", "$MEDIATED_TOKEN", token) + .expect("integration secret binding should be valid"), + ], + ) + .expect("secret mediation should be configured"); + let service = SandboxService::new(backend.clone()).with_network_backend(network); + let request = EnsureSandboxRequest::new( + sandbox_name, + SandboxSpec { + image: ImageSource::Reference { + reference: "docker.io/library/alpine:3.22".to_string(), + }, + platform: native_linux_platform(), + resources: resources(), + init_system: sandbox::init::InitSystem::Backend, + retention_policy: RetentionPolicy::Delete, + }, + ); + let sandbox = service.ensure(&request).await.expect("controlled Sandbox should start"); + + let test_result = AssertUnwindSafe(async { + let allowed = sandbox + .run_execution(shell("wget -T 10 -qO- http://example.com")) + .await + .expect("allowed request should execute"); + assert!(allowed.status.success(), "allowed request failed: {allowed:?}"); + { + let requests = policy.requests.borrow(); + assert_action(&requests, "dns.query"); + assert_action(&requests, "network.connect"); + assert_http_request(&requests, "example.com", "http"); + } + + backend.stop(sandbox.id()).await.expect("Sandbox should stop"); + backend.start(sandbox.id()).await.expect("Sandbox should restart"); + let requests_before_restart = policy.requests.borrow().len(); + let after_restart = sandbox + .run_execution(shell("wget -T 10 -qO- https://example.net")) + .await + .expect("request after runtime restart should execute"); + assert!( + after_restart.status.success(), + "controller should accept a fresh runtime session" + ); + { + let requests = policy.requests.borrow(); + assert_http_request(&requests[requests_before_restart..], "example.net", "https"); + } + + assert_mediated_secret_enforcement(&sandbox, policy.as_ref()).await; + + let requests_before_denial = policy.requests.borrow().len(); + policy.deny("http.request"); + let denied = sandbox + .run_execution(shell("wget -T 5 -qO- https://example.org")) + .await + .expect("denied request should still produce an exit status"); + assert!( + !denied.status.success(), + "http.request denial unexpectedly allowed egress" + ); + { + let requests = policy.requests.borrow(); + let denied_requests = &requests[requests_before_denial..]; + assert_action(denied_requests, "network.connect"); + assert_action(denied_requests, "http.request"); + } + }) + .catch_unwind() + .await; + service + .delete(request.name()) + .await + .expect("controlled Sandbox should delete"); + if let Err(payload) = test_result { + std::panic::resume_unwind(payload); + } +} + +#[tokio::test(flavor = "local")] +#[ignore = "requires Internet access, a Docker Engine API, Microsandbox host runtime and hardware virtualization"] +async fn resource_restart_after_reopening_the_provider_keeps_network_control() { + let temporary = tempfile::tempdir().expect("temporary integration home should be created"); + let home = temporary.path().join("control-plane"); + let spec = |cpu: &str| SandboxSpec { + image: ImageSource::Reference { + reference: "docker.io/library/alpine:3.22".to_string(), + }, + platform: native_linux_platform(), + resources: resources_with_cpu(cpu), + init_system: sandbox::init::InitSystem::Backend, + retention_policy: RetentionPolicy::Delete, + }; + let sandbox_name = SandboxName::new("controlled-restart").expect("test Sandbox name should be valid"); + { + let backend = Rc::new(MicrosandboxProvider::open(&home).await.expect("Backend should open")); + let network = Rc::new(MicrosandboxNetworkBackend::new(Rc::new(RecordingPolicy::allow_all()))); + // Dropped without release: the runtime keeps running, as when agentd exits. + let _abandoned = SandboxService::new(backend) + .with_network_backend(network) + .ensure(&EnsureSandboxRequest::new(sandbox_name.clone(), spec("1"))) + .await + .expect("controlled Sandbox should start"); + } + + // A reopened Provider holds no process-local Network control state, as + // after an agentd restart while the Sandbox kept running. Growing the CPU + // count restarts the runtime before its Network endpoint is reopened. + let policy = Rc::new(RecordingPolicy::allow_all()); + policy.deny("network.connect"); + let backend = Rc::new(MicrosandboxProvider::open(&home).await.expect("Backend should reopen")); + let network = Rc::new(MicrosandboxNetworkBackend::new(policy.clone())); + let service = SandboxService::new(backend).with_network_backend(network); + let request = EnsureSandboxRequest::new(sandbox_name, spec("2")); + let sandbox = service + .ensure(&request) + .await + .expect("resource change should restart the Sandbox"); + + let test_result = AssertUnwindSafe(async { + let denied = sandbox + .run_execution(shell("wget -T 5 -qO- http://example.com")) + .await + .expect("denied request should still produce an exit status"); + assert!( + !denied.status.success(), + "restarted Sandbox reached the network without host control" + ); + assert_action(&policy.requests.borrow(), "network.connect"); + }) + .catch_unwind() + .await; + service + .delete(request.name()) + .await + .expect("controlled Sandbox should delete"); + if let Err(payload) = test_result { + std::panic::resume_unwind(payload); + } +} + +async fn assert_mediated_secret_enforcement(sandbox: &SandboxHandle, policy: &RecordingPolicy) { + let requests_before_secret = policy.requests.borrow().len(); + let mediated = sandbox + .run_execution(shell( + "wget -T 10 -qO /dev/null --header='Authorization: Bearer $MEDIATED_TOKEN' https://example.net", + )) + .await + .expect("mediated request should execute"); + assert!(mediated.status.success(), "mediated HTTPS request should succeed"); + { + let requests = policy.requests.borrow(); + let mediated_requests = &requests[requests_before_secret..]; + assert_action(mediated_requests, "http.request"); + let secret_use = mediated_requests + .iter() + .find(|request| request.action.as_str() == "secret.use") + .expect("secret use should be authorized independently"); + assert_eq!(secret_use.resource.kind, "secret"); + assert_eq!(secret_use.resource.id, "PROVIDER_TOKEN"); + assert_eq!( + secret_use.context.attributes["http.authority"].as_str(), + Some("example.net") + ); + assert_eq!( + secret_use.context.attributes["secret.locations"] + .as_strings() + .expect("locations should be a list"), + ["header"] + ); + } + + // A placeholder the secret is not substituted into, such as conversation history in a model + // request body, does not block the request. + let history = sandbox + .run_execution(shell( + "wget -T 10 -S -O /dev/null --header='Authorization: Bearer $MEDIATED_TOKEN' \ + --post-data='history: $MEDIATED_TOKEN' https://example.net 2>&1; true", + )) + .await + .expect("request with a body placeholder should execute"); + let history = String::from_utf8_lossy(&history.stdout); + assert!( + history.contains("HTTP/1.1 "), + "a placeholder in the request body blocked the request: {history}" + ); + + policy.deny("secret.use"); + let denied = sandbox + .run_execution(shell( + "wget -T 5 -qO /dev/null --header='Authorization: Bearer $MEDIATED_TOKEN' https://example.net", + )) + .await + .expect("denied secret request should still produce an exit status"); + assert!(!denied.status.success(), "secret.use denial allowed egress"); +} + +fn assert_action(requests: &[AuthorizationRequest], action: &str) { + assert!( + requests.iter().any(|request| request.action.as_str() == action), + "expected {action} authorization request" + ); +} + +fn assert_http_request(requests: &[AuthorizationRequest], authority: &str, scheme: &str) { + let request = requests + .iter() + .find(|request| request.action.as_str() == "http.request") + .expect("HTTP request should be authorized independently"); + assert_eq!(request.resource.kind, "externalService"); + assert_eq!(request.resource.id, authority); + assert_eq!(request.context.attributes["http.scheme"].as_str(), Some(scheme)); + assert_eq!(request.context.attributes["http.method"].as_str(), Some("GET")); + assert_eq!(request.context.attributes["http.path"].as_str(), Some("/")); + assert_eq!(request.context.attributes["http.version"].as_str(), Some("http1")); +} + +fn shell(script: &str) -> ExecutionSpec { + ExecutionSpec::command( + sandbox::SandboxPath::new("/bin/sh"), + ["-c".to_string(), script.to_string()], + ) +} + +fn resources() -> SandboxResources { + resources_with_cpu("1") +} + +fn resources_with_cpu(cpu: &str) -> SandboxResources { + SandboxResources::new( + cpu.parse::().expect("test CPU should be valid"), + "512Mi".parse::().expect("test memory should be valid"), + RootFilesystem::layered("2Gi".parse::().expect("root filesystem should be valid")), + ) +} + +fn native_linux_platform() -> Platform { + Platform::new( + "linux", + match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + architecture => architecture, + }, + ) +} diff --git a/sandbox/microsandbox/tests/runtime.rs b/sandbox/microsandbox/tests/runtime.rs new file mode 100644 index 0000000..940f2e9 --- /dev/null +++ b/sandbox/microsandbox/tests/runtime.rs @@ -0,0 +1,735 @@ +// A Provider handle lives for the whole test; tightening its drop adds nothing. +#![allow(clippy::expect_used, clippy::significant_drop_tightening)] + +use std::{io::Cursor, path::PathBuf, rc::Rc}; + +use bytes::Bytes; +use futures_util::StreamExt as _; +use sandbox::{ + ByteQuantity, CpuQuantity, EnsureSandboxRequest, Hostname, OperationEvent, Platform, ProgressEvent, + RetentionPolicy, RootFilesystem, Sandbox, SandboxName, SandboxResources, SandboxService, SandboxSpec, SandboxState, + backend::SandboxBackend as _, + execution::{self, ExecutionSpec, StartExecutionRequest}, + image::ImageSource, + mount::Mount, + terminal::{StartTerminalExecutionRequest, TerminalEvent, TerminalSize}, + volume::{EnsureVolumeRequest, VolumeName}, +}; +use sandbox_microsandbox::MicrosandboxProvider; +use sha2::{Digest as _, Sha256}; +use tokio::io::AsyncReadExt as _; + +#[tokio::test(flavor = "local")] +#[ignore = "requires a Docker Engine API, Microsandbox host runtime and hardware virtualization"] +async fn retained_lifecycle_execution_files_and_volumes() { + let temporary = RetainedOnFailureTempDir::new(); + let backend_home = temporary.path().join("control-plane"); + let reference_backend_home = temporary.path().join("reference-control-plane"); + let backend = Rc::new( + MicrosandboxProvider::open(&backend_home) + .await + .expect("Backend should open"), + ); + let home = backend + .ensure_volume(EnsureVolumeRequest::new( + VolumeName::new("home").expect("valid Volume name"), + )) + .await + .expect("retained volume should be created"); + let service = SandboxService::new(backend.clone()); + let mut request = EnsureSandboxRequest::new( + SandboxName::new("integration-worker").expect("test Sandbox name should be valid"), + SandboxSpec { + image: ImageSource::Build { + context: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/runtime-image"), + dockerfile: PathBuf::from("Dockerfile"), + target: None, + }, + platform: native_linux_platform(), + resources: direct_resources("1", "512Mi", "4Gi"), + init_system: sandbox::init::InitSystem::Backend, + retention_policy: RetentionPolicy::Retain, + }, + ) + .with_hostname(Hostname::new("integration-host").expect("test hostname should be valid")) + .with_mounts([Mount::Volume { + id: home.id.clone(), + target: sandbox::SandboxPath::new("/workspace"), + read_only: false, + }]); + let (mut sandbox, events) = collect_progress(service.ensure(&request)) + .await + .expect("Sandbox should be built and started"); + assert_provisioning_progress(&events); + assert_eq!(sandbox.state, SandboxState::Running); + assert_hostname(backend.as_ref(), &sandbox, "integration-host").await; + assert_direct_root_filesystem(backend.as_ref(), &sandbox).await; + assert_nested_container_networking(backend.as_ref(), &sandbox).await; + + sandbox = assert_resource_update_and_root_growth(backend.as_ref(), &service, &mut request, sandbox).await; + + let output = run(backend.as_ref(), &sandbox.id, ExecutionSpec::image_entrypoint()).await; + assert_eq!(output.stdout.as_ref(), b"default-entrypoint\n"); + + assert_terminal_execution(backend.as_ref(), &sandbox).await; + + backend + .write_file( + &sandbox.id, + &sandbox::SandboxPath::new("/workspace/retained.txt"), + Box::pin(Cursor::new(b"retained".to_vec())), + ) + .await + .expect("file should stream into the Sandbox"); + assert_atomic_replacement(backend.as_ref(), &sandbox).await; + backend.stop(&sandbox.id).await.expect("Sandbox should stop"); + + drop(service); + drop(backend); + let backend = MicrosandboxProvider::open(&backend_home) + .await + .expect("Backend should reopen from the same home"); + assert_eq!( + backend + .find(request.name()) + .await + .expect("Sandbox should be re-adopted") + .state, + SandboxState::Stopped + ); + backend.start(&sandbox.id).await.expect("Sandbox should restart"); + + assert_eq!( + read(&backend, &sandbox.id, "/workspace/retained.txt").await, + b"retained" + ); + assert_immediate_restart_and_delete(&backend, &request, &sandbox).await; + assert_build_cache_reused(backend, &request, &home.id).await; + assert_reference_image_resolves(reference_backend_home).await; +} + +/// A direct root filesystem pulled from a registry is prepared without Microsandbox's layered +/// image artifacts, so a restart must boot from the Sandbox's own root disk without them. +#[tokio::test(flavor = "local")] +#[ignore = "requires a Microsandbox host runtime, hardware virtualization and registry access"] +async fn direct_reference_sandbox_restarts_on_its_root_filesystem() { + let temporary = RetainedOnFailureTempDir::new(); + let home = temporary.path().join("control-plane"); + let backend = Rc::new(MicrosandboxProvider::open(&home).await.expect("Backend should open")); + let service = SandboxService::new(backend.clone()); + let request = EnsureSandboxRequest::new( + SandboxName::new("direct-reference-worker").expect("test Sandbox name should be valid"), + SandboxSpec { + image: ImageSource::Reference { + reference: "docker.io/library/alpine:3.22".to_string(), + }, + platform: native_linux_platform(), + resources: direct_resources("1", "512Mi", "1Gi"), + init_system: sandbox::init::InitSystem::Backend, + retention_policy: RetentionPolicy::Retain, + }, + ); + let (sandbox, _) = collect_progress(service.ensure(&request)) + .await + .expect("OCI reference should resolve and start"); + assert_direct_root_filesystem(backend.as_ref(), &sandbox).await; + assert_guest_heartbeat_advances(backend.as_ref(), &sandbox.id).await; + backend + .write_file( + &sandbox.id, + &sandbox::SandboxPath::new("/root/retained.txt"), + Box::pin(Cursor::new(b"retained".to_vec())), + ) + .await + .expect("file should stream into the Sandbox"); + + backend.stop(&sandbox.id).await.expect("Sandbox should stop"); + let stopped = backend + .inspect(&sandbox.id) + .await + .expect("stopped Sandbox should be inspected"); + assert_eq!(stopped.guest_heartbeat, None, "a stopped guest reports no heartbeat"); + backend + .start(&sandbox.id) + .await + .expect("stopped direct Sandbox should restart"); + assert_direct_root_filesystem(backend.as_ref(), &sandbox).await; + assert_guest_heartbeat_advances(backend.as_ref(), &sandbox.id).await; + assert_eq!(read(&backend, &sandbox.id, "/root/retained.txt").await, b"retained"); + + // A paused VM refuses a graceful stop and a frozen one never answers it; + // stopping either must still end it, and it starts again on its own disk. + // Finding and signalling the VM process reads the host's `/proc`. + if cfg!(target_os = "linux") { + assert_stop_ends_the_vm(&backend, &service, &request, &sandbox, |runtime| { + msb(&home, &["pause", runtime]); + }) + .await; + assert_stop_ends_the_vm(&backend, &service, &request, &sandbox, |runtime| { + signal(&runtime_processes(runtime), "STOP"); + }) + .await; + } + + backend.delete(&sandbox.id).await.expect("Sandbox should be deleted"); +} + +/// Disrupts the running Sandbox's runtime with `disrupt`, then checks that a +/// stop ends its VM process within a bound and that it starts again on its root disk. +async fn assert_stop_ends_the_vm( + backend: &MicrosandboxProvider, + service: &SandboxService, + request: &EnsureSandboxRequest, + sandbox: &Sandbox, + disrupt: impl FnOnce(&str), +) { + let runtime = format!("sandbox-{}", sandbox.id.as_uuid().simple()); + assert!( + !runtime_processes(&runtime).is_empty(), + "the running Sandbox should have a runtime process" + ); + disrupt(&runtime); + let started = tokio::time::Instant::now(); + let stopped = service.stop(request.name()).await; + let elapsed = started.elapsed(); + // An exited process has an empty command line, so only a live runtime is left here. + let survivors = runtime_processes(&runtime); + signal(&survivors, "KILL"); + stopped.expect("a disrupted Sandbox should stop"); + assert!( + survivors.is_empty(), + "runtime processes {survivors:?} outlived the stop" + ); + assert!( + elapsed < std::time::Duration::from_secs(30), + "stopping a disrupted Sandbox should be bounded, took {elapsed:?}" + ); + let (restarted, _) = collect_progress(service.ensure(request)) + .await + .expect("a stopped direct Sandbox should start again"); + assert_eq!(restarted.id, sandbox.id); + assert_eq!(restarted.state, SandboxState::Running); + assert_eq!(read(backend, &sandbox.id, "/root/retained.txt").await, b"retained"); +} + +/// Host processes whose command line names the Sandbox's runtime. +fn runtime_processes(runtime: &str) -> Vec { + std::fs::read_dir("/proc") + .expect("/proc should be readable") + .filter_map(|entry| entry.ok()?.file_name().to_str()?.parse::().ok()) + .filter(|pid| *pid != std::process::id()) + .filter(|pid| { + std::fs::read(format!("/proc/{pid}/cmdline")).is_ok_and(|cmdline| { + cmdline + .split(|byte| *byte == 0) + .any(|argument| argument == runtime.as_bytes()) + }) + }) + .collect() +} + +/// Runs the Provider's own `msb` against its runtime home. +fn msb(home: &std::path::Path, arguments: &[&str]) { + let runtime = home.join("runtime"); + let status = std::process::Command::new(runtime.join("bin").join("msb")) + .args(arguments) + .env("MSB_HOME", &runtime) + .status() + .expect("msb should run"); + assert!(status.success(), "msb {arguments:?} should succeed"); +} + +fn signal(pids: &[u32], signal: &str) { + for pid in pids { + let status = std::process::Command::new("kill") + .arg(format!("-{signal}")) + .arg(pid.to_string()) + .status() + .expect("kill should run"); + assert!(status.success(), "kill -{signal} {pid} should succeed"); + } +} + +/// A running guest's heartbeat advances on its own, without traffic to the guest. +async fn assert_guest_heartbeat_advances(backend: &MicrosandboxProvider, id: &sandbox::SandboxId) { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + let mut first = None; + loop { + let heartbeat = backend + .inspect(id) + .await + .expect("running Sandbox should be inspected") + .guest_heartbeat; + match (first, heartbeat) { + (None, Some(heartbeat)) => first = Some(heartbeat), + (Some(first), Some(heartbeat)) if heartbeat != first => return, + _ => {} + } + assert!( + tokio::time::Instant::now() < deadline, + "guest heartbeat should advance within 10s, first observed {first:?}" + ); + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } +} + +/// A replacement must never expose a partial file: a reader polling the path throughout the +/// write sees the old or the new contents only, the replaced file keeps its mode, and no +/// staging file is left behind. +async fn assert_atomic_replacement(backend: &MicrosandboxProvider, sandbox: &Sandbox) { + let path = "/workspace/replaced.txt"; + let old = vec![b'a'; 4 * 1024 * 1024]; + let new = vec![b'b'; 4 * 1024 * 1024]; + backend + .write_file( + &sandbox.id, + &sandbox::SandboxPath::new(path), + Box::pin(Cursor::new(old.clone())), + ) + .await + .expect("initial file should stream into the Sandbox"); + let mode = run( + backend, + &sandbox.id, + shell(&format!("stat -c %a {path} && chmod 4750 {path} && stat -c %a {path}")), + ) + .await; + assert_eq!(mode.stdout.as_ref(), b"644\n4750\n", "a new file gets the default mode"); + // One digest per observation, each from a single open of the path, so an observation can + // only be the complete old file, the complete new file, or a torn one. The reader marks + // its first observation so the replacement provably overlaps with it. + let old_digest = hex(&Sha256::digest(&old)); + let new_digest = hex(&Sha256::digest(&new)); + let ready = "/workspace/.reader-ready"; + let reader = backend + .start_execution( + &sandbox.id, + StartExecutionRequest::new(shell(&format!( + "end=$(($(date +%s) + 20)); while [ $(date +%s) -lt $end ]; do \ + digest=$(sha256sum < {path} | cut -d ' ' -f 1); echo \"$digest\"; touch {ready}; \ + [ \"$digest\" = {new_digest} ] && break; done" + ))), + ) + .await + .expect("reader should start"); + for attempt in 0.. { + let probe = run(backend, &sandbox.id, shell(&format!("test -f {ready}"))).await; + if probe.status.success() { + break; + } + assert!(attempt < 100, "reader never started observing the file"); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + backend + .write_file( + &sandbox.id, + &sandbox::SandboxPath::new(path), + Box::pin(Cursor::new(new.clone())), + ) + .await + .expect("replacement should stream into the Sandbox"); + let observed = reader.collect().await.expect("reader should exit"); + let lines = String::from_utf8(observed.stdout.to_vec()).expect("reader output should be UTF-8"); + assert!(!lines.is_empty(), "reader observed nothing"); + for line in lines.lines() { + assert!( + line == old_digest || line == new_digest, + "reader observed a partial or mixed file: {line:?}" + ); + } + assert!(lines.contains(&old_digest), "reader never observed the original"); + assert!(lines.contains(&new_digest), "reader never observed the replacement"); + assert_eq!(read(backend, &sandbox.id, path).await, new); + let after = run( + backend, + &sandbox.id, + shell(&format!( + "rm {ready}; stat -c %a {path}; ls -A /workspace | grep -c agent- || true" + )), + ) + .await; + assert_eq!(after.stdout.as_ref(), b"4750\n0\n"); +} + +fn hex(bytes: &[u8]) -> String { + use std::fmt::Write as _; + + bytes.iter().fold(String::new(), |mut hex, byte| { + let _ = write!(hex, "{byte:02x}"); + hex + }) +} + +async fn assert_hostname(backend: &MicrosandboxProvider, sandbox: &Sandbox, expected: &str) { + assert_eq!(sandbox.hostname.as_str(), expected); + let output = run(backend, &sandbox.id, shell("hostname")).await; + assert!(output.status.success()); + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), expected); +} + +async fn assert_direct_root_filesystem(backend: &MicrosandboxProvider, sandbox: &Sandbox) { + let output = run( + backend, + &sandbox.id, + shell("awk '$2 == \"/\" { print $3 }' /proc/mounts"), + ) + .await; + assert_eq!(output.stdout.as_ref(), b"ext4\n"); +} + +async fn assert_nested_container_networking(backend: &MicrosandboxProvider, sandbox: &Sandbox) { + let output = run( + backend, + &sandbox.id, + shell( + r"set -eu +cleanup() { + iptables -t nat -F SBX_KUBE_PROXY_TEST 2>/dev/null || true + iptables -t nat -X SBX_KUBE_PROXY_TEST 2>/dev/null || true + nft delete table ip sandbox_test 2>/dev/null || true +} +trap cleanup EXIT +iptables -t nat -N SBX_KUBE_PROXY_TEST +iptables -t nat -A SBX_KUBE_PROXY_TEST -m statistic --mode random --probability 0.5 -j RETURN +nft add table ip sandbox_test +nft add chain ip sandbox_test service +nft add rule ip sandbox_test service meta mark set numgen random mod 2 +", + ), + ) + .await; + assert!( + output.status.success(), + "nested container networking kernel probes failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +async fn assert_resource_update_and_root_growth( + backend: &MicrosandboxProvider, + service: &SandboxService, + request: &mut EnsureSandboxRequest, + sandbox: Sandbox, +) -> Sandbox { + request.spec_mut().resources = direct_resources("2", "768Mi", "5Gi"); + let (resized, events) = collect_progress(service.ensure(request)) + .await + .expect("Sandbox resources should be updated"); + assert_eq!(resized.id, sandbox.id); + assert_eq!(resized.resources, request.spec().resources); + assert!(events.iter().any( + |event| matches!(event, ProgressEvent::StepStarted { name, .. } if name == "Update Microsandbox VM resources") + )); + + let root_size = run(backend, &resized.id, shell("df -kP / | awk 'END { print $2 }'")).await; + let root_kib = String::from_utf8(root_size.stdout.to_vec()) + .expect("root filesystem size should be UTF-8") + .trim() + .parse::() + .expect("root filesystem size should be numeric"); + assert!( + root_kib > 4 * 1024 * 1024, + "root filesystem should have grown past 4 GiB" + ); + let cpus = run(backend, &resized.id, shell("nproc")).await; + assert_eq!(cpus.stdout.as_ref(), b"2\n", "the restarted VM should have 2 CPUs"); + + request.spec_mut().resources = direct_resources("2", "768Mi", "4Gi"); + let error = service + .ensure(request) + .await + .expect_err("Microsandbox root filesystem shrink should be rejected"); + assert!(matches!( + error, + sandbox::Error::Component { source, .. } + if matches!(*source, sandbox::Error::UnsupportedResourceChange { resource: "rootFilesystem", .. }) + )); + request.spec_mut().resources = direct_resources("2", "768Mi", "5Gi"); + resized +} + +async fn assert_reference_image_resolves(backend_home: PathBuf) { + let backend = Rc::new( + MicrosandboxProvider::open(backend_home) + .await + .expect("reference Backend should open"), + ); + let service = SandboxService::new(backend.clone()); + let request = EnsureSandboxRequest::new( + SandboxName::new("reference-worker").expect("test Sandbox name should be valid"), + SandboxSpec { + image: ImageSource::Reference { + reference: "docker.io/library/alpine:3.22".to_string(), + }, + platform: native_linux_platform(), + resources: resources("1", "512Mi", "4Gi"), + init_system: sandbox::init::InitSystem::Backend, + retention_policy: RetentionPolicy::Delete, + }, + ); + + let (sandbox, events) = collect_progress(service.ensure(&request)) + .await + .expect("OCI reference should resolve and start"); + assert!( + events + .iter() + .any(|event| matches!(event, ProgressEvent::StepStarted { name, .. } if name == "Pull OCI image")) + ); + let output = run(backend.as_ref(), &sandbox.id, shell("cat /etc/alpine-release")).await; + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).starts_with("3.22.")); + assert_hostname(backend.as_ref(), &sandbox, "reference-worker").await; + + service + .release(request.name(), request.spec().retention_policy) + .await + .expect("reference Sandbox should be deleted"); +} + +async fn assert_immediate_restart_and_delete( + backend: &MicrosandboxProvider, + request: &EnsureSandboxRequest, + sandbox: &Sandbox, +) { + backend.stop(&sandbox.id).await.expect("Sandbox should stop again"); + assert_eq!( + backend + .find(request.name()) + .await + .expect("stopped Sandbox should remain discoverable") + .state, + SandboxState::Stopped + ); + backend + .start(&sandbox.id) + .await + .expect("Sandbox should immediately restart again"); + + backend.delete(&sandbox.id).await.expect("Sandbox should be deleted"); +} + +async fn assert_build_cache_reused( + backend: MicrosandboxProvider, + request: &EnsureSandboxRequest, + home_id: &sandbox::volume::VolumeId, +) { + let backend = Rc::new(backend); + let service = SandboxService::new(backend.clone()); + let (sandbox, events) = collect_progress(service.ensure(request)) + .await + .expect("Sandbox should rebuild from the retained Docker cache"); + assert!( + step_output(&events, "Build Docker image").iter().any(|bytes| { + [b"CACHED".as_slice(), b"Using cache".as_slice()] + .iter() + .any(|marker| bytes.windows(marker.len()).any(|window| window == *marker)) + }), + "second Docker build should report a reused layer; events: {events:#?}" + ); + for skipped in ["Export Docker image", "Import Microsandbox image"] { + assert!( + !events + .iter() + .any(|event| matches!(event, ProgressEvent::StepStarted { name, .. } if name == skipped)), + "reused Microsandbox image should skip {skipped}" + ); + } + + backend + .delete(&sandbox.id) + .await + .expect("rebuilt Sandbox should be deleted"); + backend + .delete_volume(home_id) + .await + .expect("retained volume should be deleted"); +} + +async fn assert_terminal_execution(backend: &dyn sandbox::backend::SandboxBackend, sandbox: &Sandbox) { + let mut terminal = backend + .start_terminal_execution( + &sandbox.id, + StartTerminalExecutionRequest::new( + shell("read -r value; set -- $(stty size); printf 'terminal:%s:%sx%s\\n' \"$value\" \"$1\" \"$2\""), + TerminalSize::new(31, 97).expect("initial terminal size should be valid"), + ), + ) + .await + .expect("terminal Execution should start"); + terminal + .control + .resize(TerminalSize::new(42, 111).expect("resized terminal dimensions should be valid")) + .await + .expect("terminal should resize"); + terminal + .control + .write_input(Bytes::from_static(b"hello\n")) + .await + .expect("terminal input should be written"); + + let mut output = Vec::new(); + let status = loop { + match terminal + .events + .next() + .await + .expect("terminal event stream should report exit") + .expect("terminal event should succeed") + { + TerminalEvent::Output(bytes) => output.extend_from_slice(&bytes), + TerminalEvent::Exited(status) => break Ok(status), + TerminalEvent::Failed { message } => break Err(message), + _ => {} + } + } + .expect("terminal process should start and exit"); + let output = String::from_utf8_lossy(&output); + assert!(status.success()); + assert!( + output.contains("terminal:hello:42x111"), + "unexpected terminal output: {output}" + ); +} + +async fn collect_progress( + mut pending: sandbox::PendingSandbox<'_>, +) -> Result<(Sandbox, Vec), sandbox::Error> { + let mut events = Vec::new(); + while let Some(event) = pending.next().await { + match event? { + OperationEvent::Progress(event) => events.push(event), + OperationEvent::Ready(sandbox) => return Ok((sandbox.snapshot().clone(), events)), + _ => {} + } + } + Err(sandbox::Error::OperationStreamEnded) +} + +/// Output of every occurrence of the named step. +fn step_output<'a>(events: &'a [ProgressEvent], step: &str) -> Vec<&'a [u8]> { + let ids = events + .iter() + .filter_map(|event| match event { + ProgressEvent::StepStarted { id, name, .. } if name == step => Some(id), + _ => None, + }) + .collect::>(); + events + .iter() + .filter_map(|event| match event { + ProgressEvent::StepOutput { id, bytes, .. } if ids.contains(&id) => Some(bytes.as_ref()), + _ => None, + }) + .collect() +} + +fn assert_provisioning_progress(events: &[ProgressEvent]) { + for expected in [ + "Check Docker Engine", + "Build Docker image", + "Retain Docker build cache", + "Look up imported Microsandbox image", + "Create Microsandbox VM", + ] { + assert!( + events + .iter() + .any(|event| { matches!(event, ProgressEvent::StepStarted { name, .. } if name == expected) }) + ); + } +} + +struct RetainedOnFailureTempDir(Option); + +impl RetainedOnFailureTempDir { + fn new() -> Self { + Self(Some( + tempfile::tempdir().expect("temporary integration home should be created"), + )) + } + + fn path(&self) -> &std::path::Path { + self.0.as_ref().expect("temporary integration home should exist").path() + } +} + +impl Drop for RetainedOnFailureTempDir { + fn drop(&mut self) { + if std::thread::panicking() + && let Some(temporary) = self.0.take() + { + eprintln!( + "retaining failed Microsandbox integration home at {}", + temporary.keep().display() + ); + } + } +} + +async fn read(backend: &MicrosandboxProvider, id: &sandbox::SandboxId, path: &str) -> Vec { + let mut reader = backend + .read_file(id, &sandbox::SandboxPath::new(path)) + .await + .expect("Sandbox file should open"); + let mut contents = Vec::new(); + reader + .read_to_end(&mut contents) + .await + .expect("Sandbox file should stream out"); + contents +} + +fn resources(cpu: &str, memory: &str, root_filesystem: &str) -> SandboxResources { + SandboxResources::new( + cpu.parse::().expect("test CPU should be valid"), + memory.parse::().expect("test memory should be valid"), + RootFilesystem::layered( + root_filesystem + .parse::() + .expect("test root filesystem should be valid"), + ), + ) +} + +fn direct_resources(cpu: &str, memory: &str, root_filesystem: &str) -> SandboxResources { + SandboxResources::new( + cpu.parse::().expect("test CPU should be valid"), + memory.parse::().expect("test memory should be valid"), + RootFilesystem::direct( + root_filesystem + .parse::() + .expect("test root filesystem should be valid"), + ), + ) +} + +fn shell(script: &str) -> ExecutionSpec { + ExecutionSpec::command( + sandbox::SandboxPath::new("/bin/sh"), + ["-c".to_string(), script.to_string()], + ) +} + +async fn run( + backend: &dyn sandbox::backend::SandboxBackend, + sandbox_id: &sandbox::SandboxId, + spec: ExecutionSpec, +) -> execution::ExecutionOutput { + let execution = backend + .start_execution(sandbox_id, StartExecutionRequest::new(spec)) + .await + .expect("Execution should start"); + execution.collect().await.expect("Execution should exit") +} + +fn native_linux_platform() -> Platform { + Platform::new( + "linux", + match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + architecture => architecture, + }, + ) +}