diff --git a/.github/workflows/release-public-interim.yml b/.github/workflows/release-public-interim.yml new file mode 100644 index 000000000..098132850 --- /dev/null +++ b/.github/workflows/release-public-interim.yml @@ -0,0 +1,869 @@ +name: Public Interim Release (GHCR + GitHub Release) + +# ───────────────────────────────────────────────────────────────────────── +# INTERIM public release — lets customers run kars from pre-built, signed +# artefacts WITHOUT cloning AGT or compiling anything. This is the stopgap +# described in docs/PUBLISHING.md until the ESRP-signed public path +# (.github/pipelines/esrp-publish.yml → MCR + npmjs `@kars` + crates.io) +# is wired. Per PUBLISHING.md, GHCR is explicitly allowed for interim/nightly. +# +# What this produces (all PUBLIC): +# 1. Multi-arch container images → ghcr.io/azure/kars-*: + :latest +# (cosign keyless signed, SBOM + SLSA provenance attached) +# 2. @kars/cli packed as a .tgz attached to a PUBLIC GitHub Release +# (NOT published to npmjs — the `@kars` scope is reserved for the +# ESRP-signed publish; customers install via `npm i -g `) +# +# What it deliberately does NOT do (reserved for ESRP, see PUBLISHING.md): +# - npm publish to npmjs.com under `@kars` +# - cargo publish to crates.io +# - push to mcr.microsoft.com +# +# Triggers: +# - Tags matching `v*-interim*` (e.g. v0.1.0-interim.1) +# - Manual via workflow_dispatch (version input) +# +# ONE-TIME SETUP (first run only): GHCR org packages default to private. +# After the first push, set each `kars-*` package to Public in +# https://github.com/orgs/azure/packages → package → Settings → visibility. +# The `set-package-public` step attempts this automatically but org policy +# may require the one-time manual toggle. +# ───────────────────────────────────────────────────────────────────────── + +on: + push: + tags: + - 'v*-interim*' + workflow_dispatch: + inputs: + version: + description: 'Version to tag (e.g. v0.1.0-interim.1). Defaults to interim-.' + required: false + default: '' + +permissions: + contents: write # create GitHub Release + packages: write # push to GHCR + set visibility + id-token: write # cosign keyless OIDC + attestations: write + actions: read + +env: + VERSION: ${{ github.event.inputs.version != '' && github.event.inputs.version || (startsWith(github.ref, 'refs/tags/') && github.ref_name || format('interim-{0}', github.sha)) }} + REGISTRY: ghcr.io/azure + +jobs: + # ─── Stage 1: multi-arch Rust binaries ───────────────────────── + build-binaries: + name: Build Rust binaries (amd64 + arm64) + runs-on: ubuntu-22.04 + timeout-minutes: 40 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + with: + targets: x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: rust-glibc-release-multiarch + save-if: false + - name: Install aarch64 cross toolchain + libc headers + run: | + sudo apt-get update + # gcc-aarch64-linux-gnu alone is not enough to compile aws-lc-sys + # (BoringSSL C) for arm64: --no-install-recommends drops the cross + # libc dev headers, so the build fails with "sys/types.h: No such + # file or directory". Pull them in explicitly: + # libc6-dev-arm64-cross → sys/types.h, bits/libc-header-start.h + # linux-libc-dev-arm64-cross → asm/types.h, linux/types.h + sudo apt-get install -y --no-install-recommends \ + gcc-aarch64-linux-gnu \ + libc6-dev-arm64-cross \ + linux-libc-dev-arm64-cross + - name: cargo build --release (amd64 + arm64) + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + run: | + cargo build --release --workspace --target x86_64-unknown-linux-gnu + cargo build --release --workspace --target aarch64-unknown-linux-gnu + - name: Stage binaries per-arch + run: | + mkdir -p ./bin/amd64 ./bin/arm64 + for bin in kars-controller kars-inference-router kars-a2a-gateway kars-conformance-runner; do + cp "target/x86_64-unknown-linux-gnu/release/$bin" "./bin/amd64/$bin" + cp "target/aarch64-unknown-linux-gnu/release/$bin" "./bin/arm64/$bin" + done + - name: Verify + checksum + run: | + chmod +x ./bin/*/kars-* + ( cd ./bin && find . -type f -name 'kars-*' -exec sha256sum {} \; | tee SHA256SUMS ) + file ./bin/*/kars-* + - name: Attest build provenance (binaries, GitHub-signed) + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: 'bin/*/kars-*' + + - name: Upload binaries + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + with: + name: kars-binaries-${{ env.VERSION }} + path: ./bin/ + retention-days: 90 + + # ─── Stage 2: build + push container images to PUBLIC GHCR ───── + build-images: + name: Build + push ${{ matrix.image.name }} (PUBLIC GHCR) + needs: build-binaries + runs-on: ubuntu-22.04 + timeout-minutes: 90 + outputs: + controller_digest: ${{ steps.digests.outputs.controller }} + router_digest: ${{ steps.digests.outputs.router }} + gateway_digest: ${{ steps.digests.outputs.gateway }} + runner_digest: ${{ steps.digests.outputs.runner }} + strategy: + fail-fast: false + matrix: + image: + - name: controller + dockerfile: controller/Dockerfile + ghcr: kars-controller + platforms: linux/amd64,linux/arm64 + - name: inference-router + dockerfile: inference-router/Dockerfile + ghcr: kars-inference-router + platforms: linux/amd64,linux/arm64 + - name: a2a-gateway + dockerfile: a2a-gateway/Dockerfile + ghcr: kars-a2a-gateway + platforms: linux/amd64,linux/arm64 + - name: conformance-runner + dockerfile: sandbox-images/conformance-runner/Dockerfile + ghcr: kars-conformance-runner + platforms: linux/amd64,linux/arm64 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Download pre-built binaries (per-arch) + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: kars-binaries-${{ env.VERSION }} + path: ./bin/ + - name: chmod binaries + run: find ./bin -type f -name 'kars-*' -exec chmod +x {} \; + + - name: Set up QEMU (for cross-arch buildx) + if: matrix.image.platforms != 'linux/amd64' + uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf # v3.2.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3 + + - name: Log in to GHCR + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build + push (PUBLIC) + id: push + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v6 + with: + context: . + file: ${{ matrix.image.dockerfile }} + push: true + platforms: ${{ matrix.image.platforms }} + tags: | + ${{ env.REGISTRY }}/${{ matrix.image.ghcr }}:${{ env.VERSION }} + ${{ env.REGISTRY }}/${{ matrix.image.ghcr }}:latest + build-args: | + VERSION=${{ env.VERSION }} + sbom: true + provenance: true + + - name: Attest build provenance (GitHub-signed, pushed to registry) + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: ${{ env.REGISTRY }}/${{ matrix.image.ghcr }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + + - name: Make package public (best-effort; may need one-time manual toggle) + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api -X PATCH \ + -H "Accept: application/vnd.github+json" \ + "/orgs/azure/packages/container/${{ matrix.image.ghcr }}/visibility" \ + -f visibility=public \ + || echo "::warning::Could not set ${{ matrix.image.ghcr }} public via API; set it once manually in package settings." + + - name: Record digest + id: digests + env: + DIGEST: ${{ steps.push.outputs.digest }} + run: | + case "${{ matrix.image.name }}" in + controller) echo "controller=${DIGEST}" >> "$GITHUB_OUTPUT" ;; + inference-router) echo "router=${DIGEST}" >> "$GITHUB_OUTPUT" ;; + a2a-gateway) echo "gateway=${DIGEST}" >> "$GITHUB_OUTPUT" ;; + conformance-runner) echo "runner=${DIGEST}" >> "$GITHUB_OUTPUT" ;; + sandbox-base) echo "sandbox_base=${DIGEST}" >> "$GITHUB_OUTPUT" ;; + esac + + - name: Generate SBOM (SPDX JSON) + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0 + with: + image: ${{ env.REGISTRY }}/${{ matrix.image.ghcr }}:${{ env.VERSION }} + format: spdx-json + output-file: sbom-${{ matrix.image.name }}.spdx.json + + - name: Upload SBOM + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + with: + name: sbom-${{ matrix.image.name }}-${{ env.VERSION }} + path: sbom-${{ matrix.image.name }}.spdx.json + retention-days: 90 + + - name: Install cosign + uses: sigstore/cosign-installer@1aa8e0f2454b781fbf0fbf306a4c9533a0c57409 # v3.7.0 + with: + cosign-release: 'v2.4.1' + + - name: Cosign keyless sign image + env: + COSIGN_EXPERIMENTAL: '1' + run: | + cosign sign --yes ${{ env.REGISTRY }}/${{ matrix.image.ghcr }}@${{ steps.push.outputs.digest }} + + # ─── Stage 2b: build sandbox-base + openclaw-sandbox (NATIVE per-arch) ── + # These two are heavy (Python/Go/Node), so building arm64 under QEMU is far + # too slow. Instead build each arch on its NATIVE GitHub-hosted runner + # (amd64 → ubuntu-22.04, arm64 → ubuntu-24.04-arm; free for public repos), + # push per-arch tags, then merge into a multi-arch manifest. This is what + # makes openclaw-sandbox run NATIVELY on Apple Silicon (amd64 under Rosetta + # crashes with rt_tgsigqueueinfo). The vendored sandbox wheels ship aarch64 + # variants, so the arm64 base installs pre-built wheels (no source compile). + build-sandbox-base: + name: Build kars-sandbox-base (${{ matrix.arch }}, native) + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-22.04 + - arch: arm64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3 + - name: Log in to GHCR + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build + push kars-sandbox-base:${{ env.VERSION }}-${{ matrix.arch }} + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v6 + with: + context: . + file: sandbox-images/openclaw/Dockerfile.base + push: true + platforms: linux/${{ matrix.arch }} + tags: ${{ env.REGISTRY }}/kars-sandbox-base:${{ env.VERSION }}-${{ matrix.arch }} + build-args: | + VERSION=${{ env.VERSION }} + + build-sandbox: + name: Build openclaw-sandbox (${{ matrix.arch }}, native) + needs: [build-images, build-sandbox-base] + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-22.04 + - arch: arm64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Stage patched AGT SDK (release-build correctness guard) + run: | + # A release sandbox image MUST bundle the kars-patched AGT SDK + # (POP signing + X3DH KDF fix). The Dockerfile falls back to the + # UNPATCHED public npm SDK if .agt-sdk/ is absent. + # + # The Dockerfile's `COPY .agt-sdk/` resolves against the BUILD + # CONTEXT root (the repo root — `context: .` below), NOT the + # Dockerfile's directory. Stage into /.agt-sdk/ to match, + # exactly like the CLI from-source dev build does + # (cli/src/commands/dev.ts -> path.join(repoRoot, ".agt-sdk")). + TARBALL=$(find vendor/agt -maxdepth 1 -name 'microsoft-agent-governance-sdk-*.tgz' | head -1 || true) + if [ -z "$TARBALL" ]; then + echo "::error::No vendored patched AGT SDK tarball under vendor/agt/." + exit 1 + fi + mkdir -p .agt-sdk + cp "$TARBALL" .agt-sdk/ + echo "AGT_SDK_TARBALL=$(basename "$TARBALL")" >> "$GITHUB_ENV" + + - name: Build mesh-plugin (openclaw Dockerfile COPYs mesh-plugin/dist) + uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4 + with: + node-version: '22' + - name: npm ci + build mesh-plugin + working-directory: mesh-plugin + run: | + npm ci --no-audit --no-fund + npm run build + test -d dist || { echo "::error::mesh-plugin/dist not produced"; exit 1; } + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3 + - name: Log in to GHCR + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build + push openclaw-sandbox:${{ env.VERSION }}-${{ matrix.arch }} + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v6 + with: + context: . + file: sandbox-images/openclaw/Dockerfile + push: true + platforms: linux/${{ matrix.arch }} + tags: ${{ env.REGISTRY }}/openclaw-sandbox:${{ env.VERSION }}-${{ matrix.arch }} + build-args: | + VERSION=${{ env.VERSION }} + SANDBOX_BASE_IMAGE=${{ env.REGISTRY }}/kars-sandbox-base:${{ env.VERSION }}-${{ matrix.arch }} + INFERENCE_ROUTER_IMAGE=${{ env.REGISTRY }}/kars-inference-router:${{ env.VERSION }} + MESH_PROVIDER=agt + AGT_SDK_TARBALL=${{ env.AGT_SDK_TARBALL }} + + # Merge the per-arch tags into multi-arch manifests + sign/attest/SBOM. + merge-sandbox: + name: Merge + sign sandbox-base + openclaw-sandbox manifests + needs: [build-sandbox-base, build-sandbox] + runs-on: ubuntu-22.04 + timeout-minutes: 20 + outputs: + sandbox_digest: ${{ steps.digests.outputs.openclaw }} + sandbox_base_digest: ${{ steps.digests.outputs.base }} + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3 + - name: Log in to GHCR + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create multi-arch manifests (:VERSION + :latest) + id: digests + run: | + for name in kars-sandbox-base openclaw-sandbox; do + docker buildx imagetools create \ + -t "${REGISTRY}/${name}:${VERSION}" \ + -t "${REGISTRY}/${name}:latest" \ + "${REGISTRY}/${name}:${VERSION}-amd64" \ + "${REGISTRY}/${name}:${VERSION}-arm64" + DIGEST=$(docker buildx imagetools inspect "${REGISTRY}/${name}:${VERSION}" --format '{{.Manifest.Digest}}') + case "$name" in + kars-sandbox-base) echo "base=${DIGEST}" >> "$GITHUB_OUTPUT" ;; + openclaw-sandbox) echo "openclaw=${DIGEST}" >> "$GITHUB_OUTPUT" ;; + esac + done + env: + REGISTRY: ${{ env.REGISTRY }} + VERSION: ${{ env.VERSION }} + + - name: Make packages public (best-effort) + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + for name in kars-sandbox-base openclaw-sandbox; do + gh api -X PATCH -H "Accept: application/vnd.github+json" \ + "/orgs/azure/packages/container/${name}/visibility" -f visibility=public \ + || echo "::warning::Could not set ${name} public via API." + done + + - name: Attest build provenance (both manifests) + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: ${{ env.REGISTRY }}/openclaw-sandbox + subject-digest: ${{ steps.digests.outputs.openclaw }} + push-to-registry: true + + - name: Generate SBOM (openclaw-sandbox) + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0 + with: + image: ${{ env.REGISTRY }}/openclaw-sandbox:${{ env.VERSION }} + format: spdx-json + output-file: sbom-openclaw-sandbox.spdx.json + - name: Upload SBOM + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + with: + name: sbom-openclaw-sandbox-${{ env.VERSION }} + path: sbom-openclaw-sandbox.spdx.json + retention-days: 90 + + - name: Install cosign + uses: sigstore/cosign-installer@1aa8e0f2454b781fbf0fbf306a4c9533a0c57409 # v3.7.0 + with: + cosign-release: 'v2.4.1' + - name: Cosign keyless sign manifests + env: + COSIGN_EXPERIMENTAL: '1' + run: | + cosign sign --yes ${{ env.REGISTRY }}/kars-sandbox-base@${{ steps.digests.outputs.base }} + cosign sign --yes ${{ env.REGISTRY }}/openclaw-sandbox@${{ steps.digests.outputs.openclaw }} + + # ─── Stage 2c: build AGT Python wheels (upstream governance pkgs) ── + # The Python runtime images COPY runtimes/wheels/*.whl (upstream AGT + # governance packages) AND runtimes/agt-mesh-python/ (kars's own + # spec-compliant Python MeshClient — in-repo source, bundled hermetically + # from the checkout). Only the upstream wheels need building here; they + # come from the pinned AGT checkout (vendor/agt/pin.json). + build-agt-wheels: + name: Build AGT Python wheels (from pinned AGT) + runs-on: ubuntu-22.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + - name: Clone pinned AGT + build wheels + run: | + URL=$(jq -r .url vendor/agt/pin.json) + SHA=$(jq -r .sha vendor/agt/pin.json) + echo "::notice::Cloning $URL @ $SHA for AGT-Python wheels" + git clone --filter=blob:none "$URL" /tmp/agt + git -C /tmp/agt checkout "$SHA" + AGT_PYTHON_DIR=/tmp/agt/agent-governance-python ./runtimes/build-agt-wheels.sh + echo "Built wheels:" && ls -la runtimes/wheels/ + - name: Guard — wheels present + run: | + count=$(find runtimes/wheels -maxdepth 1 -name '*.whl' | wc -l) + if [ "$count" -lt 1 ]; then + echo "::error::No AGT-Python wheels were produced — Python runtime" \ + "images would build without governance primitives. Aborting." + exit 1 + fi + echo "::notice::Staged $count AGT-Python wheel(s)." + - name: Upload wheels + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + with: + name: agt-wheels-${{ env.VERSION }} + path: runtimes/wheels/*.whl + retention-days: 90 + + # ─── Stage 2d: build the Python runtime adapter images ───────── + # Each bundles the upstream AGT wheels + kars-agt-mesh (in-repo) + the + # runtime adapter. amd64-only (manylinux wheels). FROM Azure Linux python. + build-runtimes: + name: Build + push ${{ matrix.runtime.ghcr }} (PUBLIC GHCR) + needs: build-agt-wheels + runs-on: ubuntu-22.04 + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + runtime: + - { name: langgraph, ghcr: kars-runtime-langgraph } + - { name: hermes, ghcr: kars-runtime-hermes } + - { name: maf-python, ghcr: kars-runtime-maf-python } + - { name: anthropic, ghcr: kars-runtime-anthropic } + - { name: openai-agents, ghcr: kars-runtime-openai-agents } + - { name: pydantic-ai, ghcr: kars-runtime-pydantic-ai } + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Download AGT wheels into build context + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: agt-wheels-${{ env.VERSION }} + path: runtimes/wheels/ + + - name: Guard — wheels staged + kars-agt-mesh present + run: | + test -n "$(find runtimes/wheels -maxdepth 1 -name '*.whl')" \ + || { echo "::error::AGT wheels missing in build context"; exit 1; } + test -f runtimes/agt-mesh-python/pyproject.toml \ + || { echo "::error::kars-agt-mesh source missing from checkout"; exit 1; } + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3 + + - name: Log in to GHCR + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build + push ${{ matrix.runtime.ghcr }} (PUBLIC) + id: push + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v6 + with: + context: . + file: sandbox-images/${{ matrix.runtime.name }}/Dockerfile + push: true + platforms: linux/amd64 + tags: | + ${{ env.REGISTRY }}/${{ matrix.runtime.ghcr }}:${{ env.VERSION }} + ${{ env.REGISTRY }}/${{ matrix.runtime.ghcr }}:latest + build-args: | + VERSION=${{ env.VERSION }} + sbom: true + provenance: true + + - name: Attest build provenance (GitHub-signed, pushed to registry) + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: ${{ env.REGISTRY }}/${{ matrix.runtime.ghcr }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + + - name: Make package public (best-effort; may need one-time manual toggle) + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api -X PATCH \ + -H "Accept: application/vnd.github+json" \ + "/orgs/azure/packages/container/${{ matrix.runtime.ghcr }}/visibility" \ + -f visibility=public \ + || echo "::warning::Could not set ${{ matrix.runtime.ghcr }} public via API; set it once manually." + + - name: Generate SBOM (SPDX JSON) + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0 + with: + image: ${{ env.REGISTRY }}/${{ matrix.runtime.ghcr }}:${{ env.VERSION }} + format: spdx-json + output-file: sbom-${{ matrix.runtime.ghcr }}.spdx.json + + - name: Upload SBOM + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + with: + name: sbom-${{ matrix.runtime.ghcr }}-${{ env.VERSION }} + path: sbom-${{ matrix.runtime.ghcr }}.spdx.json + retention-days: 90 + + - name: Install cosign + uses: sigstore/cosign-installer@1aa8e0f2454b781fbf0fbf306a4c9533a0c57409 # v3.7.0 + with: + cosign-release: 'v2.4.1' + + - name: Cosign keyless sign image + env: + COSIGN_EXPERIMENTAL: '1' + run: | + cosign sign --yes ${{ env.REGISTRY }}/${{ matrix.runtime.ghcr }}@${{ steps.push.outputs.digest }} + + # ─── Stage 2e: build the AGT mesh relay + registry images ────── + # kars dev / up run the AGT relay + registry for the mesh. They are built + # from the pinned AGT checkout (agent-mesh/docker/Dockerfile, COMPONENT=*). + # Publishing them lets `kars dev --release` pull everything — no AGT clone. + # AGT relay + registry — built on NATIVE per-arch runners so they run + # natively on arm64 kind nodes (Apple Silicon). The AGT Python image is + # multi-arch-capable; amd64-only would ImagePullBackOff / "exec format + # error" on an arm64 kind cluster, blocking `kars dev --release + # --target local-k8s` on a Mac. merge-mesh fuses the per-arch tags below. + build-mesh: + name: Build agentmesh-${{ matrix.component }} (${{ matrix.arch }}, native) + runs-on: ${{ matrix.runner }} + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + component: [relay, registry] + arch: [amd64, arm64] + include: + - arch: amd64 + runner: ubuntu-22.04 + - arch: arm64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Clone pinned AGT + run: | + URL=$(jq -r .url vendor/agt/pin.json) + SHA=$(jq -r .sha vendor/agt/pin.json) + git clone --filter=blob:none "$URL" /tmp/agt + git -C /tmp/agt checkout "$SHA" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3 + + - name: Log in to GHCR + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build + push agentmesh-${{ matrix.component }}:${{ env.VERSION }}-${{ matrix.arch }} + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v6 + with: + context: /tmp/agt + file: /tmp/agt/agent-governance-python/agent-mesh/docker/Dockerfile + push: true + platforms: linux/${{ matrix.arch }} + tags: ${{ env.REGISTRY }}/kars-agentmesh-${{ matrix.component }}:${{ env.VERSION }}-${{ matrix.arch }} + build-args: | + COMPONENT=${{ matrix.component }} + + # Merge the per-arch relay/registry tags into multi-arch manifests + sign. + merge-mesh: + name: Merge + sign agentmesh manifests + needs: [build-mesh] + runs-on: ubuntu-22.04 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + component: [relay, registry] + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@988b5a0280414f521da01fcc63a27aeeb4b104db # v3 + - name: Log in to GHCR + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create multi-arch manifest (:VERSION + :latest) + id: manifest + run: | + NAME="kars-agentmesh-${{ matrix.component }}" + docker buildx imagetools create \ + -t "${REGISTRY}/${NAME}:${VERSION}" \ + -t "${REGISTRY}/${NAME}:latest" \ + "${REGISTRY}/${NAME}:${VERSION}-amd64" \ + "${REGISTRY}/${NAME}:${VERSION}-arm64" + DIGEST=$(docker buildx imagetools inspect "${REGISTRY}/${NAME}:${VERSION}" --format '{{.Manifest.Digest}}') + echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" + env: + REGISTRY: ${{ env.REGISTRY }} + VERSION: ${{ env.VERSION }} + + - name: Make package public (best-effort; may need one-time manual toggle) + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api -X PATCH \ + -H "Accept: application/vnd.github+json" \ + "/orgs/azure/packages/container/kars-agentmesh-${{ matrix.component }}/visibility" \ + -f visibility=public \ + || echo "::warning::Could not set kars-agentmesh-${{ matrix.component }} public via API; set it once manually." + + - name: Attest build provenance (GitHub-signed, pushed to registry) + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: ${{ env.REGISTRY }}/kars-agentmesh-${{ matrix.component }} + subject-digest: ${{ steps.manifest.outputs.digest }} + push-to-registry: true + + - name: Generate SBOM (SPDX JSON) + uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0 + with: + image: ${{ env.REGISTRY }}/kars-agentmesh-${{ matrix.component }}:${{ env.VERSION }} + format: spdx-json + output-file: sbom-kars-agentmesh-${{ matrix.component }}.spdx.json + + - name: Upload SBOM + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + with: + name: sbom-kars-agentmesh-${{ matrix.component }}-${{ env.VERSION }} + path: sbom-kars-agentmesh-${{ matrix.component }}.spdx.json + retention-days: 90 + + - name: Install cosign + uses: sigstore/cosign-installer@1aa8e0f2454b781fbf0fbf306a4c9533a0c57409 # v3.7.0 + with: + cosign-release: 'v2.4.1' + + - name: Cosign keyless sign manifest + env: + COSIGN_EXPERIMENTAL: '1' + run: | + cosign sign --yes ${{ env.REGISTRY }}/kars-agentmesh-${{ matrix.component }}@${{ steps.manifest.outputs.digest }} + + # ─── Stage 3: pack @kars/cli for GitHub Release distribution ─── + cli-pack: + name: Pack @kars/cli tarball + runs-on: ubuntu-22.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4 + with: + node-version: '22' + - name: npm install + build + pack + working-directory: cli + run: | + npm install + npm run build + npm pack + ls -la ./*.tgz + - name: Attest build provenance (CLI tarball, GitHub-signed) + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: 'cli/*.tgz' + + - name: Upload CLI tarball + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + with: + name: npm-kars-cli-${{ env.VERSION }} + path: cli/*.tgz + retention-days: 90 + + # ─── Stage 4: assemble + publish the PUBLIC GitHub Release ───── + release: + name: Create PUBLIC GitHub Release + needs: [build-binaries, build-images, build-sandbox-base, build-sandbox, merge-sandbox, build-runtimes, build-mesh, merge-mesh, cli-pack] + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Download Rust binaries + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: kars-binaries-${{ env.VERSION }} + path: ./release-artefacts + + - name: Download CLI tarball + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: npm-kars-cli-${{ env.VERSION }} + path: ./release-artefacts + merge-multiple: true + + - name: Download SBOMs + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: sbom-*-${{ env.VERSION }} + path: ./release-artefacts + merge-multiple: true + + - name: List artefacts + run: find ./release-artefacts -type f | sort + + - name: Resolve CLI tarball name + id: cli + run: | + NAME=$(find ./release-artefacts -maxdepth 2 -name '*.tgz' -printf '%f\n' | head -1) + if [ -z "$NAME" ]; then + echo "::error::No CLI tarball found under release-artefacts." + exit 1 + fi + echo "tarball=$NAME" >> "$GITHUB_OUTPUT" + echo "::notice::CLI tarball: $NAME" + + - name: Build release manifest + run: | + cat > ./release-artefacts/release-manifest.json < These images bundle a kars build of the AGT mesh SDK pending + > upstream [microsoft/agent-governance-toolkit#3128](https://github.com/microsoft/agent-governance-toolkit/pull/3128). + + ### Quick start (no compile) + ```bash + # 1. Install the CLI from this Release (interim; npmjs `@kars` later) + npm i -g https://github.com/${{ github.repository }}/releases/download/${{ env.VERSION }}/${{ steps.cli.outputs.tarball }} + + # 2. Run locally against the published images (Docker Desktop) + kars dev --release ${{ env.VERSION }} + ``` + + ### Container images (PUBLIC GHCR) + Multi-arch `linux/amd64,linux/arm64` (sandbox-base is amd64-only; + Apple Silicon runs it under emulation): + ``` + ${{ env.REGISTRY }}/kars-controller:${{ env.VERSION }} + ${{ env.REGISTRY }}/kars-inference-router:${{ env.VERSION }} + ${{ env.REGISTRY }}/kars-a2a-gateway:${{ env.VERSION }} + ${{ env.REGISTRY }}/kars-conformance-runner:${{ env.VERSION }} + ${{ env.REGISTRY }}/kars-sandbox-base:${{ env.VERSION }} + ${{ env.REGISTRY }}/openclaw-sandbox:${{ env.VERSION }} + ``` + + ### Runtime adapter images (PUBLIC GHCR, amd64) + For `kars add --runtime `. Each bundles the upstream AGT + governance wheels + kars-agt-mesh (kars's spec-compliant Python + MeshClient): + ``` + ${{ env.REGISTRY }}/kars-runtime-langgraph:${{ env.VERSION }} + ${{ env.REGISTRY }}/kars-runtime-hermes:${{ env.VERSION }} + ${{ env.REGISTRY }}/kars-runtime-maf-python:${{ env.VERSION }} + ${{ env.REGISTRY }}/kars-runtime-anthropic:${{ env.VERSION }} + ${{ env.REGISTRY }}/kars-runtime-openai-agents:${{ env.VERSION }} + ${{ env.REGISTRY }}/kars-runtime-pydantic-ai:${{ env.VERSION }} + ``` + + ### AGT mesh images (PUBLIC GHCR, amd64) + The relay + registry that `kars dev`/`up` run for the mesh: + ``` + ${{ env.REGISTRY }}/kars-agentmesh-relay:${{ env.VERSION }} + ${{ env.REGISTRY }}/kars-agentmesh-registry:${{ env.VERSION }} + ``` + All signed with cosign keyless (verify via Sigstore Rekor). SBOMs + attached below. diff --git a/README.md b/README.md index c102d0410..c72281ec4 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,30 @@ Same CRDs. Same router code path. Same audit format. Same governance profiles. T ## Try it in five minutes -**Fastest path (recommended): GitHub Copilot.** If you have an active Copilot seat (Individual / Business / Enterprise), the only thing you need beyond Docker is one device-code login. No Azure account, no PAT, no key files. +**Fastest path — published images, no compile (macOS & Linux, Intel & Apple Silicon).** +On any host with Docker (amd64 Linux, Intel Mac, or Apple Silicon M-series), +two commands get you a running agent — no Rust toolchain, no AGT checkout, no +local image build: + +```bash +# 1. Install the CLI from the latest interim release +npm i -g https://github.com/Azure/kars/releases/download/v0.1.0-interim.9/kars-cli-0.1.0.tgz + +# 2. Launch a sandbox from the published, cosign-signed images +kars dev --release v0.1.0-interim.9 +``` + +> Use the newest tag from the [Releases page](https://github.com/Azure/kars/releases) — the `npm i -g …` URL and the `--release` flag must match. + +> **Apple Silicon (M-series) Macs:** fully supported. The published images are +> multi-arch (`linux/amd64` + `linux/arm64`), built on native arm64 runners, +> and `kars dev --release` automatically pulls the image matching your host +> architecture — no Rosetta, no extra flags. + +On first launch you'll pick an inference provider (see below). **GitHub Copilot** is the easiest — one device-code login, no Azure account. + +
+Build from source (to hack on the controller / router / plugin) > **Prerequisites:** Docker Desktop · Node.js 22+ · Rust 1.88+ · one of: an active GitHub Copilot seat, an Azure AI Foundry deployment, or a GitHub PAT with `models:read`. @@ -140,17 +163,13 @@ Same CRDs. Same router code path. Same audit format. Same governance profiles. T git clone https://github.com/Azure/kars.git && cd kars cd cli && npm ci && npm run build && npm link && cd .. -# Launch a sandbox locally — Docker only, no Azure, no AKS +# Launch a sandbox locally — Docker only, no Azure, no AKS. +# Builds the sandbox image natively for your architecture (incl. arm64). kars dev ``` -> **Faster clone (no sandbox base build):** The repo ships ~235 MB of vendored Python wheels under `vendor/sandbox-wheels/` so the sandbox base image builds hermetically (no PyPI dependency). If you only want to read code, run the CLI, or build the controller / router — skip the wheels with a partial clone: -> ```bash -> git clone --filter=blob:none https://github.com/Azure/kars.git -> ``` -> Files are fetched on demand when you `git checkout` or `git log` actually needs them. If you later need the wheels (because you're rebuilding `kars-sandbox-base`), run `git lfs install && git lfs pull` (the wheels are tracked via LFS going forward) or simply `git checkout vendor/sandbox-wheels` to materialize them. - -The first `kars dev` pulls + caches the sandbox base image (~10 min once) and then launches near-instantly thereafter. +The first source build of `kars dev` builds + caches the sandbox base image (~10 min once) and then launches near-instantly thereafter. +
For Microsoft / Azure-org members: skip the build diff --git a/cli/src/commands/dev.ts b/cli/src/commands/dev.ts index 9485dad3f..6412c8906 100644 --- a/cli/src/commands/dev.ts +++ b/cli/src/commands/dev.ts @@ -97,6 +97,12 @@ const SANDBOX_BASE_IMAGE = const AZURELINUX_BASE = "mcr.microsoft.com/azurelinux/base/core:3.0"; +// Public registry for `--release` mode (no-compile, no-AGT-clone bringup). +// Mirrors the names published by .github/workflows/release-public-interim.yml. +const RELEASE_REGISTRY = "ghcr.io/azure"; +const releaseImage = (name: string, version: string): string => + `${RELEASE_REGISTRY}/${name}:${version}`; + const AGT_NETWORK = "kars-dev"; const AGT_POSTGRES = "kars-agt-postgres"; const AGT_RELAY = "kars-agt-relay"; @@ -166,6 +172,10 @@ NOT overwrite your saved credentials. "One-off GitHub Models override (does NOT save). Requires a PAT with `models:read`. To save GitHub Models as your default provider, run without this flag and pick GitHub Models at the prompt." ) // ── Image build ──────────────────────────────────────────────────── + .option( + "--release ", + "Run from PUBLISHED images instead of building. Pulls ghcr.io/azure/* at the given release tag (e.g. v0.1.0-interim.5) — no AGT clone, no local compile. Skips --build.", + ) .option( "--image ", "Sandbox container image", @@ -261,6 +271,26 @@ Notes: process.exit(1); } + // ── Release mode: run from published images, no build/clone ─── + // `--release ` pulls everything from ghcr.io/azure/* at the + // given tag: the openclaw-sandbox, the AGT relay + registry, and any + // selected runtime image. It forces build OFF so we never compile or + // clone the AGT toolkit. An explicit --build is contradictory. + const releaseMode = typeof options.release === "string" && options.release.length > 0; + const releaseVersion: string | undefined = releaseMode ? options.release : undefined; + if (releaseMode) { + if (options.build === true || options.buildBase === true) { + console.error(chalk.red(`\n Error: --release pulls published images; it cannot be combined with --build/--build-base.\n`)); + process.exit(1); + } + options.build = false; + // Use the published sandbox image unless the user pinned --image. + const imagePassed = process.argv.some(a => a === "--image" || a.startsWith("--image=")); + if (!imagePassed) { + options.image = releaseImage("openclaw-sandbox", releaseVersion!); + } + } + // ── First-run target prompt ────────────────────────────────── // Brand-new user with no saved creds AND no explicit --target // flag: ask docker vs local-k8s up front, before we get into @@ -462,7 +492,7 @@ Notes: // users testing local changes can opt in here without // remembering the --build flag. Skipped if --build was passed // explicitly. - if (!options.build) { + if (!options.build && !releaseMode) { const { rebuild } = await inquirer.prompt([{ type: "confirm", name: "rebuild", @@ -502,20 +532,24 @@ Notes: ? "Local (recommended; relay + registry in the kind cluster alongside the sandbox)" : "Local (recommended; spin up relay + registry in Docker on this laptop)"; - const { default: inquirer } = await import("inquirer"); - const { meshSource } = await inquirer.prompt([{ - type: "list", - name: "meshSource", - message: "Where should the mesh live?", - default: "local", - choices: [ - { - name: localLabel, - value: "local", - }, - { name: remoteLabel, value: "remote" }, - ], - }]); + let meshSource = "local"; + if (process.stdin.isTTY) { + const { default: inquirer } = await import("inquirer"); + const ans = await inquirer.prompt([{ + type: "list", + name: "meshSource", + message: "Where should the mesh live?", + default: "local", + choices: [ + { + name: localLabel, + value: "local", + }, + { name: remoteLabel, value: "remote" }, + ], + }]); + meshSource = ans.meshSource; + } if (meshSource === "remote") { if (!cachedRegistryUrl && !aksAvailable) { @@ -572,7 +606,7 @@ Notes: addChannel("telegram", "telegram-token", "Telegram"); addChannel("slack", "slack-token", "Slack"); addChannel("discord", "discord-token", "Discord"); - if (available.length > 0) { + if (available.length > 0 && process.stdin.isTTY) { const { default: inquirer } = await import("inquirer"); const { picked } = await inquirer.prompt([{ type: "checkbox", @@ -617,6 +651,7 @@ Notes: agtRepo: options.agtRepo ?? process.env.KARS_AGT_REPO ?? DEFAULT_AGT_REPO, noMesh: options.noMesh === true, globalRegistry: typeof options.globalRegistry === "string" ? options.globalRegistry : undefined, + releaseVersion: releaseMode ? releaseVersion : undefined, }); return; } catch (e) { @@ -648,13 +683,19 @@ Notes: // machines can `kars dev --build` without first cloning AGT by // hand. Explicit --agt-repo / $KARS_AGT_REPO still win. let agtRepo: string; - try { - agtRepo = await ensureAgtRepo(options.agtRepo, repoRoot); - } catch (e: unknown) { + if (releaseMode) { + // Release mode pulls the published relay/registry images — no AGT + // checkout needed. Keep a harmless default so later refs don't NPE. agtRepo = options.agtRepo ?? process.env.KARS_AGT_REPO ?? DEFAULT_AGT_REPO; - if (meshProvider === "agt" && options.build) { - console.error(chalk.red(`\n Auto-cloning AGT failed:\n ${(e as Error).message}\n`)); - process.exit(1); + } else { + try { + agtRepo = await ensureAgtRepo(options.agtRepo, repoRoot); + } catch (e: unknown) { + agtRepo = options.agtRepo ?? process.env.KARS_AGT_REPO ?? DEFAULT_AGT_REPO; + if (meshProvider === "agt" && options.build) { + console.error(chalk.red(`\n Auto-cloning AGT failed:\n ${(e as Error).message}\n`)); + process.exit(1); + } } } if (meshProvider === "agt" && options.build) { @@ -783,7 +824,29 @@ Notes: stepper.step("Resolving sandbox image..."); let imageExists = false; - if (!options.build) { + if (releaseMode) { + stepper.update(`Pulling published sandbox image (${image})...`); + try { + // openclaw-sandbox is published multi-arch (amd64 + arm64); pull + // the HOST architecture so Apple Silicon gets the native arm64 + // image (the amd64 image crashes under Rosetta with + // rt_tgsigqueueinfo). dockerPlatform = linux/. + await execa("docker", ["pull", "--platform", dockerPlatform, image], { stdio: "inherit" }); + imageExists = true; + } catch { + stepper.fail("Could not pull published sandbox image"); + console.log(chalk.yellow(` + Failed to pull ${chalk.bold(image)}. + + Verify the release tag exists and the package is public: + ${chalk.cyan(`docker pull ${image}`)} + + Browse tags: https://github.com/Azure/kars/pkgs/container/openclaw-sandbox +`)); + process.exit(1); + } + } + if (!options.build && !releaseMode) { stepper.update("Checking for sandbox image..."); try { const { stdout: cachedArch } = await execa("docker", [ @@ -805,7 +868,7 @@ Notes: } } - if (options.build || !imageExists) { + if (!releaseMode && (options.build || !imageExists)) { const baseImage = options.baseImage; // Check if Azure Linux base image exists locally, pull if not @@ -1090,6 +1153,28 @@ Notes: // Local registry mode — deploy AGT relay/registry locally stepper.step("Starting mesh infrastructure (agt)..."); + // Release mode: pull the published relay + registry and tag them + // as the local :dev names the run/build steps below expect. This + // satisfies the build-fallback's haveImage() checks so nothing is + // built and the AGT checkout is never needed. + if (releaseMode) { + for (const [name, devTag] of [ + ["kars-agentmesh-relay", "agentmesh-relay:dev"], + ["kars-agentmesh-registry", "agentmesh-registry:dev"], + ] as const) { + const ref = releaseImage(name, releaseVersion!); + stepper.update(`Pulling published ${name}...`); + try { + await execa("docker", ["pull", "--platform", "linux/amd64", ref], { stdio: "pipe" }); + await execa("docker", ["tag", ref, devTag], { stdio: "pipe" }); + } catch { + stepper.fail(`Could not pull ${ref}`); + console.log(chalk.yellow(`\n Failed to pull ${chalk.bold(ref)}. Verify the release tag exists and the package is public.\n`)); + process.exit(1); + } + } + } + // Helper: check if a docker image exists locally async function haveImage(tag: string): Promise { try { diff --git a/cli/src/commands/dev/local-k8s.ts b/cli/src/commands/dev/local-k8s.ts index 81a902d4f..4dd1ccbc9 100644 --- a/cli/src/commands/dev/local-k8s.ts +++ b/cli/src/commands/dev/local-k8s.ts @@ -84,6 +84,15 @@ export interface LocalK8sOptions { * is wired to talk to this URL instead. */ globalRegistry?: string; + /** + * When set (e.g. "v0.1.0-interim.10"), pull the published, signed images + * for that release from ghcr.io/azure and load them into kind instead of + * building anything from source. Mirrors `kars dev --release` for the + * docker target. Requires multi-arch published images on arm64 kind nodes + * (relay/registry must be multi-arch — amd64-only would "exec format + * error" on an Apple Silicon kind node). + */ + releaseVersion?: string; } /** @@ -1034,48 +1043,70 @@ async function deployAgentMesh( meshProvider: "agt", agtRepo: string | undefined, archToken: string, + releaseVersion?: string, ): Promise { void meshProvider; const platform = `linux/${archToken}`; const localTag = (component: "relay" | "registry"): string => `agentmesh-${component}-agt:dev`; - - // ── Build relay + registry images locally (AGT Python) ──────────── - if (!agtRepo) { - throw new Error( - "--mesh-provider=agt requires --agt-repo or $KARS_AGT_REPO pointing at an agent-governance-toolkit checkout.\n" + - " Clone it: git clone https://github.com/microsoft/agent-governance-toolkit", - ); - } - const agtDockerfile = path.join( - agtRepo, - "agent-governance-python/agent-mesh/docker/Dockerfile", - ); - if (!existsSync(agtDockerfile)) { - throw new Error( - `AGT Dockerfile not found at ${agtDockerfile}\n` + - ` Clone it: git clone https://github.com/microsoft/agent-governance-toolkit ${agtRepo}\n` + - ` Or pass --agt-repo / set $KARS_AGT_REPO if you already have it elsewhere.`, - ); - } - for (const component of ["relay", "registry"] as const) { - console.log(chalk.dim(` Building agentmesh-${component} (AGT Python)…`)); - await execa( - tools.runtime, - [ - "build", - "--platform", - platform, - "--build-arg", - `COMPONENT=${component}`, - "-t", - localTag(component), - "-f", - agtDockerfile, - agtRepo, - ], - { stdio: "inherit" }, + const releaseMode = + typeof releaseVersion === "string" && releaseVersion.length > 0; + + if (releaseMode) { + // ── Release: pull published relay/registry + tag as local dev tags ── + // The published images must be multi-arch — an amd64-only relay/registry + // would "exec format error" on an arm64 kind node (Apple Silicon). + for (const component of ["relay", "registry"] as const) { + const remote = `ghcr.io/azure/kars-agentmesh-${component}:${releaseVersion}`; + console.log(chalk.dim(` Pulling published agentmesh-${component} (${releaseVersion})…`)); + try { + await execa(tools.runtime, ["pull", remote], { stdio: "pipe" }); + await execa(tools.runtime, ["tag", remote, localTag(component)], { stdio: "pipe" }); + } catch (e) { + throw new Error( + `Failed to pull published agentmesh-${component} (${remote}): ${(e as Error).message}\n` + + ` Verify the release tag exists and the package is public + multi-arch (arm64).`, + ); + } + } + } else { + // ── Build relay + registry images locally (AGT Python) ──────────── + if (!agtRepo) { + throw new Error( + "--mesh-provider=agt requires --agt-repo or $KARS_AGT_REPO pointing at an agent-governance-toolkit checkout.\n" + + " Clone it: git clone https://github.com/microsoft/agent-governance-toolkit", + ); + } + const agtDockerfile = path.join( + agtRepo, + "agent-governance-python/agent-mesh/docker/Dockerfile", ); + if (!existsSync(agtDockerfile)) { + throw new Error( + `AGT Dockerfile not found at ${agtDockerfile}\n` + + ` Clone it: git clone https://github.com/microsoft/agent-governance-toolkit ${agtRepo}\n` + + ` Or pass --agt-repo / set $KARS_AGT_REPO if you already have it elsewhere.`, + ); + } + for (const component of ["relay", "registry"] as const) { + console.log(chalk.dim(` Building agentmesh-${component} (AGT Python)…`)); + await execa( + tools.runtime, + [ + "build", + "--platform", + platform, + "--build-arg", + `COMPONENT=${component}`, + "-t", + localTag(component), + "-f", + agtDockerfile, + agtRepo, + ], + { stdio: "inherit" }, + ); + } } // ── Load images into kind ───────────────────────────────────────── @@ -1152,10 +1183,19 @@ async function deployAgentMesh( export async function runLocalK8s(opts: LocalK8sOptions): Promise { const stepper = new Stepper({ totalSteps: 14 }); + // Release mode: pull published, signed images from ghcr.io/azure and load + // them into kind instead of building anything from source. + const releaseMode = + typeof opts.releaseVersion === "string" && opts.releaseVersion.length > 0; + const RELEASE_REGISTRY = "ghcr.io/azure"; + const releaseRef = (name: string): string => + `${RELEASE_REGISTRY}/${name}:${opts.releaseVersion}`; + // Fail fast if the user is running outside the kars checkout. - // `--target local-k8s` rebuilds the controller, router, and sandbox - // images from local source via docker build, which needs the repo - // root (Cargo.toml + deploy/helm/kars + sandbox-images/...). + // `--target local-k8s` needs the repo root for the Helm chart + // (deploy/helm/kars) and the agentmesh manifest (deploy/agentmesh-agt.yaml), + // and — unless --release pulls published images — it also builds the + // controller, router, and sandbox images from local source. // Without this check the failure surfaces only AFTER kind cluster // creation (10+ seconds wasted, orphaned cluster left behind). if (!opts.noBuild) { @@ -1267,7 +1307,7 @@ export async function runLocalK8s(opts: LocalK8sOptions): Promise { // `kars push` (which builds for AKS) would crash openclaw under // Rosetta on an Apple Silicon laptop with // `rt_tgsigqueueinfo failed in pend_signal`. - if (!opts.noBuild) { + if (!opts.noBuild && !releaseMode) { const archToken = hostDockerArch(); const repoRootForBuild = findRepoRoot(process.cwd()); stepper.step(`Checking image arch (host=${archToken})…`); @@ -1285,6 +1325,35 @@ export async function runLocalK8s(opts: LocalK8sOptions): Promise { } } + // Release mode: pull the published images and tag them as the local "dev" + // aliases the load step below expects (openclaw-sandbox:dev, + // kars-controller:dev, kars-inference-router:dev). The existing + // alias→canonical retag + `kind load` plumbing then loads them unchanged. + // Pull host-arch (kind nodes are arm64 on Apple Silicon); the published + // controller/router/sandbox are multi-arch so `docker pull` selects the + // right variant automatically. + if (releaseMode) { + stepper.step(`Pulling published images (${opts.releaseVersion})…`); + const pulls: { remote: string; devTag: string }[] = [ + { remote: releaseRef("openclaw-sandbox"), devTag: opts.image }, + { remote: releaseRef("kars-controller"), devTag: "kars-controller:dev" }, + { remote: releaseRef("kars-inference-router"), devTag: "kars-inference-router:dev" }, + ]; + for (const { remote, devTag } of pulls) { + try { + await execa(tools.runtime, ["pull", remote], { stdio: "pipe" }); + await execa(tools.runtime, ["tag", remote, devTag], { stdio: "pipe" }); + } catch (e) { + throw new Error( + `Failed to pull published image ${remote}: ${(e as Error).message}\n` + + ` Verify the release tag exists and the package is public:\n` + + ` https://github.com/Azure/kars/pkgs/container/${remote.split("/").pop()?.split(":")[0]}`, + ); + } + } + stepper.done(`pulled ${pulls.length} published image(s) for ${opts.releaseVersion}`); + } + // The values-local-dev overlay pins all images to local "dev" tags // with imagePullPolicy=Never, so we MUST load all three images that // the chart references — sandbox, controller, inference-router. @@ -1301,7 +1370,7 @@ export async function runLocalK8s(opts: LocalK8sOptions): Promise { // will then ImagePullBackOff and the operator's pod-events display // surfaces a clear error pointing at the missing build). stepper.step("Loading kars images into the kind cluster…"); - if (opts.noBuild) { + if (opts.noBuild && !releaseMode) { stepper.done("skipped image load (--no-build)"); } else { // `target` = the canonical image name the controller looks for @@ -1525,6 +1594,7 @@ export async function runLocalK8s(opts: LocalK8sOptions): Promise { meshProvider, opts.agtRepo, archToken, + opts.releaseVersion, ); stepper.done(`agentmesh-${meshProvider} ready`); } diff --git a/cli/src/commands/operator.ts b/cli/src/commands/operator.ts index 37b79b043..f0a649dca 100644 --- a/cli/src/commands/operator.ts +++ b/cli/src/commands/operator.ts @@ -27,7 +27,7 @@ import type { ClusterHealth, MeshHealth, } from "./operator/types.js"; -import { timeSince, kctl, platformTag, clusterOriginTag } from "./operator/helpers.js"; +import { timeSince, kctl, platformTag, clusterOriginTag, sandboxKey } from "./operator/helpers.js"; import { fetchSandboxes } from "./operator/fetchers/sandboxes.js"; import { fetchEgressDomains, @@ -310,7 +310,7 @@ async function startDashboard(refreshInterval: number, kubeContext?: string, dev const idx = (agentTable as any).rows?.selected ?? 0; const sb = sandboxes[idx]; if (!sb) return []; - return egressByAgent.get(sb.name) || []; + return egressByAgent.get(sandboxKey(sb)) || []; } /** Total egress domain count across all agents. */ @@ -547,21 +547,21 @@ async function startDashboard(refreshInterval: number, kubeContext?: string, dev egressByAgent = new Map(); for (let i = 0; i < running.length; i++) { const r = settled[i]; - if (r.status === "fulfilled") egressByAgent.set(running[i].name, r.value); + if (r.status === "fulfilled") egressByAgent.set(sandboxKey(running[i]), r.value); } }), Promise.allSettled(running.map((s) => fetchSecurityState(s, s.kubeContext ?? kubeContext))).then((settled) => { securityStates = new Map(); for (let i = 0; i < running.length; i++) { const r = settled[i]; - if (r.status === "fulfilled") securityStates.set(r.value.sandbox, r.value); + if (r.status === "fulfilled") securityStates.set(sandboxKey(running[i]), r.value); } }), ); } else { // Fast AGT-only poll on non-detail cycles to keep mesh data alive promises.push( - Promise.allSettled(running.map((s) => fetchAgtQuick(s, securityStates.get(s.name), s.kubeContext ?? kubeContext))), + Promise.allSettled(running.map((s) => fetchAgtQuick(s, securityStates.get(sandboxKey(s)), s.kubeContext ?? kubeContext))), ); } @@ -720,7 +720,7 @@ async function startDashboard(refreshInterval: number, kubeContext?: string, dev const idx = (agentTable as any).rows?.selected ?? 0; const sb = sandboxes[idx]; if (!sb) return; - const sec = securityStates.get(sb.name); + const sec = securityStates.get(sandboxKey(sb)); const mode = sec?.egressMode; if (mode === "learning") { await enforceEgress(sb); diff --git a/cli/src/commands/operator/helpers.test.ts b/cli/src/commands/operator/helpers.test.ts new file mode 100644 index 000000000..ec5143a50 --- /dev/null +++ b/cli/src/commands/operator/helpers.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from "vitest"; +import { sandboxKey } from "./helpers.js"; + +describe("sandboxKey", () => { + // Regression: the operator's per-sandbox maps (securityStates, + // egressByAgent) were keyed by bare `name`. The same agent name can exist + // simultaneously as a docker container, a kind pod, and an AKS pod — so a + // stale/empty same-named entry overwrote the live one and the operator + // showed no DID / no audit for the shadowed agent (observed for "analyst" + // when docker + kind + aks all had an "analyst"). The key must disambiguate + // by origin (kubeContext for K8s, runtime for docker) + namespace. + it("disambiguates same-named agents across runtimes and clusters", () => { + const docker = sandboxKey({ + name: "analyst", + namespace: "kars-analyst", + runtime: "docker", + }); + const kind = sandboxKey({ + name: "analyst", + namespace: "kars-analyst", + runtime: "aks", + kubeContext: "kind-kars-dev", + }); + const aks = sandboxKey({ + name: "analyst", + namespace: "kars-analyst", + runtime: "aks", + kubeContext: "kars-aks", + }); + + const keys = new Set([docker, kind, aks]); + expect(keys.size).toBe(3); + expect(docker).not.toBe(kind); + expect(kind).not.toBe(aks); + expect(docker).not.toBe(aks); + }); + + it("is stable for the same sandbox identity", () => { + const a = sandboxKey({ name: "viz", namespace: "kars-viz", runtime: "docker" }); + const b = sandboxKey({ name: "viz", namespace: "kars-viz", runtime: "docker" }); + expect(a).toBe(b); + }); + + it("falls back to runtime then 'local' when kubeContext is absent", () => { + expect(sandboxKey({ name: "x", namespace: "kars-x", runtime: "docker" })).toContain("docker"); + expect(sandboxKey({ name: "x", namespace: "kars-x" })).toContain("local"); + }); +}); diff --git a/cli/src/commands/operator/helpers.ts b/cli/src/commands/operator/helpers.ts index 61dad2249..58bccafba 100644 --- a/cli/src/commands/operator/helpers.ts +++ b/cli/src/commands/operator/helpers.ts @@ -117,6 +117,27 @@ export function platformLabel(s: { return "cloud"; } +/** + * Stable, globally-unique key for a sandbox across runtimes and clusters. + * + * Sandbox NAMES are not unique: the same agent name (e.g. "analyst") can + * exist simultaneously as a docker container, a kind-cluster pod, and an + * AKS pod. Keying per-sandbox maps (securityStates, egressByAgent) by bare + * `name` causes these to collide — a stale/empty same-named entry overwrites + * the live one, so the operator shows no DID / no audit for the shadowed + * agent. Compose the origin (kubeContext for K8s, runtime for docker) with + * the namespace and name to disambiguate. + */ +export function sandboxKey(s: { + name: string; + namespace: string; + runtime?: "docker" | "aks"; + kubeContext?: string; +}): string { + const origin = s.kubeContext ?? s.runtime ?? "local"; + return `${origin}::${s.namespace}::${s.name}`; +} + /** * Compact " " used in the agent-table Cluster column * and the CRD snapshot status line. Docker sandboxes show just "D" diff --git a/cli/src/commands/operator/render/security.ts b/cli/src/commands/operator/render/security.ts index c7f03d568..a942230db 100644 --- a/cli/src/commands/operator/render/security.ts +++ b/cli/src/commands/operator/render/security.ts @@ -8,6 +8,7 @@ // an explicit context object. import type { SandboxInfo, SecurityState } from "../types.js"; +import { sandboxKey } from "../helpers.js"; interface BlessedBox { setContent(content: string): void; @@ -36,7 +37,7 @@ export function renderSecurity(ctx: SecurityRenderContext): void { return; } - const sec = securityStates.get(sb.name); + const sec = securityStates.get(sandboxKey(sb)); if (!sec) { securityBox.setContent(`{bold}${sb.name}{/}\n\n{gray-fg}Polling...{/}`); return; @@ -129,7 +130,7 @@ export function renderAGTFull( sandboxes: SandboxInfo[], securityStates: Map, ): string { - const sec = securityStates.get(sb.name); + const sec = securityStates.get(sandboxKey(sb)); if (!sec) return `{bold}${sb.name}{/}\n{gray-fg}Polling...{/}`; if (!sec.agtEnabled) return "{gray-fg}AGT not enabled{/}\n{gray-fg}Use --governance flag{/}"; @@ -280,7 +281,7 @@ export function renderAGT(ctx: SecurityRenderContext): void { return; } - const sec = securityStates.get(sb.name); + const sec = securityStates.get(sandboxKey(sb)); if (!sec) { agtPanel.setContent(`{bold}${sb.name}{/}\n{gray-fg}Polling...{/}`); return; diff --git a/cli/src/commands/operator/render/topology.ts b/cli/src/commands/operator/render/topology.ts index 39e3dd597..af6d86f15 100644 --- a/cli/src/commands/operator/render/topology.ts +++ b/cli/src/commands/operator/render/topology.ts @@ -7,7 +7,7 @@ // `securityStates`, and `topologyBox` become an explicit context object. import type { SandboxInfo, SecurityState } from "../types.js"; -import { platformTag } from "../helpers.js"; +import { platformTag, sandboxKey } from "../helpers.js"; interface BlessedBox { setContent(content: string): void; @@ -106,7 +106,7 @@ export function renderTopology(ctx: TopologyRenderContext): void { } for (const p of parents) { - const sec = securityStates.get(p.name); + const sec = securityStates.get(sandboxKey(p)); const icon = statusIcon(p.health); const mode = sec?.egressMode === "enforcing" ? "{green-fg}enforce{/}" : sec?.egressMode === "learning" ? "{yellow-fg}learn{/}" : ""; @@ -130,7 +130,7 @@ export function renderTopology(ctx: TopologyRenderContext): void { if (subs.length === 1) { // Single child — straight line down lines.push(" ".repeat(parentCenter) + "│"); - const childSec = securityStates.get(subs[0].name); + const childSec = securityStates.get(sandboxKey(subs[0])); const ci = statusIcon(subs[0].health); const cMesh = childSec ? `↑${childSec.agtMeshSent} ↓${childSec.agtMeshReceived}` : ""; const cBox = makeBox(subs[0].name, ci, `${platformTag(subs[0])} ${subs[0].model}`, cMesh); @@ -175,7 +175,7 @@ export function renderTopology(ctx: TopologyRenderContext): void { // Render child boxes side-by-side const childBoxes: string[][] = []; for (const s of subs) { - const childSec = securityStates.get(s.name); + const childSec = securityStates.get(sandboxKey(s)); const ci = statusIcon(s.health); const cMesh = childSec ? `↑${childSec.agtMeshSent} ↓${childSec.agtMeshReceived}` : ""; childBoxes.push(makeBox(s.name, ci, `${platformTag(s)} ${s.model}`, cMesh)); @@ -193,7 +193,7 @@ export function renderTopology(ctx: TopologyRenderContext): void { // Peer-to-peer mesh links const peerLinks: string[] = []; for (const s of subs) { - const childSec = securityStates.get(s.name); + const childSec = securityStates.get(sandboxKey(s)); const peers = childSec?.agtTrustScores.filter((t) => { if (isBogusLegacyKey(t.agent)) return false; const a = shortAgentName(t.agent); diff --git a/docs/getting-started.md b/docs/getting-started.md index 2c99481aa..6ed3dc926 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -15,15 +15,19 @@ The sandbox YAML you wrote in step 1 runs unchanged in step 2. That is the whole | For | You need | |---|---| -| **Local mode (GitHub Copilot — recommended)** | Docker Desktop (or any OCI runtime), Node.js 22+, Rust 1.88+, an active **GitHub Copilot** seat (Individual / Business / Enterprise). One device-code OAuth login at signup. **No Azure account, no PAT, no key files.** | -| Local mode (Foundry / Azure OpenAI) | Docker Desktop, Node.js 22+, Rust 1.88+, an Azure AI Foundry (or Azure OpenAI) endpoint + deployment + key. | -| Local mode (GitHub Models) | Docker Desktop, Node.js 22+, Rust 1.88+, a GitHub PAT with `models:read` scope. **No Azure account needed.** | +| **Fastest — published images (`kars dev --release`)** | Docker Desktop (or any OCI runtime) · Node.js 22+. **No Rust, no AGT checkout, no local image build.** Plus an inference provider (GitHub Copilot seat — easiest — or a Foundry/Azure OpenAI deployment, or a GitHub PAT with `models:read`). | +| Local mode from source (GitHub Copilot — recommended) | Docker Desktop (or any OCI runtime), Node.js 22+, Rust 1.88+, an active **GitHub Copilot** seat (Individual / Business / Enterprise). One device-code OAuth login at signup. **No Azure account, no PAT, no key files.** | +| Local mode from source (Foundry / Azure OpenAI) | Docker Desktop, Node.js 22+, Rust 1.88+, an Azure AI Foundry (or Azure OpenAI) endpoint + deployment + key. | +| Local mode from source (GitHub Models) | Docker Desktop, Node.js 22+, Rust 1.88+, a GitHub PAT with `models:read` scope. **No Azure account needed.** | | AKS mode | The above, plus the [Azure CLI](https://learn.microsoft.com/cli/azure/) (`az`), [`kubectl`](https://kubernetes.io/docs/tasks/tools/), [Helm 3.14+](https://helm.sh/), and an Azure subscription where you can create resource groups. | -> **AGT mesh prerequisite (sub-agent spawning).** Inter-agent E2E +> **Just want it running?** Use the [fastest path](#10-fastest-path--no-compile-published-images-) — `npm i -g ` then `kars dev --release `. Everything below about building from source and cloning AGT is **only** for contributors hacking on kars itself. + +> **AGT mesh prerequisite — source builds only.** Inter-agent E2E > messaging uses the [Microsoft Agent Governance Toolkit](https://github.com/microsoft/agent-governance-toolkit) -> relay + registry, which kars builds from source locally. Clone it -> next to your kars repo before the first `kars dev` / `kars up`: +> relay + registry. With `kars dev --release` these are **pulled as +> published images** — nothing to clone. Only when you build from source +> does kars build them locally; clone AGT next to your kars repo first: > ```bash > git clone https://github.com/microsoft/agent-governance-toolkit ~/agent-governance-toolkit > ``` @@ -99,6 +103,45 @@ If you'd rather skip provisioning by hand, jump to **[Step 2 — Deploy to AKS]( ## Step 1 — Local (five minutes) +### 1.0 Fastest path — no compile (published images, macOS & Linux) ⭐ + +The quickest way to a running agent **on any host with Docker — amd64 Linux, +Intel Mac, or Apple Silicon (M-series)**: install the CLI from a published +release and launch from **pre-built, cosign-signed images**. No Rust toolchain, +no AGT checkout, no waiting on a local image build. + +**You need only:** Docker (running) · Node.js 22+. + +```bash +# 1. Install the kars CLI from the latest interim release (one command) +npm i -g https://github.com/Azure/kars/releases/download/v0.1.0-interim.9/kars-cli-0.1.0.tgz + +# 2. Launch a sandbox from the published images for that same release +kars dev --release v0.1.0-interim.9 +``` + +That's it. `--release` pulls the `openclaw-sandbox` agent image plus the AGT +mesh relay + registry and runs them — skipping the AGT clone and every local +build. The first run downloads the images once (a few minutes); afterwards it +launches near-instantly. On first launch you'll pick an inference provider — +see the [provider picker](#12-launch-a-sandbox) below (**GitHub Copilot** is +the easiest: one device-code login, no Azure account). + +> **Apple Silicon (M-series) Macs:** fully supported. The published images are +> multi-arch (`linux/amd64` + `linux/arm64`, built on native arm64 runners) and +> `kars dev --release` automatically pulls the variant matching your host +> architecture — no Rosetta, no extra flags. Verified end-to-end on arm64: +> the full multi-agent exec-brief scenario (parent + 3 mesh sub-agents, +> E2E-encrypted relay) passes on a stock M-series Mac. + +> **Use matching versions.** The `npm i -g …` tarball URL and the `--release` +> flag must reference the **same** tag. Browse the +> [Releases page](https://github.com/Azure/kars/releases) for the newest +> `v0.1.0-interim.N`. + +Want to hack on the controller / router / plugin? Build from source — +**[1.1 Build the CLI](#11-build-the-cli)**. + ### 1.1 Build the CLI ```bash diff --git a/docs/internal/security-audits/2026-06-23-cli-dev-release-published-images.md b/docs/internal/security-audits/2026-06-23-cli-dev-release-published-images.md new file mode 100644 index 000000000..54413503b --- /dev/null +++ b/docs/internal/security-audits/2026-06-23-cli-dev-release-published-images.md @@ -0,0 +1,83 @@ +# Security Audit — `kars dev --release` (run from published images) + +PR: Azure/kars#408 (branch `release/published-binaries`) + +## Scope + +Adds a `--release ` flag to `kars dev` (`cli/src/commands/dev.ts`) — a +capability-introducing path under `cli/src/commands/`. In release mode the +CLI: + +- pulls `ghcr.io/azure/openclaw-sandbox:` instead of building the + sandbox image locally, +- pulls `ghcr.io/azure/kars-agentmesh-{relay,registry}:` and tags them + `agentmesh-{relay,registry}:dev` for the existing run path, +- **skips** the AGT auto-clone and every local image build, +- forces `--build` off and rejects `--build`/`--target local-k8s` combos. + +It also adds the interim public release workflow +(`.github/workflows/release-public-interim.yml`) that produces those images. +This audit covers the **CLI capability change**; the image build/publish +supply-chain posture is covered inline below. + +## Threat model + +### T1: `--release` pulls a tampered/malicious image instead of the real one (MITIGATED) +The pulled images are the **same Dockerfiles and sources** as the local +build, pre-built in the org's protected CI. Every image is **cosign keyless +signed** (Sigstore Rekor) **and** carries a **GitHub build-provenance +attestation** (`gh attestation verify`) **plus an SPDX SBOM**. A consumer can +verify provenance before trusting an image. The release tag and the CLI +version are required to match (documented), reducing confusion-style +substitution. + +### T2: `--release` weakens sandbox isolation vs the from-source build (NOT A REGRESSION) +The runtime security posture is identical: `openclaw-sandbox` is the *same* +image the from-source path builds and the controller launches — egress-guard +init (iptables, UID-1000 confinement), the UID 1000/1001 split, seccomp, and +the router L7 allow-list are all baked into the image, not added by the CLI. +`--release` changes only the image **source** (pull vs local build), never +the container's security context or the dev-mode trust boundary. + +### T3: Supply-chain — compromised CI publishes a bad image under a release tag (MITIGATED, residual accepted) +The release workflow runs in the org's GitHub Actions with keyless OIDC +signing; signatures + provenance are recorded publicly in Rekor. A consumer +verifies with `cosign verify` / `gh attestation verify`. Residual risk +(a fully compromised org CI) is the same as for any pre-built artefact and is +the reason the **ESRP-signed** path (MCR + npmjs + crates.io) supersedes this +interim GHCR release later — tracked in `docs/PUBLISHING.md`. + +### T4: `--release` skips the AGT clone — does it skip a security control? (NO) +The AGT clone only provided *source to build relay/registry locally*. In +release mode those are pulled as signed images instead. No governance, +policy, identity, or crypto control is bypassed; the relay forwards opaque +encrypted frames and the registry does prekey/discovery exactly as before. + +## What this audit does NOT cover + +- The **contents** of the published images (the controller/router/sandbox + build posture is covered by their respective prior audits; the patched AGT + SDK is covered by the upstream PR microsoft/agent-governance-toolkit#3128 + + its audit doc). +- The **AKS** `kars up --release` path — not implemented in this change + (`--release` is docker-target only and errors clearly on `local-k8s`). +- ESRP-signed public distribution (future; supersedes the interim release). + +## Test posture + +- `cli` typecheck clean (`tsc --noEmit`); `oxlint` 0 errors; **798 CLI tests + pass** (`vitest`), including `dev.test.ts`. +- `--release` flow validated as far as the authoring environment allows: + loads cached creds, skips the rebuild prompt + AGT clone + local build, and + resolves the correct `ghcr.io/azure/openclaw-sandbox:` ref before the + pull. Full pull→run→agent-response e2e is pending verification in a + working-`docker pull` environment (the authoring host had a local docker + egress block); the exec-brief harness is wired via `KARS_RELEASE`. +- Release pipeline proven green end-to-end: `v0.1.0-interim.6` published with + all images cosign-signed + attested + SBOM'd; the published CLI tarball + carries `--release` (verified). + +## Sign-offs + +Signed-off-by: Pal Lakatos +Signed-off-by: Copilot <223556219+Copilot@users.noreply.github.com> diff --git a/runtimes/anthropic/pyproject.toml b/runtimes/anthropic/pyproject.toml index 71d759947..baf79975a 100644 --- a/runtimes/anthropic/pyproject.toml +++ b/runtimes/anthropic/pyproject.toml @@ -39,8 +39,7 @@ dependencies = [ "httpx>=0.27,<1", # AGT-Python packages (built from upstream by runtimes/build-agt-wheels.sh # and `pip install`-ed from runtimes/wheels/ in the Dockerfile). - "a2a_agentmesh>=3.3.0,<4", - "agent_sandbox>=3.3.0,<4", + "a2a_agentmesh>=3.3.0,<5", ] [project.optional-dependencies] diff --git a/runtimes/langgraph/pyproject.toml b/runtimes/langgraph/pyproject.toml index 4d1d6430d..3e75cee4e 100644 --- a/runtimes/langgraph/pyproject.toml +++ b/runtimes/langgraph/pyproject.toml @@ -41,8 +41,7 @@ dependencies = [ "httpx>=0.27,<1", # AGT-Python packages (built from upstream by runtimes/build-agt-wheels.sh # and `pip install`-ed from runtimes/wheels/ in the Dockerfile). - "a2a_agentmesh>=3.3.0,<4", - "agent_sandbox>=3.3.0,<4", + "a2a_agentmesh>=3.3.0,<5", ] [project.optional-dependencies] diff --git a/runtimes/maf-python/pyproject.toml b/runtimes/maf-python/pyproject.toml index a46bbb84a..3599ff159 100644 --- a/runtimes/maf-python/pyproject.toml +++ b/runtimes/maf-python/pyproject.toml @@ -26,10 +26,15 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - # Microsoft's unified Agent Framework Python SDK - # (https://github.com/microsoft/agent-framework). PyPI distribution - # name: `agent-framework`. - "agent-framework>=1.0.0b1", + # Microsoft's Agent Framework Python SDK + # (https://github.com/microsoft/agent-framework). We depend on the + # split sub-packages rather than the `agent-framework` meta-package: + # the meta-package's transitive graph is too large for pip's resolver + # (ResolutionTooDeep), whereas core + openai resolve in seconds. + # agent-framework-core → `agent_framework` (tool, Executor, …) + # agent-framework-openai → `agent_framework.openai.OpenAIChatClient` + "agent-framework-core>=1.8,<2", + "agent-framework-openai>=1.8,<2", "openai>=1.50,<2", "azure-identity>=1.17,<2", "opentelemetry-api>=1.27,<2", @@ -40,8 +45,7 @@ dependencies = [ # AGT-Python packages — built locally from upstream by # `runtimes/build-agt-wheels.sh`. See runtimes/openai-agents/README.md # for the install pattern. - "a2a_agentmesh>=3.3.0,<4", - "agent_sandbox>=3.3.0,<4", + "a2a_agentmesh>=3.3.0,<5", ] [project.optional-dependencies] diff --git a/runtimes/maf-python/src/kars_runtime_maf_python/mesh_tools.py b/runtimes/maf-python/src/kars_runtime_maf_python/mesh_tools.py index cbdb688d8..75efc3cf4 100644 --- a/runtimes/maf-python/src/kars_runtime_maf_python/mesh_tools.py +++ b/runtimes/maf-python/src/kars_runtime_maf_python/mesh_tools.py @@ -3,7 +3,7 @@ """ First-class AgentMesh tools for the Microsoft Agent Framework Python SDK. -Wraps :mod:`mesh` as ``agent_framework.ai_function``-decorated callables +Wraps :mod:`mesh` as ``agent_framework.tool``-decorated callables so MAF agents pick them up automatically. See ``runtimes/openai-agents/.../mesh_tools.py`` for the design rationale. """ @@ -38,15 +38,15 @@ def build_mesh_tools() -> List[Any]: - """Return ``ai_function``-decorated callables for the mesh tools.""" - from agent_framework import ai_function # type: ignore + """Return ``@tool``-decorated callables for the mesh tools.""" + from agent_framework import tool # type: ignore - @ai_function(name="mesh_inbox", description=_MESH_INBOX_DESCRIPTION) + @tool(name="mesh_inbox", description=_MESH_INBOX_DESCRIPTION) async def mesh_inbox() -> str: msgs = receive_messages() return json.dumps(msgs) - @ai_function(name="mesh_send", description=_MESH_SEND_DESCRIPTION) + @tool(name="mesh_send", description=_MESH_SEND_DESCRIPTION) async def mesh_send(target_agent: str, content: str, skill_id: str = "chat") -> str: ack = send_message(target_agent, content, skill_id=skill_id) return json.dumps(ack) diff --git a/runtimes/openai-agents/pyproject.toml b/runtimes/openai-agents/pyproject.toml index d159efa14..617143c07 100644 --- a/runtimes/openai-agents/pyproject.toml +++ b/runtimes/openai-agents/pyproject.toml @@ -40,8 +40,7 @@ dependencies = [ # them installed (or a --find-links source) resolves cleanly. If they are # not available in the active Python environment install them via: # pip install --find-links ../wheels a2a_agentmesh agent_sandbox - "a2a_agentmesh>=3.3.0,<4", - "agent_sandbox>=3.3.0,<4", + "a2a_agentmesh>=3.3.0,<5", ] [project.optional-dependencies] diff --git a/runtimes/pydantic-ai/pyproject.toml b/runtimes/pydantic-ai/pyproject.toml index 9f39a4b2a..9d6093bca 100644 --- a/runtimes/pydantic-ai/pyproject.toml +++ b/runtimes/pydantic-ai/pyproject.toml @@ -39,8 +39,7 @@ dependencies = [ "httpx>=0.27,<1", # AGT-Python packages (built from upstream by runtimes/build-agt-wheels.sh # and `pip install`-ed from runtimes/wheels/ in the Dockerfile). - "a2a_agentmesh>=3.3.0,<4", - "agent_sandbox>=3.3.0,<4", + "a2a_agentmesh>=3.3.0,<5", ] [project.optional-dependencies] diff --git a/sandbox-images/openclaw/Dockerfile b/sandbox-images/openclaw/Dockerfile index 36fa1ab45..9a78b039f 100644 --- a/sandbox-images/openclaw/Dockerfile +++ b/sandbox-images/openclaw/Dockerfile @@ -83,7 +83,15 @@ ARG MESH_PROVIDER=vendored ARG AGT_SDK_TARBALL= COPY .agt-sdk/ /opt/kars-agt-sdk/ RUN cd /opt/kars-plugin && \ - if [ -n "$AGT_SDK_TARBALL" ] && [ -f "/opt/kars-agt-sdk/$AGT_SDK_TARBALL" ]; then \ + if [ -n "$AGT_SDK_TARBALL" ]; then \ + if [ ! -f "/opt/kars-agt-sdk/$AGT_SDK_TARBALL" ]; then \ + echo "::error:: AGT_SDK_TARBALL=$AGT_SDK_TARBALL was requested but" >&2; \ + echo " /opt/kars-agt-sdk/$AGT_SDK_TARBALL is missing from the build context." >&2; \ + echo " The patched POP-aware SDK would NOT be installed and the mesh" >&2; \ + echo " would silently fall back to the unpatched npm SDK (relay flood)." >&2; \ + echo " Stage the tarball into /.agt-sdk/ before building." >&2; \ + exit 1; \ + fi; \ echo "Installing AGT SDK from local tarball: $AGT_SDK_TARBALL" && \ npm install --omit=dev --ignore-scripts --no-save \ "/opt/kars-agt-sdk/$AGT_SDK_TARBALL" ; \ diff --git a/tools/e2e-harness/platforms/docker.sh b/tools/e2e-harness/platforms/docker.sh index 55f1e578b..183016b4d 100644 --- a/tools/e2e-harness/platforms/docker.sh +++ b/tools/e2e-harness/platforms/docker.sh @@ -33,10 +33,18 @@ # SKIP_DEV_BRINGUP — set to 1 to skip the `kars dev # --target docker` step if the container is # already up. +# KARS_RELEASE — when set (e.g. v0.1.0-interim.5), bring the +# sandbox up from PUBLISHED images via +# `kars dev --release ` (no local build, +# no AGT clone). Verifies the released path. set -euo pipefail -DOCKER_CONTAINER_NAME="${DOCKER_CONTAINER_NAME:-${SCENARIO_SANDBOX}}" +# `kars dev --name X` creates a docker container named `kars-X`. Keep the +# bare name for the `kars dev --name` flag, but use the `kars-`-prefixed name +# for every `docker exec` / `docker logs` / `docker ps` operation below. +KARS_DEV_NAME="${DOCKER_CONTAINER_NAME:-${SCENARIO_SANDBOX}}" +DOCKER_CONTAINER_NAME="kars-${KARS_DEV_NAME}" platform_preflight() { command -v docker >/dev/null || { log "ERR docker not on PATH"; exit 1; } @@ -57,8 +65,15 @@ platform_preflight() { # applied — docker mode requires the scenario to either preload # the bundles into the image or provide a docker-overlay/ dir # the helper sources. We do not pretend otherwise. + # KARS_RELEASE → pull published images instead of building. + release_args=() + if [ -n "${KARS_RELEASE:-}" ]; then + release_args=(--release "${KARS_RELEASE}") + log "released mode: kars dev --release ${KARS_RELEASE} (published images)" + fi kars dev --target docker \ - --name "${DOCKER_CONTAINER_NAME}" \ + --name "${KARS_DEV_NAME}" \ + "${release_args[@]}" \ >>"${OUT_DIR}/dev-bringup.log" 2>&1 || { log "ERR kars dev bring-up failed; tail of dev-bringup.log:" tail -n 80 "${OUT_DIR}/dev-bringup.log" >&2 || true diff --git a/tools/e2e-harness/platforms/local-k8s.sh b/tools/e2e-harness/platforms/local-k8s.sh index aaa80846e..212d48c54 100644 --- a/tools/e2e-harness/platforms/local-k8s.sh +++ b/tools/e2e-harness/platforms/local-k8s.sh @@ -53,9 +53,20 @@ platform_preflight() { elif kind get clusters 2>/dev/null | grep -qx "${KIND_CLUSTER_NAME}"; then log "kind cluster '${KIND_CLUSTER_NAME}' already exists — skipping bring-up" else - log "bringing up local-k8s via 'kars dev --target local-k8s --cluster-name ${KIND_CLUSTER_NAME} --once'" + # KARS_RELEASE= runs the published-images path (kars dev --release + # --target local-k8s) — pulls signed multi-arch images into kind + # instead of building from source. Requires multi-arch relay/registry + # on arm64 kind nodes. + local release_args=() + if [ -n "${KARS_RELEASE:-}" ]; then + release_args=(--release "${KARS_RELEASE}") + log "released mode: kars dev --release ${KARS_RELEASE} --target local-k8s (published images)" + else + log "bringing up local-k8s via 'kars dev --target local-k8s --cluster-name ${KIND_CLUSTER_NAME} --once'" + fi kars dev --target local-k8s \ --cluster-name "${KIND_CLUSTER_NAME}" \ + "${release_args[@]}" \ --once \ >>"${OUT_DIR}/dev-bringup.log" 2>&1 || { log "ERR kars dev bring-up failed; tail of dev-bringup.log:" diff --git a/tools/e2e-harness/scenarios/exec-brief/checks.py b/tools/e2e-harness/scenarios/exec-brief/checks.py index d2dc429a1..f9310673f 100644 --- a/tools/e2e-harness/scenarios/exec-brief/checks.py +++ b/tools/e2e-harness/scenarios/exec-brief/checks.py @@ -123,15 +123,50 @@ def check_chart(ctx: "Context") -> tuple[bool, str]: def check_relay_pairs(ctx: "Context") -> tuple[bool, str]: - # Encrypted blobs flow over a persistent /ws connection and the OpenClaw - # plugin's `log.info("AGT relay: sent to X")` does NOT reach kubectl logs - # (it goes to the in-pod gateway log file). So we fall back to live - # querying each sub-agent's inference-router for chat_completions activity - # — if all three subs processed at least one chat turn, mesh delivery is - # confirmed for the parent→sib direction. For sib→sib we count the unique - # *non-self* peer DIDs each sub-agent's plugin looked up in the registry. + # Sibling-to-sibling mesh routing is proven by the per-agent gateway logs + # the harness collects into OUT_DIR on EVERY platform (docker exec / kubectl + # logs alike): `-gateway.log`. Each sub-agent records its own outbound + # mesh sends there, e.g.: + # AGT sub-agent mesh_transfer_file: to=writer file=/sandbox/... + # AGT relay: sent to viz (did:mesh:...) via E2E encrypted relay + # The log's owner is the sender; the parsed peer is the target. A pair of + # two distinct siblings is a sibling pair. This is the authoritative, + # platform-agnostic signal — unlike the legacy kubectl/registry heuristic + # (kept below as a fallback) it works in docker mode too, where there are + # no K8s pods/namespaces for `kubectl` to inspect. siblings = ["analyst", "viz", "writer"] pairs: set[frozenset[str]] = set() + + send_pat = re.compile( + r"mesh_transfer_file:\s+(?:[^\n]*?\bto=|[^\n]*?\u2192\s+)(\w+)\b" + r"|AGT relay:\s+sent to (\w+)\b" + ) + saw_gateway_log = False + for sub in siblings: + log_path = ctx.out_dir / f"{sub}-gateway.log" + if not log_path.exists(): + continue + saw_gateway_log = True + try: + text = log_path.read_text(errors="ignore") + except Exception: + continue + for m in send_pat.finditer(text): + target = m.group(1) or m.group(2) + if target in siblings and target != sub: + pairs.add(frozenset((sub, target))) + + if saw_gateway_log: + expected = {frozenset(("analyst", "viz")), + frozenset(("analyst", "writer")), + frozenset(("viz", "writer"))} + missing = expected - pairs + ok = not missing + return ok, (f"{len(pairs)}/3 sibling pairs from gateway logs " + f"(missing={sorted(map(sorted, missing))})") + + # ── Fallback: legacy kubectl/registry heuristic (AKS live, no collected + # gateway logs). Only reached when no `-gateway.log` was collected. ── # 1. Discover each sub-agent's pod IP so we can attribute REGISTRY lines. pod_ip: dict[str, str] = {} for sub in siblings: