diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000000..edbab7014b --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,478 @@ +version: 2.1 + +# Fork (giantswarm/kagent-upstream): publishes the line's images and charts to +# gsoci.azurecr.io (FORK.md, "What is published"). Hand-written on purpose — the +# repository runs upstream's GitHub Actions for tests and scans (ci.yaml, +# image-scan.yaml) and this pipeline for publishing, nothing generated. +# +# Two workflows. `publish` runs on the consumed branch `giantswarm` (a dev build +# in gitsemver's dev shape) and on the line's release tags `vX.Y.Z` and +# `vX.Y.Z-rc.N` (the version is the tag without `v`; FORK.md, "Release scheme"): +# every image is built natively per architecture by the orb's `build-image` job +# — amd64 and arm64, pushed by digest — and joined into one tagged index, signed +# and attested, by its `push-to-registries` job; then the repo-owned +# `push-charts` stamps the chart defaults with the version and the runtime +# images' digests and pushes both charts, signed, and `record` re-reads every +# digest from the registry, checks the chart against them and appends the row +# to the ledger. `validate` runs the same eight builds with `push: false` on +# every other branch of the line (pull requests, `sync/**` candidates) and +# publishes nothing. The mirrored upstream refs (`main`, `release/v*.x`, +# upstream's tags) run nothing: they are read-only copies, never built here. + +orbs: + architect: giantswarm/architect@10.10.0 + +# The one registry the line publishes to: visibility, host, the context's +# credential variables, and whether dev builds are pushed (they are — the dev +# channel and agentlab follow them). One line, no trailing newline: the orb +# reads it line by line. The same value goes to every build and push job, so +# they agree on where the per-architecture digests live. +registries: ®istries |- + public gsoci.azurecr.io ACR_GSOCI_USERNAME ACR_GSOCI_PASSWORD true + +# The upstream commit the consumed branch is pinned to (`git merge-base +# giantswarm main`; a re-pin moves it, FORK.md "Re-pin"), stamped on every +# published index so a consumer reads which kagent it runs from the registry. +annotations: &annotations |- + index:io.giantswarm.upstream.version=main@bf8afa5 + +# Build arguments, one NAME=VALUE per line, what the Makefile's +# TOOLS_IMAGE_BUILD_ARGS pass and the Dockerfiles use; ${DOCKER_IMAGE_TAG} (the +# version gitsemver resolved) and ${CIRCLE_SHA1} are expanded by the orb at +# build time. BASE_IMAGE_REGISTRY and TOOLS_NODE_VERSION keep the Dockerfiles' +# defaults, which equal the Makefile's. The Makefile's BuildDate -X flag has no +# job variable to come from and is left to the binary's default. +build-args: + controller: &build-args-controller |- + VERSION=${DOCKER_IMAGE_TAG} + LDFLAGS=-X github.com/kagent-dev/kagent/go/core/internal/version.Version=${DOCKER_IMAGE_TAG} -X github.com/kagent-dev/kagent/go/core/internal/version.GitCommit=${CIRCLE_SHA1} + BUILD_PACKAGE=core/cmd/controller/main.go + golang-adk: &build-args-golang-adk |- + VERSION=${DOCKER_IMAGE_TAG} + LDFLAGS=-X github.com/kagent-dev/kagent/go/core/internal/version.Version=${DOCKER_IMAGE_TAG} -X github.com/kagent-dev/kagent/go/core/internal/version.GitCommit=${CIRCLE_SHA1} + BUILD_PACKAGE=adk/cmd/main.go + ui: &build-args-ui |- + VERSION=${DOCKER_IMAGE_TAG} + claude-harness: &build-args-claude-harness |- + VERSION=${DOCKER_IMAGE_TAG} + +# `publish`: the consumed branch and the line's release tags. Every job of the +# workflow carries the same filters (a tag runs only jobs that admit it). +publish-filters: &publish-filters + branches: + only: giantswarm + tags: + only: /^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$/ + +# `validate`: every other branch of the line. The mirrored upstream branches +# are excluded; upstream's tags run nothing because no tag filter admits them. +validate-filters: &validate-filters + branches: + ignore: /^(giantswarm|main|release\/.*)$/ + +jobs: + push-charts: + executor: architect/architect + resource_class: small + steps: + - checkout + - attach_workspace: + at: . + - run: + name: Stamp the chart defaults and push both charts to gsoci + command: | + set -euo pipefail + # envsubst renders the Chart-template.yaml files (make helm-version). + apk add --no-cache gettext >/dev/null + # The version the image jobs published under (.build_version, persisted + # by the controller's push job — the value is the same for every image). + VERSION=$(cat .build_version) + export VERSION + registry=gsoci.azurecr.io + repo=giantswarm/kagent + # One credential for helm (its own registry config) and for the + # signing step (the docker config cosign and oras read). + printf '%s' "$ACR_GSOCI_PASSWORD" | helm registry login "$registry" --username "$ACR_GSOCI_USERNAME" --password-stdin + mkdir -p ~/.docker + auth=$(printf '%s:%s' "$ACR_GSOCI_USERNAME" "$ACR_GSOCI_PASSWORD" | base64 -w0) + printf '{"auths":{"%s":{"auth":"%s"}}}' "$registry" "$auth" > ~/.docker/config.json + chmod 600 ~/.docker/config.json + # Point the chart's defaults at the images published by this + # pipeline and record the version as appVersion. The tag has to be + # an explicit default: the chart falls back to .Chart.Version, which + # helm-controller extends with `+` — not a valid image tag. + # The runtime images are pinned by index digest as well (the image + # jobs have finished, so the indexes exist; resolved the way + # `record` does): a Harness accepts nothing but a digest, and the + # chart's own default Harness and its runtime-images ConfigMap + # render from these keys. The Substrate worker image is the + # Makefile's pin — the one Substrate the e2e cluster runs (ci.yaml + # reads it the same way). sed keeps the file's formatting (`helm + # show values` is documentation); the checks make a missed anchor + # fail the job instead of shipping upstream's defaults. + digest() { oras manifest fetch --descriptor "$1" | jq -r .digest; } + adk_digest=$(digest "${registry}/${repo}/golang-adk:${VERSION}") + claude_digest=$(digest "${registry}/${repo}/claude-harness:${VERSION}") + test -n "${adk_digest}" && test -n "${claude_digest}" + eval "$(make -s substrate-pin)" + worker_registry="${SUBSTRATE_REPO#oci://}" + worker_image="${worker_registry%/helm}/ateom-gvisor:${SUBSTRATE_VERSION}" + sed -i \ + -e "s|^tag: \"\"$|tag: \"${VERSION}\"|" \ + -e "s|^registry: \"ghcr.io\"$|registry: \"${registry}\"|" \ + -e "s|^\(\s*repository: \)kagent-dev/kagent/|\1${repo}/|" \ + -e "/^ agentImage:$/,/^ [^ #]/ s|^ digest: \"\"$| digest: \"${adk_digest}\"|" \ + -e "/^runtimeImages:$/,/^[^ #]/ s|^ digest: \"\"$| digest: \"${claude_digest}\"|" \ + -e "s|^ workerImage: \"\"$| workerImage: \"${worker_image}\"|" \ + helm/kagent/values.yaml + if grep -q '^\s*repository: kagent-dev/kagent/' helm/kagent/values.yaml; then + echo "ERROR: an upstream image repository survived the stamping" >&2 + exit 1 + fi + # yq compares in place: its scalar output keeps or drops the quotes by version. + stamped() { yq -e ".$1 == \"$2\"" helm/kagent/values.yaml >/dev/null || { echo "ERROR: $1 is not stamped with $2" >&2; exit 1; }; } + stamped tag "${VERSION}" + stamped registry "${registry}" + stamped controller.agentImage.digest "${adk_digest}" + stamped runtimeImages.claudeHarness.digest "${claude_digest}" + stamped substrateWorkerPool.workerImage "${worker_image}" + for chart in kagent kagent-crds; do + printf 'appVersion: %s\n' "${VERSION}" >> "helm/${chart}/Chart-template.yaml" + done + # helm-publish pushes to $(HELM_REPO)/kagent/helm. + make helm-publish HELM_REPO="oci://${registry}/giantswarm" + # The pushed charts, by digest, for the signing step. + : > /tmp/.cosign_refs + for chart in kagent-crds kagent; do + ref="${registry}/${repo}/helm/${chart}" + echo "${ref}@$(digest "${ref}:${VERSION}")" | tee -a /tmp/.cosign_refs + done + - architect/cosign-sign-verify + + record: + # The ledger (FORK.md): the published version, the commit, the upstream pin + # and every digest — printed here, stored as the job's artifact and appended + # as a row of builds.md on the `ledger` branch. Runs only after both pushes + # succeeded; a chart whose stamped pins disagree with the pushed indexes + # fails here. + executor: architect/architect + resource_class: small + steps: + - checkout + - attach_workspace: + at: . + - architect/generate-github-token + - run: + name: Check the chart against the image digests and record the build + command: | + set -euo pipefail + VERSION=$(cat .build_version) + registry=gsoci.azurecr.io + repo=giantswarm/kagent + digest() { oras manifest fetch --descriptor "$1" | jq -r .digest; } + declare -A D + for image in controller ui golang-adk claude-harness; do + D[$image]=$(digest "${registry}/${repo}/${image}:${VERSION}") + done + for chart in kagent kagent-crds; do + D[chart_${chart}]=$(digest "${registry}/${repo}/helm/${chart}:${VERSION}") + done + git remote add upstream https://github.com/kagent-dev/kagent.git + git fetch -q upstream main + pin=$(git merge-base "$CIRCLE_SHA1" upstream/main) + pin_date=$(git show -s --format=%cs "$pin") + # The chart's stamped runtime-image pins have to be the index + # digests this row records — a consumer reads the chart, the ledger + # is what a re-pin is checked against, and the two must not disagree. + helm show values "oci://${registry}/${repo}/helm/kagent" --version "${VERSION}" > /tmp/.values.yaml + check() { + yq -e ".$1 == \"$2\"" /tmp/.values.yaml >/dev/null || { + echo "ERROR: chart ${VERSION}: $1 is $(yq ".$1" /tmp/.values.yaml), the $3 index is $2" >&2 + exit 1 + } + } + check controller.agentImage.digest "${D[golang-adk]}" golang-adk + check runtimeImages.claudeHarness.digest "${D[claude-harness]}" claude-harness + check tag "${VERSION}" version + check registry "${registry}" registry + # The pin annotation on the published indexes is static in this + # file and moves by hand after a re-pin (FORK.md, "Re-pin"). A dev + # build whose annotation lags behind the pin warns; a release must + # not carry a wrong pin and is refused before its ledger row. + stamped_pin=$(oras manifest fetch "${registry}/${repo}/controller:${VERSION}" | jq -r '.annotations["io.giantswarm.upstream.version"] // empty') + if [ "${stamped_pin}" != "main@${pin:0:7}" ]; then + msg="the index annotation io.giantswarm.upstream.version is '${stamped_pin}', the pin is main@${pin:0:7} — move the annotation in .circleci/config.yml (FORK.md, 'Re-pin')" + if [ -n "${CIRCLE_TAG:-}" ]; then + echo "ERROR: release ${VERSION}: ${msg}" >&2 + exit 1 + fi + echo "WARNING: dev build ${VERSION}: ${msg}" >&2 + fi + row="| $(date -u +'%Y-%m-%d %H:%M') | \`${VERSION}\` | \`${CIRCLE_SHA1:0:7}\` | \`${pin:0:7}\` (${pin_date}) | \`${D[controller]}\` | \`${D[ui]}\` | \`${D[golang-adk]}\` | \`${D[claude-harness]}\` | \`${D[chart_kagent]}\` | \`${D[chart_kagent-crds]}\` | [run](${CIRCLE_BUILD_URL}) |" + { + echo "## Published ${VERSION}" + echo + echo "Commit \`${CIRCLE_SHA1:0:7}\`, upstream pin \`${pin:0:7}\` (${pin_date})." + echo + echo "| artifact | digest |" + echo "|---|---|" + for image in controller ui golang-adk claude-harness; do + echo "| \`${registry}/${repo}/${image}:${VERSION}\` | \`${D[$image]}\` |" + done + echo "| \`oci://${registry}/${repo}/helm/kagent:${VERSION}\` | \`${D[chart_kagent]}\` |" + echo "| \`oci://${registry}/${repo}/helm/kagent-crds:${VERSION}\` | \`${D[chart_kagent-crds]}\` |" + } > /tmp/published.md + cat /tmp/published.md + # CircleCI's checkout rewires https://github.com to SSH with the + # project's read-only deploy key (a global insteadOf); the ledger + # push uses the token over HTTPS, so it runs without that config. + GIT_CONFIG_GLOBAL=/dev/null \ + LEDGER_TOKEN="$GITHUB_TOKEN" \ + LEDGER_REMOTE_URL="https://github.com/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}.git" \ + LEDGER_GIT_NAME='circleci-architect[bot]' \ + LEDGER_GIT_EMAIL='240937895+circleci-architect[bot]@users.noreply.github.com' \ + scripts/fork/ledger-append.sh builds.md "$row" + - store_artifacts: + path: /tmp/published.md + destination: published.md + +workflows: + # Each image: one native build per architecture (the resource class is the + # architecture — `large` is amd64, `arm.large` is arm64), pushed by digest, + # then one merge job that tags the index with the version, signs it and its + # per-platform SBOM attestations, and stamps the pin annotation. Only the + # controller's merge persists `.build_version` — concurrent jobs must not + # persist the same path — and the chart jobs read it from there. + publish: + jobs: + # controller + - architect/build-image: + name: build-controller-amd64 + context: architect + image: giantswarm/kagent/controller + platform: linux/amd64 + resource_class: large + dockerfile: go/Dockerfile + build-context: go + build-args: *build-args-controller + registries-data: *registries + filters: *publish-filters + - architect/build-image: + name: build-controller-arm64 + context: architect + image: giantswarm/kagent/controller + platform: linux/arm64 + resource_class: arm.large + dockerfile: go/Dockerfile + build-context: go + build-args: *build-args-controller + registries-data: *registries + filters: *publish-filters + - architect/push-to-registries: + name: push-controller + context: architect + image: giantswarm/kagent/controller + merge-digests: true + platforms: linux/amd64,linux/arm64 + registries-data: *registries + annotations: *annotations + persist-build-version: true + requires: [build-controller-amd64, build-controller-arm64] + filters: *publish-filters + # ui + - architect/build-image: + name: build-ui-amd64 + context: architect + image: giantswarm/kagent/ui + platform: linux/amd64 + resource_class: large + dockerfile: ui/Dockerfile + build-context: ui + build-args: *build-args-ui + registries-data: *registries + filters: *publish-filters + - architect/build-image: + name: build-ui-arm64 + context: architect + image: giantswarm/kagent/ui + platform: linux/arm64 + resource_class: arm.large + dockerfile: ui/Dockerfile + build-context: ui + build-args: *build-args-ui + registries-data: *registries + filters: *publish-filters + - architect/push-to-registries: + name: push-ui + context: architect + image: giantswarm/kagent/ui + merge-digests: true + platforms: linux/amd64,linux/arm64 + registries-data: *registries + annotations: *annotations + persist-build-version: false + requires: [build-ui-amd64, build-ui-arm64] + filters: *publish-filters + # golang-adk + - architect/build-image: + name: build-golang-adk-amd64 + context: architect + image: giantswarm/kagent/golang-adk + platform: linux/amd64 + resource_class: large + dockerfile: go/Dockerfile + build-context: go + build-args: *build-args-golang-adk + registries-data: *registries + filters: *publish-filters + - architect/build-image: + name: build-golang-adk-arm64 + context: architect + image: giantswarm/kagent/golang-adk + platform: linux/arm64 + resource_class: arm.large + dockerfile: go/Dockerfile + build-context: go + build-args: *build-args-golang-adk + registries-data: *registries + filters: *publish-filters + - architect/push-to-registries: + name: push-golang-adk + context: architect + image: giantswarm/kagent/golang-adk + merge-digests: true + platforms: linux/amd64,linux/arm64 + registries-data: *registries + annotations: *annotations + persist-build-version: false + requires: [build-golang-adk-amd64, build-golang-adk-arm64] + filters: *publish-filters + # claude-harness + - architect/build-image: + name: build-claude-harness-amd64 + context: architect + image: giantswarm/kagent/claude-harness + platform: linux/amd64 + resource_class: large + dockerfile: go/harness/claude/Dockerfile + build-context: go + build-args: *build-args-claude-harness + registries-data: *registries + filters: *publish-filters + - architect/build-image: + name: build-claude-harness-arm64 + context: architect + image: giantswarm/kagent/claude-harness + platform: linux/arm64 + resource_class: arm.large + dockerfile: go/harness/claude/Dockerfile + build-context: go + build-args: *build-args-claude-harness + registries-data: *registries + filters: *publish-filters + - architect/push-to-registries: + name: push-claude-harness + context: architect + image: giantswarm/kagent/claude-harness + merge-digests: true + platforms: linux/amd64,linux/arm64 + registries-data: *registries + annotations: *annotations + persist-build-version: false + requires: [build-claude-harness-amd64, build-claude-harness-arm64] + filters: *publish-filters + # charts and the ledger + - push-charts: + context: architect + requires: [push-controller, push-ui, push-golang-adk, push-claude-harness] + filters: *publish-filters + - record: + context: architect + requires: [push-charts] + filters: *publish-filters + + # The same eight builds, nothing pushed: a Dockerfile regression surfaces on + # the pull request instead of on the consumed branch. No credentials, no + # digests, no attestations; with nothing pushed there is nothing to merge. + validate: + jobs: + - architect/build-image: + name: validate-controller-amd64 + image: giantswarm/kagent/controller + platform: linux/amd64 + resource_class: large + dockerfile: go/Dockerfile + build-context: go + build-args: *build-args-controller + push: false + filters: *validate-filters + - architect/build-image: + name: validate-controller-arm64 + image: giantswarm/kagent/controller + platform: linux/arm64 + resource_class: arm.large + dockerfile: go/Dockerfile + build-context: go + build-args: *build-args-controller + push: false + filters: *validate-filters + - architect/build-image: + name: validate-ui-amd64 + image: giantswarm/kagent/ui + platform: linux/amd64 + resource_class: large + dockerfile: ui/Dockerfile + build-context: ui + build-args: *build-args-ui + push: false + filters: *validate-filters + - architect/build-image: + name: validate-ui-arm64 + image: giantswarm/kagent/ui + platform: linux/arm64 + resource_class: arm.large + dockerfile: ui/Dockerfile + build-context: ui + build-args: *build-args-ui + push: false + filters: *validate-filters + - architect/build-image: + name: validate-golang-adk-amd64 + image: giantswarm/kagent/golang-adk + platform: linux/amd64 + resource_class: large + dockerfile: go/Dockerfile + build-context: go + build-args: *build-args-golang-adk + push: false + filters: *validate-filters + - architect/build-image: + name: validate-golang-adk-arm64 + image: giantswarm/kagent/golang-adk + platform: linux/arm64 + resource_class: arm.large + dockerfile: go/Dockerfile + build-context: go + build-args: *build-args-golang-adk + push: false + filters: *validate-filters + - architect/build-image: + name: validate-claude-harness-amd64 + image: giantswarm/kagent/claude-harness + platform: linux/amd64 + resource_class: large + dockerfile: go/harness/claude/Dockerfile + build-context: go + build-args: *build-args-claude-harness + push: false + filters: *validate-filters + - architect/build-image: + name: validate-claude-harness-arm64 + image: giantswarm/kagent/claude-harness + platform: linux/arm64 + resource_class: arm.large + dockerfile: go/harness/claude/Dockerfile + build-context: go + build-args: *build-args-claude-harness + push: false + filters: *validate-filters diff --git a/.github/actions/build-release-artifacts/action.yaml b/.github/actions/build-release-artifacts/action.yaml deleted file mode 100644 index 808bee5f5c..0000000000 --- a/.github/actions/build-release-artifacts/action.yaml +++ /dev/null @@ -1,16 +0,0 @@ -name: Build release artifacts -description: Build CLI binaries, checksums and versioned Helm chart archives -inputs: - version: - description: Version without a v prefix - required: true -runs: - using: composite - steps: - - name: Build CLI and charts - shell: bash - env: - VERSION: ${{ inputs.version }} - run: | - make build-cli - make helm-version diff --git a/.github/actions/publish-helm/action.yaml b/.github/actions/publish-helm/action.yaml deleted file mode 100644 index 0d17b4a7ad..0000000000 --- a/.github/actions/publish-helm/action.yaml +++ /dev/null @@ -1,22 +0,0 @@ -name: Publish Helm charts -description: Package and publish the kagent and kagent-crds charts -inputs: - version: - description: Version without a v prefix - required: true - token: - description: GitHub token with packages write permission - required: true -runs: - using: composite - steps: - - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ inputs.token }} - - name: Build and push charts - shell: bash - env: - VERSION: ${{ inputs.version }} - run: make helm-publish diff --git a/.github/actions/publish-image/action.yaml b/.github/actions/publish-image/action.yaml deleted file mode 100644 index ab9e9b53b9..0000000000 --- a/.github/actions/publish-image/action.yaml +++ /dev/null @@ -1,36 +0,0 @@ -name: Publish image -description: Build and publish a multi-platform kagent component image -inputs: - version: - description: Version without a v prefix - required: true - image: - description: Component image name - required: true - token: - description: GitHub token with packages write permission - required: true -runs: - using: composite - steps: - - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ inputs.token }} - - uses: docker/setup-qemu-action@v4 - - uses: docker/setup-buildx-action@v4 - with: - name: kagent-builder-v0.23.0 - platforms: linux/amd64,linux/arm64 - version: v0.23.0 - use: 'true' - - name: Build and push image - shell: bash - env: - VERSION: ${{ inputs.version }} - IMAGE: ${{ inputs.image }} - DOCKER_BUILD_ARGS: '--push --platform linux/amd64,linux/arm64' - DOCKER_BUILDER: docker buildx build - DOCKER_REGISTRY: ghcr.io - run: make "build-$IMAGE" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f457a8cdd0..7e33c944a2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,18 +1,15 @@ name: CI Build +# Fork (giantswarm/kagent-upstream): the consumed branch and the sync +# candidates run this suite on every push and pull request, on GitHub-hosted +# runners (the org has no Blacksmith runners), and report one `ci-ok` status +# for the ruleset. No paths-ignore: a docs-only pull request must still report +# the required checks to be mergeable. See FORK.md. on: push: - branches: [main, "release/**"] - paths: - - "**" - - "!**/*.md" - - "docs/env.md" + branches: [main, "release/**", giantswarm, "sync/**"] pull_request: - branches: [main, "release/**"] - paths: - - "**" - - "!**/*.md" - - "docs/env.md" + branches: [main, "release/**", giantswarm] merge_group: workflow_dispatch: inputs: @@ -53,18 +50,31 @@ jobs: strategy: fail-fast: false matrix: - sandbox_class: [gvisor, microvm] + # The Giant Swarm line of Substrate publishes the gVisor worker only + # (its FORK.md, "Publishing"); the platform runs no micro-VM pool. + sandbox_class: [gvisor] env: VERSION: v0.0.1-test - SUBSTRATE_VERSION: 0.3.0-alpha3 KIND_SANDBOX_CLASS: ${{ matrix.sandbox_class }} # Keep the four-vCPU runner bounded; manual runs can compare higher concurrency. E2E_PARALLEL: ${{ inputs.e2e_parallel || '4' }} - runs-on: blacksmith-4vcpu-ubuntu-2404 + # The upstream release whose kubectl-ate performs the CA/JWT bootstrap + # below; the Giant Swarm line of Substrate publishes no CLI. Same pin as + # the Makefile's SUBSTRATE_VERSION is built from. + KUBECTL_ATE_VERSION: 0.0.29 + runs-on: ubuntu-latest timeout-minutes: 40 steps: - name: Checkout repository uses: actions/checkout@v6 + - name: Resolve the Substrate pin + # One source of truth: the Makefile's SUBSTRATE_VERSION / SUBSTRATE_REPO + # (the Giant Swarm line of Substrate, gsoci.azurecr.io/giantswarm/substrate) drive + # the charts' dependencies and the Substrate this cluster runs. + run: | + make -s substrate-pin | tee -a "$GITHUB_ENV" + registry=$(make -s substrate-pin | sed -n 's|^SUBSTRATE_REPO=oci://||p') + echo "SUBSTRATE_IMAGE_REGISTRY=${registry%/helm}" | tee -a "$GITHUB_ENV" - name: Initialize Environment uses: ./.github/actions/initialize-environment - name: Set up Go @@ -88,12 +98,13 @@ jobs: # See https://github.com/openai/codex/issues/14919 run: | sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true - - name: Setup Blacksmith Builder - uses: useblacksmith/setup-docker-builder@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 with: - cache-key: kagent-e2e - platforms: linux/amd64 - nofallback: true + name: ${{ env.BUILDX_BUILDER_NAME }} + version: ${{ env.BUILDX_VERSION }} + driver-opts: network=host + use: "true" - name: Set up Helm uses: azure/setup-helm@v5.0.1 @@ -112,14 +123,24 @@ jobs: - name: Install Agent Substrate run: | - curl -fsSL -o kubectl-ate "https://github.com/kagent-dev/substrate/releases/download/v${SUBSTRATE_VERSION}/kubectl-ate-linux-amd64" + curl -fsSL -o kubectl-ate "https://github.com/kagent-dev/substrate/releases/download/v${KUBECTL_ATE_VERSION}/kubectl-ate-linux-amd64" chmod +x kubectl-ate - helm upgrade --install substrate-crds oci://ghcr.io/kagent-dev/substrate/helm/substrate-crds --version "${SUBSTRATE_VERSION}" --namespace ate-system --create-namespace - helm upgrade --install substrate oci://ghcr.io/kagent-dev/substrate/helm/substrate --version "${SUBSTRATE_VERSION}" --namespace ate-system \ + # The egress gateway dials upstream HTTPS itself and trusts this CA + # there; the egress credential tests sign their HTTPS upstreams with it. + openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 1 -subj "/CN=kagent e2e egress upstream" \ + -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign" \ + -keyout "$RUNNER_TEMP/egress-ca.key" -out "$RUNNER_TEMP/egress-ca.crt" + helm upgrade --install substrate-crds "${SUBSTRATE_REPO}/substrate-crds" --version "${SUBSTRATE_VERSION}" --namespace ate-system --create-namespace + # Golden boots fetch git skill and plugin sources in atespace + # ate-golden, so it resolves kagent Secrets as the session atespace does. + helm upgrade --install substrate "${SUBSTRATE_REPO}/substrate" --version "${SUBSTRATE_VERSION}" --namespace ate-system \ --set-string 'atelet.extraArgs[0]=--localhost-registry-replacement=kind-registry:5000' \ --set-string 'ateApi.extraArgs[0]=--template-resync-interval=250ms' \ --set 'credentialProvider.namespacePolicies[0].atespace=kagent' \ - --set 'credentialProvider.namespacePolicies[0].allowedNamespaces[0]=kagent' + --set 'credentialProvider.namespacePolicies[0].allowedNamespaces[0]=kagent' \ + --set 'credentialProvider.namespacePolicies[1].atespace=ate-golden' \ + --set 'credentialProvider.namespacePolicies[1].allowedNamespaces[0]=kagent' \ + --set-file "atenetEgress.upstreamTrust.caBundle=$RUNNER_TEMP/egress-ca.crt" ./kubectl-ate --context kind-kagent admin make-ca-pool --ca-id=1 --name=service-dns-ca-pool --secret-namespace=podcertificate-controller-system ./kubectl-ate --context kind-kagent admin make-ca-pool --ca-id=1 --name=pod-identity-ca-pool --secret-namespace=podcertificate-controller-system ./kubectl-ate --context kind-kagent admin make-jwt-pool --key-id=1 --name=actor-id-jwt-pool --secret-namespace=ate-system @@ -128,7 +149,7 @@ jobs: actor_id_ca_root="$(kubectl get secret actor-id-ca-pool -n ate-system -o jsonpath='{.data.pool}' | base64 --decode | jq -r '.CAs[0].RootCertificateDER' | base64 --decode | openssl x509 -inform der -outform pem)" kubectl create secret generic actor-id-ca-certs -n ate-system --from-literal=ca.crt="${actor_id_ca_root}" kubectl create configmap ate-api-authentication -n ate-system --from-literal=authentication.yaml=$'actorIdentityJWTProvider: kubernetes\njwtProviders:\n- name: kubernetes\n issuer: https://kubernetes.default.svc\n audiences: [api.ate-system.svc]\n certificateAuthorityFile: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt\n discoveryTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token\n' - helm upgrade substrate oci://ghcr.io/kagent-dev/substrate/helm/substrate --version "${SUBSTRATE_VERSION}" --namespace ate-system --reuse-values --wait --timeout 5m + helm upgrade substrate "${SUBSTRATE_REPO}/substrate" --version "${SUBSTRATE_VERSION}" --namespace ate-system --reuse-values --wait --timeout 5m - name: Cache Cloud Hypervisor assets if: matrix.sandbox_class == 'microvm' @@ -157,7 +178,7 @@ jobs: --set substrateWorkerPool.create=true --set substrateWorkerPool.replicas=4 --set-string substrateWorkerPool.sandboxClass=${{ matrix.sandbox_class }} - --set-string substrateWorkerPool.workerImage=ghcr.io/kagent-dev/substrate/ateom-${{ matrix.sandbox_class }}:v${{ env.SUBSTRATE_VERSION }} + --set-string substrateWorkerPool.workerImage=${{ env.SUBSTRATE_IMAGE_REGISTRY }}/ateom-${{ matrix.sandbox_class }}:${{ env.SUBSTRATE_VERSION }} --set otel.traces.enabled=true --set-string otel.exporter.otlp.endpoint=http://e2e-otlp.kagent.svc.cluster.local:14317 run: | @@ -213,6 +234,7 @@ jobs: export KAGENT_E2E_CLI="$PWD/core/bin/kagent-local" export KAGENT_E2E_API_URL="$KAGENT_API_URL" export KAGENT_E2E_LOCAL_HOST=172.17.0.1 + export KAGENT_E2E_EGRESS_CA_FILE="$RUNNER_TEMP/egress-ca.crt" KAGENT_E2E_EGRESS_CA_KEY_FILE="$RUNNER_TEMP/egress-ca.key" envsubst < core/test/e2e/manifests/tracing.yaml.tmpl | kubectl apply -f - # TestMain keeps the receiver alive for every runtime's response flush. export KAGENT_E2E_OTLP_LISTEN_ADDRESS=:14317 @@ -222,15 +244,27 @@ jobs: worker_logs_pid=$! kubectl logs -n ate-system -l app --all-containers --prefix --timestamps --tail=-1 --follow --max-log-requests=20 > "$RUNNER_TEMP/e2e-logs/substrate.log" 2>&1 & substrate_logs_pid=$! - trap 'kill "$worker_logs_pid" "$substrate_logs_pid" 2>/dev/null || true; wait "$worker_logs_pid" "$substrate_logs_pid" 2>/dev/null || true' EXIT + # Tests roll the controller (withControllerEnv, the sandbox restart), and a + # log read at the end holds only the final pod: every controller pod is + # followed from the moment it runs, into a file of its own. + controller_log_pids="$RUNNER_TEMP/controller-log-pids" + : > "$controller_log_pids" + ( + followed=" " + while true; do + for pod in $(kubectl get pods -n kagent -l app.kubernetes.io/component=controller -o jsonpath='{.items[?(@.status.phase=="Running")].metadata.name}'); do + case "$followed" in *" $pod "*) continue ;; esac + followed="$followed$pod " + kubectl logs -n kagent "$pod" --all-containers --prefix --timestamps --tail=-1 --follow > "$RUNNER_TEMP/e2e-logs/controller-$pod.log" 2>&1 & + echo $! >> "$controller_log_pids" + done + sleep 5 + done + ) & + controller_logs_pid=$! + trap 'kill "$worker_logs_pid" "$substrate_logs_pid" "$controller_logs_pid" $(cat "$controller_log_pids") 2>/dev/null || true; wait "$worker_logs_pid" "$substrate_logs_pid" "$controller_logs_pid" $(cat "$controller_log_pids") 2>/dev/null || true' EXIT go test -v github.com/kagent-dev/kagent/go/core/test/e2e -shuffle=on -parallel "$E2E_PARALLEL" -count=1 -timeout=30m 2>&1 | tee "$RUNNER_TEMP/e2e-logs/tests.log" - - name: Collect E2E controller logs - if: always() - run: | - mkdir -p "$RUNNER_TEMP/e2e-logs" - kubectl logs -n kagent deployment/kagent-controller --timestamps > "$RUNNER_TEMP/e2e-logs/controller.log" 2>&1 || true - - name: Upload E2E logs if: always() uses: actions/upload-artifact@v7 @@ -288,6 +322,11 @@ jobs: with: go-version-file: go/go.mod cache: false + - name: Cache the contract's upstream sources at their pins + uses: actions/cache@v6 + with: + path: telemetry/deps + key: telemetry-deps-${{ hashFiles('telemetry/versions.env') }} - name: Check the registry and generated conventions run: make semconv-verify @@ -296,7 +335,7 @@ jobs: - setup env: VERSION: v0.0.1-test - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-latest strategy: fail-fast: false matrix: @@ -344,7 +383,7 @@ jobs: - setup env: VERSION: v0.0.1-test - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-latest strategy: fail-fast: false matrix: @@ -412,6 +451,17 @@ jobs: export KUBEBUILDER_ASSETS="$(make -s envtest-path)" go test -race -skip 'TestE2E.*' -v ./... + # Fork: the claude harness splits Claude Code off only when it runs as root, + # so its root-only tests run again as root, with no capability beyond the + # CHOWN, SETUID and SETGID the claude translator asks Substrate for. + - name: Run the claude harness tests as root + working-directory: go + run: | + go test -c -o "$RUNNER_TEMP/claude-adapter.test" ./harness/claude/internal/adapter/ + caps="--bounding-set=-all,+chown,+setuid,+setgid --inh-caps=-all --ambient-caps=-all" + sudo setpriv $caps -- grep -E '^Cap(Prm|Eff)' /proc/self/status + sudo setpriv $caps -- "$RUNNER_TEMP/claude-adapter.test" -test.count=1 -test.v + helm-unit-tests: env: VERSION: v0.0.1-test @@ -439,7 +489,7 @@ jobs: helm unittest helm/tools/grafana-mcp ui-tests: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-latest permissions: contents: read pull-requests: write @@ -491,22 +541,38 @@ jobs: working-directory: ./ui run: yarn test:pw + # What a failed attempt leaves behind: its screenshot and video, and the trace of + # its retry. The log names the step that timed out; only the trace shows what was + # on the page under the pointer while it did, and the runner discards it otherwise. + # Kept whether the job failed or a retry passed: a flaky test leaves the job green + # and is the case that most needs its trace. A run whose every attempt passed + # writes nothing here (Playwright's `.last-run.json` is hidden, and hidden files + # are not uploaded), so it keeps no artifact. + - name: Keep the browser suite's evidence of a failed attempt + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ui-test-results + path: ui/test-results + if-no-files-found: ignore + retention-days: 7 + # This job builds the Docker images for the controller, UI, ADKs, and CLI on arm64. build: env: VERSION: v0.0.1-test strategy: matrix: + # Fork: the images the platform publishes (FORK.md) — the Python ADK, + # the Codex harness and the CLI image are not offered. image: - controller - ui - - kagent-adk - - cli - golang-adk - claude-harness - codex-harness - sandbox-guest - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-latest services: registry: image: registry:2 @@ -519,12 +585,13 @@ jobs: uses: docker/setup-qemu-action@v4 with: platforms: linux/amd64,linux/arm64 - - name: Setup Blacksmith Builder - uses: useblacksmith/setup-docker-builder@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 with: - cache-key: kagent-build-${{ matrix.image }} - platforms: linux/amd64,linux/arm64 - nofallback: true + name: ${{ env.BUILDX_BUILDER_NAME }} + version: ${{ env.BUILDX_VERSION }} + driver-opts: network=host + use: "true" - name: Run make build env: DOCKER_BUILD_ARGS: >- @@ -649,3 +716,31 @@ jobs: exit 1 fi echo "✓ Controller manifests are up to date" + + # Fork: the one status the ruleset requires. Every job above must succeed; a + # leg a job skips internally (the upgrade tests on a non-release base) still + # ends in success, a skipped or failed job does not. + ci-ok: + if: always() + needs: + - setup + - test-e2e + - proto-check + - upgrade-tests + - rolling-upgrade-tests + - go-unit-tests + - helm-unit-tests + - ui-tests + - build + - go-lint + - python-test + - python-lint + - manifests-check + runs-on: ubuntu-latest + steps: + - name: Every job succeeded + env: + RESULTS: ${{ toJSON(needs) }} + run: | + jq -r 'to_entries[] | "\(.key): \(.value.result)"' <<< "$RESULTS" + jq -e 'all(.[]; .result == "success")' <<< "$RESULTS" > /dev/null diff --git a/.github/workflows/image-scan.yaml b/.github/workflows/image-scan.yaml index 25011f2991..609fd0e808 100644 --- a/.github/workflows/image-scan.yaml +++ b/.github/workflows/image-scan.yaml @@ -1,15 +1,19 @@ name: Scan images +# Fork (giantswarm/kagent-upstream): every image the fork publishes is scanned +# on every push to the consumed branch and the sync candidates, on every pull +# request to the consumed branch and weekly (the schedule runs on the default +# branch, which is the consumed branch); the Go module graph is scanned with +# govulncheck. One `scan-ok` status for the ruleset. See FORK.md. on: - # Trigger once a week on the main branch + # Trigger once a week on the default branch schedule: - cron: '0 0 * * 0' # Every Monday at midnight UTC - # Trigger the workflow on push to main and release branches push: - branches: [main, "release/**"] - paths-ignore: - - '**/*.md' + branches: [main, "release/**", giantswarm, "sync/**"] + pull_request: + branches: [giantswarm] workflow_dispatch: @@ -29,6 +33,7 @@ jobs: strategy: fail-fast: false matrix: + # Fork: the images the platform publishes (FORK.md). include: - build_target: controller image_name: controller @@ -36,9 +41,6 @@ jobs: - build_target: ui image_name: ui tag_suffix: "" - - build_target: kagent-adk - image_name: kagent-adk - tag_suffix: "" - build_target: golang-adk image_name: golang-adk tag_suffix: "" @@ -51,7 +53,7 @@ jobs: - build_target: sandbox-guest image_name: sandbox-guest tag_suffix: "" - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-latest services: registry: image: registry:2 @@ -62,8 +64,13 @@ jobs: uses: actions/checkout@v6 - name: Set up QEMU uses: docker/setup-qemu-action@v4 - - name: Setup Blacksmith Builder - uses: useblacksmith/setup-docker-builder@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + with: + name: ${{ env.BUILDX_BUILDER_NAME }} + version: ${{ env.BUILDX_VERSION }} + driver-opts: network=host + use: "true" - name: Set version id: vars run: echo "version=v0.0.0-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT @@ -93,3 +100,34 @@ jobs: severity: 'CRITICAL,HIGH' ignore-unfixed: true exit-code: '1' + + # Fork: the Go module graph. Upstream ships the target (go/Makefile) without a + # workflow that runs it. + govulncheck: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go/go.mod + cache: true + cache-dependency-path: go/go.sum + - name: Scan the Go module graph + run: scripts/fork/govulncheck.sh + + # Fork: the one status the ruleset requires. + scan-ok: + if: always() + needs: + - build + - govulncheck + runs-on: ubuntu-latest + steps: + - name: Every scan succeeded + env: + RESULTS: ${{ toJSON(needs) }} + run: | + jq -r 'to_entries[] | "\(.key): \(.value.result)"' <<< "$RESULTS" + jq -e 'all(.[]; .result == "success")' <<< "$RESULTS" > /dev/null diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml deleted file mode 100644 index 40bddca088..0000000000 --- a/.github/workflows/nightly.yaml +++ /dev/null @@ -1,110 +0,0 @@ -name: Nightly Release - -on: - schedule: - - cron: '0 2 * * *' - workflow_dispatch: {} - -permissions: - actions: read - contents: read - packages: write - -jobs: - prepare: - if: github.repository == 'kagent-dev/kagent' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) - runs-on: ubuntu-latest - outputs: - release: ${{ steps.prepare.outputs.release }} - version: ${{ steps.prepare.outputs.version }} - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Prepare nightly release - id: prepare - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - PREVIOUS_SHA=$(gh run list --repo "$GITHUB_REPOSITORY" --workflow nightly.yaml \ - --branch "$GITHUB_REF_NAME" --status success --limit 1 \ - --json headSha --jq '.[0].headSha // empty') - # Prefix the SHA with g so an all-numeric SHA remains valid SemVer. - VERSION="0.0.0-alpha.g${GITHUB_SHA:0:12}" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - if [ "$GITHUB_EVENT_NAME" = workflow_dispatch ] || [ "$PREVIOUS_SHA" != "$GITHUB_SHA" ]; then - echo "release=true" >> "$GITHUB_OUTPUT" - else - echo "release=false" >> "$GITHUB_OUTPUT" - echo 'HEAD matches the previous successful nightly; skipping release.' >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - { - echo "# Nightly $VERSION" - echo - echo "Commit: $GITHUB_SHA" - echo - if [ -n "$PREVIOUS_SHA" ]; then - echo "Compare: https://github.com/$GITHUB_REPOSITORY/compare/$PREVIOUS_SHA...$GITHUB_SHA" - echo - git log --format='- %h %s (%an)' "$PREVIOUS_SHA..HEAD" - else - echo 'First nightly: showing the most recent commits.' - git log -20 --format='- %h %s (%an)' - fi - echo - echo "Images: ghcr.io/kagent-dev/kagent/:$VERSION" - echo "Helm charts: oci://ghcr.io/kagent-dev/kagent/helm/{kagent,kagent-crds} --version $VERSION" - echo 'CLI binaries, checksums and versioned charts are uploaded as artifacts on this run.' - } >> "$GITHUB_STEP_SUMMARY" - - push-images: - needs: prepare - if: needs.prepare.outputs.release == 'true' - runs-on: blacksmith-2vcpu-ubuntu-2404 - strategy: - matrix: - image: - - controller - - ui - - kagent-adk - - golang-adk - - claude-harness - - codex-harness - - sandbox-guest - steps: - - uses: actions/checkout@v6 - - uses: ./.github/actions/publish-image - with: - version: ${{ needs.prepare.outputs.version }} - image: ${{ matrix.image }} - token: ${{ secrets.GITHUB_TOKEN }} - - push-helm-chart: - needs: [prepare, push-images] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: ./.github/actions/publish-helm - with: - version: ${{ needs.prepare.outputs.version }} - token: ${{ secrets.GITHUB_TOKEN }} - - artifacts: - needs: [prepare, push-helm-chart] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: ./.github/actions/build-release-artifacts - with: - version: ${{ needs.prepare.outputs.version }} - - uses: actions/upload-artifact@v7 - with: - name: nightly-${{ needs.prepare.outputs.version }} - path: | - go/core/bin/kagent-* - dist/kagent-*.tgz - if-no-files-found: error diff --git a/.github/workflows/sync-upstream.yaml b/.github/workflows/sync-upstream.yaml new file mode 100644 index 0000000000..ef12b2dad5 --- /dev/null +++ b/.github/workflows/sync-upstream.yaml @@ -0,0 +1,217 @@ +name: Sync with upstream + +# Fork (giantswarm/kagent-upstream): the weekly re-pin of the consumed branch +# (FORK.md, "Re-pin"). Fast-forwards the mirror (main, release/v*.x, tags — +# scripts/fork/mirror.sh) to upstream, rebases the carried commits onto the upstream head (scripts/fork/repin.sh), +# lets CI Build and Scan images test the candidate branch, and force-pushes the +# consumed branch so the CircleCI pipeline publishes the dev build. When a carried +# commit conflicts or a check fails it opens a pull request against the +# consumed branch that names the cause, and pushes nothing else. +# +# Who pushes matters. The ledger is pushed with GITHUB_TOKEN (no workflow +# reacts). Everything else — the mirror, the candidate, the consumed branch — +# is pushed with a token of the org's heraldbot GitHub App (contents + +# workflows write; the ruleset's bypass actor): GITHUB_TOKEN may not update a +# workflow file, not even in a mirrored upstream commit, and the App may. An +# App push starts workflows, wanted on the candidate and the consumed branch +# (CI Build, Scan images; the CircleCI pipeline reacts to the push as well) and unwanted on the mirrored refs, +# where upstream's own files would run — the sync cancels those right away. +on: + schedule: + - cron: '0 5 * * 1' # Monday 05:00 UTC + workflow_dispatch: + inputs: + upstream_ref: + description: 'Upstream ref to re-pin onto (the mirror main always follows upstream main)' + default: main + dry_run: + description: 'Rebase and report only; push nothing' + type: boolean + default: false + mirror_only: + description: 'Fast-forward the mirror (main, release branches, tags) and stop' + type: boolean + default: false + +permissions: + contents: write + actions: write # cancel the runs upstream's workflow files start on freshly mirrored refs + +concurrency: + group: sync-upstream + cancel-in-progress: false + +env: + CONSUMED: giantswarm + UPSTREAM_REPO: kagent-dev/kagent + UPSTREAM_REF: ${{ inputs.upstream_ref || 'main' }} + DRY_RUN: ${{ inputs.dry_run == true }} + MIRROR_ONLY: ${{ inputs.mirror_only == true }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 200 + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ env.CONSUMED }} + fetch-depth: 0 + persist-credentials: false + + - name: Mint the bot token + id: app + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ secrets.HERALD_CLIENT_ID }} + private-key: ${{ secrets.HERALD_APP_KEY }} + + - name: Prepare + run: | + git config user.name "HeraldBot[bot]" + git config user.email "149080493+heraldbot[bot]@users.noreply.github.com" + git remote add upstream "https://github.com/${UPSTREAM_REPO}.git" + git fetch -q upstream main + [ "$UPSTREAM_REF" = main ] || git fetch -q upstream "$UPSTREAM_REF" + git fetch -q origin main "$CONSUMED" + + - name: Fast-forward the mirror (main, release branches, tags) + env: + BOT_TOKEN: ${{ steps.app.outputs.token }} + run: | + date -u +%FT%TZ > mirror-started-at + if [ "$DRY_RUN" = true ]; then + scripts/fork/mirror.sh "$BOT_TOKEN" --check + else + scripts/fork/mirror.sh "$BOT_TOKEN" + fi + + - name: Quiet the mirror + if: env.DRY_RUN != 'true' + env: + GH_TOKEN: ${{ github.token }} + run: scripts/fork/mirror-quiet.sh "$(cat mirror-started-at)" mirror-pushed.txt + + - name: Rebase the carried commits + id: repin + if: env.MIRROR_ONLY != 'true' + run: | + set +e + UPSTREAM_REMOTE=upstream REPORT=repin-report.md REPORT_ENV=repin-report.env scripts/fork/repin.sh + rc=$? + set -e + cat repin-report.md >> "$GITHUB_STEP_SUMMARY" + cat repin-report.env >> "$GITHUB_OUTPUT" + case $rc in 0|2) ;; *) exit "$rc" ;; esac + + - name: Dry run + if: env.DRY_RUN == 'true' && env.MIRROR_ONLY != 'true' + run: | + echo "dry run: status=${{ steps.repin.outputs.status }}; nothing pushed" + [ "${{ steps.repin.outputs.status }}" != conflict ] + + - name: Conflict — open the pull request + if: env.DRY_RUN != 'true' && steps.repin.outputs.status == 'conflict' + env: + BOT_TOKEN: ${{ steps.app.outputs.token }} + GH_TOKEN: ${{ steps.app.outputs.token }} + CANDIDATE: ${{ steps.repin.outputs.candidate }} + TARGET: ${{ steps.repin.outputs.target }} + CONFLICT: ${{ steps.repin.outputs.conflict }} + CONFLICT_SUBJECT: ${{ steps.repin.outputs.conflict_subject }} + run: | + # A signal, not a proposal: the branch is the consumed head plus one empty + # commit, so the pull request can exist; [skip ci] keeps the checks quiet. + git checkout -q -B "$CANDIDATE" "origin/$CONSUMED" + git commit -q --allow-empty \ + -m "sync: upstream ${TARGET:0:7} conflicts with carried commit ${CONFLICT:0:7} [skip ci]" \ + -m "${CONFLICT_SUBJECT}" -m "Details and the manual procedure: ${RUN_URL}" + scripts/fork/git-push-as.sh "$BOT_TOKEN" origin "$CANDIDATE" + { + echo "The automated re-pin stopped: a carried commit does not apply onto upstream \`${TARGET:0:7}\`. Nothing was pushed to \`${CONSUMED}\`." + echo + cat repin-report.md + echo + echo "Finish by hand (FORK.md, \"Manual re-pin\"), then close this pull request — do not merge it. Run: ${RUN_URL}" + } > pr-body.md + # --repo: in a checkout of a fork, gh otherwise targets the parent repository. + gh pr create --repo "$GITHUB_REPOSITORY" --base "$CONSUMED" --head "$CANDIDATE" \ + --title "sync: re-pin onto upstream ${TARGET:0:7} blocked — ${CONFLICT_SUBJECT}" \ + --body-file pr-body.md + exit 1 + + - name: Push the candidate + if: env.DRY_RUN != 'true' && steps.repin.outputs.status == 'rebased' + env: + BOT_TOKEN: ${{ steps.app.outputs.token }} + run: scripts/fork/git-push-as.sh "$BOT_TOKEN" origin "${{ steps.repin.outputs.candidate }}" + + - name: Wait for ci-ok and scan-ok on the candidate + if: env.DRY_RUN != 'true' && steps.repin.outputs.status == 'rebased' + id: checks + env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ steps.repin.outputs.new_head }} + run: | + deadline=$((SECONDS + 170 * 60)) + while :; do + state=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${SHA}/check-runs?per_page=100" \ + --jq '[.check_runs[] | select(.name == "ci-ok" or .name == "scan-ok") | {(.name): "\(.status)/\(.conclusion)"}] | add // {}') + echo "$(date -u +%H:%M:%S) ${state}" + if jq -e '[."ci-ok", ."scan-ok"] | all(. == "completed/success")' <<< "$state" > /dev/null; then + echo "result=green" >> "$GITHUB_OUTPUT"; break + fi + if jq -e '[."ci-ok", ."scan-ok"] | all(. != null and startswith("completed/"))' <<< "$state" > /dev/null; then + echo "result=red" >> "$GITHUB_OUTPUT"; echo "state=${state}" >> "$GITHUB_OUTPUT"; break + fi + if [ "$SECONDS" -gt "$deadline" ]; then + echo "result=timeout" >> "$GITHUB_OUTPUT"; echo "state=${state}" >> "$GITHUB_OUTPUT"; break + fi + sleep 60 + done + + - name: Land the re-pin + if: steps.checks.outputs.result == 'green' + env: + GITHUB_TOKEN: ${{ github.token }} + BOT_TOKEN: ${{ steps.app.outputs.token }} + CANDIDATE: ${{ steps.repin.outputs.candidate }} + NEW_HEAD: ${{ steps.repin.outputs.new_head }} + OLD_HEAD: ${{ steps.repin.outputs.old_head }} + PIN: ${{ steps.repin.outputs.pin }} + TARGET: ${{ steps.repin.outputs.target }} + run: | + scripts/fork/git-push-as.sh "$BOT_TOKEN" origin \ + --force-with-lease="refs/heads/${CONSUMED}:${OLD_HEAD}" "${NEW_HEAD}:refs/heads/${CONSUMED}" + scripts/fork/git-push-as.sh "$BOT_TOKEN" origin --delete "$CANDIDATE" + dropped="${{ steps.repin.outputs.dropped }}" + scripts/fork/ledger-append.sh re-pins.md \ + "| $(date -u +'%Y-%m-%d %H:%M') | \`${PIN:0:7}\` | \`${TARGET:0:7}\` ($(git show -s --format=%cs "$TARGET")) | \`${OLD_HEAD:0:7}\` → \`${NEW_HEAD:0:7}\` | ${{ steps.repin.outputs.replayed_count }} | ${dropped:-—} | [run](${RUN_URL}) |" + echo "### Landed" >> "$GITHUB_STEP_SUMMARY" + echo "\`${CONSUMED}\` is \`${NEW_HEAD:0:7}\` (was \`${OLD_HEAD:0:7}\`); the CircleCI pipeline publishes the dev build." >> "$GITHUB_STEP_SUMMARY" + + - name: Checks failed — open the pull request + if: steps.checks.outputs.result == 'red' || steps.checks.outputs.result == 'timeout' + env: + BOT_TOKEN: ${{ steps.app.outputs.token }} + GH_TOKEN: ${{ steps.app.outputs.token }} + CANDIDATE: ${{ steps.repin.outputs.candidate }} + TARGET: ${{ steps.repin.outputs.target }} + RESULT: ${{ steps.checks.outputs.result }} + STATE: ${{ steps.checks.outputs.state }} + run: | + { + echo "The automated re-pin stopped: the rebased candidate did not pass the checks (${RESULT}: \`${STATE}\`). Nothing was pushed to \`${CONSUMED}\`." + echo + echo "The checks ran on this branch's head; their runs are in the Actions tab for \`${CANDIDATE}\`." + echo + cat repin-report.md + echo + echo "Finish by hand (FORK.md, \"Manual re-pin\"): fix on this branch, let the checks pass, force-push it onto \`${CONSUMED}\`, then close this pull request — do not merge it. Run: ${RUN_URL}" + } > pr-body.md + # --repo: in a checkout of a fork, gh otherwise targets the parent repository. + gh pr create --repo "$GITHUB_REPOSITORY" --base "$CONSUMED" --head "$CANDIDATE" \ + --title "sync: re-pin onto upstream ${TARGET:0:7} blocked — checks ${RESULT}" \ + --body-file pr-body.md + exit 1 diff --git a/.github/workflows/tag.yaml b/.github/workflows/tag.yaml deleted file mode 100644 index f52f61bfc6..0000000000 --- a/.github/workflows/tag.yaml +++ /dev/null @@ -1,156 +0,0 @@ -name: Tag and Push - -on: - push: - tags: - - "v*.*.*" - workflow_dispatch: - inputs: - version: - description: 'Version number' - -# https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#release -# GITHUB_SHA = Last commit in the tagged release -# GITHUB_REF = Tag ref of release refs/tags/ -jobs: - setup: - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - version: ${{ steps.version.outputs.version }} - steps: - - name: Resolve version - id: version - env: - VERSION: ${{ inputs.version || github.ref_name }} - run: echo "version=${VERSION#v}" >> "$GITHUB_OUTPUT" - - push-images: - needs: setup - strategy: - matrix: - image: - - controller - - ui - - kagent-adk - - golang-adk - - claude-harness - - codex-harness - - sandbox-guest - runs-on: blacksmith-2vcpu-ubuntu-2404 - permissions: - contents: read - packages: write - steps: - - name: 'Checkout GitHub Action' - uses: actions/checkout@v6 - - - uses: ./.github/actions/publish-image - with: - version: ${{ needs.setup.outputs.version }} - image: ${{ matrix.image }} - token: ${{ secrets.GITHUB_TOKEN }} - - push-helm-chart: - needs: - - setup - - push-images - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - name: 'Checkout GitHub Action' - uses: actions/checkout@v6 - - - uses: ./.github/actions/publish-helm - with: - version: ${{ needs.setup.outputs.version }} - token: ${{ secrets.GITHUB_TOKEN }} - - release-python-packages: - needs: setup - env: - VERSION: ${{ needs.setup.outputs.version }} - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - name: 'Checkout GitHub Action' - uses: actions/checkout@v6 - - name: Install uv - uses: astral-sh/setup-uv@v7 - - name: 'Release Python Packages' - working-directory: python - run: | - uv sync --all-extras - # Bump every package version to $VERSION first - uv version $VERSION --package kagent-core - uv version $VERSION --package kagent-skills - uv version $VERSION --package kagent-adk - uv version $VERSION --package kagent-langgraph - uv version $VERSION --package kagent-crewai - uv version $VERSION --package kagent-openai - uv version $VERSION --package agentsts-core - uv version $VERSION --package agentsts-adk - # Update intra-repo dependency lower bounds to match the new version. - # Without this, a user upgrading e.g. kagent-adk via pip/uv could have - # older intra-repo siblings still locked at a stale version that may be - # missing symbols the new package requires. - for pkg in kagent-core kagent-skills agentsts-core agentsts-adk; do - find packages -name "pyproject.toml" \ - -exec sed -i "s/\"${pkg}>=[^\"]*\"/\"${pkg}>=${VERSION}\"/g" {} + - done - # Build all packages - uv build --package kagent-core - uv build --package kagent-skills - uv build --package kagent-adk - uv build --package kagent-langgraph - uv build --package kagent-crewai - uv build --package kagent-openai - uv build --package agentsts-core - uv build --package agentsts-adk - uv publish --token ${{ secrets.PYPI_TOKEN }} - - build-release-artifacts: - needs: setup - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@v6 - - uses: ./.github/actions/build-release-artifacts - with: - version: ${{ needs.setup.outputs.version }} - - uses: actions/upload-artifact@v7 - with: - name: release-artifacts - path: | - go/core/bin/kagent-* - dist/kagent-*.tgz - if-no-files-found: error - - release: - # Publish after all packages and release artifacts are ready. - needs: - - build-release-artifacts - - push-helm-chart - - release-python-packages - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/download-artifact@v8 - with: - name: release-artifacts - - name: Release - uses: softprops/action-gh-release@v3 - if: startsWith(github.ref, 'refs/tags/') - with: - generate_release_notes: true - files: | - go/core/bin/kagent-* - dist/kagent-*.tgz diff --git a/.github/workflows/zz_generated.add-team-labels.yaml b/.github/workflows/zz_generated.add-team-labels.yaml new file mode 100644 index 0000000000..f57a3e245c --- /dev/null +++ b/.github/workflows/zz_generated.add-team-labels.yaml @@ -0,0 +1,58 @@ +name: Add appropriate labels to issue + +on: + issues: + types: [assigned] + +jobs: + build_user_list: + name: Get yaml config of GS users + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Get user-mapping + env: + GH_TOKEN: ${{ secrets.ISSUE_AUTOMATION }} + run: | + mkdir -p artifacts + gh api -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" \ + /repos/giantswarm/github/contents/tools/issue-automation/user-mapping.yaml \ + | jq -r '.content' \ + | base64 -d > artifacts/users.yaml + - name: Upload Artifact + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: users + path: artifacts/users.yaml + retention-days: 1 + + add_label: + name: Add team label when assigned + runs-on: ubuntu-latest + needs: build_user_list + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + id: download-users + with: + name: users + - name: Find team label based on user names + run: | + event_assignee=$(cat $GITHUB_EVENT_PATH | jq -r .assignee.login | tr '[:upper:]' '[:lower:]') + echo "Issue assigned to: ${event_assignee}" + + TEAMS=$(cat ${{steps.download-users.outputs.download-path}}/users.yaml | tr '[:upper:]' '[:lower:]' | yq ".${event_assignee}.teams" -o csv | tr ',' ' ') + + echo "LABEL<> $GITHUB_ENV + for team in ${TEAMS}; do + echo "Team: ${team} | Label: team/${team}" + echo "team/${team}" >> $GITHUB_ENV + done + echo "EOF" >> $GITHUB_ENV + - name: Apply label to issue + if: ${{ env.LABEL != '' && env.LABEL != 'null' && env.LABEL != null }} + uses: actions-ecosystem/action-add-labels@18f1af5e3544586314bbe15c0273249c770b2daf # v1.1.3 + with: + github_token: ${{ secrets.ISSUE_AUTOMATION }} + labels: | + ${{ env.LABEL }} diff --git a/.github/workflows/zz_generated.add-to-project-board.yaml b/.github/workflows/zz_generated.add-to-project-board.yaml new file mode 100644 index 0000000000..31a91981e8 --- /dev/null +++ b/.github/workflows/zz_generated.add-to-project-board.yaml @@ -0,0 +1,97 @@ +name: Add Issue to Project when assigned + +on: + issues: + types: + - assigned + - labeled + +jobs: + build_user_list: + name: Get yaml config of GS users + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Get user-mapping + env: + GH_TOKEN: ${{ secrets.ISSUE_AUTOMATION }} + run: | + mkdir -p artifacts + gh api -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" \ + /repos/giantswarm/github/contents/tools/issue-automation/user-mapping.yaml \ + | jq -r '.content' \ + | base64 -d > artifacts/users.yaml + - name: Upload Artifact + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: users + path: artifacts/users.yaml + retention-days: 1 + - name: Get label-mapping + env: + GH_TOKEN: ${{ secrets.ISSUE_AUTOMATION }} + run: | + mkdir -p artifacts + gh api -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" \ + /repos/giantswarm/github/contents/tools/issue-automation/label-mapping.yaml \ + | jq -r '.content' \ + | base64 -d > artifacts/labels.yaml + - name: Upload Artifact + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: labels + path: artifacts/labels.yaml + retention-days: 1 + + add_to_personal_board: + name: Add issue to personal board + runs-on: ubuntu-latest + needs: build_user_list + if: github.event.action == 'assigned' + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + id: download-users + with: + name: users + - name: Find personal board based on user names + run: | + event_assignee=$(cat $GITHUB_EVENT_PATH | jq -r .assignee.login | tr '[:upper:]' '[:lower:]') + echo "Issue assigned to: ${event_assignee}" + + BOARD=($(cat ${{steps.download-users.outputs.download-path}}/users.yaml | tr '[:upper:]' '[:lower:]' | yq ".${event_assignee}.personalboard")) + echo "Personal board URL: ${BOARD}" + + echo "BOARD=${BOARD}" >> $GITHUB_ENV + - name: Add issue to personal board + if: ${{ env.BOARD != 'null' && env.BOARD != '' && env.BOARD != null }} + uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2.0.0 + with: + project-url: ${{ env.BOARD }} + github-token: ${{ secrets.ISSUE_AUTOMATION }} + + add_to_team_board: + name: Add issue to team board + runs-on: ubuntu-latest + needs: build_user_list + if: github.event.action == 'labeled' + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + id: download-labels + with: + name: labels + - name: Find team board based on label + run: | + event_label=$(cat $GITHUB_EVENT_PATH | jq -r .label.name | tr '[:upper:]' '[:lower:]') + echo "Issue labelled with: ${event_label}" + + BOARD=($(cat ${{steps.download-labels.outputs.download-path}}/labels.yaml | tr '[:upper:]' '[:lower:]' | yq ".[\"${event_label}\"].projectboard")) + echo "Team board URL: ${BOARD}" + + echo "BOARD=${BOARD}" >> $GITHUB_ENV + - name: Add issue to team board + if: ${{ env.BOARD != 'null' && env.BOARD != '' && env.BOARD != null }} + uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2.0.0 + with: + project-url: ${{ env.BOARD }} + github-token: ${{ secrets.ISSUE_AUTOMATION }} diff --git a/.github/workflows/zz_generated.auto_release.yaml b/.github/workflows/zz_generated.auto_release.yaml new file mode 100644 index 0000000000..ea92a53996 --- /dev/null +++ b/.github/workflows/zz_generated.auto_release.yaml @@ -0,0 +1,549 @@ +# DO NOT EDIT. Generated with: +# +# devctl +# +# https://github.com/giantswarm/devctl/blob/d712a93893c8b9dfc099a157d3ebd2da8300d0d1/pkg/gen/input/workflows/internal/file/auto_release.yaml.template +# +# yamllint disable rule:truthy +# Push-based release tagger + release-page publisher. +# +# Runs on push to main (normal releases) and to backport branches +# (release-2.x, release-v3.7.x, etc. -- see `on:` block below for the +# exact pattern). Inspects conventional commits reachable from the pushed +# ref since the latest reachable v*.*.* tag via git-cliff, and: +# +# 1. creates the next vX.Y.Z-rc.N git tag if a bump is warranted: every +# releasable push cuts a release candidate +# 2. creates the matching GitHub Release with notes generated by git-cliff, +# flagged as a pre-release +# 3. checks that CircleCI picked the tag up and triggers the pipeline if +# the webhook got lost (repos with a .circleci/config.yml only) +# +# A stable vX.Y.Z is cut only by running this workflow by hand with +# `release-type: stable`, which promotes the latest release candidate: the +# stable tag lands on that candidate's commit, so what ships is what was +# tested. +# +# The tag push then triggers downstream publishing (for chart/service/CLI +# repos, this is the CircleCI architect pipeline firing on the same /^v.*/ +# tag filter -- go-build + push-to-registries + push-to-app-catalog + +# upload-release-assets as appropriate for the repo type). Architect appends +# any binary artifacts to the release we just created here. +name: Auto-release + +on: + push: + branches: + - giantswarm + # A fork line's maintenance branches, release-X.Y matched whole: each + # releases the patches of its own X.Y series, whose highest stable tag + # the baseline step below takes instead of the line's. + - 'release-[0-9]+.[0-9]+' + # `auto` applies the same rule as a push, which is what a re-run wants. + # `stable` promotes the latest release candidate since the last stable + # release, and fails when there is none. + workflow_dispatch: + inputs: + release-type: + description: 'Which kind of release to cut' + type: choice + options: [auto, stable] + default: auto + +permissions: + contents: write # push tags and create the GitHub Release + # git-cliff's GitHub API lookups (commit.remote.pr_number / username + # in release notes) need PR read access via GITHUB_TOKEN. + pull-requests: read + # The stable run reads the commit statuses of the candidate it promotes. + statuses: read + +# Scope the concurrency lock per branch so a backport tag on release-2.x +# doesn't block (or get blocked by) a main release, and vice versa. A second +# push to the same branch while a tag is being computed still waits. +concurrency: + group: auto-release-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + tag: + name: Tag + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # full history so git-cliff sees every commit + fetch-tags: true # ensure tags are present (shallow runners drop them by default) + + # Install git-cliff once and call it from shell so we can do all the + # expensive work (commits walk + GitHub API PR lookups) in a single + # invocation, then cheaply re-render from the cached JSON context. + - name: Install git-cliff + uses: giantswarm/install-binary-action@5bef88f65012037dd836117c8d344b21bb559854 # v4.1.0 + with: + binary: git-cliff + version: "2.13.1" + # yamllint disable-line rule:line-length + download_url: 'https://github.com/orhun/git-cliff/releases/download/v${version}/git-cliff-${version}-x86_64-unknown-linux-gnu.tar.gz' + tarball_binary_path: 'git-cliff-${version}/git-cliff' + smoke_test: '${binary} --version' + + # A fork line re-pins by rebasing its branch onto a new upstream + # commit, and the line's earlier tags stop being reachable; the + # upstream tags the mirror copies are reachable but belong to + # upstream's series, a major below the line's. The line's baseline is + # therefore its highest stable tag. One a re-pin left behind is moved, + # in this checkout only (the release is created through the API, no tag + # is pushed from here), onto the commit the branch still shares with + # it, so the re-pin releases what changed since: at least a minor for + # upstream's features. git-cliff counts the tags of the line's major + # alone, and the steps below read the baseline from LINE_TAG instead + # of `git describe`. + - name: Resolve the fork line's baseline + id: line + run: | + set -euo pipefail + stable='^v[0-9]+\.[0-9]+\.[0-9]+$' + # A maintenance branch release-X.Y releases the X.Y series alone: + # its baseline is the series' highest stable tag, git-cliff counts + # the series' tags, and every release is a patch, since the next + # minor of the line is the consumed branch's to cut. + series="" + if [[ "$GITHUB_REF_NAME" =~ ^release-([0-9]+)\.([0-9]+)$ ]]; then + series="v${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" + stable="^v${BASH_REMATCH[1]}\.${BASH_REMATCH[2]}\.[0-9]+$" + fi + line=$(git tag --list 'v*.*.*' --sort=-v:refname | { grep -E "$stable" || true; } | head -1) + if [ -z "$line" ]; then + if [ -n "$series" ]; then + echo "::error title=No series baseline::${GITHUB_REF_NAME} releases the ${series} series, which has no stable ${series}.Z tag to count from: cut ${series}.0 on giantswarm first." + exit 1 + fi + echo "no stable tag yet: the line starts from inception" + exit 0 + fi + if ! git merge-base --is-ancestor "$line" HEAD; then + base=$(git merge-base "$line" HEAD) + git tag --force "$line" "$base" >/dev/null + echo "::notice title=Re-pinned line::${line} is not on ${GITHUB_REF_NAME} since a re-pin; the release counts from $(git log -1 --format='%h %s' "$base"), the commit the branch shares with it." + echo "LINE_REPINNED=true" >> "$GITHUB_ENV" + fi + major=${line%%.*} + pattern="^${major}\.[0-9]+\.[0-9]+$" + if [ -n "$series" ]; then + pattern=$stable + echo "LINE_BUMP=patch" >> "$GITHUB_ENV" + fi + echo "line baseline: ${line}, tags matching ${pattern}" + { + echo "LINE_TAG=${line}" + echo "GIT_CLIFF__GIT__TAG_PATTERN=${pattern}" + } >> "$GITHUB_ENV" + + # A stable release promotes the latest release candidate since the last + # stable release rather than tagging the branch head: whatever merged + # after that candidate has not run as one. Checking the candidate out + # before git-cliff runs keeps the version, the notes and the tag's + # commit all on what was tested. + - name: Resolve the candidate to promote + id: promote + if: inputs.release-type == 'stable' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + # The same baseline as the decide step below. + last=${LINE_TAG:-$(git describe --tags --abbrev=0 --match='v*.*.*' --exclude='*-*' --exclude='*+*' 2>/dev/null || echo "")} + # `--merged HEAD` keeps a candidate cut on another branch out; + # `--no-merged` drops the candidates the last stable release already + # contains. `sort -V` orders rc.10 after rc.9 and a moved target + # (v2.0.0-rc.1 after v1.3.0-rc.2) after the one it replaced. + # `|| true`: grep exits 1 when there is no candidate at all. + rc=$(git tag --list --merged HEAD ${last:+--no-merged "$last"} 'v*.*.*-rc.*' \ + | { grep -E -- '-rc\.[0-9]+$' || true; } | sort -V | tail -1) + if [ -z "$rc" ]; then + echo "::error title=No release candidate to promote::no release candidate since ${last:-inception} on ${GITHUB_REF_NAME}. A releasable push cuts one; promote it once it is good." + exit 1 + fi + + # The tag alone does not say the candidate was built. Its GitHub + # pre-release is created with the tag, and the pipelines that build + # and publish it report on its commit: a failed or still running one + # refuses the promotion. A commit no pipeline reports on has nothing + # to wait for. + if ! prerelease=$(gh release view "$rc" --json isPrerelease --jq .isPrerelease 2>/dev/null); then + echo "::error title=Candidate not released::${rc} has no GitHub release; the run that cut it did not finish. Cut a new candidate." + exit 1 + fi + if [ "$prerelease" != true ]; then + echo "::error title=Candidate not a pre-release::the GitHub release of ${rc} is not a pre-release." + exit 1 + fi + if ! status=$(gh api "repos/${GH_REPO}/commits/${rc}/status" --jq '"\(.state) \(.total_count)"'); then + echo "::error title=Candidate status unreadable::the commit statuses of ${rc} could not be read (the job needs statuses: read); nothing is promoted." + exit 1 + fi + read -r state count <<<"$status" + if [ "$count" != 0 ] && [ "$state" != success ]; then + echo "::error title=Candidate not built::the commit statuses of ${rc} are ${state}: https://github.com/${GH_REPO}/commit/$(git rev-parse "${rc}^{commit}"). Promote it once its pipelines passed." + exit 1 + fi + + after=$(git log --format='%h %s' "${rc}..HEAD") + git checkout -q --detach "$rc" + echo "promoting ${rc} at $(git rev-parse --short HEAD)" + if [ -n "$after" ]; then + echo "not in this release, merged after ${rc}; they go in the next candidate:" + echo "$after" + { + echo "### Not in this release" + echo "Merged after ${rc}; they go in the next candidate." + echo '```' + echo "$after" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + fi + echo "rc=${rc}" >> "$GITHUB_OUTPUT" + + - name: Compute next version + id: cliff + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + # Single expensive call: walks commits, queries the GitHub API for + # per-commit PR lookups, emits the JSON context for ONLY the bumped + # release. + # + # Flag choice matters here. Both `--latest` and `--unreleased` + # *look* like single-release filters, but only one composes with + # `--bump`: + # --latest --bump : returns the latest EXISTING tag -- drops the + # bump silently. We don't want this. + # --unreleased --bump : filters to commits that aren't in any tag + # yet, computes the bumped version from + # them, returns JSON for that bumped + # release only. This is what we want. + # + # Without either filter, --bump --context returns the full release + # history in JSON and the render step below would concatenate every + # past release into each new release's notes. + # + # A maintenance branch bumps the patch alone (LINE_BUMP from the + # baseline step). A fixed bump moves the version whatever the + # commits, so a context without a commit git-cliff counts has + # nothing to release. + git-cliff --unreleased --bump "${LINE_BUMP:-auto}" --context > cliff-context.json + NEXT=$(jq -r 'if (.[0].commits | length) == 0 then empty else .[0].version // empty end' cliff-context.json) + echo "next computed: ${NEXT:-}" + echo "version=${NEXT}" >> "$GITHUB_OUTPUT" + + # Every releasable push cuts the next candidate for the version + # git-cliff computed; `stable` tags that version itself, on the + # candidate the step above checked out. + - name: Decide whether to tag + id: decide + env: + NEXT: ${{ steps.cliff.outputs.version }} + WANT: ${{ inputs.release-type || 'auto' }} + PROMOTE: ${{ steps.promote.outputs.rc }} + CLIFF_CONTEXT: cliff-context.json + run: | + set -euo pipefail + # `git describe --tags --abbrev=0` returns the closest tag reachable + # from HEAD -- NOT the highest tag in the repo overall. This matters + # for backports: on release-2.x, HEAD's reachable history terminates + # before v3.0.0 was tagged, so describe correctly returns v2.3.5 + # (the baseline of the 2.x line) and we end up tagging v2.3.6 rather + # than something nonsensical relative to v3.0.0. + # + # The excludes hold this baseline to the tags cliff.toml's + # `tag_pattern` counts as releases: `*-*` drops a pre-release name, + # `*+*` a build-metadata one. A tag only one of the two recognises + # splits `last` from `NEXT`, the `NEXT = last` comparison below can + # never be true again, and the job tags on every push even when + # there is nothing to release. + last=${LINE_TAG:-$(git describe --tags --abbrev=0 --match='v*.*.*' --exclude='*-*' --exclude='*+*' 2>/dev/null || echo "")} + echo "branch: ${GITHUB_REF_NAME}" + echo "last reachable stable tag: ${last:-}" + + unconventional=0 + while IFS= read -r -d '' subject; do + # A leftover -rc type is still conventional: cliff.toml normalises + # it to its plain type. + if ! printf '%s' "$subject" | grep -qE '^[a-z]+(-rc)?(\([^)]*\))?!?: '; then + # git-cliff drops a subject it cannot parse as conventional + # (`filter_unconventional`): the commit neither bumps the + # version nor appears in any release's notes, silently. The + # title check guards the pull request's title, but a + # one-commit pull request squash-merges under its commit's + # own subject unless the repository names the squash commit + # after the title (squash_merge_commit_title PR_TITLE, the + # repository baseline). Say so where the run is read. + unconventional=$((unconventional + 1)) + echo "::warning title=Unconventional commit subject::\"${subject}\" is not a conventional commit: git-cliff neither releases nor lists it. A one-commit pull request merges under its commit's subject unless the repository's squash commit title is PR_TITLE." + fi + done < <(git log ${last:+"${last}..HEAD"} -z --format='%s') + echo "unreleased commits: ${unconventional} unconventional (neither released nor listed)" + + if [ "$WANT" = stable ]; then + is_rc=false + else + is_rc=true + fi + + # `NEXT` equal to `last` means nothing releasable landed since the + # last stable release, so there is no candidate to cut either: + # without this the run cuts ${last}-rc.$(( n + 1 )), a candidate for + # a version that already shipped. + if [ -z "${NEXT}" ] || [ "${NEXT}" = "${last}" ]; then + echo "No releasable commits since ${last:-inception}; skipping tag." + echo "tag=" >> "$GITHUB_OUTPUT" + echo "prerelease=" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ "$is_rc" = true ]; then + # Highest existing RC for this exact target, so a moved target + # (a breaking change during a cycle) restarts at rc.1, and rc.9 is + # followed by rc.10 rather than rc.2. `--merged HEAD` scopes the + # search the way the describe baseline above is scoped: a candidate + # for the same target cut on another branch must not shift the + # numbering on this one. A re-pinned fork line is the exception: + # its earlier candidates for the same target are on the history + # the rebase left behind, unreachable but this branch's own, and + # their tags still exist, so the count takes every tag of the + # target or the push would try to cut a tag that already exists. + # `|| true`: grep exits 1 on the first candidate of a series, + # when no ${NEXT}-rc.* tag exists yet, and pipefail would abort. + scope=(--merged HEAD) + if [ "${LINE_REPINNED:-}" = true ]; then + scope=() + fi + n=$(git tag --list ${scope[@]+"${scope[@]}"} "${NEXT}-rc.*" | sed 's/.*-rc\.//' | { grep -E '^[0-9]+$' || true; } | sort -n | tail -1) + + # `NEXT` stays ahead of `last` until the stable release, so the + # comparison above cannot stop a push that carries only commits + # git-cliff skips (docs, style) or drops as non-conventional. + # Without this check that push spends an rc number, a GitHub + # pre-release and a publish pipeline on a README typo. + # + # cliff-context.json holds exactly the commits git-cliff counted + # for this release, so intersecting it with the range since the + # last candidate needs no second copy of cliff.toml's skip rules. + if [ -n "${n}" ]; then + counted=$(jq -r '.[0].commits[].id' "$CLIFF_CONTEXT") + since=$(git rev-list "${NEXT}-rc.${n}..HEAD") + fresh=0 + if [ -n "$counted" ] && [ -n "$since" ]; then + fresh=$(printf '%s\n' "$since" | { grep -cxF "$counted" || true; }) + fi + if [ "$fresh" -eq 0 ]; then + echo "No releasable commits since ${NEXT}-rc.${n}; skipping tag." + echo "tag=" >> "$GITHUB_OUTPUT" + echo "prerelease=" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + tag="${NEXT}-rc.$(( ${n:-0} + 1 ))" + else + # git-cliff ran on the promoted candidate, so it has to land on + # that candidate's own version. A mismatch means the candidate's + # tag and its commits disagree; tagging either would ship a + # version nobody tested. + if [ "${PROMOTE%-rc.*}" != "${NEXT}" ]; then + echo "::error title=Candidate does not match::${PROMOTE} is a candidate for ${PROMOTE%-rc.*}, but its commits release ${NEXT}." + exit 1 + fi + tag="${NEXT}" + fi + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + echo "prerelease=${is_rc}" >> "$GITHUB_OUTPUT" + echo "target=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + # cliff-context.json names the stable target git-cliff bumped to, and + # the tag actually cut is ${NEXT}-rc.N for a candidate. cliff.toml puts + # `{{ version }}` in the "Full Changelog" compare link, so the context has + # to carry the chosen tag before the render or the link points at a tag + # that does not exist. The render reads the cached context, so the one + # expensive git-cliff call above stays the only one. + - name: Render release notes + id: notes + if: steps.decide.outputs.tag != '' + env: + TAG: ${{ steps.decide.outputs.tag }} + run: | + set -euo pipefail + # `error` rather than a silent `[{version: $tag}]`: jq builds that + # object out of an empty context, and the render would then produce + # empty notes for a tag that is about to be created. + jq --arg tag "$TAG" \ + 'if (.[0].version // "") == "" then error("cliff-context.json carries no version") else .[0].version = $tag end' \ + cliff-context.json > cliff-context.tagged.json + # Cheap render from the cached context -- no API calls, no git walk. + # `--strip all` drops header/footer; body-only is what gh release + # create --notes-file expects. + git-cliff --from-context cliff-context.tagged.json --strip all --output release-notes.md + + # Create the tag AND the GitHub Release in one atomic API call. GitHub's + # release-create endpoint accepts a tag_name + target_commitish and, if + # the tag doesn't already exist, creates it pointing at that commitish + # as part of the same operation. Either both exist or neither does -- + # no risk of a tag-without-release "stuck" state, no race window with + # CircleCI's `upload-release-assets` job that starts polling the + # release the instant it sees the tag. + # + # Trade-off: the tag created this way is **lightweight** (just a ref), + # not annotated. For our use -- git describe, architect's tag filter, + # gitsemver, gh release view -- both behave identically. Nothing in the + # giantswarm stack distinguishes them. + - name: Create release (and the tag, atomically) + id: release + if: steps.decide.outputs.tag != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ steps.decide.outputs.tag }} + PRERELEASE: ${{ steps.decide.outputs.prerelease }} + TARGET: ${{ steps.decide.outputs.target }} + run: | + set -euo pipefail + # A release candidate is flagged as a GitHub pre-release so it does + # not surface as the repository's "Latest release". + flags=() + if [ "$PRERELEASE" = "true" ]; then + flags+=(--prerelease) + fi + # GitHub marks a stable release as "Latest" by default. A release of + # a maintenance branch (release-v3.x) is an older line's: it must not + # take the marker from the release branch's newest stable, which + # `gh release view`, releases/latest and install scripts resolve to. + if [ "$GITHUB_REF_NAME" != 'giantswarm' ]; then + flags+=(--latest=false) + fi + gh release create "$TAG" \ + --title "$TAG" \ + --notes-file release-notes.md \ + --target "$TARGET" \ + "${flags[@]}" + + # A tag that never reaches CircleCI is a silent failure: the GitHub + # Release above exists and looks finished, but nothing gets built or + # published. GitHub delivers the tag-push webhook exactly once and never + # retries it; on 2026-09-04 ~22:00 UTC CircleCI's receiver answered + # three of ~55 tag pushes with an empty HTTP 400 and those releases had + # no pipeline until somebody noticed. So ask CircleCI directly: poll its + # pipeline list for this tag and, if nothing shows up, trigger the + # pipeline through the API. + # + # CIRCLECI_API_TOKEN is an org-level secret holding a CircleCI personal + # API token (a machine account's -- CircleCI's v2 API accepts no other + # kind for triggering, and needs one to read private projects). Without + # it the step still reads public projects and stays a detector: a + # missing pipeline fails the run with the exact command to fix it by + # hand. Private projects and repos without a CircleCI project both read + # as 404 unauthenticated, so without the token that case is a warning. + # + # Only a positively observed empty list (HTTP 200) triggers: a CircleCI + # API outage fails the step instead of risking a second pipeline on the + # tag, which would race the image and chart pushes. + - name: Verify CircleCI picked up the tag + if: steps.decide.outputs.tag != '' && hashFiles('.circleci/config.yml') != '' + env: + TAG: ${{ steps.decide.outputs.tag }} + CIRCLECI_API_TOKEN: ${{ secrets.CIRCLECI_API_TOKEN }} + # A healthy CircleCI creates the pipeline 1-2 s after the tag push. + CIRCLECI_PIPELINE_WAIT_SECONDS: "120" + run: | + set -euo pipefail + api="https://circleci.com/api/v2/project/gh/${GITHUB_REPOSITORY}/pipeline" + auth=() + if [ -n "${CIRCLECI_API_TOKEN}" ]; then + auth=(-H "Circle-Token: ${CIRCLECI_API_TOKEN}") + fi + manual="curl -X POST -H 'Circle-Token: ' -H 'Content-Type: application/json' -d '{\"tag\":\"${TAG}\"}' ${api}" + + deadline=$((SECONDS + CIRCLECI_PIPELINE_WAIT_SECONDS)) + last_code="" + found="" + while :; do + # Newest first; the tag pipeline is created within seconds of the + # push, so two pages (40 pipelines) cover even a merge burst. + url="$api" + pages=0 + while [ -n "$url" ] && [ "$pages" -lt 2 ]; do + resp=$(curl -sS "${auth[@]}" -w '\n%{http_code}' "$url" || printf '\n000') + last_code=${resp##*$'\n'} + body=${resp%$'\n'*} + [ "$last_code" = "200" ] || break + found=$(jq -r --arg tag "$TAG" '[.items[] | select(.vcs.tag == $tag)][0] | select(. != null) | .number' <<<"$body") + [ -z "$found" ] || break + next=$(jq -r '.next_page_token // empty' <<<"$body") + url=${next:+"${api}?page-token=${next}"} + pages=$((pages + 1)) + done + if [ -n "$found" ]; then + echo "CircleCI pipeline #${found} exists for ${TAG}: https://app.circleci.com/pipelines/github/${GITHUB_REPOSITORY}/${found}" + exit 0 + fi + case "$last_code" in + 200) ;; # positively empty; keep waiting + 404|401|403) break ;; # will not change by waiting + esac + if [ "$SECONDS" -ge "$deadline" ]; then + break + fi + echo "no CircleCI pipeline for ${TAG} yet (HTTP ${last_code}); retrying in 10 s" + sleep 10 + done + + case "$last_code" in + 200) + if [ -z "${CIRCLECI_API_TOKEN}" ]; then + echo "::error::CircleCI never built ${TAG}: no pipeline ${CIRCLECI_PIPELINE_WAIT_SECONDS} s after the tag push" \ + "(GitHub delivers the webhook once and never retries) and no CIRCLECI_API_TOKEN secret to trigger one." \ + "Fix by hand: ${manual}" + exit 1 + fi + echo "CircleCI never built ${TAG}; triggering the pipeline through the API" + resp=$(curl -sS "${auth[@]}" -w '\n%{http_code}' -X POST -H 'Content-Type: application/json' -d "{\"tag\":\"${TAG}\"}" "$api" || printf '\n000') + code=${resp##*$'\n'} + body=${resp%$'\n'*} + if [ "$code" != "201" ]; then + echo "::error::triggering the CircleCI pipeline for ${TAG} failed (HTTP ${code}): ${body}" + exit 1 + fi + number=$(jq -r .number <<<"$body") + echo "triggered CircleCI pipeline #${number} for ${TAG}: https://app.circleci.com/pipelines/github/${GITHUB_REPOSITORY}/${number}" + ;; + 404) + if [ -z "${CIRCLECI_API_TOKEN}" ]; then + echo "::warning::cannot verify that CircleCI built ${TAG}: ${api} answers 404 without a token" \ + "(private project, or no CircleCI project for this repo)." \ + "Set the CIRCLECI_API_TOKEN org secret to make this check real." + exit 0 + fi + # The first tag of a repository created pull-request-last lands + # before the repository set-up reconciler follows the project on + # CircleCI; the reconciler reports the missed tag build and never + # rebuilds a tag. Following takes an administrator's token this + # workflow does not hold, so this is not the workflow's to fix. + echo "::warning::${TAG} is released; CircleCI does not follow this project yet." \ + "The repository set-up reconciler follows it and reports the missed tag build —" \ + "cut the next tag, or trigger this tag's pipeline by hand once the project is followed: https://app.circleci.com/pipelines/github/${GITHUB_REPOSITORY}" + exit 0 + ;; + 401|403) + echo "::error::CircleCI rejected CIRCLECI_API_TOKEN (HTTP ${last_code}); cannot verify that ${TAG} was built." + exit 1 + ;; + *) + echo "::error::could not reach the CircleCI API (last HTTP ${last_code}) within ${CIRCLECI_PIPELINE_WAIT_SECONDS} s;" \ + "cannot verify that ${TAG} was built. Not triggering blindly (a second pipeline on the tag would race" \ + "the image and chart pushes). Check https://app.circleci.com/pipelines/github/${GITHUB_REPOSITORY}" \ + "and, if the tag is missing: ${manual}" + exit 1 + ;; + esac diff --git a/.github/workflows/zz_generated.semantic_pull_request.yaml b/.github/workflows/zz_generated.semantic_pull_request.yaml new file mode 100644 index 0000000000..aa44c5bdea --- /dev/null +++ b/.github/workflows/zz_generated.semantic_pull_request.yaml @@ -0,0 +1,45 @@ +# DO NOT EDIT. Generated with: +# +# devctl +# +# https://github.com/giantswarm/devctl/blob/6bd76e24b11b67d6d99715b8f484f454e05abbd9/pkg/gen/input/workflows/internal/file/semantic_pull_request.yaml.template +# +name: semantic-pull-request + +on: + pull_request: + types: + - opened + - edited + - synchronize + merge_group: {} + +permissions: {} + +jobs: + semantic-pull-request: + uses: giantswarm/github-workflows/.github/workflows/semantic-pull-request.yaml@main + permissions: + pull-requests: read + with: + # `types` REPLACES the action's default list rather than extending it, so + # all eleven defaults from commitizen/conventional-commit-types are + # repeated here, plus the additions below. Entries are regexes the action + # wraps in `^ $`. + # + # `security` is not one of the action's defaults and has to be listed to + # be accepted at all. cliff.toml maps it to the Security changelog group, + # which no title can reach while the type is rejected. + types: | + feat + fix + docs + style + refactor + perf + test + build + ci + chore + revert + security diff --git a/.gitignore b/.gitignore index bdb34f29ba..c6fc4222a8 100644 --- a/.gitignore +++ b/.gitignore @@ -223,3 +223,6 @@ python/packages/kagent-adk/tests/fixtures/certs/*.srl ## Upgrade test: previous-release worktree (created by `make run-upgrade-tests`) .upgrade-prev/ + +## Telemetry contract: its upstream registries and Weaver policies at their pins (fetched by `make semconv-deps`) +/telemetry/deps/ diff --git a/.govulncheck-ignore b/.govulncheck-ignore new file mode 100644 index 0000000000..18d7f5740c --- /dev/null +++ b/.govulncheck-ignore @@ -0,0 +1,19 @@ +# Time-boxed govulncheck ignores for the Giant Swarm kagent line (FORK.md, "CI and +# security"; scripts/fork/govulncheck.sh applies them). One per line: +# GO-YYYY-NNNN until=YYYY-MM-DD # : why unresolvable, what to track +# An entry is re-reviewed at its date; a fixable finding is bumped, never extended. +# +# github.com/ollama/ollama@v0.32.15 — kagent imports the Ollama API *client* +# (adk/pkg/models, adk/pkg/embedding, core/internal/service/model); the eight +# advisories describe the Ollama *server* (authentication, token exposure, DoS, +# path handling) and the Go vulnerability database marks every one "fixed: N/A", +# so no module version clears them. Re-review when upstream bumps the module or +# the database records a fixed version; tracked in giantswarm/giantswarm#37742. +GO-2025-4251 until=2026-12-31 # github.com/ollama/ollama@v0.32.15: server-side, no fixed version, client import only +GO-2025-3824 until=2026-12-31 # github.com/ollama/ollama@v0.32.15: server-side, no fixed version, client import only +GO-2025-3695 until=2026-12-31 # github.com/ollama/ollama@v0.32.15: server-side, no fixed version, client import only +GO-2025-3689 until=2026-12-31 # github.com/ollama/ollama@v0.32.15: server-side, no fixed version, client import only +GO-2025-3582 until=2026-12-31 # github.com/ollama/ollama@v0.32.15: server-side, no fixed version, client import only +GO-2025-3559 until=2026-12-31 # github.com/ollama/ollama@v0.32.15: server-side, no fixed version, client import only +GO-2025-3558 until=2026-12-31 # github.com/ollama/ollama@v0.32.15: server-side, no fixed version, client import only +GO-2025-3557 until=2026-12-31 # github.com/ollama/ollama@v0.32.15: server-side, no fixed version, client import only diff --git a/.nancy-ignore.generated b/.nancy-ignore.generated new file mode 100644 index 0000000000..b4df6fc2e3 --- /dev/null +++ b/.nancy-ignore.generated @@ -0,0 +1,2 @@ +# This file is generated by https://github.com/giantswarm/github +# Repository specific ignores should be added to .nancy-ignore diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000..50467e7773 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,43 @@ +# This file is maintained centrally at +# https://github.com/giantswarm/github/blob/main/languages/go/.pre-commit-config.yaml + +minimum_pre_commit_version: '2.17' +repos: + # shell scripts + - repo: https://github.com/detailyang/pre-commit-shell + rev: '1.0.5' + hooks: + - id: shell-lint + args: [ --format=json ] + exclude: ".*\\.template" + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-added-large-files + - id: check-merge-conflict + - id: check-shebang-scripts-are-executable + - id: detect-private-key + - id: end-of-file-fixer + exclude: ".*testdata/.*" + - id: mixed-line-ending + - id: trailing-whitespace + exclude: ".*testdata/.*" + + - repo: https://github.com/dnephin/pre-commit-golang + rev: v0.5.1 + hooks: + - id: go-fmt + - id: go-mod-tidy + - id: golangci-lint + args: + - -E=gosec + - -E=goconst + - -E=govet + # timeout is needed for CI + - --timeout=300s + # List all issues found + - --max-same-issues=0 + - --max-issues-per-linter=0 + - id: go-imports + args: [ -local, github.com/giantswarm/kagent-upstream ] diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000000..7dd191a958 --- /dev/null +++ b/.trivyignore @@ -0,0 +1,14 @@ +# Time-boxed Trivy ignores for the Giant Swarm kagent line (FORK.md, "CI and security"). +# Trivy reads this file from the repository root; `exp:` is Trivy's own expiry — an +# expired line stops ignoring. Only findings with no fix path belong here; a fixable +# finding is fixed. Re-review each entry at its date. +# +# istio.io/istio v0.0.0-20260820015531-47320ba1a73b (controller binary): the module has +# no Go-module semver releases (istio tags are `1.x.y`, not module versions), so kagent +# can only depend on a pseudo-version, which Trivy compares below every fixed version +# (1.1.13 … 1.13.1) although the pinned commit (2026-08-20) is years past those fixes. +# False positive by version comparison; clears when Trivy/istio publish module versions. +CVE-2019-14993 exp:2026-12-31 +CVE-2021-39155 exp:2026-12-31 +CVE-2021-39156 exp:2026-12-31 +CVE-2022-23635 exp:2026-12-31 diff --git a/CODEOWNERS b/CODEOWNERS index 1a39418bbc..316dd43b2b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,9 +1,2 @@ -/* @kagent-dev/maintainers -.github/ @kagent-dev/maintainers @Charlesthebird -python/ @kagent-dev/maintainers @kagent-dev/developers -go/ @kagent-dev/maintainers -ui/ @peterj @Charlesthebird -helm/ @kagent-dev/maintainers @kagent-dev/developers -go/adk/ @kagent-dev/maintainers @supreme-gg-gg @dimetron -go/core/ @kagent-dev/maintainers @kagent-dev/developers -go/api/ @kagent-dev/maintainers @kagent-dev/developers +# generated by giantswarm/github actions - changes will be overwritten +* @giantswarm/team-bumblebee diff --git a/DCO b/DCO new file mode 100644 index 0000000000..716561d5d2 --- /dev/null +++ b/DCO @@ -0,0 +1,36 @@ +Developer Certificate of Origin +Version 1.1 + +Copyright (C) 2004, 2006 The Linux Foundation and its contributors. +660 York Street, Suite 102, +San Francisco, CA 94110 USA + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + + +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +(b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index ecd10cf4ca..d882c9227c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -258,7 +258,11 @@ make semconv-verify # what CI runs: check, policy tests, generate, drift che These targets need either Docker or a local `weaver` of exactly the version in `telemetry/versions.env`. A different local version is refused, so a green run -on a laptop means the same as in CI. See +on a laptop means the same as in CI. The upstream sources of the contract (the +registries the manifest depends on and the shared Weaver policies) are fetched +once, at the refs pinned in the same file, into the git-ignored `telemetry/deps` +(`make semconv-deps`, which `semconv-check` and `semconv-generate` run); from then on the targets need +no GitHub access. See [docs/architecture/telemetry.md](docs/architecture/telemetry.md) for the contract. To look at traces locally, `make otel-local` starts Jaeger with an OTLP receiver diff --git a/FORK.md b/FORK.md new file mode 100644 index 0000000000..a0dd2cb5a8 --- /dev/null +++ b/FORK.md @@ -0,0 +1,420 @@ +# The Giant Swarm kagent line + +This repository is the kagent the [Giant Swarm Agent Platform](https://github.com/giantswarm/agent-platform) +runs: upstream [kagent-dev/kagent](https://github.com/kagent-dev/kagent) `main` at a pinned commit, plus the +patches the platform cannot run without until they are merged upstream. Team Bumblebee owns it. The line +exists because the platform moved to the kagent API v2 (`kagent.dev/v1alpha3`, Substrate actors) before +upstream has released it; the day upstream ships a release that contains every carried patch, the platform +switches to that release and this line ends. + +This repository is public, and so is this file: a row's context and evidence columns name no installation, +customer, management cluster or employee. An environment appears by its role (a production installation, a +test installation), a customer by none; the upstream and giantswarm issues and pull requests that carry the +detail are linked instead. + +| | | +|---|---| +| Consumed branch (and default branch) | `giantswarm` (the same name as the Substrate line's) — what the release tags are cut from, and what the platform's dev channel and agentlab follow between releases | +| Mirror | `main`, every `release/v*.x` branch and every tag — read-only mirrors of upstream, fast-forwarded by the sync workflow, never edited (upstream's CI reads release branches and tags to pick the upgrade-test baseline) | +| Maintenance branch | `release-1.0` — the 1.0 line, for installations whose kagent still speaks the v0.0.29 ate-api contract (the agent-platform meta chart's `>=1.0.0 <1.1.0`): `v1.0.1` plus what a 1.0.x patch needs, cut on 2026-09-23 because `giantswarm` moved to upstream `main@844ea06b` and kagent-dev/substrate v0.2.0-beta4 the day before, a contract change the Substrate line's `release-1.0` makes the same way. Pull requests against it are rebase-merged like the consumed branch's and run the same suites; its releases are `v1.0.Z` tags on it, published by the same pipeline (a push to it validates, only its tags publish); every fix it carries is also carried on `giantswarm`. Retired once the fleet runs the 1.1 line | +| Maintenance branch | `release-1.1` — the 1.1 line, for installations on the agent-platform meta chart's `>=1.1.0 <1.2.0`: `v1.1.0` plus what a 1.1.x patch needs, cut on 2026-09-25 because `giantswarm` moved to upstream `main@dd3b240` that day, a re-pin whose release is a minor. It carries, rebase-merged in giantswarm/kagent-upstream#105, the 1.1.0 ledger commit (`729f30d9`), the 1.0 schema upgrade migration `000002` (`209fd652`; `41f05e24` on `giantswarm`, giantswarm/kagent-upstream#96) and the a2agateway's runtime-lost refusal (`34dbed7f`; `7eabf64f` on `giantswarm`, giantswarm/kagent-upstream#100). Pull requests against it are rebase-merged and run the same suites; its releases are `v1.1.Z` tags on it, published by the same pipeline; every fix it carries is also carried on `giantswarm`. Retired once the fleet runs the 1.2 line | +| Maintenance branch | `release-1.2` — the 1.2 line, for installations on the agent-platform meta chart's `>=1.2.0 <1.3.0` (the fleet today): `v1.2.2-rc.1`'s commit plus what a 1.2.x patch needs, cut on 2026-10-01, the day `giantswarm` moved on to the 1.3.0 release candidates (`v1.3.0-rc.N`, a re-pin whose release is a minor). Pull requests against it are rebase-merged and run the same suites (`ci.yaml` names it); its releases are `v1.2.Z` tags on it, published by the same pipeline: a merge is released by the Auto Release workflow as the next `v1.2.Z-rc.N`, counted from the highest stable `v1.2.Z` and a patch whatever its type (`gen.ci.maintenanceBranches` in the giantswarm/github team file; the workflow and `cliff.toml` are the rendering of `giantswarm`), and the stable `v1.2.Z` is that workflow dispatched on the branch with `release-type: stable`. Interim, while giantswarm/architect-orb#972 is open: the branch pipeline's release-version guard (architect orb 10.10.0, pinned in `.circleci/config.yml`) fails its eight `validate-{controller,ui,golang-adk,claude-harness}-{amd64,arm64}` jobs on the commit the workflow has just tagged `v1.2.Z-rc.N`, so beside the tag pipeline's fourteen green statuses the candidate's commit carries a combined status of `failure`, and the stable dispatch and `devctl release promote` both take a candidate only at `success`; the stable is an annotated `v1.2.Z` tag on the candidate's commit, pushed by hand after the candidate's agentlab proof (`git tag -a v1.2.Z -m v1.2.Z ` and `git push origin v1.2.Z`), published by the tag pipeline like any tag, with no GitHub release object of its own, as `v1.2.7` was cut on `138ca56f`, the commit of `v1.2.7-rc.1`. The dispatch is the way again, and this interim passage leaves, once `.circleci/config.yml` pins the orb release that fixes the guard (`v1.2.2`: the A2A gateway runs a shared turn in the instance owner's session, giantswarm/kagent-upstream#138, the same commit as #136 on `giantswarm`); every fix it carries is also carried on `giantswarm`. Retired once the fleet runs the 1.3 line | +| Automation branches | `sync/**` (re-pin candidates), `ledger` (machine-written records) — never commit to them by hand | +| Upstream pin | `main@bf8afa56` (2026-10-05, from `main@dd3b2405` of 2026-09-25: 58 upstream commits, among them kagent-dev/kagent#2939 `8be872b2` (the turn runs inside the runtime behind a gRPC TaskStore; the gateway relays the stream), #2952 `907daf90` (explicit `Agent`s and `Session`s under `api.kagent.dev`, no admission labels), #2963 (kagent-dev/substrate v0.3.0-alpha1), #2994 `cb6b756c` (the preparation and retry rewrite), #3004 `936ee56d` (kagent-dev/substrate v0.3.0-alpha3), #2987 `4dce53de` (session share expiry), #2752 `ae2b2149` (the discovery opt-out)). `git merge-base giantswarm main` and the newest row of [`re-pins.md`](../../blob/ledger/re-pins.md) on the `ledger` branch give the commit and date | +| Substrate the pin runs with | the Giant Swarm line's re-base onto kagent-dev/substrate v0.3.0-alpha3, the `go/go.mod` replace target of upstream `main@bf8afa56` itself (kagent-dev/kagent#3004): `go/go.mod` replaces `agent-substrate/substrate` with `github.com/giantswarm/substrate v1.5.0` (the line's release whose Actor API takes the quiescence fencing token, giantswarm/substrate#182) and the Makefile's `SUBSTRATE_VERSION` names the same release, which stamps the published chart's worker image and chart dependency | +| Substrate version the pin runs with | `1.5.0` of the Giant Swarm line [giantswarm/substrate](https://github.com/giantswarm/substrate) (2026-10-08, promoted from `v1.5.0-rc.13`): `1.4.0` plus the Actor API's fencing token on Pause, Suspend and Resume (giantswarm/substrate#182), which the quiescence fence needs, the carried `ExternalSnapshot` source field on its own number with the rows of earlier releases rewritten on read (giantswarm/substrate#165), the OpenFGA schema adopted from OpenFGA's migrations, the pinned images read from a file, the DSN redaction and the line's CI fixes since. Previous: `1.5.0-rc.6` (2026-10-07). `1.4.0` itself (2026-10-05, tag `v1.4.0` on `e27ae953`, the head of its `sync/20261005-v0.3.0-alpha3` force-pushed to `giantswarm` and promoted from `v1.4.0-rc.8`): the line re-based onto kagent-dev/substrate v0.3.0-alpha3 with its 84 carried commits, among them giantswarm/substrate#103 (the carry of agent-substrate/substrate#1918 with #1906 and #1910: the image's `USER` and `WORKDIR` honoured, the merged rootfs root searchable by non-root, durable-dir volumes writable by non-root), #115 (image-layer files keep the owner their layer gives them) and #112 (the actor is told why a credential was refused), and the agentgateway line's 2.3.0 as the atenet data plane. The ate-api contract is v0.3.0-alpha3's, the one upstream kagent `main@bf8afa56` compiles against. The candidates `v1.4.0-rc.1` to `rc.7` (`81abb52e` and before) ran the old contract, upstream v0.2.0-beta5's, on the history the re-base left behind; `rc.8` was tagged by hand on 2026-10-05 because the auto-release counted only the candidates reachable from the rebased branch (giantswarm/devctl#2498). Previous: `1.4.0` (`e27ae953`), `1.4.0-rc.7` (`81abb52e`, moved in giantswarm/kagent-upstream#125), `1.1.0` (`7fd77115`, moved in giantswarm/kagent-upstream#94) | +| Tracking | giantswarm/giantswarm#37010 (the line), giantswarm/giantswarm#37742 (the upstream exit of every patch) | + +## Which kagent are we running + +Carried commits, in the order they sit on top of the pin (`git log --oneline main..giantswarm`). +Their SHAs change at every re-pin; the ledger records the SHAs of each rebase. + +| Carried commit (subject) | Why the platform needs it | Upstream | +|---|---|---| +| `fix(helm): sanitise the chart version in the app.kubernetes.io/version label` | helm-controller installs the chart as `+`, which is not a valid label value | to file; giantswarm/giantswarm#37742 row 6 (kagent-dev/kagent#695 reports the same failure on Flux OCI installs, closed NOT_PLANNED 2026-05) | +| `fix(helm): trim every trailing non-alphanumeric from the truncated label values` | `helm.sh/chart` and `app.kubernetes.io/version` are cut to 63 characters and only a trailing `-` was trimmed; a dev build of a long branch (`0.11.0-dev.fork-repin-substrate-gs5.…`) was cut after a `.` and the apiserver refused every labelled object, so the upgrade failed and rolled back | to file; giantswarm/giantswarm#37742 row 62 | +| `fix(docker): apk upgrade in the Go runtime image` | the same Alpine finding in the controller and Go ADK images (`go/Dockerfile`), which #2756 did not cover | to file; giantswarm/giantswarm#37742 row 20 (upstream's `go/Dockerfile` still has no `apk upgrade`; kagent-dev/kagent#2951 pins packages in `ui/Dockerfile` only) | +| `ci(fork): publish dev images and charts to ghcr.io/ from the poc branch` | the GitHub Actions publish the line began with (`tag.yaml`); superseded by `ci(fork): publish to gsoci from CircleCI` below, which removed the workflow — the commit stays in the stack | fork-only, never upstream | +| `ci(fork): retry image builds and keep the matrix running on a single failure` | hosted-runner flakiness of `apk.cgr.dev`; the retry lives on in the pipeline's image jobs | fork-only | +| `ci(fork): test and scan the consumed branch, re-pin weekly by workflow, keep a ledger` | this document, `ci.yaml`/`image-scan.yaml` on the consumed branch, `sync-upstream.yaml`, the ledger | fork-only | +| `ci(fork): stamp the golang-adk digest and SUBSTRATE_VERSION into the chart at publish` | the published chart carries the index digests of its own runtime images and the Substrate worker image of the Makefile's pin, so the platform's meta chart stops re-pinning by hand what the line already knows (giantswarm/giantswarm#37765) | fork-only | +| `ci(fork): publish to gsoci from CircleCI` | the org publishes to `gsoci.azurecr.io` from CircleCI only — the architect orb's multi-arch builds, cosign keyless signatures under the project's CircleCI identity, SBOM attestations — and never from a GitHub Actions job holding a registry credential. `.circleci/config.yml` ("What is published") replaces `tag.yaml`, which pushed to ghcr.io; the stamping and the ledger row moved with it | fork-only, never upstream — ours to keep | +| `feat(helm): controller.agentImage.digest and an optional default Harness for the chart's own runtime image` | the platform `Harness` of the Go ADK runtime renders from the chart's own digest (`harness.create`, off by default) instead of a digest copied into the meta chart | fork-only — kagent-dev/kagent#2529 (no build-time digest) closed NOT_PLANNED 2026-09-27 and kagent-dev/kagent#2536 (the key this patch uses) closed unmerged: upstream declined the digest twice, so the branch `upstream/helm-default-harness` is not sent; the Harness template is rewritten for #2952's `Agent` shape at the re-pin; giantswarm/giantswarm#37742 row 36 | +| `fix(helm): drop empty-valued matchLabels from the Harness admission selector` | the platform Harness admits templates by `agent-platform.giantswarm.io/harness` alone, so the meta chart has to remove the chart's default selector key `kagent.dev/harness`; the null that deletes a key on coalesce does not survive the JSON merge patch of a kagent HelmRelease that pre-existed the cut-over (a test installation, 2026-09-13: the selector kept both labels and no AgentTemplate was admitted — giantswarm/agent-platform#418). The template now drops every `matchLabels` entry whose value is the empty string, which every values path stores, and fails the render when nothing is left to select by (an empty selector would admit every template) | superseded upstream: kagent-dev/kagent#2952 removed the Harness admission selector, so the patch drops at the re-pin; giantswarm/giantswarm#37742 row 41 — the selector itself left the chart with `fix(helm): render the chart's Harness on api.kagent.dev/v1alpha3 without the admission selector` below; the commit stays in the stack | +| `feat(helm): a ConfigMap of the chart's runtime image references for the Substrate image cache` and `fix(helm): label the runtime images ConfigMap for helm-controller to watch` | the pinned-image set of Substrate's image cache follows the chart's digests instead of a third copy in the meta chart; the label `reconcile.fluxcd.io/watch: Enabled` (helm-controller's default `--watch-configs-label-selector`) makes the substrate HelmRelease that reads the ConfigMap through `valuesFrom` follow a Harness-image-only change at once instead of on its 10-minute interval (giantswarm/giantswarm#37767) | to file — prepared in the fork as one commit: branch `upstream/helm-runtime-images-configmap`; queued behind the Substrate `pinnedImages` flag it feeds; giantswarm/giantswarm#37742 row 37 | +| `fix(helm): keep the kagent-crds CRDs on uninstall (helm.sh/resource-policy: keep)` | the `kagent-crds` chart ships the CRDs as plain templates (no `crds/` directory), so an uninstall of that release deleted the five CRDs and every `AgentTemplate`/`RemoteMCPServer` with them; the meta chart relies on both surviving. Added as a `+kubebuilder:metadata:annotations` marker on every `api.kagent.dev` type (the seven of the line: the five carried from kagent.dev plus `Agent` and `SandboxTemplate`, which 1.3.0 shipped without it, so an uninstall of its kagent-crds release deleted every Agent), so controller-gen emits it and `make controller-manifests` stays the only producer of the templates | to file — prepared in the fork: branch `upstream/kagent-crds-resource-policy-keep`; giantswarm/giantswarm#37742 row 22 | +| `feat(api): a per-source credential for private git skill and plugin sources` (with `fix(skills): git sends the Authorization the egress gateway replaces`) | two of the platform's live agents take their skills from private repositories, and `main` has no credential path for artifact sources (the materialiser shells out to git with the process environment and no helper). `skills[].source.git.credentialRef` (and `plugins[].source.git.credentialRef`) names a same-namespace Secret key; the compiler binds it to the source's host as `Authorization: Basic ` through the egress gateway's credential injection (upstream kagent-dev/kagent#2868), the same path a model key takes; the actor holds an inert placeholder in `KAGENT_ARTIFACT_CREDENTIAL_` and clones with no credential of its own. Substrate 1.5's egress effect replaces a credential header the request carries and adds none, so the compiled source is marked `authenticated` and the materialiser has git send `Authorization: Basic ` for that source's URL only (`http..extraHeader` in the invocation's environment, `GIT_TERMINAL_PROMPT=0`); a public source sends none (giantswarm/kagent-upstream#251). The Secret key holds the base64 of `:` (GitHub: `x-access-token:`). One credential serves every credentialed source of the agent tree on that host; a public source on the same host sends no header and gets no credential; two Secrets for one host are refused. Reshaped at the 2026-09-22 re-pin: before it the token was resolved into the actor's environment and offered by a git credential helper, the design giantswarm/giantswarm#37742 row 26 marked as interim. Chosen over an ambient per-Harness credential in the spike giantswarm/giantswarm#37321: a missing Secret fails only the referencing templates, and the token reaches only the actors that reference it | kagent-dev/kagent#2899 — draft, merge conflicts since #2952, from the fork branch `upstream/artifact-source-credential-ref`; giantswarm/giantswarm#37742 row 4 | +| `feat(substrate): the compiled ActorTemplate carries the revision's egress policy as its default` | atenet denies every request of an actor without an `EgressPolicy` (403); the controller creates the policy only in the Session workflow, so the golden actor Substrate creates for a template had none and a golden boot that fetches git skills before readyz failed (`agentlab skills-test` on the beta4 pin). `ActorTemplateForRevision` sets the Substrate line's `default_egress_policy` (giantswarm/substrate#56) from the revision's destinations and credentials; the revision digest carries `actorTemplateFormat: 2` so templates compiled before are re-created under a new name. `go/go.mod` replaces `agent-substrate/substrate` with the Substrate line's commit that carries the field — the first time the kagent line's Go dependency is the fork, not the upstream tag | design B of [agent-substrate/substrate#1324](https://github.com/agent-substrate/substrate/issues/1324) / [kagent-dev/kagent#2604](https://github.com/kagent-dev/kagent/issues/2604); our drafts kagent-dev/kagent#2942 and agent-substrate/substrate#1868 were closed on 2026-09-29 in favour of the maintainer's [agent-substrate/substrate#1558](https://github.com/agent-substrate/substrate/pull/1558), which adds the field; this patch leaves at the re-pin that carries #1558; giantswarm/giantswarm#37742 row 66 | +| `fix(controller): start a crashed golden boot over instead of failing the Agent for good` | a golden boot that ends `GoldenActorCrashed` was terminal in the controller: Substrate marks the ActorTemplate failed once and never retries it, and the controller only carried that verdict into `Ready=False` — so a worker pod replaced under the golden actor (the WorkerPool rolling while a Harness change recompiles every template, as in the 4.7.19 → 4.8.0 upgrade) left every AgentTemplate unrunnable until something else changed the Harness (giantswarm/giantswarm#37766). Substrate reports no cause on the Actor, so an infrastructure crash and a workload that exits after it came up end in the same unattributed message (`golden actor crashed before its snapshot was taken`); the controller now starts exactly that crash over — deletes the crashed ActorTemplate with its golden actor and creates the desired one again — up to five times per revision with a doubling backoff (20 s, 40 s, 80 s, then 2 min), reports `Ready=False ActorTemplateRetrying` meanwhile and `ActorTemplateFailed` with the count once the budget is spent; a crash the resume attributed (the Substrate line's fail-fast for an unpullable image), `GoldenActorInvalid` and `GoldenActorUnexpectedState` stay final at once | superseded structurally by kagent-dev/kagent#2994 (`cb6b756c`: the queue owns retries, a golden failure stays terminal), so the patch cannot rebase; its semantics are rebuilt on #2994 in the re-pin candidate and go upstream from there; giantswarm/giantswarm#37742 row 39 | +| `fix(adk): load a SKILL.md whose frontmatter carries fields outside the agentskills.io set` | the Go ADK's `skill.Parse` decodes the `SKILL.md` frontmatter with `KnownFields(true)` and knows the agentskills.io fields only, so one field outside that set — Claude Code's `user-invocable`/`argument-hint`, a top-level `version:` — failed the whole agent's golden boot (`failed to load skills: invalid frontmatter … field user-invocable not found in type skill.Frontmatter`); on the production installation's cut-over (2026-09-13) `sre-agent` (four private `giantswarm/claude-code` skills) and `vera` (`loomora-marketplace-data` with `version:`) stayed unrunnable while the same skills had loaded on kagent 0.10's Python runtime, whose `Frontmatter` allows extra fields (giantswarm/giantswarm#37772). The runtime now hands the ADK a filesystem view (`go/adk/pkg/skillfs.Lenient`) in which every skill's `SKILL.md` carries only the fields the ADK knows; the ADK's parser, validation (`name`/`description` required, directory name must match) and resource rules stay the single authority, `metadata` is untouched, and a `SKILL.md` the filter cannot read as frontmatter is served unchanged so the ADK reports it as before | to file — prepared in the fork: branch `upstream/skills-lenient-frontmatter` (mirror `main` @ the pin `6a6b54ff` + the patch, `84fb5872`, DCO-signed, no fork references; its message is the pull-request text). google/adk-go searched 2026-09-13: no issue or PR on unknown frontmatter fields (`main` still `KnownFields(true)`; #1296/#1301 fixed the scalar `allowed-tools` form only), the agentskills.io reference validator rejects unknown fields while adk-python's `Frontmatter` allows them; kagent-dev/kagent has nothing on it. The ADK-side fix (decode without `KnownFields`) is a candidate for google/adk-go once the team reviews the queue; giantswarm/giantswarm#37742 row 43 | +| `feat(models): add prompt caching for the Anthropic provider` and `test(adk): deep-copy caller objects in the prompt cache no-mutation tests` | the Go ADK sent every Anthropic request without `cache_control`, so each model call of an agent loop re-processed the whole prefix — the system prompt and tool schemas (~6 k tokens for the platform's default agent), then the growing history — at full input price and with a time to first token that followed the uncached prefix (production, 2026-09-14: 26 of 26 `claude-sonnet-5` requests with `cache_read.input_tokens=0`; 0.7-1.5 s to the first token at 6 k input tokens, 9.7-13.5 s at 33-40 k; one twelve-call status question re-sent 340 k input tokens for $0.73 — giantswarm/giantswarm#37788). `spec.anthropic.promptCaching` (bool) and `spec.anthropic.cacheTTL` (`5m`, `1h`) on `ModelConfig`, wired through `adk.Anthropic` to both runtimes: when on, every Messages request marks the last tool definition, the last system block and the last content block of the latest turn with `cache_control`, so each call reads its previous history from the cache and writes only the new turn, and `cache_read_input_tokens` surfaces as the usage's cached count. Opt-in upstream, as Bedrock's knob is; the platform's meta chart turns it on for every Anthropic ModelConfig it renders | kagent-dev/kagent#2788 — **open**, merge conflicts since #2952 (`a0c974bd`, `f5679c2a`), carried as a cherry-pick until upstream merges it and the next re-pin drops it; giantswarm/giantswarm#37742 row 7 | +| `feat(helm): the Harness's context compaction from harness.compaction` | upstream kagent-dev/kagent#2790 (`7471879e`) puts context compaction on `Harness.spec.kagent.compaction`, not on the AgentTemplate, so the platform `Harness` the chart renders (`harness.create`) is the one place every admitted agent takes it from; `harness.compaction` (empty by default) renders under `spec.kagent` | fork-only (the chart's `Harness` template is the fork's) | +| `fix(a2agateway): end a send whose runtime is lost, and delete a paused Actor as it is` | a session kagent parks on `input-required`/`auth-required` is paused, not suspended: its only checkpoint lives on the worker's node, and when that node goes (a spot interruption, a consolidation, a node roll) nothing notices until the person answers — the resume fails after Substrate's parking budget with the text of the last refusal, kagent's gateway records it as one more failed dispatch, every retry repeats the wait, and the session cannot be deleted because `Delete` suspends first and suspending a paused Actor dials the atelet on the node that is gone (production, 2026-09-15, a session paused 2026-09-14 06:06Z whose node was interrupted 12:20Z; giantswarm/giantswarm#37795). The gateway now asks the lifecycle workflow after every failed runtime stream whether the runtime is lost — the Actor `CRASHED` (what Substrate makes of an Actor it cannot bring back: a restore out of time, a worker gone, and with the Substrate line's companion patch a pause checkpoint whose node is gone) or gone — and if it is, fails the task with the cause under the message prefix `runtime lost: ` (`go/api/a2a` `RuntimeLostMessagePrefix`), moves the instance to `FAILED` with `failure.reason` `RuntimeLost` and the same message, and refuses later sends with that message without dialing the runtime, instead of asking the runtime whether it took the task (a second parking budget). The transcript stays readable; `Delete` deletes a `PAUSED` or `CRASHED` Actor as it is (`any_state` for the paused one) and suspends only a live Actor first. A runtime whose state Substrate cannot report is not presumed lost. Docs: `docs/architecture/runtime-and-lifecycle.md` "Lost runtimes" | superseded structurally by kagent-dev/kagent#2939 (the gateway task run this patches is gone): the patch drops at the re-pin, the requirement is rebuilt on the Session model in the re-pin candidate and needs an upstream issue (kagent-dev/kagent#2447 is the adjacent report). Was prepared in the fork: branch `upstream/runtime-lost` (`3e5b77ef`, on the mirror `main` plus `upstream/a2agateway-dispatch-failure` and `upstream/a2agateway-detached-dispatch`, which it stacks on); carried as `e821fcdc` (giantswarm/kagent-upstream#43), released in `v0.11.0-gs.17`; giantswarm/giantswarm#37742 row 45 (the Substrate line's fail-fast and crash reason are row 44; the pause TTL is a separate patch) | +| `feat(session): suspend a runtime paused for input once the pause is older than a TTL` | a turn that ends `input-required`/`auth-required` pauses the actor on its worker's node (`PauseActor`, the upstream design of kagent-dev/kagent#2714) and nothing made that checkpoint durable, so a spot interruption, a consolidation or a node roll while a person took their time lost the session's runtime state: the reply's resume found no worker on the recorded node, Substrate reported the locality miss as `no free workers available`, the task failed after the 60 s resume budget and the instance could neither be resumed, suspended nor deleted (production, 2026-09-14/15, giantswarm/giantswarm#37795). The controller's leader now sweeps READY sessions whose latest task waits for input without a recorded snapshot and, once the pause is older than `KAGENT_PAUSED_RUNTIME_TTL` (2m; `controller.pausedRuntimeTTL`; 0 disables), suspends the actor durably — `SuspendActor` uploads the pause checkpoint to the snapshot store — and records the snapshot as the task's boundary (guarded on the task still waiting, so a reply that landed meanwhile keeps its own boundary); the sweep takes the session's quiesce lock without waiting and the gateway dispatches under the runtime-call lock, so a suspend and a reply never overlap. A reply within the TTL keeps the in-place resume; a later one restores from the external snapshot on any worker. The platform's meta chart leaves the default | superseded structurally by kagent-dev/kagent#2939 and #2970 (idle expiry deletes and skips waiting tasks, so a paused turn is not covered): the patch drops at the re-pin and is rebuilt as `KAGENT_PAUSED_RUNTIME_TTL` on the Session model in the candidate. Was prepared as `upstream/a2agateway-pause-ttl` on the mirror; the first half of giantswarm/giantswarm#37742 row 45 (the second half is the row above: giantswarm/kagent-upstream#43, `v0.11.0-gs.17`); kagent-dev/kagent#2503 is the adjacent resume-reliability issue | +| `fix(session): leave an expired pause on a lost node alone, and delete an Actor that cannot be suspended as it is` | the pause TTL's first sweep on a test installation (2026-09-15 13:46Z) tried to suspend three paused Actors whose checkpoint nodes were gone: Substrate fails the upload at once (`UploadPausedCheckpoint: while getting atelet conn for node …`) and leaves the Actor `SUSPENDING`, which the lost-runtime patch's `Delete` then suspends first — and fails the same way — so sessions that were deletable while `PAUSED` were not any more. `Idle` counts a `PAUSED` Actor only while a worker runs on its checkpoint's node (`ListWorkers`), so the sweep leaves a pause on a lost node to the node-loss handling (Substrate row 44); `Delete` falls back to an any-state delete when the pre-delete suspend fails | superseded structurally with the row above (kagent-dev/kagent#2939); drops at the re-pin; giantswarm/giantswarm#37742 row 45 | +| `feat(runtime): name the agent and the user on every model call` | under the kagent API v2 every agent is a Substrate actor in a shared WorkerPool and every actor connection leaves the worker through the Substrate egress, so a gateway in front of the model providers (the platform's `llm` listener, which prices every call and labels usage per agent and user) sees one caller for every agent and no user: since the platform's cut-over every dollar of agent spend sat on the egress row and the `user` label was unknown (giantswarm/giantswarm#37871). The controller now injects `KAGENT_AGENT_TEMPLATE` next to `KAGENT_NAMESPACE`; the Go ADK sets `x-kagent-agent` and `x-kagent-agent-namespace` on every model call after a ModelConfig's default headers (so none can name another agent) and `x-kagent-user`, the person of the turn — the identity the controller's trusted-proxy authenticator resolved from the caller's validated token, which the a2a gateway forwards to the actor as metadata of the same name; a session without claims (unsecure mode and its `admin@kagent.dev` default, an agent's propagated caller id) forwards nothing, so the header is absent, never a token or a stand-in; the Claude harness sends the two agent headers through `ANTHROPIC_CUSTOM_HEADERS`, which the controller renders and owns. The gateway reads the headers only from the egress's traffic (giantswarm/agent-platform#586) | to file — prepared in the fork: branch `upstream/model-call-identity-headers`; kagent-dev/kagent#2901 (open: `X-Agent-Id` on the kagent runtime's model calls, issue #2737) overlaps the agent half, so the filing is coordinated there; at the re-pin the gateway hop moves to the runtime executor (#2939); giantswarm/giantswarm#37742 row 48 | +| `ci(fork): publish through the orb's stock jobs with decoupled versions` | the RFCs (giantswarm/rfc `semantic-versioning-of-upstream-software`, `semver-based-automatic-upgrades`) give a package of upstream software its own stable semver, `-rc.N` and gitsemver's dev shape, nothing else: the images are built by the orb's `build-image` (native per architecture, the Makefile's build arguments through `build-args`) and `push-to-registries` (merge, sign, attest, the upstream-pin annotation), the version is gitsemver's (`vX.Y.Z` tags → `X.Y.Z`, first `1.0.0`), the pipeline's own `version` and image jobs and the `-gs.N` scheme are gone, pull request branches build without pushing, and `mirror.sh` refuses an upstream tag the fork already holds at another commit ("What is published", "Release scheme") | fork-only, never upstream — ours to keep | +| `ci: pin giantswarm/architect orb 10.10.0` | the hand-written CircleCI config pins the orb release that carries giantswarm/architect-orb#952 and #956; the line has `gen.ci.generate: false`, so align-files does not carry the orb version here | fork-only | +| `ci(fork): count the candidates the re-pin left behind when numbering the next one` | the generated auto-release numbers the next `-rc.N` by counting the candidates of the target reachable from the branch; after a re-pin none of the earlier ones is reachable, so the push after the 2026-10-05 re-pin would have cut `v1.3.0-rc.1`, a tag that exists, and failed as giantswarm/substrate's did (its `v1.4.0-rc.8` was tagged by hand). The baseline step exports `LINE_REPINNED=true` when it moved the line's stable tag, and the counter then takes every tag of the target | the same change as giantswarm/devctl#2498 on the template; the align-files bot regenerates the file with it once devctl releases, and this commit falls away | +| `fix(ui): the chat journeys no longer race two tooltips and the reply` | the fork's `ui-tests` runs upstream's browser suite on GitHub-hosted runners, slower than upstream's, and on every run one to three chat journeys timed out on Firefox — three consecutive attempts failed a pull request that touched nothing under `ui/` (giantswarm/kagent-upstream#68). Two of the three were the product: the rail row's ellipsis tooltip opened to the right of the name, over the row's own menu button, and a pointer moving from the name to the menu landed on the tooltip, which kept it open with the button unreachable under it for the rest of the test (both engines, every run, reproduced locally with the trace); the snapshot mark's Delete carried a tooltip that its own comment forbids, beside the confirmation the pointer opens. The third was the spec: the composer disables the snapshot button while the reply streams, and the checkpoints spec asserted it enabled from the moment the reader's message showed, giving a streamed reply the assertion's five seconds (the turn takes five on a mildly loaded run). The specs wait on the turn's end (`sendAndAwaitTurn`; the transcript spec asserts the assembled reply after the turn, as its second turn already did), press the rail's Duplicate once it has stopped moving, the rail tooltip opens above the row, the Delete tooltip is gone, and the twelve-step questions journey is two journeys with their own budgets and one page load fewer (a boot of the dev server is ten seconds and more on a busy runner) | to file — prepared in the fork: branch `upstream/ui-chat-journeys` (mirror `main` + the patch, DCO-signed, no fork references; its message is the pull-request text). kagent-dev/kagent searched 2026-09-23: #2773 (two specs flaking under parallel load) and #2871/#2877 (the Cancel label two confirmations shared) are the neighbours, nothing on the tooltips or the snapshot button's wait; upstream's own `ui-tests` on Blacksmith runners reports no flaky test. giantswarm/giantswarm#37742 row 51 | +| `ci(fork): keep the browser suite's evidence of a failed run` | `ui-tests` uploads `ui/test-results` as a seven-day artifact when the job fails: the screenshot and video of every failed attempt, the trace of every retry. The log names the step that timed out; only the trace shows what was on the page and under the pointer while it did, and the hosted runner discarded it with the workspace (giantswarm/kagent-upstream#68 had to be diagnosed from a local reproduction for that reason) | fork-only | +| `ci(fork): keep the evidence of a flaky browser test too` | the evidence step above ran only when the job failed, so a test that failed its first attempt and passed its retry — green job, reported flaky — left nothing: giantswarm/kagent-upstream#79's schedules lifecycle and extension-points page load could not be diagnosed for that reason. The step runs unless the job was cancelled; a run whose every attempt passed writes no files there (Playwright's `.last-run.json` is hidden and not uploaded) and keeps no artifact | fork-only | +| `test(ui): count a journey's steps per test, and budget the transcript's` | after the chat journeys fix the fork's `ui-tests` still failed on every attempt of a loaded Firefox run, on pull requests that touched nothing under `ui/` (giantswarm/kagent-upstream#68): the transcript journey is fifteen steps around two page loads, three streamed turns, a reload and a conversation switch on the thirty-second default, 31–34 s on the hosted runner with every step waiting on the page. The suite's own rule gives a test of ten or more steps `LIFECYCLE_TIMEOUT`, but its check counted steps per file and only in files holding one test; it counts per test now, finds this journey alone, and the journey sets the budget in its own body. Profiled first: a turn's time is React's development build re-rendering the page per streamed word, no race in the product | to file — prepared in the fork: branch `upstream/ui-journey-budget` (mirror `main` + both `test(ui)` commits, DCO-signed, no fork references). kagent-dev/kagent searched 2026-09-24: nothing on the transcript journey, the step check or the templates delete; #2610/#2612 (earlier waits) are the neighbours. giantswarm/giantswarm#37742 row 57 | +| `test(ui): press a template's own Delete, once its page is there` | the agent-templates journey's step 13 was reported flaky on most `ui-tests` runs of both engines: the router renders a new route in a transition, so the address matches while the list is still on screen, and the list row's unlabelled delete carries the page's test id — the press opened the row's confirmation, which left with the list; on Firefox the result link was pressed while the searched list re-rendered, took the focus and navigated nowhere. The step waits for the one row and for the page's labelled Delete, as steps 2 and 11 already did | to file — same branch and row as the line above | +| `test(ui): press the checkpoints' delete buttons once they have arrived` | "a snapshot is deleted from its record" failed its first attempt on loaded Firefox runs of `ui-tests` (twice in five) and passed on retry: the record's Delete was pressed while antd still zoomed the modal in, the raw click was computed then, and the confirmation never opened. The record's Delete and both confirmations' Delete are pressed with the suite's `pressOnce`, as their Cancel already were | to file — same branch and row as the two lines above | +| `feat(models): turn a thinking model's reasoning off on Ollama` | the platform's model-manager wires every managed Ollama model as a native `provider: Ollama` ModelConfig, and a model with Ollama's thinking capability (Qwen3.5, Qwen3, Granite 4.2) thinks before every answer unless the request says `think: false`, which the provider never sent: on a CPU an agent turn paid 1.5–2k thinking tokens a call, and `qwen3.5:2b` wrote its whole final answer into `message.thinking` with an empty `content`, which the runtime does not read, so the agent replied with nothing (giantswarm/agentlab#246). `spec.ollama.think` (optional bool; unset sends no field and keeps Ollama's default) is sent as the chat request's top-level `think` by the Go and the Python runtime; the UI's model form offers it as a default/On/Off choice and sends a boolean. The Go provider now builds the request once for the streaming and the non-streaming path | to file — prepared branch `upstream/ollama-think` (giantswarm/giantswarm#37742 row 56); kagent-dev/kagent#2989 (open issue, 2026-09-28) asks for `think` and `keep_alive` on OllamaConfig: the branch is the fix, offered there | +| `test(ui): press a transcript's controls once its diagram has landed` | the fork's `ui-tests` reported "a failed turn is reported and can be retried" flaky on Firefox (giantswarm/kagent-upstream#79): Retry took the focus and resent nothing. The seeded reply's mermaid diagram lands seconds after the page, replaces its listing with a taller SVG, and moves everything below it until the transcript re-pins on the next frame; a press whose button-up is handled in between comes up beside the control and no click is dispatched (reproduced on both engines). A rendering diagram carries `chat-mermaid-pending`, and the suite waits for none before pressing inside the transcript (Retry, the snapshot mark's controls) | to file — prepared in the fork: branch `upstream/ui-press-waits` (on `upstream/ui-journey-budget`, the four `test(ui)` commits, DCO-signed, no fork references). kagent-dev/kagent searched 2026-09-24: nothing on the lost press, `pressUntil`'s first attempt or the bare extension points; #2636 (mermaid in chat) and #2825 (`pressUntil`) are where the code came from. giantswarm/giantswarm#37742 row 59 | +| `test(ui): wait for pressUntil's control before pressing it` | `pressUntil` skipped a first attempt whose control was not drawn yet and then waited out the settled assertion's five seconds: traced on the schedules lifecycle's "Weekly" option in two of three loaded Firefox runs, part of what took the journey past its sixty-second budget (giantswarm/kagent-upstream#79). It waits for the control first, as `pressOnce` does | to file — same branch and row as the line above | +| `test(ui): leave the schedules lifecycle through its Back button` | the lifecycle's last step loaded the list again, a load of the dev server the journey is short of, and counted zero links before the list had rendered; it goes back with the page's Back and waits for the seeded row first | to file — same branch and row as the line above | +| `test(ui): one test per claim for a bare build's extension points` | "a bare build renders the application and nothing else" timed out in its last `page.goto` on a loaded Firefox run (giantswarm/kagent-upstream#79): five independent claims, six page loads on the thirty-second budget of one. Four tests of one to three loads each | to file — same branch and row as the line above | +| `fix(models): stream the Anthropic call behind a non-streaming request` | the Go runtime answered a non-streaming `GenerateContent` with the Anthropic SDK's `Messages.New`, which refuses a request before sending it when its `max_tokens` could take longer than ten minutes to generate (more than 21,333 tokens, or a model's own non-streaming cap), and the request carries the ModelConfig's `maxTokens`, sized for the agent's streamed replies. An installation's default ModelConfig caps output at 32000 because an adaptive-thinking model shares `max_tokens` with the visible answer, so the context compaction summarizer — the ADK's LLM summarizer asks for a non-streaming call — failed at every crossing (`tail-retention summarization failed: summarizer model call failed: anthropic API error: streaming is required for operations that may take longer than 10 minutes`) and every conversation ran uncompacted. The non-streaming path streams the call and accumulates the message with the SDK's `Message.Accumulate`; a caller still gets one final response | to file — prepared branch `upstream/anthropic-nonstreaming-stream` (giantswarm/giantswarm#37742 row 60); no upstream issue or pull request (kagent-dev/kagent searched 2026-09-24) | +| `fix(migrations): refuse to start on a 1.x database instead of migrating it` | every 1.x release recorded its baseline as core version 1, the version this tree's baseline carries too, so Goose took a 1.x database for current and the controller started on the `agent_instance` tables. The core source checks for that table before any migration or verification and fails startup with the remedy (`migrations.ErrLegacyBaseline`): a fresh database, the old one kept for retention. The line does not migrate the 1.x schema; each installation's cut-over starts on a fresh database on its existing CNPG cluster (decision of 2026-09-29: conversations, shares, scheduled-run executions and Slack thread bindings are not carried over) | fork-only: upstream has no released baseline to protect | +| `fix(helm): render the chart's Harness on api.kagent.dev/v1alpha3 without the admission selector` | upstream kagent-dev/kagent#2952 moved the API to `api.kagent.dev/v1alpha3` and replaced the admission labels by an explicit `Agent` whose `harnessRef` names its Harness, so the chart's Harness (`harness.create`) renders on the new group with no `selector`, and the meta chart's override of the selector key becomes a no-op | fork-only | +| `fix(session): end a working turn whose runtime stopped saving events` | a turn's events reach its task only through the gateway run that dispatched or observes it, so a runtime lost after the turn's first event (a spot reclaim, a worker roll) or a controller restarted mid-turn left the task `TASK_STATE_WORKING` for good: the instance kept it as its active task, refused every later message as a conflict, and every client showed a turn that never ends (giantswarm/giantswarm#37988). `SUBMITTED` had the dispatch grace period; `WORKING` had no bound. The controller's leader now sweeps working tasks that recorded no event for longer than `KAGENT_STALLED_TURN_TIMEOUT` (1h; `controller.stalledTurnTimeout`; 0 disables) and that no run of the gateway follows, and fails each with the interruption message, under the instance's quiesce lock taken without waiting. Docs: `docs/architecture/runtime-and-lifecycle.md` | giantswarm/giantswarm#37742 row 80: superseded structurally by kagent-dev/kagent#2939 at the re-pin, rebuilt as `KAGENT_STALLED_TURN_TIMEOUT` on the Session model in the candidate; the upstream issue is row 45's | +| `fix(harness): settle a cancel for a task the runtime holds nothing for as canceled` | a Stop pressed right after a turn starts reaches the runtime before its first event; the runtime then ends the dispatch stream with gRPC `INTERNAL` instead of a `CANCELED` update and no longer answers for the task, so the gateway took it for a dispatch the runtime never took and recorded `TASK_STATE_FAILED`, and the cancel answered `FAILED` (2 of 3 Stop runs in the agentlab, giantswarm/kagent-upstream#128). The gateway now marks the run before it forwards the cancel, and a stream that ends with an error while its task is being cancelled settles the task `TASK_STATE_CANCELED` through the terminal path (quiesce, snapshot, one event to every observer); a lost runtime still fails the task first | giantswarm/giantswarm#37742 row 89: superseded structurally by kagent-dev/kagent#2939 at the re-pin (`cancelTask` recovers a cancel through `GetCancelResult`); to verify against this race on the new path before re-porting | +| `feat(harness): forward the caller's credential on MCP calls from the Claude harness` (with `fix(harness): refuse forwarder paths with dot segments or encoded slashes` and `fix(harness): check the raw forwarder path and forward only transport headers`) | a coding agent on the `claude` Harness must call muster as the invoking person: the claude executor never read the A2A `authorization`, so the harness could reach MCP servers with static credentials only (bumblebee-plans#60, D8); gated on `KAGENT_PROPAGATE_TOKEN` like the ADK, the credential rides a per-turn loopback forwarder and is dropped before a turn parks; the forwarded path stays absolute for an upstream URL without a path, a path with a dot segment, an encoded slash, backslash or `%`, a `;` or a control byte is refused before it is matched (D23), and only the transport and trace headers of Claude's request reach the server | kagent-dev/kagent#3018 (`a9617a48d`), carried as the pull request has it; the two forwarder fixes are to file on top of it; giantswarm/giantswarm#37742 row 5 | +| `fix(harness): keep the compiled skills on Claude turns that need approval` | Claude Code counts skills under an `--add-dir` directory as part of the project setting source, so the `--setting-sources ""` the driver passed on approval turns dropped every compiled skill (Claude Code 2.1.260); the driver now passes only `--settings` and `--permission-prompt-tool`, and the rendered `ask` rules still hold over user and project `allow` rules (bumblebee-plans#60, D5) | to file | +| `feat(api): Agent.spec.egress — HTTP(S) origins an Agent may reach` | a coding agent must reach github.com, the installation's git hosts and its toolchain's registries, and no field added a destination: an actor's allowlist was only what its revision compiled (bumblebee-plans#60, D11). `Agent.spec.egress[]` lists HTTP(S) origins (`https://proxy.golang.org`, or a leftmost-label wildcard with at least two labels under it) the Agent may reach besides what its revision compiles; the compiler adds them to the revision's destinations and the policy shares the validation. On the Agent, not the Harness: the hosts belong to the workload and its environment, and a template stays portable. The Generic agent chart emits them per agent (giantswarm/agent#56) | kagent-dev/kagent#3019 (`5d5ca194d`), carried as the pull request has it; giantswarm/giantswarm#37742 rows 81 to 86 hold the coding-agents patches | +| `fix(a2agateway): run a turn a share of the session authorizes in its owner's session` | the runtime keys its session on `x-user-id` and the context id, and the runtime dialer forwarded the visitor there, so a turn a session share authorized started an empty session in the conversation's context: a Slack collaborator klaus-gateway lets into a thread (giantswarm/klaus-gateway#356) got an agent with no history (production, 2026-10-01, giantswarm/giantswarm#38089). The dialer now names the share's owner, as the HTTP A2A handler does for a session share; the visitor stays the person of the turn (`x-kagent-user`) and keeps their own credentials (MCP and model calls). What the runtime keys on `x-user-id` follows the owner too, as before klaus-gateway#356: the long-term memory of the agent and the sessions of its sub-agents | kagent-dev/kagent#3066 (`71b1e7857`), carried as the pull request has it; giantswarm/giantswarm#37742 row 112 | +| `fix(adk): defer MCP tool discovery that awaits the caller's credential` | an agent's golden boot runs the Go ADK to readiness with no person behind it, and the ADK listed the tools of every MCP server at startup to classify its MCP App tools. A server that takes the caller's token (`KAGENT_PROPAGATE_TOKEN`, the per-agent muster `RemoteMCPServer`) answered `401` to that unauthenticated list, and the egress access log of every golden boot read like an authentication outage. The ADK now leaves such a server alone at startup when the boot has no `Authorization` for it, logs the deferral at debug, and discovers the tools at the first turn with the caller's token, as the 401 fallback already did; a server with a static credential is still classified at startup (giantswarm/kagent-upstream#99) | to file; giantswarm/giantswarm#37742 row 103 | +| `feat(helm): harnesses renders further Harnesses beside the chart's own` (on `api.kagent.dev/v1alpha3`: an entry renders no admission selector and no egress; an `Agent` names its Harness in `harnessRef` and declares its egress itself) | the platform ships coding agents as AgentTemplates admitted by a `claude` Harness, one per toolchain image (bumblebee-plans#60, D1, D21), and the chart rendered one Harness only, the Go ADK's; the lab applied the Claude one by hand. `harnesses[]` (empty by default) renders one Harness per entry with the policy shape of `harness.*` plus `runtime: claude|kagent`; a claude entry defaults to the chart's `claude-harness` image at its published digest and to the selector `kagent.dev/harness: `; names must be distinct from the chart's own Harness, and overlapping matchLabels selectors fail the render (an AgentTemplate matched by two Harnesses runs under both); each entry's image joins the pre-pull ConfigMap; the selector helper is shared. Transitional pairing: the selectors go when the line moves to upstream's `Agent.harnessRef` | to file with the default-Harness row (`upstream/helm-default-harness`); giantswarm/giantswarm#38006 | +| `feat(runtime): name the session on every model call` | the model calls name the agent and the person of the turn (the row "name the agent and the user" above) but not the conversation, so the platform's `llm` listener cannot price one session (a Dev Portal conversation, a Slack thread) and a tenant's report cannot list its most expensive sessions (giantswarm/giantswarm#38023). The a2a gateway's runtime dialer forwards the id of the `Session` it routed the turn to (the header name is unchanged) as `x-kagent-agent-instance-id`, the name of the header that routes a call to an instance, never a value the caller sent; the Go ADK's HTTP transport sets it on every model call of that turn and removes it from any call outside a turn, so no ModelConfig can name a session. An accounting identity, never an authorization input; only the model and embedding clients use that transport, so a sub-agent's A2A call is never routed by it. The Claude harness sends no per-turn headers yet | to file — prepared in the fork, after the row above it, whose upstream filing it extends; giantswarm/giantswarm#37742 row 108 | +| `fix(session): fail a session whose resume finds its snapshot lost` | a resume that Substrate answers with `DataLoss` (the Actor's snapshot or the golden one it builds on is gone from the store) can never succeed, yet the workflow left the claimed `RESUME` operation pending as if its outcome were uncertain: every later resume and every delete was refused as an unfinished lifecycle operation, and only recreating the database cleared the instance (giantswarm/kagent-upstream#151). The claiming executor now records the known failure: the session goes `FAILED` with the reason `RuntimeLost` and the Substrate message, the operation is cleared, the resume RPC answers `FailedPrecondition`, and a delete removes the Actor and tombstones the session as for any other. Any other resume error stays pending as before | to file: upstream's session workflow (kagent-dev/kagent#2952) left a claimed resume pending on the same answer; the change is ported onto that workflow since the 2026-10-05 re-pin and is filed from there; giantswarm/giantswarm#37742 row 110 | +| `feat(harness): run Claude Code as the image's unprivileged user when the harness runs as root` (with its fixes), `feat(translator): the claude Harness asks Substrate for the capabilities that run Claude Code unprivileged` and `feat(harness): keep Claude's policy out of its reach` | Claude Code ran as root in the claude harness, the same user as the harness process that holds the model key placeholder and the turn's credential (bumblebee-plans#60, U2). The image runs the harness as root (`USER 0:0`; the Codex image keeps `USER 65532`, its harness holds no caller credential). On every start the harness takes `/data` and everything in it other than Claude's trees back (links removed, group and other write cleared, also on a volume an earlier image ran as uid 65532), rebuilds `/data/generated` (skill and plugin packages, the skills Claude loads) as root readable by the `kagent` group only, takes back and hands to uid 65532 only the root directories of Claude's trees (the workspace, `/data/claude`), never walking their contents, and starts Claude Code as that user with no supplementary groups; `/data` stays the harness's and only gains search permission. What Claude obeys is root-owned and `kagent`-group readable only: the permission rules (the approval `ask` rules) in `/etc/claude-code/managed-settings.json` with `allowManagedPermissionRulesOnly`, rendered on every start so that no user, project or workspace rule applies, and `mcp.json` and the Google credentials in `/run/kagent-claude`. `KAGENT_PROPAGATE_TOKEN` with MCP servers refuses a harness that is not root. Approval stays the MCP server's: Claude can read the loopback token in `mcp.json`, and hooks in its own settings can answer a prompt. The claude translator adds `CHOWN`, `SETGID` and `SETUID` to the ActorTemplate. Both images set `WORKDIR /`. CI runs the root-only adapter tests as root with only those three capabilities. On gVisor it needs Substrate 1.4.0-rc.7 or later: the carry of agent-substrate/substrate#1918 with #1906 and #1910 (giantswarm/substrate#103), and the image-layer owners (giantswarm/substrate#115) for the Go ADK, which then runs as its image's `USER 65532` and creates `/config` for it. Proven in agentlab (U2; on 2026-10-02 the managed settings on the pinned 2.1.260 under `-p` pause a muster call for approval) and by the e2e on 1.4.0-rc.7 | to file after agent-substrate/substrate#1918 (giantswarm/giantswarm#37742 row 85) | +| `fix(session): check a share's expiry against the clock that set it` | the controller sets a share's `expires_at` from its own clock and the token lookup compared it with Postgres `now()`, so skew between the two could move a share's expiry; the lookup compares with the controller's time | to file against kagent-dev/kagent#2987 (`4dce53de`), the review fix of #124's Session port | +| `fix(session): delete expired shares on the expiration sweep` | an expired share stops granting access at its `expires_at`, but its row stayed in `session_share` until the owner revoked it or the session was deleted; the platform's Slack gateway (giantswarm/klaus-gateway) keeps one share per thread, so every thread nobody writes to again left a dead row behind (giantswarm/giantswarm#38105). The leader's expiration sweep deletes the shares expired at the controller's clock on its poll interval and counts them in `kagent.session.share_expired`; a zero idle TTL turns idle deletion off and no longer idles the worker | to file against kagent-dev/kagent#2987 (`4dce53de`) — prepared in the fork: branch `upstream/session-share-expiry-sweep` (mirror `main` + the patch, DCO-signed, no fork references); giantswarm/giantswarm#37742 row 122 | +| `fix(session): resume a session of a superseded revision on the current one` | the workflow resumed a session's Actor on the runtime revision it was prepared on and refused an Actor of any other ActorTemplate, so a conversation started before its agent was re-rendered by a newer release kept the old revision's template for good: a revision rendered before the egress trust settings (`SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE` → `/run/kagent/egress/trust-bundle.pem`) failed every model call with `x509: certificate signed by unknown authority` once the egress gateway intercepted TLS, while new conversations of the same agent worked, and on an installation upgraded through several releases most READY conversations sat on superseded revisions whose templates, rendered before credential injection, still carried the model credential inline and were never collected (giantswarm/kagent-upstream#170). Before a turn dials a quiesced Actor (under the turn's dispatch reservation), on the Resume RPC of a suspended session, and on the leader's sweep every `KAGENT_REVISION_REPOINT_INTERVAL` (5m; `controller.revisionRepointInterval`; 0 disables) over the settled READY sessions on superseded revisions, an Actor whose agent has a newer current revision in the same atespace is moved onto that revision's ActorTemplate (Substrate's `UpdateActor`, accepted while the Actor is suspended; the resume restores its data onto the new template's golden), given that revision's egress allowlist and recorded as prepared on it, so the superseded revision becomes unreferenced and the runtime revision GC deletes it with its ActorTemplate. The repoint is idempotent at every step, a lifecycle operation that claimed the session meanwhile wins and the Actor goes back, a template Substrate refuses for a different sandbox config or volume layout is logged and the session stays on its revision; a live or paused Actor is never moved. The 1.3 counterpart of `release-1.2`'s giantswarm/kagent-upstream#171 and #190 on the session workflow. Docs: `docs/architecture/runtime-and-lifecycle.md` "Superseded revisions" | to file — prepared in the fork: branch `upstream/session-superseded-revision` (mirror `main` + the patch, DCO-signed, no fork references); giantswarm/giantswarm#37742 row 123 | +| `feat(api): Harness.spec.claude.limits — a budget and a turn cap on every turn of a Claude Code agent` | a coding agent's turn must be bounded in cost and length (bumblebee-plans#60, D6): `Harness.spec.claude.limits.budgetUSD` / `maxTurns` bound every turn of every agent on the Harness and compile to `--max-budget-usd` / `--max-turns`; a turn that reaches a limit completes with the reason and the usage under `kagent.dev/a2a/usage` (prompt, cached and output tokens, `costUsd`, `numTurns`) on the task instead of failing, and a failed turn carries the usage it spent. On the Claude harness spec because only that runtime enforces them; the chart's `harnesses[].limits` renders them on a claude entry and fails the render on a kagent one | kagent-dev/kagent#3020 (`aa6ee178e`), carried as the pull request has it, its limit-exit and usage fixes included; closes kagent-dev/kagent#2854 and #2855 | +| `fix(translator): the claude compiler accepts a ModelConfig with prompt caching on` | the platform's default ModelConfig turns on `anthropic.promptCaching` with the 5m `cacheTTL` for the Go ADK runtime (kagent-dev/kagent#2788), and the claude compiler rejected it, so a coding agent needed a second ModelConfig. Claude Code caches prompts on its own, so the compiler accepts promptCaching with the default `cacheTTL` on Anthropic and Bedrock, and one ModelConfig serves both runtimes | to file; giantswarm/giantswarm#37742 | +| `fix(translator): the claude compiler caps Claude Code's output at the ModelConfig's maxTokens` | an installation's default Anthropic ModelConfig sets `anthropic.maxTokens` for the Go ADK runtime (a test installation: 32000), and the claude compiler rejected any Anthropic option beyond the base URL, so a coding agent on that ModelConfig stayed `Compatible=False`. The compiler passes `maxTokens` (Anthropic and Anthropic on Vertex AI) to Claude Code as `CLAUDE_CODE_MAX_OUTPUT_TOKENS`, which caps the output of every model request and is lowered to the model's own cap; a Harness cannot set that variable itself | to file; giantswarm/giantswarm#37742 | +| `fix(helm): keep the chart's kagent-api RemoteMCPServer out of tool discovery under trusted-proxy auth` | under `controller.auth.mode: trusted-proxy` the controller validates a bearer on every call, its own MCP endpoint included, and the discovery it runs on the chart's `kagent-api` RemoteMCPServer carries none: every listing answered 401 and the reconcile backed off forever (agentlab, 2026-10-05). The chart labels the object `kagent.dev/discovery=disabled` under trusted-proxy auth | to file: upstream's chart has the same gap under its trusted-proxy mode (kagent-dev/kagent#2971) | +| `test(ui): budget the extension points journey now that its file holds a second test` | upstream added a second test to `extension-points.withExtension.spec.ts`, so its eleven-step journey no longer carried the file's lifecycle budget and the layout convention of `test(ui): count a journey's steps per test` failed on every run; the journey sets the budget in its body | fork-only: the convention is this line's | +| `test(ui): measure the composer once the other conversation has loaded` | the composer-position check measured the second conversation while its transcript was still loading on a slow runner (the composer sits under the loading skeleton there, 385 px higher), which the test's own comment rules out of its question for the first measurement; it now waits for the loaded state (the sibling's empty transcript) before measuring, as it does for the first | to file upstream (the test is upstream's, `ui/playwright/tests/chat/transcript.spec.ts`); the UI itself also moves the composer during the load, which is a UX finding to report alongside | +| `test(e2e): prove the paused-runtime TTL, the lost runtime, the Claude turn limit and the caller credential` | the e2e suite on the gVisor matrix proves four carried behaviours the unit tests could only fake: the durable suspend of a pause older than `KAGENT_PAUSED_RUNTIME_TTL` and the resume from its snapshot, the `RuntimeLost` failure of a paused session whose worker pod is deleted (on an isolated one-worker WorkerPool), a Claude turn completed at `maxTurns` with its limit and usage, and the caller's bearer on an MCP call from the Claude harness under `KAGENT_PROPAGATE_TOKEN` | fork-only until the behaviours are upstream; the limit and credential cases belong with kagent-dev/kagent#3020 and #3018 | +| `fix(adk): name the person of the turn as the request span's end user` | the request span took `enduser.id` from `x-user-id`, which names the owner of the session; since a turn a session share authorizes runs in the owner's session (the row "run a shared turn in the instance owner's session" above), the traces of a collaborator's turn named the owner while its model usage named the visitor (giantswarm/giantswarm#38089). The span now takes the person of the turn from `x-kagent-user` and keeps `x-user-id` only for a turn whose caller the gateway did not resolve | to file with row 48, which brings `x-kagent-user` upstream, and row 112; giantswarm/giantswarm#37742 row 113 | +| `fix(session): settle a failed quiesce from the Actor's state` | the idle lifecycle worker claims a turn's boundary and then pauses or suspends the Actor; when that call returned an error, for example a `SuspendActor` that outlived its two-minute deadline because the sandbox teardown hung (giantswarm/substrate#124), the worker only logged `runtime boundary outcome unknown` and kept the claim, so the session refused every later send and lifecycle operation until its row was edited with SQL (giantswarm/kagent-upstream#166). The worker now reads the Actor again with a backoff (1 s, doubling to a minute) while it is `SUSPENDING`, `PAUSING` or Substrate cannot answer; a `SUSPENDED` Actor's external snapshot finishes the claim, a `PAUSED` Actor finishes a pause, and any other state releases the claim without a snapshot (`ReleaseSessionQuiescence`), so the next send is admitted and a lost runtime fails the session there. Docs: `docs/architecture/runtime-and-lifecycle.md` | prepared branch `upstream/session-settle-failed-quiesce` for kagent-dev/kagent#3010 (its case B, reached through a failed quiesce); giantswarm/giantswarm#37742 row 116 | +| `fix(session): take over a quiescence claim whose holder stopped` | the settling of the row above lived only in the memory of the API process that held the claim, and a held claim was never reassigned, so a restart, a rollout or a crash of that process while it settled, or while its Pause or Quiesce was in flight, wedged the session again (giantswarm/kagent-upstream#175). A claim now holds a lease in database time (`quiescence_claimed_until`, 30 s) that its holder renews every 10 s until the boundary is settled; a claim whose lease ran out, or that has none (claims made before the upgrade), is claimed again under a new executor id as `TakenOver` and settled from the Actor's state, never by repeating the runtime request; the stopped holder's renew, finish and release are refused. The column comes with the core migration `000002_quiescence_claim_lease.sql`, because the line's released databases already hold `000001`; upstream folds schema changes into `000001` until its release, so the upstream version of the commit puts the column there. Docs: `docs/architecture/runtime-and-lifecycle.md` | to file with the row above on kagent-dev/kagent#3010; queued in giantswarm/giantswarm#37742 by the upstream exit | +| `fix(session): fence a frozen quiescence holder's late Pause or Suspend` | the claim's records fence a holder that stopped, not one frozen past its lease (a GC pause, a node stall, a slow network) while its Pause or Suspend is in flight: the successor settles a RUNNING Actor as not quiesced and releases the claim, and the late request then pauses or suspends an Actor the session believes is running (giantswarm/kagent-upstream#181). Every Pause, Suspend and fencing Resume a holder sends now carries a Substrate `FencingToken` (its executor id and a generation from the `quiescence_fencing_generation` sequence, which grows with every request); before releasing a claim on a RUNNING Actor the holder sends a fenced Resume with a new generation, which records the token without touching the runtime, so Substrate refuses a late request with `FailedPrecondition`; a holder whose own request is refused that way was superseded and leaves the claim to its successor. The token rides `substrate.WithFencingToken` on the request's context, so the lifecycle and gateway calls without one are unchanged. Needs a Substrate that takes the token (the line's `1.5.0`, giantswarm/substrate#182; an older Substrate refuses the field as unknown, `InvalidArgument` naming protobuf tag 10001, and the next row's fallback then sends the calls unfenced). The sequence comes with the core migration `000004_quiescence_fencing_generation.sql`; the upstream version puts it into `000001`. Docs: `docs/architecture/runtime-and-lifecycle.md` | none upstream; written upstream-ready, filed with the row above once upstream Substrate takes the token (giantswarm/substrate#182's row); queued in giantswarm/giantswarm#37742 | +| `fix(substrate): send unfenced calls to a Substrate without the token` | the row above made every Pause, Suspend and fencing Resume carry the token, and a Substrate whose Actor API predates it (every release of the Giant Swarm line before `1.5.0-rc.6`, `1.4.0` included) rejects every field it has no descriptor for: `InvalidArgument: request: Invalid value: unknown field with protobuf tag 10001`, so every quiesce failed and the fenced Resume before a claim's release was retried forever, wedging every session after its first turn (giantswarm/kagent-upstream#230). The Substrate client detects that refusal, remembers per client (per ate-api) that the token is not taken, logs one WARN naming the ate-api endpoint and the lost fence, and sends that request and every later one unfenced; a Substrate that takes the token keeps the fence. Feature detection rather than a minimum version enforced at startup: ate-api exposes no version to check, Substrate is usually installed on its own (`substrate.enabled: false`), so the chart's dependency cannot hold the pair, and a rolling upgrade that moves kagent first keeps working. The minimum Substrate for the fence is the chart's dependency version (`SUBSTRATE_VERSION`, `1.5.0`), stated in `values.yaml`. Docs: `docs/architecture/runtime-and-lifecycle.md` | none upstream; filed with the row above, whose upstream version needs it once upstream Substrate takes the token; queued in giantswarm/giantswarm#37742 | +| `fix(session): retry a failed quiesce once the Actor runs again` | the settle of a failed quiesce (the row above it) released the claim without a snapshot as soon as the Actor was `RUNNING` again, so a first `SuspendActor` that outlived its deadline and was abandoned by Substrate (the first suspend after an atelet restart, giantswarm/substrate#162) left the turn without a snapshot for good, and nothing on the session said why (giantswarm/kagent-upstream#213). A `RUNNING` Actor now gets the pause or suspend once more, fenced with a newer token, at the settle backoff; the claim is released without a snapshot only when the retry fails too or the Actor is crashed or gone, and the release records why on the session as the output-only `Session.last_quiescence_failure` (task, reason `SuspendFailed`/`PauseFailed`/`RuntimeUnavailable`, message, time), shown by `GetSession`, `ListSessions` and the CLI's session details and cleared by the next snapshot; a successor that took over a frozen holder's claim pauses or suspends a running Actor itself. Stored in the session's protobuf, no migration. Docs: `docs/architecture/runtime-and-lifecycle.md` | prepared, not opened: branch `upstream/session-retry-failed-quiesce` (the settle row's upstream branch + the commit without the fencing token); giantswarm/giantswarm#37742 row 139 | +| `fix(a2a): wait for an in-flight actor operation instead of failing` | a turn sent while another operation held its Actor, such as the repoint of a suspended Actor onto a newer template right after a Substrate upgrade, failed: Substrate's router resumes the Actor on the turn's dial, ate-api answered `Aborted: another operation is in progress for this actor` to every resume of its parking budget, and the router refused the turn, which reached the caller as a refused send although the same turn was answered seconds later (giantswarm/kagent-upstream#191). A send the runtime refused without taking its input, on a runtime that is not lost, now makes the gateway resume the Actor itself through ate-api, waiting while it answers `Aborted` (from 500 ms, doubling to 5 s, at most two minutes), and run the send once more; any other answer, or an Actor still held at the bound, keeps the retryable refusal. The gateway waits once per turn. Docs: `docs/architecture/a2a-gateway.md` | to file; queued in giantswarm/giantswarm#37742 by the upstream exit | +| `fix(session): delete the sessions of a deleted Agent` | deleting an Agent retired its definition and left its sessions alone: a session READY on its revision kept its Actor in Substrate and the revision's ActorTemplate referenced until it idled out (seven days by default), and a session whose own delete had been refused stayed READY for good once its Agent was gone — the lab's cleanup deletes the session right after its turn, while the turn's boundary is still being settled, which refuses the delete (`FailedPrecondition`), and does not retry; the Agent deleted next, the Actor and the ActorTemplate were orphaned (giantswarm/kagent-upstream#183). The leader's expiration sweep now deletes the sessions whose Agent name has no active definition through the ordinary delete workflow with the durable reason `agent_deleted`, whatever the idle TTL, finishes on a later sweep a deletion whose runtime work failed or whose admission was refused, and leaves the sessions of an Agent replaced under its name alone; the runtime revision GC then collects the revision and its ActorTemplate. The reason joins the `session.deletion_reason` check in the core migration `000003_session_deletion_reason_agent_deleted.sql`, because the line's released databases hold `000001` and `000002`; upstream's version of the commit widens the check in `000001`. Docs: `docs/architecture/runtime-and-lifecycle.md` "Deleted Agents" | to file — prepared in the fork: branch `upstream/session-deleted-agent-sweep` (mirror `main` + the patch, DCO-signed, no fork references); giantswarm/giantswarm#37742 row 126 | +| `fix(ui): start nginx before the controller Service resolves` | the UI's nginx named the controller in a static `upstream` block, which nginx resolves once at start and refuses to start without, so a UI pod that starts before cluster DNS or the controller Service answers (after a node restart) crash-loops until they do (giantswarm/kagent-upstream#142); `/api/`, `/a2a/` and `/mcp` now proxy through a variable resolved per request by a resolver `init.sh` renders from the pod's `resolv.conf`, and a request before the controller answers gets a 502 | to file — prepared in the fork: branch `upstream/ui-nginx-resolver` (mirror `main` + the patch, DCO-signed, no fork references); giantswarm/giantswarm#37742 row 129 | +| `feat(cli): send a caller token as the bearer of every controller call` | the CLI reached the controller with `x-user-id` alone, so against a controller in `trusted-proxy` mode, which identifies the caller by the bearer its proxy verified, `session create` and `session invoke` were refused with `Unauthenticated: invalid credentials` (giantswarm/kagent-upstream#180); the CLI now sends `authorization: Bearer …` on every control-plane gRPC call and on A2A calls, from `--caller-token`, `KAGENT_TOKEN` or the current kubeconfig context's credential (an exec plugin, an id-token or a static token, read through client-go as kubectl sends it), `invoke --token`'s model key keeps the A2A bearer when both are set, and a refusal names what to do. Docs: `docs/architecture/oidc-proxy-authentication.md` "CLI", `docs/env.md` | to file — prepared in the fork: branch `upstream/cli-caller-token` (mirror `main` + the patch, DCO-signed, no fork references); giantswarm/giantswarm#37742 row 131 | +| `fix(session): end a turn whose runtime is lost after it took the input` | a turn that a runtime took before its worker went away, so that its stream broke without a boundary, hung the caller's send in the gateway's wait for the turn's boundary until the caller's own deadline, and the session kept the turn as active work until the stalled-turn sweep an hour later; and the idle worker's claim on the session's last boundary, whose Pause Substrate refused on the crashed Actor, outlived the session's failure, so the failed session's delete was refused for as long as the claim stood (giantswarm/kagent-upstream#204: `TestSessionRuntimeLostFailsTheSession` flaked in the gVisor e2e lane on whichever of the two the probe send met, 81 s or 305 s instead of 39 s). The gateway asks whether the runtime is lost before waiting on a turn the runtime took, ends the turn `FAILED` with the loss and answers the send with it at once; `FailSession` releases the session's idle work, claimed or not; the e2e bounds each probe send and reports the last answer | to file with the lost-runtime family (giantswarm/giantswarm#37742 rows 45, 80 and 116); giantswarm/giantswarm#37742 row 134 | +| `ci(fork): follow every controller pod's log through the e2e run` | the e2e job read the controller log from the Deployment once the tests were over, which holds only the final pod; the tests that roll the controller (`withControllerEnv`, the sandbox restart) lost the log of every earlier pod, among them the window of every failure analysed for giantswarm/kagent-upstream#204. The job follows every controller pod from the moment it runs, one file per pod | fork-only | +| `fix(controller): write the Harness Ready condition from its golden boots` and `fix(controller): keep a Harness Ready until a golden boot fails` | nothing wrote `Harness.status`, so `kubectl get harness` showed an empty READY column and `Harness.ready` of the gRPC API was always false although the Harness served (giantswarm/kagent-upstream#210). The controller now derives `Ready` from the Harness's WorkerPool and the golden boots of the Agents that reference it, on its own status queue: `True` (`Booted`) once one Agent's golden boot succeeded, since every revision boots the Harness's workload; `False` with the failed boot's reason and the Agent named (`ActorTemplateFailed`, `ActorTemplateRetrying`, `RuntimePreparationFailed`) while no Agent booted, or `WorkerPoolNotFound`; before any golden boot finished `True` (`WorkerPoolResolved`), since Helm's and Flux's readiness wait reads any other `Ready` as a rollout in progress and a Harness without Agents would hold every release of its chart. The status write keeps `status.capabilities` | kagent-dev/kagent#2764 reports the gap; kagent-dev/kagent#2876 (open, conflicting since #2952) sets `Ready` from the WorkerPool alone, so a Harness whose image never boots reads ready. To file — prepared in the fork as one commit: branch `upstream/harness-ready-condition`; giantswarm/giantswarm#37742 row 136 | +| `test(grpcserver): wait for the server span and metric in telemetry test` | `TestServerTelemetryShape` (upstream's, from kagent-dev/kagent#2929) failed intermittently in `go-unit-tests` with `rpc.server.call.duration was not recorded` (giantswarm/kagent-upstream#220): the client has its response before otelgrpc ends the server span and records the metric on the stats handler's `End` event, and the test read the exporter and the reader right after the calls returned. It waits, bounded, for the GetVersion server span and the metric; the assertions are unchanged. 200 of 200 under `-race`, also under parallel CPU load | to file — prepared in the fork: branch `upstream/grpcserver-telemetry-wait` (mirror `main` + the patch, DCO-signed, no fork references); giantswarm/giantswarm#37742 row 133 | +| `ci(telemetry): fetch the contract's upstream sources once at their pins` | the Telemetry Contract Check cloned three GitHub repositories on every Weaver run (the two registries the manifest depends on, the GenAI one re-cloning the core one through its own manifest, and the Weaver policy packages), eleven clones per `make semconv-verify`; one flaked clone turned the check red on a pull request's first run (giantswarm/kagent-upstream#192). The sources are pinned in `telemetry/versions.env` and fetched once by `make semconv-deps` into a git-ignored checkout the manifest and the check read from disk, cached in CI keyed on the pins; Weaver's schema URL overrides cannot avoid the clone in v0.26.1 or v0.27.0, whose loader clones a manifest dependency before consulting them | to file — prepared in the fork: branch `upstream/telemetry-deps` (mirror `main` + the patch, DCO-signed, no fork references); giantswarm/giantswarm#37742 row 135 | +| `test(sandbox): bound TestServe's process wait by the guest, not the runner` | `TestServe` (`go/sandbox/guest/cmd`) polled `GetProcess` for the first process's `COMPLETED` within `require.Eventually`'s one second, and on a loaded runner the shell's start plus the poll took longer: `go-unit-tests` failed with `Condition never satisfied` at `main_test.go:95` after 1.1 s and passed on rerun (giantswarm/kagent-upstream#236). The wait names one bound of five seconds, in the spirit of the cleanup's five-second stop; a passing run still returns at the first `COMPLETED`. 20 of 20 under `-race` | to file — prepared in the fork: branch `upstream/sandbox-testserve-wait` (mirror `main` + the patch, DCO-signed, no fork references); giantswarm/giantswarm#37742 row 141 | +| `fix(controller): patch the Harness and ModelConfig status without a resourceVersion` | the controller wrote `Harness.status` and `ModelConfig.status` with `UpdateStatus` on a copy of its informer cache, so any write to the object the cache had not observed yet (a Helm upgrade applying the chart's Harness, the controller's own previous status write) failed the next status write with `the object has been modified` and sent it back to the queue; a production installation logged it once on the Harness right after the 1.4.0 rollout. Both status writes are a JSON merge patch of the status subresource that carries no resourceVersion and only the fields the controller derives: `observedGeneration` and `conditions` on the Harness (never `status.capabilities`), `observedGeneration`, `secretHash` and `conditions` on the ModelConfig | to file: the ModelConfig half on its own (upstream `main` has the same `UpdateStatus`), the Harness half with branch `upstream/harness-ready-condition` | +| `test(core): skip the envtest tests without KUBEBUILDER_ASSETS, and set them in make test` | `TestSandboxTemplateCatalog` (grpcserver) and `TestSandboxKRTInformerQueueAndRestartCleanup` (controller) start an envtest API server and failed a plain `go test` of their package on a fresh checkout; `make test` did not set the assets either, only the CI job did. Now `make test` sets `KUBEBUILDER_ASSETS` through `setup-envtest` like the CI job, and both tests skip with a message naming the variable when it is unset (giantswarm/kagent-upstream#232) | to file; giantswarm/giantswarm#37742 (no upstream issue or pull request) | +| `fix(harness): one owner for an MCP server's Authorization under caller-token propagation` | on Substrate 1.5's replace-only egress the Claude harness's credential forwarder deleted every server's compiled `Authorization` and set the caller's token in its place, so a Secret-backed `Authorization` was never sent on a turn without a caller token and overwrote the person's token on a turn with one (giantswarm/kagent-upstream#253); a server that configures `Authorization` keeps its gateway placeholder, any other takes the caller's token or none, the adapter resolves the compiler's `${KAGENT_CLAUDE_MCP_CREDENTIAL_*}` references before forwarding, and `CompileCredentials` refuses, with `KAGENT_PROPAGATE_TOKEN=true`, a caller-owned MCP server on a host with a gateway `authorization` binding, as it refuses a model's `apiKeyPassthrough` | fork-only for the forwarder (upstream main's Claude harness has none; it rides on kagent-dev/kagent#3018); the compiler rule is to file upstream, where the Go ADK has the same overwrite | +| `test(e2e): prove the egress credential contract through the gateway for every client kind` | Substrate 1.5's egress gateway replaces a credential header only when the request carries it (agent-substrate#1751, agentgateway#3689), and private git skills stopped booting on a production installation without a test noticing: no e2e sent a credentialed request through the real gateway and looked at what the upstream received. The suite now records the upstream of every client kind (model keys as `Authorization` and `X-Api-Key`, `RemoteMCPServer` `headersFrom` on every harness and on Claude with `KAGENT_PROPAGATE_TOKEN`, with and without a caller token, git skill and plugin sources with a `credentialRef`) and requires the Secret value on every request and the placeholder on none. The CI job has the gateway trust a per-run CA (`atenetEgress.upstreamTrust.caBundle`) that signs the HTTPS git fixture | fork-only while `credentialRef` is; the model and MCP cases fit upstream next to kagent-dev/kagent#3099 | +| `fix(translator): send an Ollama Cloud model of the Python runtime to api.ollama.com` | the compiler left `OLLAMA_API_BASE` unset for a cloud model so that the runtime would route it, and only the Go runtime routes: the Python runtime sent a cloud model to `localhost:11434`. The compiler now writes the cloud endpoint whenever `OllamaReachesCloud` routes there; the Ollama SDK sends `OLLAMA_API_KEY`, the gateway placeholder, as the bearer | to file; giantswarm/giantswarm#37742 | +| `feat(harness): caller routes on the Claude harness forwarder, git through them` | a coding agent must `git clone` and `fetch` as the invoking person with no token in the sandbox (bumblebee-plans#60, D9 phase 2); whether a host's route admits `push` is the upstream's choice, and the platform's refuses it on day one (giantswarm/agent-platform#754). `KAGENT_CLAUDE_CALLER_ROUTES` maps a host to an upstream that acts on the turn caller's credential, in the platform an agentgateway route whose `oauthTokenExchange` trades it at muster's broker for the person's token at that host; the forwarder serves each host on loopback only while a turn runs and ends a turn's requests in flight when it clears the credential, and git reaches it through `GIT_CONFIG_*` `insteadOf`; a route path with a dot segment or an encoded slash is refused before it is matched, like an MCP path. Builds on the `forward the caller's credential` row | to file | + +Rules for the table: every non-`ci(fork)` row has an upstream pull request or a "to file" that +giantswarm/giantswarm#37742 tracks; a row leaves when the sync drops the commit because upstream merged it +(the re-pin report names the upstream commit) — remove the row in a follow-up pull request. Giant Swarm-specific +wiring (Substrate settings, the platform `Harness` objects, pgvector, registries) lives in the agent-platform +charts, not here. + +Dropped at the 2026-10-05 re-pin, because upstream merged the change or made it moot (the SHAs are upstream's): the two `kagent.dev/discovery=disabled` commits (kagent-dev/kagent#2752, `ae2b2149`); `fix(controller): honour AUTH_MODE and wire the trusted-proxy authenticator` (#2971, `d4ed7dea`); `fix(controller): compare the pair collections' outputs by content` (#2952, `907daf90`, `Equals()` on the collections); `feat(core): add trace_id and span_id to gRPC request logs` (#2929, `f1e46d1a`); `build: pin google.golang.org/grpc to v1.83.2 (GO-2026-6443)` (upstream's own bump, `6fcd5098`); `fix(substrate): reject a runtime environment value Substrate would refuse` and `fix(controller): stop retrying an ActorTemplate Substrate rejects` (#2994, `cb6b756c`); `feat(agentinstance): let an AgentInstance share expire` (#2987, `4dce53de`); `fix(a2agateway): fail a task whose dispatch to the runtime fails instead of leaving it submitted`, `fix(a2agateway): the turn a unary SendMessage starts outlives the caller's deadline` and `fix(a2agateway): follow a dispatched task again when its runtime stream is cut` (#2939, `8be872b2`: the turn runs inside the runtime's TaskStore and the gateway only relays the stream, so there is no gateway dispatch to fail, detach or follow again); `fix(migrations): bring a Kagent 1.0 database to the current schema` and `ci(fork): upgrade every released schema to the tree's migrations` (replaced by the 1.x refusal row above). Two carried patches have no equivalent in the stack yet and are owed: `fix(controller): start a golden boot that failed its boot bound over` (the retry of a crashed golden boot above covers `GoldenActorCrashed`; whether the Substrate line's boot-bound verdict reaches the controller as that crash is on the agentlab proof list) and `fix(a2agateway): end a turn whose runtime Substrate will not resume` (the Substrate line's `resumable: "false"` directive, giantswarm/substrate#80, has no reader on the Session workflow). The drafts of kagent-upstream#122 (closed) and #126 (open) are not in the stack; the `AgentTemplate.spec.limits` drafts were replaced by #108's landed `Harness.spec.claude.limits` commits. At the same re-pin the line's own forms of the coding-agents patches were replaced by the heads of the upstream pull requests: `Harness.spec.substrate.egress` and its wildcard fix by kagent-dev/kagent#3019 (`Agent.spec.egress`, origins), the limits commits by #3020, the caller-credential commit and its path fix by #3018, the two shared-turn commits by #3066; the chart's `harness.egress` and `harnesses[].egress` left with them, egress is declared on the Agent. + +Open pull requests of this repository that add a carried commit once merged, each with its giantswarm/giantswarm#37742 row: #65 (fail the task at `max_tokens`; upstream kagent-dev/kagent#2896, row 90), #115 (`isError` on a failed tool call; kagent-dev/kagent#2937, row 75), #116 (the rejection reason; kagent-dev/kagent#2947, row 91), #126 (caller routes on the Claude harness forwarder, git through them; row 86), #147 (Harness caller credentials the egress gateway sets as the turn's caller), Merged before the 2026-10-05 re-pin and carried above: #103, #104, #107, #108, #125 (the coding-agents drafts, rows 81 to 85), #134, #136, #137, #143, #148, #149, #155, #160, #162. #122 is closed; #124 left the stack because kagent-dev/kagent#2987 landed its Session form (`4dce53de`). + +## What is published + +`.circleci/config.yml` (the fork's own, hand-written — upstream has no CircleCI pipeline) publishes on every push to the +consumed branch `giantswarm` and on the line's release tags `vX.Y.Z` and `vX.Y.Z-rc.N`, through the architect orb's +stock jobs: one `build-image` job per image and architecture (amd64 and arm64, each native on a machine of its +architecture, pushed by digest) and one `push-to-registries` job per image, which joins the two into the tagged index, +signs and attests it. Every other branch of the line — a pull request branch, a `sync/**` re-pin candidate — runs the +same eight builds with `push: false` and publishes nothing; the mirrored upstream refs (`main`, `release/v*.x`, +upstream's tags) run nothing: + +| Artifact | Reference | +|---|---| +| Images (multi-arch, amd64 + arm64) | `gsoci.azurecr.io/giantswarm/kagent/{controller,ui,golang-adk,claude-harness}:` — upstream's Dockerfiles as they are (`go/Dockerfile` with `BUILD_PACKAGE` for the controller and the Go ADK, `ui/Dockerfile`, `go/harness/claude/Dockerfile`), built with the build arguments the Makefile passes (`VERSION`, the `LDFLAGS` `-X` flags for the version and the commit, `BUILD_PACKAGE`), signed with cosign keyless under the project's CircleCI OIDC identity (`cosign verify --certificate-oidc-issuer https://oidc.circleci.com --certificate-identity-regexp '^https://circleci\.com/api/v2/projects/[a-f0-9-]+/pipeline-definitions/[a-f0-9-]+$'`), the provenance and the SPDX SBOM attestation of each platform signed the same way; the index carries the annotation `io.giantswarm.upstream.version=main@`, the upstream pin ("Re-pin") | +| Charts | `oci://gsoci.azurecr.io/giantswarm/kagent/helm/kagent:`, `oci://gsoci.azurecr.io/giantswarm/kagent/helm/kagent-crds:` — the line's own path, not `charts/giantswarm/kagent` (the 0.10 wrapper chart's, below); signed like the images. The chart's image defaults are stamped with the same registry, repositories and version, `controller.agentImage.digest` and `runtimeImages.claudeHarness.digest` with the index digests of that build's golang-adk and claude-harness images, `substrateWorkerPool.workerImage` with the worker of the Makefile's `SUBSTRATE_VERSION` | +| Dev version of a push to `giantswarm` | gitsemver 3's dev shape, `X.Y.Z-r588f3d76th` (architect-orb 10.10.0, since 2026-09-24; builds before it carry the superseded `X.Y.Z-dev.giantswarm...h`, which the registry keeps). `X.Y.Z` is the next patch above the newest stable tag reachable from the commit (upstream's mirrored `v0.9.10` until the line's `v1.0.0` is on the branch, then `1.0.1`; a re-pin rebases the branch past the line's tags, so the base falls back to upstream's until the next release tag), `r588f3d76` the CRC32 of the branch name (`gitsemver branch-hash giantswarm`), then the committer date in UTC and the short commit — 33 characters, no `.` and no `-`. Consumers select the channel with a Flux `semverFilter` of `^.*-r588f3d76t[0-9]{14}h[0-9a-f]{7}$` next to a range whose bounds carry a pre-release (`>=0.0.0-0`; a range without one never admits a pre-release). Flux takes the highest match, and the base outranks the time stamp: after a re-pin the channel stays on the last build before it (`1.0.1-r588f3d76t…` above `0.9.11-r588f3d76t…`) until the release that follows the re-pin is tagged — a re-pin is released, not left to the filter. No superseded build matches the filter, so a consumer moving to it lands on the branch's newest current build whatever base the last superseded build carried | +| Release version of a tag | the tag without `v`: `X.Y.Z`, or `X.Y.Z-rc.N` for a release candidate (the release scheme below; the workflow's tag filter admits no other shape) | +| Digests | every build's image and chart digests (multi-arch index digests, what a `Harness` pins) are a row of [`builds.md`](../../blob/ledger/builds.md) on the `ledger` branch and the `published.md` artifact of the pipeline's `record` job | + +Only what the platform consumes is published: the Python ADK, the Codex harness and the CLI image are not +offered on the platform and are not built here. A dev build is the test artifact of the consumed branch — a merged +pull request or a landed re-pin — proven in agentlab on the meta chart's dev channel before the tag that consumers +depend on; a pull request's branch is proven with a build of its checkout swapped into the lab (the pipeline builds +it, pushes nothing). Its consumers: the agent-platform meta chart's dev line (`components.kagent` follows the channel; +its `kagent.tag` and the two `Harness` image digests are pinned there and re-pinned per build), agentlab, and the +test cluster following the channel. A release (below) is what the meta chart's release line resolves. + +Publishing does not wait for the tests. Nothing reaches the consumed branch untested — a pull request needs a +green `ci-ok` and `scan-ok` on an up-to-date branch, and the sync lands a candidate only after both passed on +its exact head (the same commits it then force-pushes) — so the push-triggered run on the consumed branch is a +repetition; a red one is a flake to re-run or an environment drift to fix, visible on the commit and in the +Actions tab, never a reason to pull a build that was green on the same tree. + +### Release scheme + +A release of the line is a tag `vX.Y.Z` on the consumed branch — its own stable semver, decoupled from upstream's +version, as the RFCs prescribe for every Giant Swarm package of upstream software (giantswarm/rfc +`semantic-versioning-of-upstream-software`, `semver-based-automatic-upgrades`): the version says nothing about the +upstream version, which is documented instead — the pin in the table above and the ledger, the annotation +`io.giantswarm.upstream.version` on every image, the release's row below. `1.0.0` is the first release of the scheme, +the next major above the coupled `0.11.0-gs.N` releases it replaces, so every consumer range moves forward and none +back; a patch or a minor of the line is a patch or a minor of the version, whatever upstream did in the re-pin between; +a major is the line's own breaking change (a chart value renamed, a CRD contract changed), never upstream's numbering. +A release candidate is `vX.Y.Z-rc.N`. The pipeline publishes a tag the way it publishes a dev build — the version is +the tag without `v`; the workflow's tag filter admits `vX.Y.Z` and `vX.Y.Z-rc.N` and nothing else: the four images and +both charts under `gsoci.azurecr.io/giantswarm/kagent` at `X.Y.Z`, the digests as a row of `builds.md`. The release's +row in the table below is written by hand. + +Why this shape: + +- **Order.** Releases sort as plain semver, so a consumer follows the line with a range — the meta chart's release + line `>=1.0.0 <2.0.0` — and re-resolves on every reconcile. Dev builds (`-r588f3d76t…`; before 2026-09-24 `-dev.…`) and release candidates (`-rc.N`) are + pre-releases, which Flux's semver (Masterminds) never matches against a range without a pre-release bound; the dev + channel is selected by `semverFilter` (above) next to a `-0` range, and nothing else. +- **No collision with the mirror.** Upstream's tags are `vX.Y.Z` too, and the mirror copies every upstream tag the + fork lacks; a name both use would make the two refs disagree. The line's tags therefore live in a major above + upstream's — `1.x` against upstream's `0.x` — and `scripts/fork/mirror.sh` refuses, naming the tag, to mirror an + upstream tag whose name the fork already holds at another commit, instead of skipping it silently. The rule: when + upstream's version approaches the line's major (upstream tags `0.99.x`, or a `1.0.0-rc`), the line moves to its next + major first — a deliberate `v2.0.0` — and the consumers' ranges with it. Upstream's version resolvers in `ci.yaml` + see the fork's tags too: `scripts/upgrade-from-version.sh` (the `adjacent` leg) is skipped on a non-release base such + as `giantswarm`, and `scripts/prev-stable-version.sh` only looks at the release line below the one being built + (`release/v0.10.x` today), where no fork tag lives. +- **Out of the wrapper's range.** Every 3.x installation of the platform selects the 0.10 wrapper chart `kagent` at + `oci://gsoci.azurecr.io/charts/giantswarm/kagent` with `>=0.2.0 <1.0.0` and re-resolves it on every reconcile. + The line's chart must never be resolvable there. It is not: it lives on another OCI path + (`gsoci.azurecr.io/giantswarm/kagent/helm`) — the wrapper's repository and the line's never share a version — and + its versions are `>=1.0.0`, outside that range besides. +- **gsoci, from CircleCI, under the line's own path.** The org publishes to `gsoci.azurecr.io` from CircleCI only — + the architect orb's multi-arch builds, signed with cosign keyless under the project's CircleCI identity — and + never from a GitHub Actions job: no Actions workflow of this repository holds a registry credential, and nothing + pushes to ghcr.io. `.circleci/config.yml` is that pipeline. The repositories keep upstream's names under the + line's prefix (`giantswarm/kagent/`, `giantswarm/kagent/helm/`) — not `charts/giantswarm/kagent`, + the wrapper chart's repository (above), and not the app catalog, which lists apps, not the component a meta chart + pins. A consumer names the registry (`kagent.registry: gsoci.azurecr.io`) and the chart repository + (`oci://gsoci.azurecr.io/giantswarm/kagent/helm`); the version scheme is the same for both. + +A tag is cut only after the build of the same commit — its dev build on the consumed branch — has passed the +platform's proofs in agentlab on the meta chart's dev channel; the tag re-publishes the same tree under the release +version. Cut it deliberately (no tag ruleset gates it; anyone with write access can push a tag): + +```bash +git tag -a v1.0.0 -m v1.0.0 && git push origin v1.0.0 +# or, without a checkout: +gh api -X POST repos/giantswarm/kagent-upstream/git/refs -f ref=refs/tags/v1.0.0 -f sha= +``` + +Then add the row below from the run summary (or `builds.md`). + +### Releases + +Rows up to `0.11.0-gs.22` are releases of the coupled scheme that preceded the one above — `vX.Y.Z-gs.N`, `X.Y.Z` the +upstream version the pin anticipated, published by the pipeline's own jobs; `v0.11.0-gs.22` the first from CircleCI. +`1.0.0` and later follow the release scheme above. +| Release | Tag | Commit | Upstream pin | Images (index digests: controller, ui, golang-adk, claude-harness) | Charts (kagent, kagent-crds) | +|---|---|---|---|---|---| +| `0.11.0-gs.1` | `v0.11.0-gs.1` (2026-09-10T23:36Z, after the agentlab proof of the dev build `0.11.0-dev.giantswarm.2026-09-10.22-06-46.h0ac5240`: platform, agents and toolsets proofs green) | `0ac5240` | `4a91c27` (2026-09-10) | `sha256:04106af50ee8e68e0388006981e25bd61b5220f50ece8fe7920269aea3b8d49c`, `sha256:3275c7ab8f09291f4806ed49e406d98c1dbec51e5d05415ed07ade2799423f81`, `sha256:969af5f733c8e2bd7756f40766352f5af744964e969a031ba146198d8becd546`, `sha256:c8a9c7c3d5dd7ecc3953fff2a12b6b4a44b46eb6a66687a5604f6851fe453ca3` | `sha256:3c9ee22cb60c493b7e99bbc27d260cb5aa5e48d0996db1bbd28abae3b17a8a9e`, `sha256:7c62f76e5693bad9b603f4e557b82da9b54060e3d1f811133905b4d6cca76d70` ([run 34542852237](https://github.com/giantswarm/kagent-upstream/actions/runs/34542852237)) | +| `0.11.0-gs.2` | `v0.11.0-gs.2` (2026-09-11T04:55Z, after the agentlab criterion-5 proof of the dev build `0.11.0-dev.giantswarm.2026-09-11.00-32-34.h3bdff38`: reached directly through a port-forward to `kagent-controller` — bypassing agentgateway — `SystemService/GetCurrentUser` returned the admin bearer's `email` while a forged `x-user-id` was ignored, a bearer-less call was refused (`Unauthenticated`), `/health` stayed `200`, and the five CRDs carried `helm.sh/resource-policy: keep`) | `3bdff381` | `4a91c27` (2026-09-10) | `sha256:9f0acec1ce78e7c9b261c199dd73be24ec404e389138a4ed909bf2e5527b8729`, `sha256:de6d8a593a49e013799a38a84d43586f2ebb84905d84b826783a343dddf88e82`, `sha256:7db42765cc401f4e356f876cf76a25de39c5109e56fabc9fe45ec0bf7e2d3137`, `sha256:82fce79679ac39a386a393aa0fbba7a2f55652bd1bc184adc5d5eae72c040c82` | `sha256:80026ab9dfbe9d82cbd2f856890cf389c58dc92c35c141af25e0a5d3e77b0c87`, `sha256:00b47fba8ae09e4e62cc5c4d6c6a40c2bb5cc596ab005e6b3d929fd6a4ec1b8c` ([run 34563969164](https://github.com/giantswarm/kagent-upstream/actions/runs/34563969164)) | +| `0.11.0-gs.3` | `v0.11.0-gs.3` (2026-09-11T07:26Z, after the agentlab proof of the dev build `0.11.0-dev.giantswarm.2026-09-11.06-51-42.hed07617` — the second automated re-pin's head, upstream `6a6b54ff`: `platform-test`, `agents-test`, `toolsets-test --skip-portal` and `skills-test` green on an isolated lab running the build as charts, images and both platform Harness digests) | `ed07617b` | `6a6b54ff` (2026-09-10) | `sha256:eeac144a79ad1c183208eeeb699a362ccc7dffd70f1032df8f5b57856dba85ac`, `sha256:77154fd68b88df89037df0c83dc26a98f1f10db5024ea8d5ca8d29cfa9e55133`, `sha256:a2d23f5eb9c01e1903459a6e742f7d4aaa5e950d7e9aa6f07f8982761be0163a`, `sha256:d8df16dc5246130ccfb1cd0ef5b6665f354d02812d2266176a3d230096556617` | `sha256:50451bb05a4706ea90503c28c574e82f64fefca5cb45ec749bf0f3e639fbd0b3`, `sha256:d7150eb061713bd42ddfd7506ebd9db7fd39c2e80b68d2bdd41100536cbb6a72` ([run 34574409389](https://github.com/giantswarm/kagent-upstream/actions/runs/34574409389)) | +| `0.11.0-gs.4` | `v0.11.0-gs.4` (2026-09-12T10:22Z, on the rebase-merged giantswarm/kagent-upstream#15 — the A2A gateway fails a task whose dispatch to the runtime fails — cut for the agentlab 4.x audit's re-pin of the meta chart, giantswarm/giantswarm#37763; the pin and the other carried patches are those of `gs.3`) | `ca399a3a` | `6a6b54ff` (2026-09-10) | `sha256:fc3eb5169a66276c467f23bf28bf932e6c7f8f92c7b56548c1f7fb38cc5868a1`, `sha256:93627659768dc3d447672807074e0d53993efb992c3cb58c85d05fb9b6ad57e3`, `sha256:6e510b241e2d000b7d10879c9fde3dd34a37879854278f0a5d386426049a88bf`, `sha256:475741e15dbe4c303a44bb80d672c05511dc3eb4118acf32b0f9ab3124a235dc` | `sha256:f5f037613d70fb982c23b4342f2d3760d828ebcc1b81e6d7a6c09944e142ca23`, `sha256:74a093857d183d5ff67fe81f3787df0699929643b7a9fbf291ae3955c548e183` ([run 34688265253](https://github.com/giantswarm/kagent-upstream/actions/runs/34688265253)) | +| `0.11.0-gs.5` | `v0.11.0-gs.5` (2026-09-12T13:26Z, on the squash-merged giantswarm/kagent-upstream#18 — Substrate re-pinned from the line's bootstrap dev build to its release `0.0.27-gs.6` — above the three commits of giantswarm/kagent-upstream#17: the publish-time stamps of the golang-adk digest and `SUBSTRATE_VERSION` into the chart (`a26a9cbf`, fork-only), `controller.agentImage.digest` with the optional default Harness (`6e52217d`) and the ConfigMap of runtime image references for the Substrate image cache (`25b4fd68`); cut for the agentlab 4.x audit, giantswarm/giantswarm#37765. The first release whose published chart carries `controller.agentImage.digest` = its golang-adk digest, `runtimeImages.claudeHarness.digest` and `substrateWorkerPool.workerImage: ghcr.io/giantswarm/substrate/ateom-gvisor:0.0.27-gs.6`; `harness.create` stays `false`) | `955ac543` | `6a6b54ff` (2026-09-10) | `sha256:df2babf1c143ff91e5a89bcbe12a865f5f4df8e6dcb242a63ab5f9b9c183b35e`, `sha256:a56386045de6bd26bce3249af22ef29067a3a79001801f48a48aab1cd72f150e`, `sha256:f9a4367296a8753795e5442b18cfcc6323289aca3988285a5a2f5f9e1222f46a`, `sha256:9039fd94e130589bdef1d531dd17f930061381c7e44bfad775c0c71cd613855a` | `sha256:58a381906624c57c4c70ca0a7d8904fe171ef8c82146e3d39e49463119a4f840`, `sha256:1e766c92446318d2fc29c4f571e0089fef93adc2cf812104eabfde9f0bd5e3d2` ([run 34696441320](https://github.com/giantswarm/kagent-upstream/actions/runs/34696441320)) | +| `0.11.0-gs.6` | `v0.11.0-gs.6` (2026-09-12T15:07Z, on the squash-merged giantswarm/kagent-upstream#20 — Substrate re-pinned from `0.0.27-gs.6` to the line's release `0.0.27-gs.7`, which carries giantswarm/substrate#25: ateom re-asserts its capacity report every 10 s, so the worker record the pod-IP repair re-creates receives its capacity within one interval (giantswarm/giantswarm#37762); no other change since gs.5. The published chart stamps `substrateWorkerPool.workerImage: ghcr.io/giantswarm/substrate/ateom-gvisor:0.0.27-gs.7`; `harness.create` stays `false`. Inert under the agent-platform 4.7.x meta chart, which forwards the worker image by hand; live from 4.8.0) | `1724f815` | `6a6b54ff` (2026-09-10) | `sha256:936bc3f31b5d30fca52a2e669b5742062e2b4bb9056c7fcd74b0ac6b90cc3064`, `sha256:4074b2c38d4ad4fd6907009ce774daf71906c6f347f34be73b6a512005f51e05`, `sha256:38c8c45a318e74af117e0811affd02bed1f97484a2ce05cef095334576147da1`, `sha256:47dff2ac4865ee296e926bc7f3e326f32f012be18ef98bba6f600d5e41711bde` | `sha256:90446d93b1d81991e496325fc471816a68dea8f365cb00fab3ebb5c3cf72386e`, `sha256:cdc8eefb63c6a60e8dbc906f95f0e30edfa07405175003b194685fd51d88fed3` ([run 34701286542](https://github.com/giantswarm/kagent-upstream/actions/runs/34701286542)) | +| `0.11.0-gs.7` | `v0.11.0-gs.7` (2026-09-12T18:48Z, on the rebase-merged giantswarm/kagent-upstream#22 — the `kagent-images` ConfigMap carries `reconcile.fluxcd.io/watch: Enabled`, so the substrate HelmRelease that reads it through `valuesFrom` follows a Harness-image-only change at once instead of on its 10-minute interval (giantswarm/giantswarm#37767); no other change since gs.6. Rolled into agentlab (agent-platform 4.8.0) at 18:58:31Z: the relabelled ConfigMap's new digests reached the substrate release in the same second, no `requestedAt` nudge; the Harness-swap proof is recorded on the issue) | `2b1cef30` | `6a6b54ff` (2026-09-10) | `sha256:98c43507ce8f21d6bbdda9d845b380cdc136ed727fe226ba24242c5fe2ad464f`, `sha256:d385c428d2121c782633b7e34d428e8d8c5c5c120258994c46f2f38933346571`, `sha256:cfd3ace31ffce7fe45bd62043eaccfa17029d983c734cca5d1f1f2322fa49e90`, `sha256:38fa0e5a4e2cc517468c3c2d1e6c322bb80eae4f36a98cd3f21836dbb8a964e0` | `sha256:73d933fac20717e12c826c39b5972a8a1451115cb73dc6220322d80cfbe1aff2`, `sha256:ef0294d07f70f722af8be35ceaaa66671e7ac04d8eddb339fd87d660431df903` ([run 34712322934](https://github.com/giantswarm/kagent-upstream/actions/runs/34712322934)) | +| `0.11.0-gs.8` | `v0.11.0-gs.8` (2026-09-12T19:15Z, on the rebase-merged giantswarm/kagent-upstream#23 — the controller starts a crashed golden boot over instead of failing the AgentTemplate for good (`251e23bb`; giantswarm/giantswarm#37766): Substrate's unattributed `GoldenActorCrashed` is retried up to five times per revision with a doubling backoff, `Ready=False ActorTemplateRetrying` meanwhile; a crash the resume attributed, `GoldenActorInvalid` and `GoldenActorUnexpectedState` stay final — cut after the agentlab proof of a controller build of the same patch on 4.8.0 (a Harness digest change with the worker pods replaced under the boots: both templates crashed, were started over and were Ready 72 s after the trigger with no Harness change; `platform-test` 9/9, `agents-test` 6/6); the pin and the other carried patches are those of `gs.7`) | `6be4b095` | `6a6b54ff` (2026-09-10) | `sha256:3777ccfa232c8c23ef9f012ba67a9693c0ef82f436e9e5415704a54c4b75c9f1`, `sha256:2f252ef63f3d01b52a02f27b324f0583beb7256d873a0118d6b77b3f61ad24dc`, `sha256:25cd94da2ed6424982fc7113ce95fe613210fcd5e278a071ad7b18d9a00bdc87`, `sha256:f0601bda7374edf8d72384c4996d2536f8ea9e9b70419cbd62ac940b66105129` | `sha256:3aee028d939b5b8b4bfcff002b5c4583947b70c7fc2fe5cb3fa8e662399ec991`, `sha256:ed87a03aff899ad14f9e785f4b768dc2bf0a10c409ce1be70e2ac66f5d13105c` ([run 34713645853](https://github.com/giantswarm/kagent-upstream/actions/runs/34713645853)) | +| `0.11.0-gs.9` | `v0.11.0-gs.9` (2026-09-13T12:40Z, on the rebase-merged giantswarm/kagent-upstream#26 — the Harness template drops every `allowedAgentTemplates.selector.matchLabels` entry whose value is the empty string and fails the render when nothing is left to select by (`c5fc92eb`; giantswarm/agent-platform#418): the meta chart forwards `kagent.dev/harness: ""` instead of a null, which the JSON merge patch of a kagent HelmRelease that pre-existed the cut-over removed instead of storing, so the platform Harness on a test installation required both labels and admitted no template — cut after the agentlab proof of the PR branch's dev build `0.11.0-dev.fix-harness-selector-drops-empty-values.2026-09-13.12-17-29.hcc74916` on the meta chart change (giantswarm/agent-platform#428): the key removed from the live HelmRelease as on a test installation, the Harness selecting by both labels and a platform-label-only AgentTemplate admitted by none; after the upgrade the HelmRelease patched to `""`, the Harness selecting by `agent-platform.giantswarm.io/harness` alone, the template `Accepted=True` → `Ready=True` (golden snapshot ready), `platform-test` 9/9, the `agents-test` agent Ready after 20 s; the pin and the other carried patches are those of `gs.8`) | `549907d2` | `6a6b54ff` (2026-09-10) | `sha256:bc678abe8e8ec4158c48c60fd01585c7f97319c3bc2b0806d2314f427d5d31c8`, `sha256:e4a93276664bd0aad495b892916567ea0348478fe510883fcad7cd4f74438cbe`, `sha256:4511c2cd495bc808c15a63d70a621bcfa432057e26c6da452fe64c3139e4146c`, `sha256:0502e5ed94bbf44f2d48eee00a1593bbbd1ac8b009c092200580023402e08c64` | `sha256:93833d6cdfdb08a1cc1c5b31b1c41cb7da636cd0d86fe3b1707dc7a5bb5982f8`, `sha256:1ee839ddb422df4c5854ca18656357065873a4ca45a93847e97f859605160aef` ([run 34757744840](https://github.com/giantswarm/kagent-upstream/actions/runs/34757744840)) | +| `0.11.0-gs.10` | `v0.11.0-gs.10` (2026-09-13T15:07Z, on the rebase-merged giantswarm/kagent-upstream#28 — the turn a unary A2A `SendMessage` starts outlives the caller's deadline (`52832849`; giantswarm/giantswarm#37771): the unary path dispatches like the streaming one, a `taskRun` on the runtime's event stream detached from the caller's context, and the caller waits on that run — a caller whose deadline expires gets `DeadlineExceeded` while the run finishes the turn and `GetTask` shows it landing; the reconciler fails a submitted task beyond the dispatch grace period that the runtime still runs but no run of this gateway records — cut after the agentlab proof of a controller build of the same tree (`kagent-controller:dev-9c5522a3`, the PR head before the rebase, via `platform.devImages.kagent` on agent-platform 4.9.1): `grpcurl -max-time 5 … SendMessage` → `DeadlineExceeded` at 4992 ms, `GetTask` `WORKING` at once and `COMPLETED` with the 654-word artifact two minutes later, the gs.9-era instance stuck on a `SUBMITTED` task accepted its next message; `platform-test` and `agents-test` green; on gs.9 the same call left the task `SUBMITTED` and the next message `FailedPrecondition`; the pin and the other carried patches are those of `gs.9`) | `52832849` | `6a6b54ff` (2026-09-10) | `sha256:147debeebbf97a57c0a8e3dfcf4704acbf7a3aa57e06ac637ba1ce0e8e130490`, `sha256:260cd6cea31b5b158c6759199310a44a164c358bff48086a2b5658c2349fdde6`, `sha256:006ca89501db3cd375e65f78f0e817cba9e1245093076b3c0d2e9696e05e236c`, `sha256:421b1e631c3e61272bc3c02e2eb05ec117a6768d79d6421a14147f4b892f5cc0` | `sha256:504e1d4e36ed3bf15618f5acf4a887ecd6b48a4cade07f90d8f18927b85433b4`, `sha256:5ea3a2ab4727ffefd6ff1ded59498305fb002c0bb6aaed8dd1b27f964f1ee50d` ([run 34764654053](https://github.com/giantswarm/kagent-upstream/actions/runs/34764654053)) | +| `0.11.0-gs.11` | `v0.11.0-gs.11` (2026-09-13T20:54Z, on the rebase-merged giantswarm/kagent-upstream#30 — the Go ADK runtime loads a `SKILL.md` whose frontmatter carries fields outside the agentskills.io set (`bd417a04`; giantswarm/giantswarm#37772): `go/adk/pkg/skillfs.Lenient` serves the ADK a view of the skills directory with only the known fields, so Claude Code's `user-invocable`/`argument-hint` and a top-level `version:` no longer fail the golden boot while `name`/`description` stay validated — cut after the agentlab proof of the PR branch's dev build `0.11.0-dev.fix-skills-lenient-frontmatter.2026-09-13.19-33-23.h23dc352` on agent-platform 4.10.1: `skills-test` with the fixture `fixtures/skills/frontmatter-fields` of giantswarm/agentlab (`user-invocable`, `argument-hint`, `version`) reached Ready on the Harness in 12 s and the turn answered the fixture's codeword; the pin and the other carried patches are those of `gs.10`) | `aca521b0` | `6a6b54ff` (2026-09-10) | `sha256:12444b6a62c31ce6c755d78e68b9b1f787c71862c191add4947f9a12924e090b`, `sha256:4015dfd54a36ee73e7b2cec5a168b19d39aa54420c9822e48790ed092486987b`, `sha256:e7c347cadce8446dc9d21c5cf32db3a4f10b5add8cf4681353ec50694779c6da`, `sha256:539b9e1af7cfda8d15e34f6f932b8fb8d43945040d9ed3fa9b98d410bff9c631` | `sha256:b79141b3466de88e8cf4998b37ddb8af7117d7f5a7aef8ef25131c17538f5ece`, `sha256:e4ba65f3f3e623ef86466b1a1006c7f05af1ad673a1898ef5c732e18ed5fc7d4` ([run 34781813093](https://github.com/giantswarm/kagent-upstream/actions/runs/34781813093)) | +| `0.11.0-gs.12` | `v0.11.0-gs.12` (2026-09-13T21:42Z, on the squash-merged giantswarm/kagent-upstream#32 — Substrate re-pinned from `0.0.27-gs.7` to the line's release `0.0.27-gs.9`, which carries giantswarm/substrate#30: the gVisor worker's terminate lists the runsc containers it knows and checks and deletes only those, and a `runsc delete` that fails after removing its container counts as done, so a superseded `AgentTemplate` revision whose golden boot had crashed is collected in one sweep instead of pinning its worker for good (giantswarm/giantswarm#37773; on the production installation two of four workers were held); no other change since gs.11. The published chart stamps `substrateWorkerPool.workerImage: ghcr.io/giantswarm/substrate/ateom-gvisor:0.0.27-gs.9`. Cut after the agentlab proof of the Substrate patch's dev build (never collected in 56 min on the gs.7 worker; collected on the sweep after the template change with the patch); live on the production installation 2026-09-13 21:50Z, the WorkerPool rolled, the two pinned workers `FREE`, the controller log clean) | `b5bfa7f1` | `6a6b54ff` (2026-09-10) | `sha256:cdb768e7de6ebb07ffeab295f88ca3b6f2d8760bc4982a4ff5ddcc0fae9064ae`, `sha256:e540e72abab945a5c5c6ed3f751ec5bd0ef2d43bbdabf4cdf447a6617a368769`, `sha256:d1008a9ff2f8f26d1f50b3ca587177fa9da8318ef1cbd6dde12a58eb9996d470`, `sha256:22e5fcb36689f4b31b06242ebd161f4cc902acc1ad1ef61dc1d4abfd5c8e7a7d` | `sha256:fd6ea78210f4503fbdfc4463e0c9712ca66ff56b65d1a68029a2dcb54719906e`, `sha256:07bf87bdd7e80b0982c92be553e393a54f33d10a5b8778686fe5e586286851f0` ([run 34784620182](https://github.com/giantswarm/kagent-upstream/actions/runs/34784620182)) | +| `0.11.0-gs.13` | `v0.11.0-gs.13` (2026-09-14T16:08Z, on the rebase-merged giantswarm/kagent-upstream#35 — kagent-dev/kagent#2739 carried as a cherry-pick (`52512ed5`; giantswarm/kagent#57): the Go ADK accepts a `ModelConfig` with `provider: OpenAI`, an `openAI.baseUrl` and no key source — the client sends no `Authorization` header instead of a golden boot that dies with `OPENAI_API_KEY environment variable is not set`; without a `baseUrl` the key stays required. Cut after the agentlab proof of the branch's Go ADK (`golang-adk:dev-4268466d`, swapped in through `platform.devImages.harness` on agent-platform 4.12.3): the keyless template that crashed its golden boot once a minute on the gs.12 Harness reached `Ready=True` on the patched one and `agentlab turn` answered `pong` in 3.97 s; the pin and the other carried patches are those of `gs.12`) | `8d917840` | `6a6b54ff` (2026-09-10) | `sha256:7659f60b53886ec253adb7ebf7ad61fbcc9f5ad353ab2970e6298efd3797883b`, `sha256:8cb004f1e089be9e2eda3c9876b84c4027af778b94b8c1067b3a4a890b721e17`, `sha256:c01d9aa87136cc1a018f6580ed5c31040e1df25d185aa7032ae7e6fc762e1bbe`, `sha256:9383c4f8996803232ee4fbcfaa7e6f9b03548b40d06199a6aabc6027e876eb09` | `sha256:1ebe1d49925ec383e280aa21024e49a167b35037419a033f4c7ac03524a605ff`, `sha256:b8da9df70c1491932214e3695ed8d1dc3d83deefb8eaf7e32cb990732aed3fb9` ([run 34866691831](https://github.com/giantswarm/kagent-upstream/actions/runs/34866691831)) | +| `0.11.0-gs.14` | `v0.11.0-gs.14` (2026-09-14T21:35Z, on the rebase-merged giantswarm/kagent-upstream#37 — the Substrate pin moved from the re-pin candidate's dev build to the line's release `0.0.30-gs.1` — on top of the 2026-09-14 re-pin onto upstream `main` `800015de` (kagent-dev/kagent#2802: Substrate v0.0.29 and the `ate-target-actor` header; the #2739 cherry-pick fell away). The third step of the three-line move (agentgateway `v1.5.1-gs.4` → Substrate `v0.0.30-gs.1` → this): cut after the agentlab proof of the three lines' builds together on agent-platform 4.15.2 as an upgrade from gs.13 / 0.0.27-gs.9 / v1.5.1-gs.3 — `platform-test` 7/7, `agents-test` (create → Ready on the new Harness and the 0.0.30 worker in 18 s → turn), `skills-test` public, `frontmatter-fields` and private with `credentialRef`, `a2a-test` 5/5, a unary `SendMessage` outliving its 5 s caller deadline (`DeadlineExceeded` → `WORKING` → `COMPLETED`, the next message accepted), `toolsets-test` 11/11 — and re-proven on the released Substrate with this commit's own dev build (`0.11.0-dev.giantswarm.2026-09-14.21-16-45.h0a89197`: `platform-test`, `skills-test`). The published chart stamps `substrateWorkerPool.workerImage: ghcr.io/giantswarm/substrate/ateom-gvisor:0.0.30-gs.1`. A first tag of the same name was cut and deleted minutes earlier (its publish cancelled after the `ui` image; the run below is the release): the meta chart's range `>=0.11.0-gs.12 <0.11.1-0` admits gs.14 on every installation the moment the chart exists, so the fleet's kagent sources were suspended until each installation's Substrate had moved) | `0a891975` | `800015de` (2026-09-14) | `sha256:c6f11d2598da141ec4eb80c427d2d9b33160185ee13b821a198051941752d09b`, `sha256:d42c71b5e99d7a9e54a500c44189ada515ecc7a06c86fcbe0f484b44c4fd2a55`, `sha256:78f999768e480a9c454bb9472a69273a8ffe5d8dd97d761cc0dd746966da0f0c`, `sha256:6c65501ca779d613fc3a12033595bef51ca858ad6d4cae56da8d982286a6c57d` | `sha256:5e1697cf89b79f395a5885970ced681115c088ba2abe2361652c363682310bb7`, `sha256:30f5d6ccef6af3c4f43f5d13d5548b4edd1beecb512be99883df8799cedc8196` ([run 34899439294](https://github.com/giantswarm/kagent-upstream/actions/runs/34899439294)) | +| `0.11.0-gs.15` | `v0.11.0-gs.15` (2026-09-15T06:00Z, on the rebase-merged giantswarm/kagent-upstream#39 — kagent-dev/kagent#2788 carried as a cherry-pick of its two commits (`6d3237df`, `3e44b3f7`): `spec.anthropic.promptCaching` / `cacheTTL` on the ModelConfig CRD and three `cache_control` breakpoints in the Go ADK — the last tool definition, the last system block, the last content block of the latest turn — so every model call after a turn's first reads its prefix from Anthropic's cache (giantswarm/giantswarm#37788); the pin and the other carried patches are those of `gs.14`. Cut after the agentlab proof of the dev build `0.11.0-dev.giantswarm.2026-09-15.05-47-53.h138ed1d` through the meta chart's dev channel on agent-platform 4.15.5 (`platform-test` 9/9; a five-call tool turn on a ~4.3 k-token prefix with `promptCaching: true` on the default ModelConfig: the first call wrote 4302 tokens to the cache and every later call read 97-99 % of its prompt from it — 4302, 4410, 5333 — against nothing on gs.14; agentgateway's LLM listener passes `cache_control` through) and, before that, of the branch's own controller and Go ADK builds. The knob is off in the chart, as upstream; the platform's meta chart turns it on (giantswarm/agent-platform#464)) | `138ed1dd` | `800015de` (2026-09-14) | `sha256:9336f29dca1f601a06b4161f1f7bd40964c0b50321ff904ad533f3bc6d847fa6`, `sha256:70acca169bc5e0ad2c7f3f2c13c7cd79d23581dda2c0d6d4546c34ac4c610ff0`, `sha256:c338b48030d9ccd643251b4417593c694191eda82b083253c0473719baa27bd3`, `sha256:4f8b017f20bf16194a8b4cbfdf38cb522e3e96125815ea5449ab5bccf419d26b` | `sha256:991dc1a1a9f2d033c6c14252bedcafb75e01462f46912f08a29b5c3e68eacc17`, `sha256:2f0f88f3d29076a170b48546c3d4817cddaaa25131302595ed363a6e20eec24f` ([run 34934918676](https://github.com/giantswarm/kagent-upstream/actions/runs/34934918676)) | +| `0.11.0-gs.16` | `v0.11.0-gs.16` (2026-09-15T09:02Z, on the rebase-merged giantswarm/kagent-upstream#41 — kagent-dev/kagent#2790 carried as a cherry-pick of its four commits (`e66fb5a9` the ADK v2.3.0 bump, `229ae568` the Go runtime honouring `context_config.compaction`, `cc11cc96` `spec.context.compaction` on the AgentTemplate CRD with its CEL rules, `646e7fe4` the mockllm e2e): tail retention and the sliding window of ADK's `session/compaction` reach the platform's Go ADK actors (giantswarm/giantswarm#37792); the pin and the other carried patches are those of `gs.15`. Cut after the agentlab proof of the branch's dev build `0.11.0-dev.feat-context-compaction-2790.2026-09-15.08-28-06.hd708902` (a tree identical to this commit) pinned as the meta chart's kagent and kagent-crds components on agent-platform 4.16.0 with Substrate 0.0.30-gs.1: `platform-test` 9/9; two AgentTemplates rendered by the agent chart's `context.compaction` values (24 000 / 4 and 8 000 / 4) admitted with the field, Ready in ~25 s, their golden boots logging `context compaction enabled …`; a tool-heavy turn on each had the summariser fire once at the threshold (1.9 k in / 0.5 k out in 10.9 s; 1.1 k / 0.4 k in 8.3 s) before the model read a 43 k / 57 k-token listing, both answers complete. Off unless a template sets it, as upstream; the platform's agent chart 1.3.0 sets the default (giantswarm/agent#38), the meta chart moves its range to this release (giantswarm/agent-platform#471)) | `b692efd7` | `800015de` (2026-09-14) | `sha256:c614a46902283ed1744c50a635a9098172b9c6e37bc910eb30e874ddbcc088a7`, `sha256:591e9ab0502b330ee7c33e695bf78440e00607089204d70c0193bba56a45b2ab`, `sha256:c083c1b44c3b84b6b0a92dc0a0c714bbd569636d1b43c4002b3dd367fbad765e`, `sha256:a7e3a9e7400b123061a7563dae23509a240c2563b79adcbb6364f08fb73fe018` | `sha256:06749d6e2ba015e8310a7a4de10c8eb1a137afcf964ebb90b494b0e418935cc1`, `sha256:56bdb3a3ee80f72a1c8a084402ed5464adcb4a24c112abe7de2e6a51f0fa9709` ([run 34950314531](https://github.com/giantswarm/kagent-upstream/actions/runs/34950314531)) | +| `0.11.0-gs.17` | `v0.11.0-gs.17` (2026-09-15T11:09Z, on the rebase-merged giantswarm/kagent-upstream#43 — `e821fcdc` `fix(a2agateway): end a turn whose runtime is lost, and delete a paused or crashed Actor as it is`: after a failed runtime stream the gateway asks Substrate for the Actor and, when it is `CRASHED` or gone, fails the task with `runtime lost: `, moves the AgentInstance to `FAILED` with `failure.reason` `RuntimeLost`, refuses later sends with that message without dialing, and no longer asks the runtime whether it took the task; `Delete` suspends only a live Actor and deletes a `PAUSED` one through `any_state` (giantswarm/giantswarm#37795, #37742 row 45); the pin and the other carried patches are those of `gs.16`. Cut after the agentlab proof of the branch's controller build on agent-platform 4.16.0 with Substrate 0.0.30-gs.1: a turn parked on a `requireApproval` tool, its Actor record rewritten to `CRASHED`, the answer failed after one atenet parking budget (30 s, was 60) with `runtime lost: Actor kagent/ai-… crashed: …`, the instance read `FAILED`/`RuntimeLost` over gRPC, the next send was refused in 10 ms, the delete took 20 ms with no `SuspendActor`; a paused Actor pinned to a gone node (the production record) deleted in 10 ms with `any_state`; a healthy turn completed; `platform-test` 9/9) | `d990b84d` | `800015de` (2026-09-14) | `sha256:757920cccf1a6a24c89ee88139e80942c18f783ab4a168bb704f9ff549da4c69`, `sha256:edb4c38d792d44e205b56646a1fa7928c992d1fc64c1c6c8a45f684199a38efe`, `sha256:fad227d8732c3c42cffb736e61d8b3de023208aa2dea078cadf90728a3c74866`, `sha256:04512e0e71b6b4600d6197be36f557ec96eb404e389bb647fbb8a4c5e17fa8cb` | `sha256:c8c752a6f376618cc9b4fe2ecce68c3d0ab00df11aadd16a4efa396643aeb138`, `sha256:0304f34b9ba1359dcd9d3b307423127f3d3ccc503c5a8e17094d74c41f7ef06d` ([run 34961733905](https://github.com/giantswarm/kagent-upstream/actions/runs/34961733905)) | +| `0.11.0-gs.18` | `v0.11.0-gs.18` (2026-09-15T13:35Z, on the rebase-merged giantswarm/kagent-upstream#44 — `f001a44f` `feat(a2agateway): suspend a runtime paused for input once the pause is older than a TTL`: the controller's leader sweeps READY instances whose latest task waits for input without a recorded snapshot and, once the pause is older than `KAGENT_PAUSED_RUNTIME_TTL` (2 m; `controller.pausedRuntimeTTL`; 0 disables), `SuspendActor`s the paused Actor and records the snapshot as the task's boundary (guarded on the task still waiting); the gateway dispatches under the runtime-call lock and the sweep takes the quiesce lock without waiting (giantswarm/giantswarm#37795, #37742 row 45); the pin and the other carried patches are those of `gs.17`. Cut after the agentlab proof of the branch's dev build `0.11.0-dev.feat-pause-ttl.2026-09-15.10-19-04.hb4491aa` pinned as the meta chart's kagent and kagent-crds components on agent-platform 4.16.0 with Substrate 0.0.30-gs.1 and the TTL at 90 s: the two sessions the lab had parked on questions for 45 h and 14 h were suspended within a second of the controller start (`SuspendActor`, `external_snapshot`, tasks still `input-required` with the boundary recorded, node checkpoints gone); a probe parked on `ask_user` was suspended 109 s after its pause; with every worker replaced and the actor's node directory wiped, the answer restored it on a fresh worker from the store (`data_on_golden`, download 19 ms) and the turn completed with the transcript intact; an answer 20 s into a pause resumed in place; controller log 0 errors, `platform-test` 9/9) | `fbab147c` | `800015de` (2026-09-14) | `sha256:f9f2f803e2027d691e336f96f157800635535d3460b3072012e5914b67e5d44e`, `sha256:70e100e94fbcb9e4dd583f5c129c46eadd3ef7bba2f3c16c897c4b941c9c6761`, `sha256:1567afac4744b080efe09e2a19f8dd6968a49c918c1240bb389ed2b04effd620`, `sha256:761720a51bca0459433880455a165715eead7dd82c49374c33d9bd2f856b540f` | `sha256:86a0f3e0b23435686f4fe3d7086c2a53959aa266da53bd6ec09fcea2d2550fc4`, `sha256:896640ff898c4ed43767cea958d662625aa65fdb02b80b6d5a3f72d9014ee0d0` ([run 34976009655](https://github.com/giantswarm/kagent-upstream/actions/runs/34976009655)) | +| `0.11.0-gs.19` | `v0.11.0-gs.19` (2026-09-15T14:56Z, on the rebase-merged giantswarm/kagent-upstream#47 — `3b665b18` `fix(a2agateway): leave a pause whose node has no worker alone, and delete a runtime that cannot be suspended as it is`: the pause TTL's first sweep on a test installation (2026-09-15 13:46Z) met three paused Actors whose checkpoint nodes were gone — Substrate fails the upload at once and leaves the Actor `SUSPENDING`, which the lost-runtime patch's `Delete` then suspends first and fails the same way, so the sessions were not deletable. `Idle` counts a `PAUSED` Actor only while a worker runs on its checkpoint's node (`ListWorkers`), so the sweep leaves a pause on a lost node to the node-loss handling (giantswarm/giantswarm#37742 row 44); `Delete` falls back to an any-state delete when the pre-delete suspend fails (giantswarm/giantswarm#37795); the pin and the other carried patches are those of `gs.18`. Cut after the agentlab proof of the branch's dev build `0.11.0-dev.fix-pause-ttl-lost-node.2026-09-15.13-54-14.hd435f11` on agent-platform 4.16.0 with Substrate 0.0.30-gs.1, the TTL at 60 s and the Actor records rewritten to a gone node: three sweeps after the TTL did `GetActor` + `ListWorkers` only and the Actor stayed `PAUSED`; the node name restored, the next sweep suspended it; a `SUSPENDING` Actor on a gone node was deleted in 22 ms — `SuspendActor` failed with that error, `DeleteActor any_state=true` succeeded; controller log 0 errors, `platform-test` 9/9) | `2e18f2b4` | `800015de` (2026-09-14) | `sha256:e937231facda222b3849664208a676fb08cd76918778c7abd3de6b24d6ec6f13`, `sha256:09a69bf72a87fc01ae167ebfea520c11ae70cabb59c1746d6e1fc0c860c2fa63`, `sha256:5c1f3b12321b90a2577787d376fae9b82f5488dc0644907d9130a989428b4d69`, `sha256:7cda86a719ebad84f41150aca318893ff49f52fe7d45721aa184f6a0bbfe76cd` | `sha256:bd4423be4d9e22bf62ddf05fab3c9e5a6eac2031d4d31380121defb09ad8822d`, `sha256:4da498de15fed904c35a6ae45815bd51e2e9a941306294d54ab9fbe78a6bb7b4` ([run 34984968970](https://github.com/giantswarm/kagent-upstream/actions/runs/34984968970)) | +| `0.11.0-gs.20` | `v0.11.0-gs.20` (2026-09-15T19:14Z, on the squash-merged giantswarm/kagent-upstream#49 — Substrate re-pinned from `0.0.30-gs.1` to the line's release `0.0.30-gs.4`, which carries giantswarm/substrate#39: the golden boot a workload keeps failing is bounded — ate-api counts the boots the workload itself fails (`WORKLOAD_NOT_READY`) in `GoldenSnapshotStatus.workload_boot_failures`, at the third crashes the golden actor (worker freed) and fails the ActorTemplate with `GoldenActorNotReady`, the bound and the last boot's error, and the worker `ateom-gvisor:0.0.30-gs.4` this chart stamps quotes the workload's last output lines in the readiness-deadline error; this controller surfaces the message verbatim as `Ready=False ActorTemplateFailed` (giantswarm/giantswarm#37801); the pin and the carried patches are those of `gs.19`. Proven on the production installation 2026-09-15 19:16–19:20Z after the roll: a private-skill template `ActorTemplateFailed: GoldenActorNotReady: … last output of "kagent": … fatal: unable to access 'https://github.com/giantswarm/claude-code/' …` 3 min 9 s after creation, collected on delete) | `f3eced68` | `800015de` (2026-09-14) | `sha256:468c26a60d83633bf509472267d52dd3a5598a86d59623edb5c6800f2c5c81b2`, `sha256:77075fdb285531c5216000a902b60c9806cadd4b7f59de6f5d28f5e88f74b09e`, `sha256:a4a3342d97edbef37367577064c9887ff120b81538dfd579ec47c07e0c3b19c8`, `sha256:91720ca82eebc03799da0dcf7956a3ef113b6505e5fc32ffe060cb14d010da1d` | `sha256:85eda33f377c791286afe4e60c632cf950484cf5869874aeeebbb9f4445470f2`, `sha256:0d42c8228b7944c9c57f844072cee2747a08ba6b05257a55e812e56671f6bf9b` ([run 35011669868](https://github.com/giantswarm/kagent-upstream/actions/runs/35011669868)) | +| `0.11.0-gs.21` | `v0.11.0-gs.21` (2026-09-19T00:32Z, on the rebase-merged giantswarm/kagent-upstream#52 — `6213c182` `feat(runtime): name the agent and the user on every model call`: every model call an actor makes carries `x-kagent-agent` and `x-kagent-agent-namespace` (the `AgentTemplate`'s name and namespace; the controller injects `KAGENT_AGENT_TEMPLATE`, the Go ADK's HTTP transport sets them after a ModelConfig's default headers, the Claude harness sends them through `ANTHROPIC_CUSTOM_HEADERS`) and `x-kagent-user`, the person of the turn — forwarded by the controller's a2a gateway only for a session whose identity the trusted-proxy authenticator resolved from the caller's validated token, read by the ADK from that metadata alone (giantswarm/giantswarm#37871, #37742 row 48); the pin and the other carried patches are those of `gs.20`. Cut after the agentlab proof of the branch's dev build `0.11.0-dev.feat-model-call-identity-headers.2026-09-19.00-03-55.hfea51de` (a tree identical to this commit) with both the controller and the Harness swapped on agent-platform 4.40.0 and the default ModelConfig routed through the `llm` listener: one turn of an `AgentTemplate` as a lab user produced `agentgateway_gen_ai_client_*` samples with `agent`, `agent_namespace` and `user` read from the headers (the person's email, never the token the two earlier builds had leaked through a context key colliding with the bearer's), `platform-test` 12/12; the container proofs of each build are on the issue. Roll note: the 4.x installations follow the chart from ghcr by range but pull the images from the gsoci mirror, which retagger fills for `-gs.N` tags on its own runs, so the fleet's `kagent`/`kagent-crds` OCIRepositories were suspended until gsoci had this release's controller tag and Harness digests, then resumed one installation at a time) | `7c0e5743` | `800015de` (2026-09-14) | `sha256:85230981ca0164603aeb5859ede4bc12e9ba461da50bf8f6ccd0997303cae472`, `sha256:cb590f490bb731acce21d378db5e3a762b66bcc4533a3a7f0262c496529f2c1c`, `sha256:18d8c2b47f5d91aa33526f456dda99eba78d83f9b83a75f233261ab5642fe8e4`, `sha256:87cd3e8945edc51b263d7c0f927f39d9fcdbab3128ed6896931df03e8151bf83` | `sha256:547339238318fa3ad603d12ce64d11d15ccc95c32618034decc1d3a1e89d284f`, `sha256:912cd4a0577cc05805678dead45b0120ad4694d10546e90624157d24a642bc2d` ([run 35409682239](https://github.com/giantswarm/kagent-upstream/actions/runs/35409682239)) | +| `0.11.0-gs.22` | `v0.11.0-gs.22` (2026-09-19T05:52Z, annotated, on the rebase-merged giantswarm/kagent-upstream#55 — the first release published to gsoci from CircleCI: the four images under `gsoci.azurecr.io/giantswarm/kagent` and both charts under `oci://gsoci.azurecr.io/giantswarm/kagent/helm`, cosign-signed under the project's CircleCI identity with per-platform SPDX attestations; `tag.yaml` and every push to ghcr.io gone ("What is published"). Code since gs.21: giantswarm/kagent-upstream#54 (the pair collections' outputs compared by content). Cut after the agentlab proof of the branch's dev build `0.11.0-dev.fork-gsoci-circleci-publish.2026-09-19.04-39-44.h3de604c` on meta chart 4.40.0 with the consumer's kagent sources pointed at the gsoci paths — `platform-test` 13/13, `agents-test` (golden boot of the gsoci runtime image on the Substrate worker, a turn, a skill re-pin Ready in 12 s, delete). The published chart stamps `registry: gsoci.azurecr.io`, the `giantswarm/kagent/*` repositories and `substrateWorkerPool.workerImage: ghcr.io/giantswarm/substrate/ateom-gvisor:0.0.30-gs.4` — the Substrate line's path until the re-pin to its `0.0.30-gs.5`. Consumers on the ghcr chart path still resolve gs.21; the switch is theirs) | `c371e27f` | `800015de` (2026-09-14) | `sha256:a3c86b4b8f7724cf3ea8a0874721f6995003313ed99dbffee670bbe99ae01b46`, `sha256:8789252c85f4d8e615ca3a55326431fd96f61e5513131c47d9997e4aabaaa392`, `sha256:8052c057198a33cb7610a8fb5f3f73c66d60755a32aaba5fe2a78278d74bca5b`, `sha256:9496d92047734990c2210a5c37cbab7db22330d9c124258ea2cedef883e8d8b1` | `sha256:10d83411f156b098f50adc6eca88b3129eaf5d473f3fefe336f0ba68d1a88564`, `sha256:9dbd6c80820dc17ac2adf94f8fbf9d5f24218fe13b1bc02ce15f29d14fbc2460` ([pipeline 12](https://app.circleci.com/pipelines/github/giantswarm/kagent-upstream/12)) | +| `1.0.0` | `v1.0.0` (2026-09-19T14:59Z, annotated, on `e09e3416` — the rebase-merged giantswarm/kagent-upstream#58: the first release of the decoupled scheme ("Release scheme"), published by the orb's stock jobs — two native `build-image` jobs per image and a `push-to-registries` merge — from CircleCI pipeline 29, 5 min 49 s from the tag to the ledger row. Code since gs.22: the Substrate re-pin to the line's `1.0.0` (the runtime of `0.0.30-gs.5`, its router and egress on the agentgateway line's `2.0.0`) and the truncated label values fix (`72efa7ea`). Cut after the consumed branch's first stock dev build of the same tree (pipeline 27) published green; the platform proof of the release is the consumer's move to the `>=1.0.0 <2.0.0` range on the same OCI paths (giantswarm/agent-platform#580). The published chart stamps `registry: gsoci.azurecr.io`, `tag: "1.0.0"`, the golang-adk and claude-harness index digests below and `substrateWorkerPool.workerImage: gsoci.azurecr.io/giantswarm/substrate/ateom-gvisor:1.0.0`, and depends on `oci://gsoci.azurecr.io/giantswarm/substrate/helm/{substrate,substrate-crds}:1.0.0`; every index carries `io.giantswarm.upstream.version=main@800015d`) | `e09e3416` | `800015de` (2026-09-14) | `sha256:35e02911720d87aa1d0c7512d4e4233168bdbdaf46b31d3b93ccbd291cf1ec0a`, `sha256:742b0d4db97ebb11310353c90a022ad9e1c08c6855eaf01b501f398dcae1d8a1`, `sha256:d185a24f7c73397561de37631058f20c31d15ea4c9e6b5f23cc9933000b0a1f9`, `sha256:fac3c48a281122fcbad7d89ceaee97fd084f1a3c8f592e43cde0a74aa549d1cc` | `sha256:0f137e3e9df1e9c9226072763e5a44a969ac80aab7367e61a4aae5c957f2cc2e`, `sha256:e2c15365fc30e8d3f0cec80e19713d24bcd7fc28ec28926ea5d9028de4ca9f3a` ([pipeline 29](https://app.circleci.com/pipelines/github/giantswarm/kagent-upstream/29)) | +| `1.0.1` | `v1.0.1` (2026-09-22T09:46Z, annotated, on `e366fda5` — the merged giantswarm/kagent-upstream#61: the controller serves Prometheus metrics at all. `app.Run` built the manager with `metricsserver.Options{BindAddress: "0"}`, which no variable changed, so upstream's own ServiceMonitor (kagent#2401) targeted a port the binary never bound; and `grpcserver.Config.Registerer` was nil, so `kagent_grpc_server_requests_total` and `..._request_duration_seconds` were recorded on every call and registered nowhere. `METRICS_BIND_ADDRESS` (default `"0"`, which keeps the server off — controller-runtime reads an empty address as unset and would fall back to `:8080`) and `METRICS_SECURE` (the `TokenReview` and `SubjectAccessReview` filter) now configure it, and the gRPC interceptors register on controller-runtime's registry. Upstream: kagent-dev/kagent#2886, open — the two commits are carried until it merges and the next re-pin drops them. No re-pin: the tree is `1.0.0` plus those two commits, so every installation on the range `>=1.0.0 <1.1.0` takes the release with the metrics server still off until a consumer sets the variable) | `e366fda5` | `800015de` (2026-09-14) | `sha256:02d3896007d26d2220c4b9c94bffea778bd3c7498e53d3ef96fbe8ff68b94813`, `sha256:425888f7279bb7969654ca23f3b341cead32baa04392d96563400e9717ff34b3`, `sha256:78726065f429e67dd3f49703e72d0ab17f3fa15a412edeecf790a786c769a069`, `sha256:81ee41c175c72f6d643126ba9509bbc3a73c2e2bee1bb0b8787fdee76aa2e4b3` | `sha256:5bb4fa61c0c25b95fb5a73b43569c3149e01f5426ca59d2090dd3d4cd9b4fe60`, `sha256:eddc875d9bcc9e448ee39042abb2fde6bd032414a31bf93cc6090af341996ec0` | +| `1.0.2` | `v1.0.2` (2026-09-23T20:34Z, annotated, on `37cba282`, the head of `release-1.0`, the 1.0 line's maintenance branch: `v1.0.1` plus the rebase-merged giantswarm/kagent-upstream#75. The controller starts a golden boot that Substrate's boot bound failed (`GoldenActorNotReady`) over within the crash retry's budget instead of failing the AgentTemplate for good (`641d2e48`; giantswarm/kagent-upstream#72; the same fix is `2fe7fb44` on `giantswarm`). `ci.yaml` and `image-scan.yaml` now run on the branch (`18db6fc4`). The Substrate stamp stays `1.0.0`, v1.0.1's own. Cut after the agentlab proof of the branch's controller build on the released meta chart 4.49.0 (Substrate 1.0.2). An AgentTemplate with a git skill booted while `atenet-egress` was scaled to zero. On 1.0.1 it stayed `ActorTemplateFailed: GoldenActorNotReady` after the egress was back. On the build it was `ActorTemplateRetrying: golden boot 1 of 6 failed (…)`, started over 20 s later, and `Ready=True` 29 s after the failure. Published by CircleCI in 6 min, tag to `builds.md` row) | `37cba282` | `800015de` (2026-09-14) | `sha256:5c17dea4128ed2bd7c03a7d9e0f87396806589a9b8a9f3e76eb4978b25bb28e9`, `sha256:515c34e5892e03e62acf57339c3f6e187bb0dc85088cf5110c28828666662ede`, `sha256:c4ae09acd3d571c045b20f125bc6ae9fdaa0509114ae3c8f4aa8ca6d019f817f`, `sha256:ec9d2b59b243159c1ea8a9c8b5029e80abe9a7ccd74422ddee7cb127c5b05ed0` | `sha256:0e91345f68af123396b7e06be13f9f9238b58a2c8e99dee9c9cd55bac33374c5`, `sha256:0b9215694c87a644f43395df4469506b00bd20168285f69521c9962a866c90b8` | +| `1.0.3` | `v1.0.3` (2026-09-24T00:32Z, annotated, on `6049aef9`, the head of `release-1.0`: `v1.0.2` plus the rebase-merged giantswarm/kagent-upstream#81. A ModelConfig for the native Ollama provider can switch a thinking model's reasoning off: `spec.ollama.think` (optional bool) is sent as the chat request's top-level `think` by the Go and the Python runtime, and the UI offers it as default/On/Off (`5176f02a`; the same patch is giantswarm/kagent-upstream#82 on `giantswarm`). The kagent-crds chart's ModelConfig CRD gains the field. The Substrate stamp stays `1.0.0`. Cut after the agentlab proof of the branch's controller and Go ADK builds on the released meta chart 4.65.3 (Substrate 1.0.3) against a CPU-only Ollama: `skills-test --model-config` passed on a native `provider: Ollama` ModelConfig with `think: false` for `qwen3.5:2b`, its requests carrying `"think": false`; without the field the agent's reply was empty, 3 of 3 (giantswarm/kagent-upstream#81, comment 5805249251). Published by CircleCI pipeline 124, tag to `builds.md` row) | `6049aef9` | `800015de` (2026-09-14) | `sha256:4329e47f624505136b86d018ef2193a5308fe19169540d5e0b25d61144cb4ab3`, `sha256:ce40923cf7655411e48af558ebb9f67eb36963942b9c297c17e3004c86963857`, `sha256:ea0e487304b9dec13e979137cbda053c4ed19756a14c00806cffdba4a85132cb`, `sha256:df5e7a278555d30755be0f0185ac1f9ebba2c4cd427fb583108a5fb93d072655` | `sha256:7263448a2aaa27f02486ad706c492aa7320ab573eae60b7072f180cdc4099e81`, `sha256:002dcb0b39eced0c2400d7c05f8cf49ed07a3ee87e80ba03a988a56a454675d2` | +| `1.0.4` | `v1.0.4` (2026-09-24T09:26Z, annotated, on `09d0075a`, the head of `release-1.0`: `v1.0.3` plus the rebase-merged giantswarm/kagent-upstream#86. The Go runtime answers a non-streaming Anthropic request by streaming the Messages call and accumulating the message, so the SDK's refusal of a non-streaming `max_tokens` above 21,333 no longer fails the context compaction summarizer on a ModelConfig with `maxTokens: 32000` (`685a12b0`; the same patch is giantswarm/kagent-upstream#87 on `giantswarm`). No CRD or chart value changes. The Substrate stamp stays `1.0.0`. Cut after the agentlab proof of the branch's Go ADK build as the platform Harness image on the released meta chart 4.66.5: with the default ModelConfig at `maxTokens: 32000` and a probe agent at `tokenThreshold: 3000`, one tool-heavy turn logged the summarizer's refusal twice on 1.0.3 and sent no summarizer request, and on the build logged none and sent the summarizer call (HTTP 200, streamed), the next agent call reading the summary instead of the history (giantswarm/kagent-upstream#86, comment 5811162214). Published by CircleCI job 853, tag to `builds.md` row) | `09d0075a` | `800015de` (2026-09-14) | `sha256:fd2ced8e5679fbede7b6661870d29b09ccb4d2a6173bfa16f1b0fbc33ae85bf4`, `sha256:aac5606b07fdfafb2baddac478a61ff05eb2265b87fa8d31d29d454eaa273eff`, `sha256:59996d78fb45df1fdaeff41e467479857b2c2cf157426864b5ddb6b35bc083e0`, `sha256:11da855870242799629915446d30dc323eed84bd91944411ac6ec46efbaea044` | `sha256:18e33c2ea4f33e47673c78a478a356a6ec39b13a97866873fece1bd42c241d85`, `sha256:a62e69a1cf6dde94a5d886c797a0fc48087aa7d71567ba6303aca36f2280f1bb` | +| `1.1.1` | `v1.1.1` (2026-09-25T11:06Z, annotated, on `34dbed7f`, the head of `release-1.1`, the 1.1 line's maintenance branch cut from `v1.1.0`: `v1.1.0` plus the rebase-merged giantswarm/kagent-upstream#105. Migration `000002` brings a Kagent 1.0 database to the current schema, so a controller on a 1.0 baseline stores runtime revisions again instead of failing on the missing `runtime_revision.credentials` column (`209fd652`; giantswarm/kagent-upstream#96, `41f05e24` on `giantswarm`); a schema that already has the columns takes its no-op path. The a2agateway ends a turn whose runtime Substrate refuses to resume (`resumable: false`, Substrate 1.1.1) at once with `runtime lost: … start a new conversation` instead of a timeout (`34dbed7f`; giantswarm/kagent-upstream#100, `7eabf64f` on `giantswarm`; giantswarm/substrate#79). No chart, Makefile or CI change against `v1.1.0`; the Substrate stamp stays `1.1.0`. The agent-platform meta chart's range `>=1.1.0 <1.2.0` admits it. Published by CircleCI pipeline 222) | `34dbed7f` | `1069fd2f` (2026-09-24) | `sha256:073c387f975e47a1fbf116b3e2a1cc1a6fd38a9ea3db9eaf571ca47fa5de5c99`, `sha256:c3ce30caad81c0cfda6e50afc568343ff8d865a12f4f7538efde4d5358f0d4ea`, `sha256:76455ae0ad039dde67aaf22947b89a201843f784aed843363add568e1caf1970`, `sha256:acbfe65469bb942a740c98beabc63bd0e11fbb51040f091ce3edacf9c605d923` | `sha256:f3cd058e8121fdde62211778706fb7b55f59fd4c82e2961c3d9742a9e7121df9`, `sha256:979f04434ecacfbece566fb9805333616bc4f15db828918b2548ee8be12e5c85` | +| `1.2.0` | `v1.2.0` (2026-09-25T13:27Z, on `f886cbba`, the head of `giantswarm` after the 2026-09-25 re-pin onto upstream `main@dd3b2405`: the carried stack of `sync/upstream-20260925-manual-2` (`7eabf64f`) plus the ActorTemplate runtime environment check and the controller's stop on a template Substrate rejects. From upstream: A2A over HTTP/JSON-RPC and per-instance Agent Cards (kagent-dev/kagent#2933, closing kagent-dev/kagent#2885) and the SDK-spec OTel values (kagent-dev/kagent#2909): the chart takes `otel.exporter.otlp.{endpoint,protocol,timeout}`, `otel.traces` and `otel.logs`, and ignores `otel.tracing` / `otel.logging`, hence a minor. No migration against `v1.1.1`. The Substrate stamp stays `1.1.0`. The agent-platform meta chart moves to `>=1.2.0 <1.3.0` with giantswarm/agent-platform#693; the 1.1 line keeps its patches on `release-1.1`. Published by CircleCI pipeline 234) | `f886cbba` | `dd3b2405` (2026-09-25) | `sha256:2fb05ff68a6c3aaaab5afbbb2fc489c33fc31fa81d430fa4ae8f83bf200d1600`, `sha256:0d5fdb7d8639e7b6df37e09008a1e69766e73045cc687c8c17d51c2564a35874`, `sha256:c0139e3fd1df6dc68c2126905569bd7df77985133778023c1a4d38a4faf4f0c3`, `sha256:12afffbcf88a1d810276b6225e43f4b63dadd1d89b9ee83b5c8c52b25555eff1` | `sha256:e2fc152ec13935caad42fc4ad05aa603bcb3d5758630690aab38a9b0924e4595`, `sha256:406afcd2a4e6f7d3b906f03f8bf140d428cdf659c4d0f1a35d94dd5d7d005071` | +| `1.2.1` | `v1.2.1` (2026-09-29T14:24Z, on `23f0253b`, the head of `giantswarm`: `v1.2.0` plus two a2agateway fixes on the same pin. A working turn that records no event for `KAGENT_STALLED_TURN_TIMEOUT` (1h; `controller.stalledTurnTimeout`) and that no run of the gateway follows fails as interrupted (giantswarm/kagent-upstream#129, giantswarm/giantswarm#37988). A cancel that races a turn's start settles as canceled (giantswarm/kagent-upstream#130, giantswarm/kagent-upstream#128). No migration against `v1.2.0`. The Substrate stamp stays `1.1.0`. The meta chart's `>=1.2.0 <1.3.0` takes it. Published by CircleCI pipeline 281) | `23f0253b` | `dd3b2405` (2026-09-25) | `sha256:0267fdc071cc8f24df89e11bc4fc344129182276dc011c3e698873a990efbea9`, `sha256:21dc2b20be4a26530d9f917d555110a365067b553f5a622003895b19771b8d3e`, `sha256:dda25bc03ae2c45ba87a11430b5ed22bed074e29832989af84e794e2252e532a`, `sha256:3facb431b78a63e2371c60488851184fd6ae77fb6b916a770a72c9d7793f6a4a` | `sha256:ab451a5b79ba5706c314e42f0483f5acec9b431b0c22fbea34571d65df2c2887`, `sha256:a161fe93ab4d01374a6d8b11a711e122bffdf586cc44f80232a78acef51541dc` | +| `1.2.2` | `v1.2.2` (2026-10-01T16:40Z, annotated, on `23e8afa7`, the head of `release-1.2`, the 1.2 line's maintenance branch cut at `v1.2.2-rc.1` for the meta chart's `>=1.2.0 <1.3.0`: `v1.2.1` plus the rebase-merged giantswarm/kagent-upstream#138 (the same fix is `8ce8a53a` on `giantswarm`, giantswarm/kagent-upstream#136) and `ci.yaml` running on the branch. The controller's A2A gateway sends the instance's creator as `x-user-id` on a turn an AgentInstance share authorizes, so a collaborator's turn runs in the owner's session instead of starting an empty one beside it; `x-kagent-user` still names the collaborator (giantswarm/giantswarm#38089). No migration against `v1.2.1`. The Substrate stamp stays `1.1.0`. Cut after the agentlab proof of the branch's controller build on the released meta chart 4.93.0 (kagent 1.2.1): an owner's turn told the agent a code word, a second lab user's turn through a read-write share answered it (on 1.2.1 the same turn answered `NONE`), the owner's next turn quoted the collaborator's question, `a2a-test` 5/5 (identity, HITL, cancel unchanged). Published by CircleCI pipeline 368) | `23e8afa7` | `dd3b2405` (2026-09-25) | `sha256:84b89de3e8badc1c50c556a378499d891510f5225a32d10fbe54ac02647c583e`, `sha256:569e175f214ca7a07d23af10ddde855bfa6d9264a3c00ae058af03fdaf32da0b`, `sha256:717eae0591f67b5b611e911c34fcd9f8be0ed2a601a88e5f12ad323853a0dff1`, `sha256:085a04559b298ef0d2ce25770b5cd255bb214c8f63aed9550d6c01601847413e` | `sha256:c0cde20e2fe05979a920e2f2c3bf3a6508e0ff2873581537036c1e3a1e421696`, `sha256:5323415feea3c64b729e141b8b31ffe215a11481bbe24f432eed4cc92e61a8de` ([pipeline 368](https://app.circleci.com/pipelines/github/giantswarm/kagent-upstream/368)) | +| `1.2.3` | `v1.2.3` (2026-10-01T23:30Z, annotated, on `b9d449d6`, the head of `release-1.2`: `v1.2.2` plus the rebase-merged giantswarm/kagent-upstream#144. The Go ADK no longer lists the tools of an MCP server that takes the caller's `Authorization` (`KAGENT_PROPAGATE_TOKEN`) at startup when the boot has none: a golden boot sends no unauthenticated `/mcp` request, the deferral is logged at debug and the first turn discovers the tools with the person's token (`75e08e2a`; giantswarm/kagent-upstream#99; the same fix is `3cf3f5f2` on `giantswarm`, giantswarm/kagent-upstream#143). No CRD, chart value or migration change. The Substrate stamp stays `1.1.0`. Cut after the agentlab proof of a build of the same tree as the Harness image: two golden boots of a muster-backed template, no `/mcp` request from `ate-golden` where 1.2.2 logged two `401`s per boot; repeated on the released image) | `b9d449d6` | `dd3b2405` (2026-09-25) | `sha256:2a570ee92bd29c0783604e245de2dc5f3a18579461aa06654be5c05622647219`, `sha256:85a0ea2e5e269a57e64b27f44de9b37c7952c265a24dbd1c2b4a06f026c1f450`, `sha256:08a093929ac20f4f8f549d80778d429da6adcb40dab6d6d7997bb940636072f3`, `sha256:463617bcfdc7dc468cac56570cbc2cf53b0de6b2f46aec69fa1a641e2d6b536e` | `sha256:00c16b406f8ea1679d8318b7eb49bea397523a7272776607d6ca36fc9385ada5`, `sha256:0b3a42e6ef0f7d445dba8b585e056e29dadb5db8e470ed041d035912408e1abb` ([pipeline](https://circleci.com/gh/giantswarm/kagent-upstream/2521)) | +| `1.2.4` | `v1.2.4` (2026-10-02T16:49Z, annotated, on `8f5cea62`, the head of `release-1.2`: `v1.2.3` plus the rebase-merged giantswarm/kagent-upstream#150. Every model call of a turn names the session it runs in: the a2a gateway's runtime dialer forwards the id of the `AgentInstance` it routed the turn to as `x-kagent-agent-instance-id`, and the Go ADK's HTTP transport sets it on every model call of that turn and on none outside one, whatever a ModelConfig sets (giantswarm/giantswarm#38023, #37742 row 108; the same change is `57d29b58` on `giantswarm`, giantswarm/kagent-upstream#149). No CRD, chart value or migration change. The Substrate stamp stays `1.1.0`. Cut after the agentlab proof of the branch's controller and Go ADK builds (`b6690be7`) with the agent-platform connectivity chart's `-access-log` policy (giantswarm/agent-platform#794): two conversations wrote two `agent_instance_id` values matching their AgentInstances from the Substrate egress, a forged header from another pod logged none. Published by CircleCI pipeline 2967) | `8f5cea62` | `dd3b2405` (2026-09-25) | `sha256:ac4289206f712e8694d91699e1b19452ed9d914f0a50782fb040557c9a60f284`, `sha256:e1a9234c78d47b08a895fddf52c37b979cca35ea62211e1531e1223eae5b4119`, `sha256:49e373e3b896317669df32166c5cf820037dbfd3de576c8290b9eddd38c2f80f`, `sha256:5aef6d0ab6bf1ab7ded74766f16ca692c40f03967b42e825d35825cfe8b8a372` | `sha256:88e880f9ce5d4fe59d646aee185f19c628dee1b7b9b6bd488f8dc8448a878225`, `sha256:8b70720d0c57a5cdd1cc5c285213775eafcd47da6387c96590725a2752991d5a` ([pipeline](https://circleci.com/gh/giantswarm/kagent-upstream/2967)) | +| `1.2.5` | `v1.2.5` (2026-10-04T13:31Z, annotated, on `6f23597f`, the head of `release-1.2`: `v1.2.4` plus the rebase-merged giantswarm/kagent-upstream#156. A resume that Substrate answers with `DataLoss` (the Actor's snapshot gone from the store) no longer leaves the claimed `RESUME` operation pending: the instance goes `FAILED` with reason `RuntimeLost` and Substrate's message, the resume RPC answers `FailedPrecondition`, and a delete removes it (giantswarm/kagent-upstream#151, #37742 row 110; the same change is `6b9f043c` on `giantswarm`, giantswarm/kagent-upstream#155). No CRD, chart value or migration change. The Substrate stamp stays `1.1.0`. Cut after the agentlab proof of the branch's controller build (`0c56cbdb`): an instance suspended to the snapshot store, its snapshot moved out of the bucket, a resume answered `FailedPrecondition` with `FAILED_GET_EXTERNAL_OBJECT`, the instance `FAILED`/`RuntimeLost`, a second resume refused, a delete `DELETED` and the instance not found. Published by CircleCI pipeline 3107) | `6f23597f` | `dd3b2405` (2026-09-25) | `sha256:fb5a848df2018b34165e083b9e6dd42bd04b384378dc0aae88bc5409fb2abe0b`, `sha256:c50b8e9ba4c93e704f763e01fa27f0b022d7f36585edaf07af27cadc0571114f`, `sha256:0859f18d1655a83be43077b1d81ebb596bc71bfe74159cfc7387f98e489ebfa8`, `sha256:449ee51f64f91b8e73f69a4ddd13d4595e3d19928bfc1ab195c7f4d5d0384a8d` | `sha256:e7a16481c6db07c0b6faff7fbdb88a0dce2f6e31177f5ce09efaa7843a09b2b6`, `sha256:95efde7717b3cc63eaed920bfffd06daf11e7430c5819f838be445a6e0be6577` ([pipeline](https://circleci.com/gh/giantswarm/kagent-upstream/3107)) | +| `1.2.6` | `v1.2.6` (2026-10-06T15:37Z, annotated, on `2a16e257`, the head of `release-1.2`: `v1.2.5` plus the rebase-merged giantswarm/kagent-upstream#167. A failed quiesce after a finished turn, or a failed pause for input, no longer leaves the task `working` with every later send refused as an active task: the gateway logs the failure with the instance and the task and stores the event without a snapshot, and the paused-runtime sweep retries the quiesce of a final turn without one (giantswarm/kagent-upstream#165, #37742 row 115; `release-1.2` only, the 1.3 counterpart is giantswarm/kagent-upstream#166). No CRD, chart value or migration change. Cut after the agentlab proof of the branch's controller build (`ab0c1c84`): with ate-api-server scaled to 0 mid-turn, 1.2.5 answered `failed to persist task` and refused the next send with `already has an active task`; the build completed the turn, logged `failed to quiesce agent instance runtime after the turn` with the instance and the task, took the next send, and its sweep recorded the snapshot of an idle Actor whose final turn had none. Published by CircleCI pipeline 546) | `2a16e257` | `dd3b2405` (2026-09-25) | `sha256:575b2c2a21793b9371c11613d4247a81ce75502ace41db765a0411112d868823`, `sha256:32208874cd419d10247ed0785d0117871ef1db9a8d3752d14f9221a10d653016`, `sha256:887218bee9342f30aa66724f3ac31c86829abc78bd969d4ab5e8bcb51f952711`, `sha256:e9aa6198d4e87de535bfa5c757f696dbb2938463dc4f9055cc5c67e84da32977` | `sha256:97d762677c29b1f552fcdf586ac91a611e0a176310bc695c3aea8f82371d9ebf`, `sha256:6aa3fd66ce187617def7e47dadbc3a6099933effe1fb443b54f664abe59db19c` ([pipeline](https://app.circleci.com/pipelines/github/giantswarm/kagent-upstream/546)) | +| `1.2.7` | `v1.2.7` (2026-10-07T00:39Z, annotated, on `138ca56f`, the head of `release-1.2`: `v1.2.6` plus the rebase-merged giantswarm/kagent-upstream#171 and #179. A conversation on a superseded runtime revision moves to its agent's current revision before a turn wakes its quiesced Actor and on the Resume RPC of a suspended instance: Substrate's `UpdateActor` repoints the suspended Actor at the current ActorTemplate (the resume restores its data onto the new template's golden), its egress policy takes that revision's allowlist, the instance is recorded on it, and the runtime revision GC deletes the superseded revision with its ActorTemplate; a conversation rendered before the egress trust settings no longer fails every model call with `x509: certificate signed by unknown authority` once the egress gateway intercepts TLS (giantswarm/kagent-upstream#170; `release-1.2` only, the `giantswarm` counterpart follows on its session workflow; a conversation from the 1.0 line moves once giantswarm/substrate#134 lets Substrate repoint across the added SystemInfo volume). Merges to the branch are released by the Auto Release workflow as `v1.2.Z-rc.N` (#179, giantswarm/kagent-upstream#176). No CRD, chart value or migration change. Proven in a lab with #171's controller on kagent 1.2.6 and Substrate 1.3.0: a conversation told a code word on one revision answered the next turn, after its agent was re-rendered twice, with the new revision's prompt and the earlier conversation intact, and again after an explicit Suspend and Resume; the instance's prepared revision followed each time and the GC removed the superseded revision with its ActorTemplate. The first stable of the auto-released series: the merge of #179 cut `v1.2.7-rc.1` on this commit (CircleCI pipeline 570), whose combined status the branch pipeline's release-version guard left at `failure` (giantswarm/architect-orb#972, the eight `validate-*` jobs), so the stable was tagged by hand as the `release-1.2` row's interim procedure says, and has no GitHub release object. Published by CircleCI pipeline 574) | `138ca56f` | `dd3b2405` (2026-09-25) | `sha256:9d00fbd6edc356325ead2e77b5a9acbf52a0311dc3141fac678950fc4c4d0b05`, `sha256:e1800e44f3d1dddfcc88dbfa3f28e6f303cf303c1b1b8f60559d146ad70f8f09`, `sha256:845237f5354bc954532e5cb77a21cc303672f3cbb3e170f5a210e07c3eb95ce3`, `sha256:e69cbc00d9779c499a7c1af26d11fbddd221dd86681fa32a2ba002bd3358bb93` | `sha256:cbdff0bfd2ef44555f5cd20a98e5a789e4cbabd639f764a0136be19bb36f399b`, `sha256:fe2b97db7e43f0e4fffacdfb00136ec9a0b3aaa8a4a7a287eaf19f62e5801ee2` ([pipeline](https://app.circleci.com/pipelines/github/giantswarm/kagent-upstream/574)) | +| `1.2.8` | `v1.2.8` (2026-10-07T04:01Z, annotated, on `dc754241`, the head of `release-1.2`: `v1.2.7` plus the rebase-merged giantswarm/kagent-upstream#190. The controller's leader sweeps READY instances on superseded revisions every `KAGENT_REVISION_REPOINT_INTERVAL` (5m; `controller.revisionRepointInterval`, a new chart value whose default `""` leaves the controller's 5m; 0 disables), by id a page at a time, and moves each suspended Actor through the same `RepointQuiesced` a turn uses, under the instance's quiesce lock taken without waiting: a conversation nobody writes to again no longer keeps its revision pinned, so the runtime revision GC collects the superseded revisions with the ActorTemplates rendered before credential injection; the turn's move and the sweep's may meet on one Actor, so the repoint is idempotent at every step (giantswarm/kagent-upstream#170; the `giantswarm` counterpart is #193 on the session workflow). No CRD or migration change. Proven in a lab upgraded in place from the 1.0 line (agent-platform 4.70.1, kagent 1.0.4) to 4.116.0 (kagent 1.2.7, Substrate 1.3.3) with the pull request's controller, then again with the candidate `v1.2.8-rc.1` (the merge's auto-release, CircleCI pipeline 600) from the published artifacts: a conversation was moved by the sweep without a turn, answered the next turn on the current revision, and the GC removed the superseded ActorTemplate and nothing else. The candidate's combined status is the branch pipeline's `failure` (giantswarm/architect-orb#972), so the stable was tagged by hand on its commit as the `release-1.2` row's interim procedure says, and has no GitHub release object. On the production installation the first sweeps moved 277 conversations off 77 superseded revisions and the GC deleted 76 ActorTemplates, the 1.0 line's among them. Published by CircleCI pipeline 606) | `dc754241` | `dd3b2405` (2026-09-25) | `sha256:34799e92a297c31273945e7ebb71a8bee0cd26c52acf8e10b235fdc20d8e89c3`, `sha256:94a8d3c0ce66d97bdb363a5125e8933692973f0e5c607bf409a39714e958e3f9`, `sha256:8dc844050dd439d206e9497fdf61420b25e41f897df48672376372966ed7354c`, `sha256:db1ee5f883343ac40d0e363df5cd456d64edfb31a70962f18fc47d76aed02ed1` | `sha256:377b4bdbb779f6726b2ab3c6a7acce0a3127757328630dcc98dea05023eb718b`, `sha256:ba9fb462f9c35b493efb6ed9671f9b08949592ba4e7dff1fac57a2ad435edba6` ([pipeline](https://app.circleci.com/pipelines/github/giantswarm/kagent-upstream/606)) | +| `1.2.9` | `v1.2.9` (2026-10-07T04:33Z, annotated, on `c40a9399`, the head of `release-1.2`: `v1.2.8` plus the rebase-merged giantswarm/kagent-upstream#195. The first turn sent to a ready conversation while another operation holds its Actor (a repoint onto a newer template right after a Substrate upgrade, which ate-api answers with `Aborted: another operation is in progress for this actor`) is no longer failed: a delivery the runtime refused before it took the task, on a runtime that is not lost, makes the gateway resume the Actor itself through ate-api, waiting while it answers `Aborted` (backoff from 500 ms doubling to 5 s, at most two minutes), and deliver the task once more; any other answer, an Actor still held at the bound, or a second refusal fails the task as before (giantswarm/kagent-upstream#191; `release-1.2` backport of giantswarm/kagent-upstream#194 on `giantswarm`). No CRD, chart value or migration change. The merge's auto-release cut `v1.2.9-rc.1` on this commit (CircleCI [run](https://circleci.com/gh/giantswarm/kagent-upstream/4227)), whose combined status the branch pipeline's release-version guard left at `failure` (giantswarm/architect-orb#972), so the stable was tagged by hand on its commit as the `release-1.2` row's interim procedure says, and has no GitHub release object. Published by CircleCI [run](https://circleci.com/gh/giantswarm/kagent-upstream/4293)) | `c40a9399` | `dd3b2405` (2026-09-25) | `sha256:8f012ca5faceedc1e5b23fe384e1502c64052a07acca1453d305d533173ecf4b`, `sha256:5843b9dad690d820381a7a6f9dab643d3c000993884e0481e1f0b5fa98ad62ef`, `sha256:86260f84309a4a8bdd3f5ff8936367c493f47c6cec65a0bfe02f90a830909499`, `sha256:e794b827e09c42755a7bf3fc117e386caeaf0298d3cb117328fd2e780a45f44c` | `sha256:4505748fbff4a1a80341ee4d21f5dec35626e4c016e43f42fee343e5710647d7`, `sha256:ecb2454af0ae54aab14997967653f70e2ad3ec0150c884f9b184d71b23a325e8` ([run](https://circleci.com/gh/giantswarm/kagent-upstream/4293)) | +| `1.2.10` | `v1.2.10` (2026-10-07T07:11Z, annotated, on `5b8f01be`, the head of `release-1.2`: `v1.2.9` plus the rebase-merged giantswarm/kagent-upstream#202 and the ledger row of `1.2.8`. The controller's leader sweeps the instances whose agent is gone (no active AgentTemplate/Harness pair at the names their revision was rendered for) every five minutes (`DefaultDeletedAgentSweepInterval`, always on) and deletes each through the ordinary delete workflow under the instance's quiesce lock taken without waiting: its runtime is suspended and deleted and its revision released, so the runtime revision GC collects the revision and its ActorTemplate; an AgentTemplate deleted while a turn's quiesce was not yet settled no longer orphans its Actor and ActorTemplate (giantswarm/kagent-upstream#183; the `giantswarm` counterpart is giantswarm/kagent-upstream#199 in the session expiration worker). No CRD, chart value or migration change. Proven in the lab with the pull request's build on the kagent 1.2 chart: an AgentTemplate deleted with a READY runtime left no Actor and no ActorTemplate behind. The merge's auto-release cut `v1.2.10-rc.1` on this commit (CircleCI [run](https://circleci.com/gh/giantswarm/kagent-upstream/4449)), whose combined status the release-version guard left at `failure` (giantswarm/architect-orb#972), so the stable was tagged by hand on its commit as the `release-1.2` row's interim procedure says, and has no GitHub release object. Published by CircleCI [run](https://circleci.com/gh/giantswarm/kagent-upstream/4463)) | `5b8f01be` | `dd3b2405` (2026-09-25) | `sha256:74b453970de2da1f670f6b1c01fe1e7aebd44824dce28a939fcfaf03b14cbecc`, `sha256:b2b268ad4fdc47d6c23c2d2c536a299472e5d342b6b21e09608bd4ffc733c0de`, `sha256:e3e59856e561b7885970163e3a85dd9aa17af4fd832c804b9d340b4c02717b62`, `sha256:34c7c16ee4fd16fb30ecd97144fdd1d5bebe929bf31b2101d455599aa177e8be` | `sha256:ac2ed37d070dfb49b54fa059e3f961d85dbc65f1788b92a0d81242dc85c470c0`, `sha256:7708006e3748787dfe218e3bfb1353c76fbc41c56203c5d3da73741c2d691e4f` ([run](https://circleci.com/gh/giantswarm/kagent-upstream/4463)) | +| `1.3.0` | `v1.3.0` (2026-10-06T08:24Z, on `f7bf3daf`, the head of `giantswarm` after the 2026-10-05 re-pin onto upstream `main@bf8afa56`, promoted from `v1.3.0-rc.16`, which the force-push cut at 08:12Z; `rc.1` to `rc.15` are the pre-re-pin history and are not in it. From upstream: the API group `api.kagent.dev/v1alpha3` (Agent, AgentTemplate, Harness, ModelConfig, ModelProviderConfig, RemoteMCPServer, SandboxTemplate) in place of `kagent.dev`. The carried and dropped patches are the 2026-10-05 row of the ledger's `re-pins.md`. The Substrate stamp moves to `1.4.0`. The controller refuses a database a 1.x controller migrated (`ErrLegacyBaseline`), so an installation moves on a fresh database and nothing in the old one is carried. Its kagent-crds lacks the keep policy on the Agent and SandboxTemplate CRDs: take `1.3.1`. Published by CircleCI pipeline 526) | `f7bf3daf` | `bf8afa56` (2026-10-05) | `sha256:39c6062756fd0a05cb59c8f2dcb86e4fbde3650c1ae9af500dd5376206a97589`, `sha256:aae92c12aa818eac28f492e03808adbff63096b3e3d5e87d8cb2a3352ce25b8e`, `sha256:08dffee4152491b51482accd81e179eeede6fc6443c0bbce110c9bd70bdb3f5f`, `sha256:c3a019f4515c1e699efe89df0222893cf719e1e7d40be76bfea90ed237984dcb` | `sha256:b9afa52f143118db351ca20900176e77537f3325dc13ffae79344bb62df75631`, `sha256:471eaf8ffffc92250d86fd28a2c4e8202c552c1edecf2f9dcc2da52dbf4eaa5f` ([pipeline](https://app.circleci.com/pipelines/github/giantswarm/kagent-upstream/526)) | +| `1.3.1` | `v1.3.1` (2026-10-06T11:19Z, on `1b166604`, the head of `giantswarm`, promoted from `v1.3.1-rc.1`: `v1.3.0` plus the rebase-merged giantswarm/kagent-upstream#164. The Agent and SandboxTemplate CRDs carry `helm.sh/resource-policy: keep` like the five other CRDs of the chart, so uninstalling or replacing the kagent-crds release no longer deletes every Agent; the agent-platform meta chart's teardown test caught it. No controller, chart value or migration change; the Substrate stamp stays `1.4.0`. The first release of the line an installation runs: two test installations moved onto it on 2026-10-06 (fresh database, the cut-over checklist passed). Published by CircleCI pipeline 533) | `1b166604` | `bf8afa56` (2026-10-05) | `sha256:49f086e2ab92afc0fc5add021e9e6423f56776ec83bafbd4d0ef0951993d20a6`, `sha256:111ef9e1f6d269c3183c7b968469e81399ae75604dad47d967b39b073d652718`, `sha256:263c9589804c1f4364cde54c452c9ad8a74462b121f4d5d0422dd1d21e494a23`, `sha256:c0fd820f088a49272ecba3531a64e527a6730544cbbb6993b40ae18a03f577ad` | `sha256:283f5835531a3f158057dfc826de494f2cd5954576a567fd9f48cf260754ff01`, `sha256:8636edc3a4ea325d4ae4133ccce8d65b8ea8a298fc790e690538480ff1eb3952` ([pipeline](https://app.circleci.com/pipelines/github/giantswarm/kagent-upstream/533)) | + +## CI and security + +`ci.yaml` (upstream's suite: Go unit tests and lint, Helm unit tests, protobuf contract check, manifests check, +UI typecheck/lint/unit/browser tests, Python tests and lint, multi-arch image builds, the e2e against kind + +Substrate, the upgrade legs) and `image-scan.yaml` (Trivy on every published image at CRITICAL/HIGH, +`govulncheck` over the Go module graph) run on every push to `giantswarm` and `sync/**`, on every pull +request to `giantswarm`, and — the scan — weekly. Fork differences from upstream's files: the triggers, +GitHub-hosted runners with a `docker/setup-buildx-action` builder instead of Blacksmith runners and their +builder, the image matrices trimmed to the published set, no `paths-ignore` (a docs-only pull request must still +report the required checks), and one aggregate job per workflow — `ci-ok`, `scan-ok` — which the ruleset requires. + +`ci.yaml`'s fork-only `released-schema-upgrade` job guards the released schemas: `scripts/fork/released-migrations.sh` +exports the migrations of every release a database may have been created by (each distinct migration set of the line's +stable `vX.Y.Z` tags; up to `X.Y.*` for a pull request to `release-X.Y`), and `TestUpgradesEveryReleasedSchema` creates a +database with each, migrates it with the tree's migrations and compares its catalog (columns, constraints with names +and definitions, indexes, views) with a fresh install's; the failure names the differing objects. It runs on the sync +candidates too, so a re-pin that edits a released migration in place does not land until it carries the migration that +brings released databases along. A deliberate difference goes into the test's `allowedReleasedExtras` with its reason +(today `agent_instance_checkpoint.source_name`, which `000002` keeps for the 1.0 controller). Without the variable +`KAGENT_RELEASED_MIGRATIONS` the test skips, so `go-unit-tests` is unchanged. + +Security findings are handled by the team's policy: a fixable finding is fixed (a dependency bump is a patch +like any other — upstream pull request first, carried here until merged; an image fix goes into the Dockerfile +the same way, as the two `apk upgrade` commits above). A finding upstream cannot fix yet, or a scanner +false positive, gets a time-boxed, justified ignore — `.trivyignore` with Trivy's native `exp:YYYY-MM-DD` and a +comment naming the module and the reason — so the line keeps moving; it is re-reviewed at the expiry. `govulncheck` +has no ignore file of its own, so `scripts/fork/govulncheck.sh` applies `.govulncheck-ignore` (`GO-… until=… # why`) +the same way: an expired entry fails the scan until it is re-reviewed, and only a finding with no fixed version +may be listed there. + +Upstream automation the fork does not need is off: Dependabot version updates are disabled on this fork (a +fork's default; dependency updates arrive through the re-pin), `stalebot`, `conventional-label` and +`label-pull-requests` are disabled workflows (repository setting, not a file edit), `migration-immutability` +only fires on pull requests to `main`/`release/**`, which do not exist here. Upstream's workflow files do start +when the mirror is pushed (the push has to be made as the App — see "The mirror"); the sync cancels those runs +within seconds, so a cancelled run per mirrored branch or tag in the Actions tab is expected, not a failure. + +## Re-pin + +The line is rebased onto upstream `main` weekly (Monday 05:00 UTC) by `.github/workflows/sync-upstream.yaml`, +also on demand (`gh workflow run sync-upstream.yaml`, inputs `upstream_ref`, `dry_run`). One run: + +1. fast-forwards the mirror — `main`, the `release/v*.x` branches, the tags — to upstream (`scripts/fork/mirror.sh`; + refuses if a mirrored branch was edited; never moves or deletes a ref) and cancels the runs upstream's own + workflow files start at the mirrored refs (`scripts/fork/mirror-quiet.sh`); +2. rebases the carried commits onto the upstream head into a candidate branch `sync/upstream--` + (`scripts/fork/repin.sh`; commits upstream merged in the meantime are dropped and named in the report, + with the upstream commit they landed as); +3. pushes the candidate (as the org's `heraldbot` GitHub App, whose token the run mints — a push by an App + triggers workflows, a push with the workflow's own token would not); `ci.yaml` and `image-scan.yaml` run on it; + the workflow waits for `ci-ok` and `scan-ok`; +4. green: force-pushes the candidate onto `giantswarm` (`--force-with-lease` on the head it started + from), deletes the candidate, appends the row to `re-pins.md`; the pipeline publishes the dev build and + appends its row to `builds.md`; +5. a conflict or a red check: nothing is pushed to the consumed branch; a pull request against + `giantswarm` names the conflicting carried commit (with the files) or the failed checks, and the + candidate branch stays for the fix. Finish by hand, then close that pull request — never merge it (a merge + would keep the old base). +6. the pin annotation `index:io.giantswarm.upstream.version=main@` in `.circleci/config.yml` names the + upstream pin on every published image ("What is published") and the sync does not edit it: after a landed re-pin, + a pull request against `giantswarm` moves it to the new pin (its own commit — a pull request cannot fix up the + `ci(fork)` carried commit; a manual re-pin may fold it). `record` compares the stamped annotation with + `git merge-base` on every build: a dev build whose annotation lags warns, a release whose annotation disagrees is + refused before its ledger row. + +A re-pin is proven before anything depends on it: the agent-platform dev line re-pins the new build's tag and +`Harness` digests, and agentlab on the dev channel runs its proofs from the published artifacts. + +### Manual re-pin + +The same steps by hand, for when the workflow is broken or a conflict needs a human: + +```bash +git clone https://github.com/giantswarm/kagent-upstream && cd kagent-upstream +git remote add upstream https://github.com/kagent-dev/kagent.git +CANDIDATE=sync/upstream-$(date -u +%Y%m%d)-manual scripts/fork/repin.sh # exit 2 = conflict, report in repin-report.md +# on a conflict: git rebase --onto upstream/main "$(git merge-base origin/giantswarm upstream/main)" ; fix ; git rebase --continue +sed -i "s|upstream.version=main@[0-9a-f]*|upstream.version=main@$(git merge-base HEAD upstream/main | cut -c1-7)|" .circleci/config.yml && git commit -qsam 'chore(fork): upstream pin annotation' # the pin annotation moves with the pin (step 6); fold it into the ci(fork) commit if you rebase interactively +git push origin "$CANDIDATE" # ci.yaml + image-scan.yaml run on sync/** ; wait for ci-ok and scan-ok +git push --force-with-lease=refs/heads/giantswarm origin "$CANDIDATE":giantswarm +git push origin --delete "$CANDIDATE" +scripts/fork/mirror.sh "$(gh auth token)" && scripts/fork/mirror-quiet.sh "" mirror-pushed.txt # or: gh workflow run sync-upstream.yaml -f mirror_only=true +scripts/fork/ledger-append.sh re-pins.md '| | | | → | | | manual |' +``` + +The force-push needs a bypass of the consumed branch's ruleset, which only the automation (the `heraldbot` +App) has: an administrator adds themselves to the bypass list for the duration and removes themselves right +after (the same discipline as lifting `enforce_admins` for a guarded merge). Never `--force` without +`--force-with-lease`. + +The 2026-10-05 re-pin (`main@dd3b2405` to `main@bf8afa56`) was done by hand, from the 2026-09-29 probe (`probe/all` on `main@e4516302`, PROBE-LOG.md in the probe worktree): `git rebase --onto` of the probe onto `main@bf8afa56`, the drops named under the table, the pull requests merged to `giantswarm` since the probe replayed in order and ported to the Session model and to egress destinations as HTTP(S) origins (`https://host:443`), the CRDs regenerated inside each API commit (`make controller-manifests`, drift-free at the head), the pin annotation moved in its own commit, and the ledger's 72 `docs(fork)` commits folded into the one ledger commit at the top of the stack (the convention from this re-pin on: a re-pin folds the ledger commits; between re-pins they accumulate as pull requests). Gates before the push: `go build`, `go vet`, `go test ./...` (the database tests over a podman socket, the controller's envtest with `setup-envtest`), `golangci-lint` with the kube-api-linter plugin, `helm unittest` of `helm/kagent` (423 tests), `make controller-manifests`. HeraldBot's blocked-sync pull request #159 is the hand-over for this re-pin: its branch `sync/upstream-20261005-bf8afa5` takes the candidate, CI runs there, and the pull request is closed after the force-push, never merged. + +### The mirror + +`main`, the `release/v*.x` branches and the tags are fast-forwarded by the sync as the App (`scripts/fork/mirror.sh`; +`protect-mirror-main` ruleset on `main`: no deletion, no force-push). The workflow's own token cannot be used: +GitHub refuses any push that creates or updates a file under `.github/workflows` without the `workflows` +permission, mirrored upstream commits included, and `GITHUB_TOKEN` never has it. An App push starts upstream's +workflow files at the mirrored refs; `scripts/fork/mirror-quiet.sh` cancels them right after. Upstream's CI +resolves the upgrade-test baseline from the fork's own release branches and tags, so the mirror must carry them +(the fork's own release tags `vX.Y.Z` share upstream's shape and live in a major above upstream's — "Release scheme"; `mirror.sh` refuses to mirror an upstream tag whose name the fork already holds at another commit, so a collision stops the sync, naming the tag, instead of one side silently winning). +If a mirrored branch was ever edited, the sync refuses to fast-forward it; restore it with +`git push --force-with-lease=refs/heads/ origin upstream/:` after lifting the ruleset +briefly, then run the sync again. `gh workflow run sync-upstream.yaml -f mirror_only=true` mirrors without +re-pinning. + +## Branch protection + +Ruleset `protect-consumed-branch` on the default branch: no deletion, no force-push, changes only through a +pull request with one approval, required status checks `ci-ok` and `scan-ok` on an up-to-date branch. Pull +requests are **rebase-merged, never squashed**: every commit on the consumed branch is one carried patch with +its own upstream exit, and the re-pin drops a commit only when its patch matches an upstream commit — a squash +would weld a cherry-picked upstream fix to unrelated changes and lose that. +Bypass: the `heraldbot` GitHub App (the org's automation App, installed on every repository; the sync +pushes with a token it mints from the org secrets) always — that is the re-pin's force-push; repository +administrators for pull requests only (merging an own green pull request without waiting for the approval). +Ruleset `protect-mirror-main` on `main`: no deletion, no force-push. Teams `team-bumblebee` and `bots` have +write access. + +## Contributing + +- **Every patch is an upstream pull request first** (kagent-dev/kagent, DCO sign-off on every commit; a + workflow run on a pull request from a fork waits for a maintainer's approval). The fork commit and the + upstream pull request are the same change and reference each other. Work on a branch of this repository + (`fix/…`, `feat/…`) and open the pull request here against `giantswarm` with the upstream link; the + upstream pull request is opened from this repository's branch too, so the same commit serves both. +- **Keep the base at `giantswarm`, a stacked pull request included.** `ci.yaml` runs on pull requests to + `main`, `release/**` and `giantswarm` only, so one based on another pull request's branch gets the + CircleCI image validations and nothing else: no `go-unit-tests`, no `go-lint`, no `ui-tests`, no + `test-e2e`, and no `ci-ok` — 8 checks where the same branch against `giantswarm` runs 41 (2026-09-26, + #112). Stack in commits instead: the diff shows the commits of the pull request below until that one + merges, and the suite runs on the code that will land. +- **A personal fork** is for exploration that may never be proposed; the moment a change is meant for the + platform it moves here. +- **A patch lives here until upstream merges it**; the next re-pin drops it. A patch upstream rejects is + either re-proposed in the shape the maintainers want or recorded in the table above as a deliberate + permanent difference with the reason — the target is none. +- **A re-pin force-pushes `giantswarm` and strands the open pull requests built on it.** The commit yours + branched from leaves the branch's history, so GitHub recomputes the merge base as the old upstream pin: + the diff grows to the whole carried stack and the pull request turns `DIRTY` (156 files, +54k lines, + 2026-09-25, #93 and #112). Check yours after a re-pin with + `gh pr view --json changedFiles,mergeStateStatus`. Recover without a force push: branch from the new + `origin/giantswarm`, cherry-pick your commits — only the FORK.md row conflicts, because the table gained + rows — then open a replacement and close the stale one naming it. An upstream pull request is unaffected: + its base, the mirror, only fast-forwards. +- Anything Giant Swarm-specific (Substrate wiring, platform `Harness` objects, registries, security contexts) + belongs to the agent-platform charts, not to this line. +- Never commit to `main` (the mirror), `sync/**` or `ledger`. + +## Ledger + +Machine-written, on the `ledger` branch: [`builds.md`](../../blob/ledger/builds.md) — one row per published +build (version, commit, upstream pin, every image and chart digest, the run); [`re-pins.md`](../../blob/ledger/re-pins.md) +— one row per re-pin (previous and new pin, old and new head, replayed and dropped commits, the run). +Human-written, here: the tables above. Together they answer "which kagent are we running" without opening an +issue tracker; giantswarm/giantswarm#37010 and #37742 hold the wider context. diff --git a/Makefile b/Makefile index bcd49bee65..ce0116eb45 100644 --- a/Makefile +++ b/Makefile @@ -76,12 +76,13 @@ SANDBOX_GUEST_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(SANDBOX_GUEST_IMAGE_NAM #take from go/go.mod AWK ?= $(shell command -v gawk || command -v awk) TOOLS_GO_VERSION ?= $(shell $(AWK) '/^go / { print $$2 }' go/go.mod) +GO_MODULE ?= $(shell $(AWK) '/^module / { print $$2 }' go/go.mod) export GOTOOLCHAIN=go$(TOOLS_GO_VERSION) -# Version information for the build -LDFLAGS := -X github.com/$(DOCKER_REPO)/go/core/internal/version.Version=$(VERSION) \ - -X github.com/$(DOCKER_REPO)/go/core/internal/version.GitCommit=$(GIT_COMMIT) \ - -X github.com/$(DOCKER_REPO)/go/core/internal/version.BuildDate=$(BUILD_DATE) +# Version information for the build (the -X targets follow the Go module path, not the image repository) +LDFLAGS := -X $(GO_MODULE)/core/internal/version.Version=$(VERSION) \ + -X $(GO_MODULE)/core/internal/version.GitCommit=$(GIT_COMMIT) \ + -X $(GO_MODULE)/core/internal/version.BuildDate=$(BUILD_DATE) #tools versions TOOLS_UV_VERSION ?= 0.10.4 @@ -188,11 +189,15 @@ SEMCONV_REGISTRY := telemetry/registry SEMCONV_GENERATE := $(WEAVER) registry generate -r $(SEMCONV_REGISTRY) --v2 -t telemetry/templates SEMCONV_GENERATED_PATHS := go/pkg/telemetry/conv python/packages/kagent-core/src/kagent/core/telemetry/_conv.py docs/architecture/telemetry-contract.md telemetry/resolved.yaml +.PHONY: semconv-deps +semconv-deps: ## Fetch the contract's upstream registries and Weaver policies at their pins; present checkouts need no network + telemetry/deps.sh + .PHONY: semconv-check -semconv-check: ## Validate the telemetry registry with Weaver and the kagent policies +semconv-check: semconv-deps ## Validate the telemetry registry with Weaver and the kagent policies $(WEAVER) registry check -r $(SEMCONV_REGISTRY) --v2 \ - --policy '$(WEAVER_PACKAGES)[policies/check/naming_conventions]' \ - --policy '$(WEAVER_PACKAGES)[policies/check/stability]' \ + --policy telemetry/deps/opentelemetry-weaver-packages/policies/check/naming_conventions \ + --policy telemetry/deps/opentelemetry-weaver-packages/policies/check/stability \ --policy telemetry/policies .PHONY: semconv-policies-test @@ -200,7 +205,7 @@ semconv-policies-test: ## Check that each kagent telemetry policy rejects its fi telemetry/policies/test.sh .PHONY: semconv-generate -semconv-generate: ## Generate the telemetry conventions, the contract reference, and the resolved snapshot +semconv-generate: semconv-deps ## Generate the telemetry conventions, the contract reference, and the resolved snapshot $(SEMCONV_GENERATE) go go/pkg/telemetry/conv $(SEMCONV_GENERATE) python python/packages/kagent-core/src/kagent/core/telemetry $(SEMCONV_GENERATE) markdown docs/architecture @@ -228,8 +233,21 @@ KMCP_VERSION ?= $(shell $(AWK) '/github\.com\/kagent-dev\/kmcp/ { print substr($ # Substrate SUBSTRATE_ENABLED ?= false -SUBSTRATE_VERSION ?= $(shell $(AWK) '/github\.com\/kagent-dev\/substrate/ { print substr($$5, 2) }' go/go.mod) # Substrate version defaults to the replace target in go.mod -SUBSTRATE_REPO ?= oci://ghcr.io/kagent-dev/substrate/helm # Override for local dev when consuming a locally-published chart, e.g. oci://localhost:5001/kagent-dev/substrate/helm +# Fork: the charts' substrate/substrate-crds dependencies come from the Giant +# Swarm line of Substrate (giantswarm/substrate, branch giantswarm: the upstream +# release go/go.mod pins plus cherry-picked fixes, published to +# gsoci.azurecr.io/giantswarm/substrate from CircleCI; its FORK.md is the ledger). The Go module pin +# in go/go.mod stays upstream's release — the ate-api contract is versioned by +# it — so the chart version is set here, not derived from go.mod, and the two +# move together at a re-pin. Upstream: SUBSTRATE_VERSION from the go.mod +# replace target, SUBSTRATE_REPO oci://ghcr.io/kagent-dev/substrate/helm. +SUBSTRATE_VERSION ?= 1.5.0 +SUBSTRATE_REPO ?= oci://gsoci.azurecr.io/giantswarm/substrate/helm # Override for local dev when consuming a locally-published chart, e.g. oci://localhost:5001/kagent-dev/substrate/helm + +.PHONY: substrate-pin +substrate-pin: ## Print the Substrate pin as KEY=VALUE lines (ci.yaml reads them, so the e2e cluster runs the same Substrate the charts depend on) + @echo SUBSTRATE_VERSION=$(strip $(SUBSTRATE_VERSION)) + @echo SUBSTRATE_REPO=$(strip $(SUBSTRATE_REPO)) HELM_ACTION=upgrade --install diff --git a/README.md b/README.md index 1b2541aa47..ffccbbfa1f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ +> **Giant Swarm fork.** This repository is the kagent line the [Giant Swarm Agent Platform](https://github.com/giantswarm/agent-platform) +> runs: upstream [kagent-dev/kagent](https://github.com/kagent-dev/kagent) `main` at a pinned commit plus the patches the platform +> carries until they are merged upstream. Which kagent is running, how the line is re-pinned, what is published, how to +> contribute: **[FORK.md](FORK.md)**. `main` here is a read-only mirror of upstream `main`; the consumed branch is `giantswarm`. +
diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000000..711537350a --- /dev/null +++ b/cliff.toml @@ -0,0 +1,163 @@ +# DO NOT EDIT. Generated with: +# +# devctl +# +# https://github.com/giantswarm/devctl/blob/5271fd6c7de989d9776e7af86a4d89c94c9274f3/pkg/gen/input/workflows/internal/file/cliff.toml.template +# +# git-cliff config for the push-based auto-release flow. +# +# A single git-cliff invocation in .github/workflows/auto-release.yaml runs +# `--unreleased --bump --context` to compute the next semver from conventional +# commits AND emit the JSON context that the same workflow then re-renders +# with `--from-context --strip all` for the release-notes body posted on the +# GitHub Release. One commit walk, one set of GitHub API PR lookups, two +# cheap outputs. + +[bump] +# `feat:` always bumps minor (not patch). Matches conventional-commits spec. +features_always_bump_minor = true +# `feat!:`/`BREAKING CHANGE` always bumps major. +breaking_always_bump_major = true +# First release in a repo with no tags yet. git-cliff defaults this to +# "0.1.0" (no prefix); we pin it to "v0.1.0" because the whole giantswarm +# stack (architect's /^v.*/ tag filter, the auto-release workflow's +# `--match='v*.*.*'` describe, gitsemver) expects the leading `v`. Once any +# v*.*.* tag exists, git-cliff carries that prefix forward on every bump, so +# this only governs the inception release. +initial_tag = "v0.1.0" + +[git] +conventional_commits = true +# Drop non-conventional commits silently rather than counting them as +# unclassified work -- the bump-decider should only act on intent-bearing +# commits. +filter_unconventional = true +# Changelog presentation order. `topo_order = false` orders releases +# chronologically (by tag date); `sort_commits = "oldest"` lists the commits +# within each release oldest-first. +topo_order = false +sort_commits = "oldest" + +# Scope `--bump`'s baseline to the current branch. This is load-bearing for +# two reasons: +# +# 1. Backports. The auto-release workflow also fires on `release-*` +# maintenance branches. Without this, git-cliff's `--bump` picks the +# GLOBALLY highest-semver tag in the repo regardless of reachability -- +# so a fix on `release-2.x` would bump from v3.1.0 (latest on main) and +# tag v3.1.1 ON the 2.x line instead of the intended v2.8.1. With +# `use_branch_tags`, git-cliff only considers tags reachable from the +# checked-out branch, so the 2.x line correctly bumps from v2.8.0. +# +# 2. Stray tags on side branches. A tag created on an unmerged branch +# (e.g. someone tags v9.0.0 while testing a release on a `bump/*` +# branch) is the global semver max but is NOT reachable from main. +# Without this it silently becomes the baseline for every subsequent +# main release, and a `feat:` on a v1.2.8 line produces v9.1.0. +# Branch-scoped tags exclude it. +use_branch_tags = true + +# Only vX.Y.Z is a release. git-cliff applies this before everything else, so +# a tag that does not match is not a boundary for `--unreleased`, not a +# baseline for `--bump` and not a release in the changelog. That is what keeps +# a candidate cycle one range: the auto-release flow cuts vX.Y.Z-rc.N tags on +# the branch head, and counting those as releases would cut every later +# candidate, and the stable release promoted from the cycle, from a partial +# delta -- a cycle a `feat` put on a minor falls back to a patch. The price is +# that candidate notes are cumulative: rc.2 lists what rc.1 listed, plus what +# is new. +# +# It also keeps a pre-release or preview tag off the version line wherever it +# lives, which `use_branch_tags` alone only does on a SIDE branch: such a name +# always carries a suffix, so it can never match. The workflow's +# `git describe --match='v*.*.*' --exclude='*-*' --exclude='*+*'` baseline +# selects the same tags, and the two have to stay in step. +tag_pattern = '^v[0-9]+\.[0-9]+\.[0-9]+$' + +# Strip the trailing " (#N)" that GitHub appends to squash-merge commit +# messages. Our template renders the PR link explicitly via cliff's GitHub +# integration (commit.remote.pr_number -- see [changelog].body), so the +# auto-appended reference would duplicate it. +# +# `(?m)` enables multi-line mode so `$` matches end-of-line (the squash +# `(#N)` lives at the end of the subject line, not the end of the full +# commit text -- git-cliff's default regex mode is single-line). This +# anchoring keeps `(#N)` references that appear inside commit bodies +# untouched. +# +# A feat-rc/fix-rc subject is released as its plain type: +# `features_always_bump_minor` keys off the type being exactly "feat", and an +# un-normalised feat-rc would bump patch instead of minor. +commit_preprocessors = [ + { pattern = '(?m) \(#\d+\)$', replace = "" }, + { pattern = '^(feat|fix)-rc', replace = "${1}" }, +] + +# Map commit types to changelog groups (Keep-a-Changelog convention): +# feat -> Added, fix -> Fixed, all "internal change" types -> Changed, +# security -> Security. +# +# `docs`, `style` and `test` are marked skip: none of them changes what +# ships. git-cliff decides the bump from the commits that are NOT skipped: a +# breaking change bumps the major, `feat` the minor, every other type at least +# the patch. A skipped commit is left out of the release notes and of the +# bump, so a docs-, style- or test-only push to main computes the current +# version and the workflow tags nothing; the change ships with the next +# release. `chore` and `ci` keep releasing: a `chore(deps)` bump changes what +# ships, a `ci` change how it is built. +commit_parsers = [ + { message = "^feat", group = "Added" }, + { message = "^fix", group = "Fixed" }, + { message = "^refactor", group = "Changed" }, + { message = "^perf", group = "Changed" }, + { message = "^docs", skip = true }, + { message = "^chore", group = "Changed" }, + { message = "^test", skip = true }, + { message = "^build", group = "Changed" }, + { message = "^ci", group = "Changed" }, + { message = "^security", group = "Security" }, + { message = "^style", skip = true }, +] + +# GitHub repo coordinates -- required for the API lookups that populate +# `commit.remote.pr_number` and `commit.remote.username` in the template +# below. The token is passed automatically via the workflow's GITHUB_TOKEN. +[remote.github] +owner = "giantswarm" +repo = "kagent-upstream" + +# Template for the rendered release notes. Body is what gets posted to the +# GitHub Release; header and footer are empty so the release page shows only +# the section list. +# +# Each bullet ends with " in [#N](pr-url) by [@user](user-url)" when +# git-cliff successfully resolves the PR for the commit via the GitHub API. +# `commit.remote` is the post-2.x replacement for the per-platform fields +# (commit.github / commit.gitlab / etc.) -- using it future-proofs the +# template against git-cliff dropping the old names. +# Backslashes at line ends escape the newline so the bullet stays on one +# line in the rendered Markdown. +[changelog] +header = "" +# `{%-` / `-%}` strip the surrounding newline so the rendered Markdown is +# tight: one blank line before each "### " header, no leading blank, no +# double-blank between header and bullets. Without the whitespace control +# the output picks up two extra blank lines per loop iteration. +body = """ +{% for group, commits in commits | group_by(attribute="group") -%} +### {{ group }} + +{% for commit in commits -%} +- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ +{% if commit.breaking %}[**breaking**] {% endif %}\ +{{ commit.message | split(pat="\n") | first | upper_first }}\ +{% if commit.remote.pr_number %} in [#{{ commit.remote.pr_number }}](https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}/pull/{{ commit.remote.pr_number }}){% endif %}\ +{% if commit.remote.username %} by [@{{ commit.remote.username }}](https://github.com/{{ commit.remote.username }}){% endif %} +{% endfor %} +{% endfor -%} +{% if previous.version %} +**Full Changelog**: https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}/compare/{{ previous.version }}...{{ version }} +{% endif -%} +""" +trim = true +footer = "" diff --git a/docs/architecture/a2a-gateway.md b/docs/architecture/a2a-gateway.md index 78303314a2..a54ba2c2c5 100644 --- a/docs/architecture/a2a-gateway.md +++ b/docs/architecture/a2a-gateway.md @@ -101,6 +101,16 @@ an error, and previously accepted input is recovered only from its own saved tas Uncertain issued work remains claimed; it cannot safely be reassigned just because a timeout expires. It blocks new execution but never hides completed task results. +Substrate's router resumes a suspended Actor on the gateway's dial and refuses +the request once its parking budget is spent. While another operation holds the +Actor, such as a repoint onto a newer template, every resume in that budget is +answered `Aborted`, and the operation may outlast it. A send the runtime refused +without taking its input, on a runtime that is not lost, therefore makes the +gateway resume the Actor itself, waiting while ate-api answers `Aborted` (backoff +from 500 ms to 5 s, at most two minutes), and run the send once more. Any other +answer, or an Actor still held at the bound, keeps the retryable refusal. The +gateway waits once per turn, and holds no dispatch reservation while it waits. + Checkpoint readiness is separate from task completion. Creating a checkpoint requires `expected_head_task_id`, the terminal task the caller intends to capture. The store checks that boundary atomically. `FailedPrecondition` includes a @@ -160,7 +170,13 @@ initial task followed by live updates. If completion races subscription setup, the gateway recovers the committed public result through the interaction service. Unary sends also recover the task containing the input if suspension interrupts the response; ambiguous message IDs and protocol errors are not recovered. Cancellation recovers only a terminal -task. An interrupted request with unfinished work +task. A send whose stream breaks after the runtime took its input, while the +task is still submitted or working, first asks whether the runtime is lost: a +crashed or missing Actor saves nothing more for the turn, so the turn ends +`FAILED` with the loss's message, the session records the loss, and the send is +answered with it at once rather than waiting for a boundary that never comes; +an intact runtime, or one whose state cannot be read, keeps the turn. An +interrupted request with unfinished work still returns an error. Clients can replace their projection with that current task and apply subsequent upstream A2A updates. There is no event cursor or promise of replaying every previous token event. diff --git a/docs/architecture/configuration-and-compilation.md b/docs/architecture/configuration-and-compilation.md index b4da4053ae..120deca785 100644 --- a/docs/architecture/configuration-and-compilation.md +++ b/docs/architecture/configuration-and-compilation.md @@ -20,6 +20,18 @@ with `tools[].subAgent.templateRef` and compile under the parent Agent's Harness Each subagent requires `templateRef` and shares the parent's runtime and Harness. Dedicated `agentRef` bindings are deferred and are not part of the served API. +`Agent.spec.egress` lists HTTP(S) origins the Agent may reach besides the +destinations its revision compiles, such as `https://proxy.golang.org`. A host +may have `*` as its leftmost label (`https://*.githubusercontent.com` matches +one label), with at least two labels under it, so nothing opens a top-level +domain or every host. The field is on the Agent, not the +template or the Harness: the hosts are a property of the workload in the place +where it runs (an internal git host, a registry mirror), so a template stays +portable and a Harness stays about the runtime. The compiler adds the origins, +in canonical form, after the runtime compiler has run, so every runtime and +every Shared subagent treats them the same, and they are part of the revision +identity. + All three are `api.kagent.dev/v1alpha3` Kubernetes resources. Infrastructure-derived values such as runtime addresses and inferred egress do not belong in the public API. @@ -293,9 +305,10 @@ specs. Create a session with `kagent agent session create --agent assistant -n kagent`. The gRPC create request and ScheduledRun target one `agent` resource reference. The controller selects that Agent's latest successful revision. Deleting an Agent -retires its definition; sessions and checkpoints retain their pinned revisions. -Recreating the same name creates a new identity and cannot inherit the old -Agent's last successful revision. +retires its definition and ends its sessions (the expiration sweep deletes them, +see [Deleted Agents](runtime-and-lifecycle.md#deleted-agents)); checkpoints retain +their pinned revisions. Recreating the same name creates a new identity and cannot +inherit the old Agent's last successful revision. This replaces implicit label selection and the old template/Harness inputs as a breaking API change. SandboxTemplate hosting configuration is not part of this diff --git a/docs/architecture/oidc-proxy-authentication.md b/docs/architecture/oidc-proxy-authentication.md index 50a76bb788..02a725746e 100644 --- a/docs/architecture/oidc-proxy-authentication.md +++ b/docs/architecture/oidc-proxy-authentication.md @@ -107,6 +107,26 @@ methods use their separate runtime authenticator; the proxy's agent-header path does not define authentication for those methods. See the [A2A gateway](a2a-gateway.md) for the runtime boundary. +## CLI + +The CLI reaches the controller as a direct user: every control-plane gRPC call +and every A2A call carries `authorization: Bearer ` beside +`x-user-id`, so a `trusted-proxy` controller identifies the caller from the +token's claims and an `insecure` one keeps selecting the partition by user ID. +The token comes from the first of `--caller-token`, `KAGENT_TOKEN` and the +credential of the current kubeconfig context — a static token, an exec plugin's +credential or an oidc `id-token`, read through client-go the way kubectl sends +it. A context whose credential is a client certificate offers no bearer. + +`kagent session invoke --token` passes a model API key through as the A2A +bearer and keeps that slot when both are set: the server reads one +authorization value. A `trusted-proxy` controller therefore takes no model key +on A2A calls; the CLI's refusal message says so. + +An `Unauthenticated` answer names what to do: with no token resolved, the +three ways to supply one; with a token sent, its source; with a model key in +the slot, the conflict. + ## Deployment configuration oauth2-proxy is an optional dependency in diff --git a/docs/architecture/runtime-and-lifecycle.md b/docs/architecture/runtime-and-lifecycle.md index 8468308df4..1b9897ad95 100644 --- a/docs/architecture/runtime-and-lifecycle.md +++ b/docs/architecture/runtime-and-lifecycle.md @@ -88,8 +88,9 @@ Substrate settlement and the complete multi-replica rollout remain acceptance wo Sessions expire after seven days without task activity by default, using the same bounded deletion workflow as Sandboxes. `controller.sessionIdleTTL` in Helm sets `KAGENT_SESSION_IDLE_TTL` on the controller. Values use Go durations (`168h` for seven days, -`720h` for thirty); `0` disables the worker, including retries, and negative values -are rejected. There is no per-agent override or maximum TTL. +`720h` for thirty); `0` disables idle deletion, including retries, and negative values +are rejected. There is no per-agent override or maximum TTL. The worker still +sweeps expired shares (below) with idle deletion off. Idle time is measured from the later of session creation and the latest stored A2A event's database timestamp. Forks retain original event timestamps, so their @@ -128,6 +129,65 @@ retention policy. History is no longer accessible through the expired Session AP `kagent_session_expired_total`). Each removal logs `expired idle session` at debug level with `session_id` and `idle_time`. +The same sweep deletes the session shares whose `expires_at` has passed, judged on +the controller's clock like the share token lookup. An expired share grants nothing +from its expiry on; without the sweep its row stayed until the owner revoked it or +the session was deleted, one dead row for every shared conversation nobody wrote to +again. Shares without an expiry are untouched. `kagent.session.share_expired` counts +the deleted shares (`kagent_session_share_expired_total`); a sweep that deleted any +logs `deleted expired session shares` at debug level with `count`. + +## Deleted Agents + +Deleting an Agent retires its definition and ends its sessions. The same sweep +deletes the sessions whose Agent name has no active definition through the +bounded deletion workflow above, with the durable reason `agent_deleted`, +whatever the idle TTL: a session's runtime does not outlive the Agent it was +created for, and the ActorTemplate of its revision is collected only once no +session references it. A deletion whose runtime work failed, or whose admission +was refused while the last turn's boundary was still being settled, stays +admitted or listed and is finished by a later sweep, never dropped. A session +whose Agent was deleted and created again under the same name before the sweep +reached it is a session of a replaced Agent, not of a deleted one: it keeps its +pinned revision, as the definition model promises, until it idles out or its +Agent is deleted for good. Retained checkpoints keep their revision pins as +before. + +`kagent.session.agent_deleted` counts the completed deletions +(`kagent_session_agent_deleted_total`); each logs `deleted the session of a +deleted Agent` at debug level with `session_id`. + +## Superseded revisions + +A session is prepared on one runtime revision of its agent and its Actor runs +on that revision's ActorTemplate. When the agent is re-rendered — a template +change, a Harness change, a release that renders every template anew — the +agent's current revision moves on and the session's does not: a revision +rendered by an older release lacks what the platform needs now, and its +ActorTemplate keeps what that release put there. A suspended Actor can be +moved: Substrate's `UpdateActor` accepts another ActorTemplate while the Actor +is suspended, as long as the sandbox config and the durable volumes stay the +same, and the next resume restores the Actor's data onto the new template's +golden snapshot. The workflow moves an Actor onto its agent's current revision +in the same atespace, gives it that revision's egress allowlist, and records +the session as prepared on it, so the superseded revision loses its last +reference and the runtime revision GC deletes it with its ActorTemplate. + +Three paths move an Actor: a turn, under its dispatch reservation and before +the gateway dials the quiesced runtime; the Resume RPC of a suspended session; +and the controller leader's sweep every `KAGENT_REVISION_REPOINT_INTERVAL` (5m +by default; `controller.revisionRepointInterval`; 0 disables), which visits +every READY session on a superseded revision whose runtime is settled — no +turn reserved or running, no idle boundary claimed, no lifecycle operation — +so a conversation nobody writes to again does not keep the old revision alive. +The turn's move and the sweep's may meet on one Actor: whichever comes second +finds the Actor moved and records what the first has yet to. A lifecycle +operation that claims the session meanwhile wins, and the Actor goes back to +the template of the revision the session records. A template Substrate +refuses — different sandbox config, a durable volume added or changed — is +logged and the session stays on its revision; a live or paused Actor is never +moved under its turn. + ## Automatic quiescence The runtime stages a final task update and acknowledges it after native cleanup. @@ -146,11 +206,60 @@ database transaction. Successful snapshot references are retried on database fai without repeating the Substrate operation. Checkpoint creation requires the matching snapshot and can return FailedPrecondition after task completion while it is pending. -Unclaimed idle work survives API restarts. A claim for possibly issued runtime work -never expires: losing the worker does not prove that the suspend stopped. Uncertain +Unclaimed idle work survives API restarts. A claim holds a lease (30 seconds, +in database time) that its worker renews every third of it until the boundary is +settled. A claim whose lease ran out, because its API replica stopped, crashed or +was rolled, is claimed again by another worker under a new executor ID. Losing the +worker does not prove that the suspend stopped, so the new holder does not repeat +the runtime request blindly: it settles the boundary from the Actor's state as below. The +stopped holder cannot renew, finish or release a claim taken over. Uncertain claims still block new work, but completed results remain readable. The recorded actor UID is checked before lifecycle calls; a same-name replacement cannot be -adopted implicitly. +adopted implicitly. A session that fails, its runtime lost, releases its idle +work with the failure, claimed or not: nothing settles a boundary of a runtime +that is gone, a held claim would only refuse the session's delete, and the +holder's late outcome finds the boundary settled and records nothing. + +A pause or suspend that returns an error leaves its outcome to the Actor's own +state. The claiming worker reads the Actor again, first after a second and then +with a backoff up to a minute, and keeps the claim while the Actor is still in +transition (`SUSPENDING`, `PAUSING`) or Substrate cannot answer. A `SUSPENDED` +Actor of the session with an external snapshot finishes the claim with that +snapshot, and a `PAUSED` Actor finishes a pause. A `RUNNING` Actor did not take +the request, or Substrate abandoned it (the first suspend after a worker restart +can outlive its deadline this way): the worker sends the pause or suspend once +more, fenced with a newer token, and settles its outcome the same way, so a +suspend that missed its deadline still ends with a snapshot once the Actor is +suspendable again. Any other state, a crashed or missing Actor included, or an +Actor still running after the retry, releases the claim without a snapshot: the +next turn takes the runtime as it is, a lost runtime fails the session there, and +the released boundary cannot be checkpointed. The release records why on the +session as `last_quiescence_failure` (the turn's task ID, a reason of +`SuspendFailed`, `PauseFailed` or `RuntimeUnavailable`, a message and the time), +so `GetSession` and `ListSessions` explain a turn without a snapshot; the next +boundary that records a snapshot clears it. Each read, the retry and the +resolution are logged with `session_id` and `task_id`. A worker that stops while +it settles leaves the claim to the takeover above. + +The claim's records fence a stopped holder; a fencing token fences one that is +frozen (a GC pause, a node stall, a slow network) while its pause or suspend is in +flight. Every Pause, Suspend and fencing Resume a holder sends carries a token: its +executor ID and a generation from the `quiescence_fencing_generation` sequence, +which grows with every request of every holder. Substrate records the newest token +per Actor and refuses an older one with `FailedPrecondition` without acting on it. +Before a claim is released on a running Actor, its holder sends a fenced Resume +with a new generation, which records the token and leaves the runtime as it is, so +a request still in flight from the same or an earlier holder cannot pause or +suspend the Actor once the next turn is admitted. A holder whose own request is +refused this way was superseded: it stops renewing and leaves the claim to its +successor. Requests without a token, from lifecycle operations and the gateway's +resume of a turn, are admitted as before. + +A Substrate whose Actor API predates the token refuses it as an unknown field +(`InvalidArgument`, protobuf tag 10001). The Substrate client then remembers that +its ate-api takes no token, logs one warning, and sends that request and every +later one unfenced: the boundary works as it did before the token, without the +fence against a frozen holder's late request. ```mermaid sequenceDiagram @@ -188,6 +297,19 @@ sequenceDiagram Clients never receive Actor addresses. The gateway derives and dials them through the private atenetwork router. +Every model call a runtime makes names the agent it is made for and the user of +the turn, as request headers: `x-kagent-agent` and `x-kagent-agent-namespace` +carry the `AgentTemplate`'s name and namespace (the controller injects them as +`KAGENT_AGENT_TEMPLATE` and `KAGENT_NAMESPACE`; the Claude harness sends the same +two through `ANTHROPIC_CUSTOM_HEADERS`), `x-kagent-user` the authenticated user +of the turn — the identity the controller resolved from the caller's validated +token and forwarded to the actor as metadata of the same name; absent when +there is none. A gateway between the runtime and the +model provider can attribute usage per agent and user from them, where the +network identity of the caller (a shared WorkerPool pod, an egress) says nothing +about the agent. They are an accounting identity the runtime asserts about +itself, never an authorization input. + Every Actor mounts a Substrate `DurableDir` at `/data`. Harnesses keep private state there—local framework state, workspaces, and downloaded assets that must survive Actor replacement. This state is runtime-private; public task history diff --git a/docs/architecture/telemetry.md b/docs/architecture/telemetry.md index d30eeec087..f7dde49691 100644 --- a/docs/architecture/telemetry.md +++ b/docs/architecture/telemetry.md @@ -116,13 +116,14 @@ and `trace_flags`. The registry depends on the core semantic conventions at v1.44.0 and on the GenAI conventions, which live in their own repository and have no release yet, -pinned by commit in `telemetry/registry/manifest.yaml`. The `gen_ai.*` names in -the generated constants come from that pin. Core names such as `service.*`, -`enduser.id` and `error.type` come from the newest core Go package, -`semconv/v1.43.0`, which is also the version the OpenTelemetry SDK uses, and -every tracer kagent creates declares its schema URL. Only the `kagent.*` and -`a2a.*` names are kagent's own. All of them are `development` until the v1.x -contract is declared stable. +pinned by commit. Both pins are in `telemetry/versions.env`; the manifest reads +the registries from the checkouts `make semconv-deps` puts in `telemetry/deps`. +The `gen_ai.*` names in the generated constants come from that pin. Core names +such as `service.*`, `enduser.id` and `error.type` come from the newest core Go +package, `semconv/v1.43.0`, which is also the version the OpenTelemetry SDK +uses, and every tracer kagent creates declares its schema URL. Only the +`kagent.*` and `a2a.*` names are kagent's own. All of them are `development` +until the v1.x contract is declared stable. ## The invocation span @@ -293,7 +294,19 @@ never reaches a resource or a metric, that every kagent span references `error.type`, and that no metric name carries a unit or `_total` suffix. Each policy has a fixture under `telemetry/policies/testdata` that it must reject. Weaver runs from the version pinned in `telemetry/versions.env`, through a local -binary of exactly that version or the pinned image. +binary of exactly that version or the pinned image. The upstream sources of the +contract, the two registries the manifest depends on and the +`naming_conventions` and `stability` policies of +[opentelemetry-weaver-packages](https://github.com/open-telemetry/opentelemetry-weaver-packages), +are pinned in the same file and fetched once into `telemetry/deps` +(`make semconv-deps`, which `semconv-check` and `semconv-generate` run). Weaver reads them from there +instead of cloning each repository on every run: the manifest names the +checkouts, and the fetch points the GenAI registry's own dependency on the core +registry at the checkout of the same pin, refusing a GenAI pin that wants +another core version, since the contract and its dependencies must agree on it. +A checkout at its pinned ref is read from disk, so the check runs without GitHub +access, and CI caches the directory keyed on the pins. Moving a pin is a change +to `telemetry/versions.env`. A rename or removal shows up as a diff in `telemetry/resolved.yaml` and the generated files. Once an attribute is declared stable, the check also runs the diff --git a/docs/env.md b/docs/env.md index 627c63c992..0dbe2022f8 100644 --- a/docs/env.md +++ b/docs/env.md @@ -34,6 +34,7 @@ This reference covers user-configurable settings for the controller, CLI, standa | `GOOGLE_CLOUD_REGION` | String | `(none)` | Go ADK Vertex AI region fallback when GOOGLE_CLOUD_LOCATION is unset. | | `GOOGLE_GENAI_USE_VERTEXAI` | String | `(none)` | When set to 'true', use Vertex AI for Gemini models. | | `KAGENT_A2A_MAX_CONTENT_LENGTH` | String | `10485760` | Maximum A2A request size in bytes for Go/Python servers. 0, none, or unlimited disables the limit; invalid values use the default. | +| `KAGENT_AGENT_TEMPLATE` | String | `(none)` | Name of the AgentTemplate the runtime executes. With KAGENT_NAMESPACE it is the identity the runtime sends on every model call, as the request headers x-kagent-agent and x-kagent-agent-namespace. Injected into agent runtimes via the controller. | | `KAGENT_API_URL` | String | `(none)` | Base URL for kagent control-plane API calls. Required by Python runtimes and supplied by the controller in managed runtimes; also used as the E2E test URL when KAGENT_E2E_API_URL is unset. | | `KAGENT_BASH_VENV_PATH` | String | `(none)` | Virtual environment used for Python skills shell commands; its bin directory is prepended to PATH and VIRTUAL_ENV is set. | | `KAGENT_CONFIG_DIR` | String | `/config` | Go ADK configuration directory; --filepath takes precedence. | @@ -99,6 +100,7 @@ This reference covers user-configurable settings for the controller, CLI, standa | `KAGENT_HELM_VERSION` | String | `(none)` | Helm chart version to deploy. When unset, the CLI uses its own version. | | `KAGENT_LOG_LEVEL` | String | `info` | Logging level for the controller, CLI, and Go/Python runtimes, including the Python ADK HTTP server: debug, info, warn, or error. Python also accepts standard Python logging levels. | | `KAGENT_POSTGRES_DATABASE_URL` | String | `postgres://postgres:kagent@kagent-postgresql.kagent.svc.cluster.local:5432/postgres` | PostgreSQL connection URL. The default applies only to the controller; kagent db requires this variable or --db-url. Helm supplies its configured connection URL. | +| `KAGENT_TOKEN` | String | `(none)` | Caller token the CLI sends as the bearer of every controller call, by which a controller in trusted-proxy mode identifies the caller. --caller-token overrides it; when both are unset the CLI sends the credential of the current kubeconfig context (an exec plugin, an id-token or a token). | | `KUBECONFIG` | String | `(none)` | Kubernetes client configuration file list for the controller, CLI Kubernetes operations, and tests. When unset, client-go uses its normal in-cluster or user kubeconfig discovery. | | `OLLAMA_API_KEY` | String | `(none)` | API key for Ollama Cloud. When set, a cloud-tagged model reaches api.ollama.com directly. | | `OPENAI_API_KEY` | String | `(none)` | API key for OpenAI. Upgrade tests fall back to a placeholder when unset or empty. | @@ -122,12 +124,14 @@ This reference covers user-configurable settings for the controller, CLI, standa | `KAGENT_OTEL_CAPTURE_RAW_API_BODIES` | Boolean | `false` | Set to true, t, or 1 (case-insensitive) to enable native Claude raw API body logging when log export is enabled. Independent of span content capture; bodies may contain sensitive data. | | `KAGENT_OTEL_MAX_CAPTURE_BYTES` | Integer | `16384` | Per-input/output content capture budget in bytes when capture is enabled. Valid values are 1 through 65536; absent or invalid values use 16384. | | `KAGENT_OTEL_RESOURCE_ATTRIBUTES` | String | `(none)` | Resource attributes, as key=value pairs, added to every agent runtime. | +| `KAGENT_PAUSED_RUNTIME_TTL` | Duration | `2m0s` | Suspend a runtime paused for input once its pause is older than this, so the reply restores it from a durable snapshot instead of the node the pause was taken on. Zero keeps every pause in place until the reply. | | `KAGENT_POSTGRES_DATABASE_MAX_CONNS` | Integer | `Greater of 4 and number of CPUs` | Maximum size of the PostgreSQL connection pool | | `KAGENT_POSTGRES_DATABASE_MAX_CONN_IDLE_TIME` | Duration | `30m0s` | Duration after which an idle connection will be automatically closed | | `KAGENT_POSTGRES_DATABASE_MAX_CONN_LIFETIME` | Duration | `1h0m0s` | Duration since creation after which a connection will be automatically closed | | `KAGENT_POSTGRES_DATABASE_MIN_CONNS` | Integer | `0` | Minimum size of the PostgreSQL connection pool | | `KAGENT_POSTGRES_DATABASE_URL` | String | `postgres://postgres:kagent@kagent-postgresql.kagent.svc.cluster.local:5432/postgres` | PostgreSQL connection URL. The default applies only to the controller; kagent db requires this variable or --db-url. Helm supplies its configured connection URL. | | `KAGENT_POSTGRES_DATABASE_URL_FILE` | String | `(none)` | File containing the PostgreSQL connection URL; takes precedence over KAGENT_POSTGRES_DATABASE_URL in the controller. | +| `KAGENT_REVISION_REPOINT_INTERVAL` | Duration | `5m0s` | How often the controller moves the quiesced runtimes of sessions whose revision their agent has superseded onto the agent's current revision, so a conversation nobody writes to again does not keep the old revision and its ActorTemplate alive. A turn moves its own runtime regardless. Zero disables the sweep. | | `KAGENT_RUNTIME_REVISION_GC_INTERVAL` | Duration | `1m0s` | Interval between unreferenced runtime revision cleanup sweeps. Must be positive. | | `KAGENT_SANDBOX_CPU` | String | `1` | CPU limit for standalone sandbox runtimes. | | `KAGENT_SANDBOX_DEFAULT_TTL` | Duration | `1h0m0s` | Default standalone sandbox lifetime. | @@ -137,10 +141,11 @@ This reference covers user-configurable settings for the controller, CLI, standa | `KAGENT_SANDBOX_MEMORY` | String | `1Gi` | Memory limit for standalone sandbox runtimes. | | `KAGENT_SCHEDULED_RUN_EXECUTION_POLL_INTERVAL` | Duration | `1s` | Interval between scheduled execution reconciliation attempts. Must be positive; longer intervals delay dispatch, status updates, deadline enforcement, and cleanup. | | `KAGENT_SCHEDULED_RUN_POLL_INTERVAL` | Duration | `1s` | Interval between reserving due scheduled runs. Must be positive; occurrences more than 30 seconds late are skipped. | -| `KAGENT_SESSION_EXPIRATION_POLL_INTERVAL` | Duration | `1m0s` | Interval between idle session expiration sweeps. Must be positive. | +| `KAGENT_SESSION_EXPIRATION_POLL_INTERVAL` | Duration | `1m0s` | Interval between the expiration sweeps, which delete idle sessions and expired session shares. Must be positive. | | `KAGENT_SESSION_IDLE_TTL` | Duration | `168h0m0s` | Delete sessions after this idle duration. Zero disables expiration; running and waiting tasks are retained. | | `KAGENT_SESSION_SHARE_MAX_TTL` | Duration | `0s` | Longest lifetime a session share may request. Shares created without a ttl receive it. Zero leaves shares unbounded. | | `KAGENT_SKIP_MIGRATIONS` | Boolean | `false` | Verify required database migrations at startup without applying them. | +| `KAGENT_STALLED_TURN_TIMEOUT` | Duration | `1h0m0s` | Fail a submitted or working turn whose runtime recorded no event for this long, so the session takes its next message. The sweep runs on the session expiration poll interval. Zero disables it. | | `KAGENT_SUBSTRATE_ATENET_ROUTER_URL` | String | `http://atenet-router.ate-system.svc:80` | Substrate router endpoint for agent and sandbox guest traffic. | | `KAGENT_SUBSTRATE_ATE_API_CA_FILE` | String | `(none)` | PEM CA bundle used to verify the Substrate API server. Empty uses system trust roots. | | `KAGENT_SUBSTRATE_ATE_API_CLIENT_CERT_FILE` | String | `(none)` | PEM bundle containing both the client certificate and private key for Substrate API mTLS. Reloaded for each TLS handshake. | @@ -194,6 +199,8 @@ This reference covers user-configurable settings for the controller, CLI, standa | `KAGENT_E2E_CLI` | String | `(none)` | CLI executable for catalog lifecycle E2E tests; make -C go e2e builds and supplies it. | | `KAGENT_E2E_CODEX_IMAGE` | String | `(none)` | Digest-pinned Codex harness image substituted into E2E lifecycle manifests. | | `KAGENT_E2E_DOCKER_REGISTRY` | String | `localhost:5001` | Image registry for upgrade tests; supplied by make from DOCKER_REGISTRY. | +| `KAGENT_E2E_EGRESS_CA_FILE` | String | `(none)` | PEM CA certificate that Substrate's egress gateway trusts on upstream HTTPS (atenetEgress.upstreamTrust.caBundle). Egress credential E2E tests sign their HTTPS upstreams with it and fail when it is unset. | +| `KAGENT_E2E_EGRESS_CA_KEY_FILE` | String | `(none)` | PEM private key of KAGENT_E2E_EGRESS_CA_FILE. | | `KAGENT_E2E_KIND_CLUSTER_NAME` | String | `kagent` | Kind cluster used by upgrade tests; supplied by make from KIND_CLUSTER_NAME. | | `KAGENT_E2E_KUBE_CONTEXT` | String | `(none)` | Kubernetes context for upgrade tests. Defaults to kind- followed by KAGENT_E2E_KIND_CLUSTER_NAME. | | `KAGENT_E2E_LOCAL_HOST` | String | `(none)` | Host reachable from E2E runtimes for local mock servers. Defaults to 172.17.0.1 on Linux and host.docker.internal on macOS; required on other systems. | diff --git a/go/Dockerfile b/go/Dockerfile index 19e54eb0dc..6469524c7e 100644 --- a/go/Dockerfile +++ b/go/Dockerfile @@ -35,9 +35,9 @@ RUN --mount=type=cache,target=/root/go/pkg/mod,rw \ FROM alpine:3.24 ARG TARGETPLATFORM -RUN apk add --no-cache bash ca-certificates git && \ - mkdir -p /skills /plugins /data/plugins && \ - chown -R 65532:65532 /skills /plugins /data +RUN apk upgrade --no-cache && apk add --no-cache bash ca-certificates git && \ + mkdir -p /config /skills /plugins /data/plugins && \ + chown -R 65532:65532 /config /skills /plugins /data WORKDIR / COPY --from=builder /app /app diff --git a/go/Makefile b/go/Makefile index b54e5569e5..55f657d518 100644 --- a/go/Makefile +++ b/go/Makefile @@ -127,7 +127,8 @@ run: fmt vet ## Run a controller from your host. go run ./core/cmd/controller/main.go .PHONY: test -test: ## Run all unit tests. +test: setup-envtest ## Run all unit tests. + KUBEBUILDER_ASSETS="$$($(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" \ KAGENT_TEST_UPDATE_GOLDEN=$(KAGENT_TEST_UPDATE_GOLDEN) go test -race -skip 'TestE2E.*' -v ./... # Pin e2e tests to the kind cluster so a stray current-context (or an diff --git a/go/adk/pkg/a2a/executor.go b/go/adk/pkg/a2a/executor.go index 51d4e7f641..83936854b6 100644 --- a/go/adk/pkg/a2a/executor.go +++ b/go/adk/pkg/a2a/executor.go @@ -201,7 +201,10 @@ func resolveStructuredOutput(config *apiadk.OutputConfig) (*structuredOutput, er // UserIDCallInterceptor returns an a2asrv.CallInterceptor that extracts the // x-user-id HTTP header from the incoming request metadata and sets it as the -// authenticated user on the CallContext. +// authenticated user on the CallContext, the x-kagent-user header — the +// person of the turn as the gateway resolved it — as the user every model call +// of the turn names, and the x-kagent-agent-instance-id header as the session +// they name. func UserIDCallInterceptor() a2asrv.CallInterceptor { return &userIDInterceptor{} } @@ -218,6 +221,18 @@ func (u *userIDInterceptor) Before(ctx context.Context, callCtx *a2asrv.CallCont if meta == nil { return ctx, nil, nil } + // The person of the turn, as the gateway resolved and forwarded it; every + // model call made under ctx names them (x-kagent-user). x-user-id below is + // the session owner, which may be a propagated or unauthenticated id, and + // is never sent as the person. + if vals, ok := meta.Get(apiadk.UserHeader); ok && len(vals) > 0 && vals[0] != "" { + ctx = models.WithUser(ctx, vals[0]) + } + // The AgentInstance the turn runs in, as the gateway routed it; every model + // call made under ctx names it (x-kagent-agent-instance-id). + if vals, ok := meta.Get(apiadk.AgentInstanceHeader); ok && len(vals) > 0 && vals[0] != "" { + ctx = models.WithAgentInstance(ctx, vals[0]) + } vals, ok := meta.Get("x-user-id") if !ok || len(vals) == 0 || vals[0] == "" { return ctx, nil, nil diff --git a/go/adk/pkg/a2a/identity_test.go b/go/adk/pkg/a2a/identity_test.go new file mode 100644 index 0000000000..c115b80f8a --- /dev/null +++ b/go/adk/pkg/a2a/identity_test.go @@ -0,0 +1,79 @@ +// Copyright 2026 The kagent Authors +// SPDX-License-Identifier: Apache-2.0 + +package a2a + +import ( + "iter" + "log/slog" + "path/filepath" + "testing" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" + "github.com/kagent-dev/kagent/go/adk/pkg/models" + localsession "github.com/kagent-dev/kagent/go/adk/pkg/session" + "github.com/kagent-dev/kagent/go/api/adk" + "github.com/stretchr/testify/require" + adkagent "google.golang.org/adk/v2/agent" + "google.golang.org/adk/v2/model" + "google.golang.org/adk/v2/runner" + adksession "google.golang.org/adk/v2/session" + "google.golang.org/genai" +) + +// The person of a turn — the x-kagent-user metadata the gateway forwards once +// it has resolved the caller — reaches the agent's invocation context, where +// the model transport reads it (models.UserFromContext). Neither the session +// owner in x-user-id nor the caller's bearer in authorization ever does: in a +// flow that puts a raw token there, no model call names it. The instance the +// turn runs in reaches it the same way, with or without a person. +func TestKAgentExecutorNamesTheResolvedUserToModelCalls(t *testing.T) { + for _, user := range []string{"", "alice"} { + t.Run("user="+user, func(t *testing.T) { + var gotUser, gotInstance string + agent, err := adkagent.New(adkagent.Config{ + Name: "identity-agent", + Run: func(ic adkagent.InvocationContext) iter.Seq2[*adksession.Event, error] { + return func(yield func(*adksession.Event, error) bool) { + gotUser = models.UserFromContext(ic) + gotInstance = models.AgentInstanceFromContext(ic) + event := adksession.NewEvent(ic, ic.InvocationID()) + event.Author = ic.Agent().Name() + event.LLMResponse = model.LLMResponse{Content: genai.NewContentFromText("done", genai.RoleModel)} + yield(event, nil) + } + }, + }) + require.NoError(t, err) + svc, err := localsession.NewLocalSessionService("sqlite:///" + filepath.Join(t.TempDir(), "sessions.db")) + require.NoError(t, err) + executor, err := NewKAgentExecutor(KAgentExecutorConfig{ + AppName: "app", SessionService: svc, Logger: slog.New(slog.DiscardHandler), + RunnerConfig: runner.Config{AppName: "app", Agent: agent}, + }) + require.NoError(t, err) + // What the gateway forwards to an actor: the caller's bearer (stored for + // API-key passthrough), the session owner, and — once resolved — the person. + params := map[string][]string{ + "authorization": {"Bearer eyJraWQiOiJyYXctdG9rZW4ifQ.eyJlbWFpbCI6ImFsaWNlIn0.sig"}, + "x-user-id": {"eyJraWQiOiJyYXctdG9rZW4ifQ.not-a-person"}, + adk.AgentInstanceHeader: {"instance"}, + } + if user != "" { + params[adk.UserHeader] = []string{user} + } + ctx, callCtx := a2asrv.NewCallContext(t.Context(), a2asrv.NewServiceParams(params)) + ctx, _, err = UserIDCallInterceptor().Before(ctx, callCtx, nil) + require.NoError(t, err) + message := a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart("hello")) + message.ContextID = "conversation" + request := &a2asrv.ExecutorContext{TaskID: a2atype.NewTaskID(), ContextID: "conversation", Message: message} + for _, err := range executor.Execute(ctx, request) { + require.NoError(t, err) + } + require.Equal(t, user, gotUser) + require.Equal(t, "instance", gotInstance) + }) + } +} diff --git a/go/adk/pkg/a2a/server/tracing.go b/go/adk/pkg/a2a/server/tracing.go index 4c2e1dcc84..abc11c9e6d 100644 --- a/go/adk/pkg/a2a/server/tracing.go +++ b/go/adk/pkg/a2a/server/tracing.go @@ -8,6 +8,7 @@ import ( a2atype "github.com/a2aproject/a2a-go/v2/a2a" "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/kagent-dev/kagent/go/adk/pkg/auth" + apiadk "github.com/kagent-dev/kagent/go/api/adk" "github.com/kagent-dev/kagent/go/pkg/tracing" "go.opentelemetry.io/otel/attribute" ) @@ -65,9 +66,7 @@ func (i *invocationInterceptor) Before(ctx context.Context, callCtx *a2asrv.Call attributes := make([]attribute.KeyValue, 0, len(i.static)+2) attributes = append(attributes, attribute.String(tracing.AttributeMethod, method)) attributes = append(attributes, i.static...) - // The gateway authenticates the caller and replaces x-user-id before - // forwarding, so this is the only trusted identity on the private path. - if userID := auth.UserIDFromContext(ctx); userID != "" { + if userID := turnUser(ctx, callCtx); userID != "" { attributes = append(attributes, attribute.String(tracing.AttributeUserID, userID)) } ctx, invocation := tracing.StartInvocation(ctx, tracing.Tracer(invocationScope), i.name, i.flush, attributes...) @@ -85,6 +84,19 @@ func (i *invocationInterceptor) Before(ctx context.Context, callCtx *a2asrv.Call return ctx, nil, nil } +// turnUser is the end user of the turn: the person the gateway resolved from +// the caller's validated token and forwards as x-kagent-user. x-user-id names +// the owner of the session, which on a turn a share authorizes is not the +// caller, so it names the end user only of a turn without a resolved person. +func turnUser(ctx context.Context, callCtx *a2asrv.CallContext) string { + if meta := callCtx.ServiceParams(); meta != nil { + if vals, ok := meta.Get(apiadk.UserHeader); ok && len(vals) > 0 && vals[0] != "" { + return vals[0] + } + } + return auth.UserIDFromContext(ctx) +} + func (i *invocationInterceptor) After(ctx context.Context, callCtx *a2asrv.CallContext, response *a2asrv.Response) error { method := callCtx.Method() if method != "SendMessage" && method != "SendStreamingMessage" { diff --git a/go/adk/pkg/a2a/server/tracing_test.go b/go/adk/pkg/a2a/server/tracing_test.go index bebb53f4cf..5d6d3e5286 100644 --- a/go/adk/pkg/a2a/server/tracing_test.go +++ b/go/adk/pkg/a2a/server/tracing_test.go @@ -20,6 +20,7 @@ import ( "github.com/a2aproject/a2a-go/v2/a2apb/v1/pbconv" "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/kagent-dev/kagent/go/adk/pkg/auth" + apiadk "github.com/kagent-dev/kagent/go/api/adk" "github.com/kagent-dev/kagent/go/pkg/tracing" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" @@ -98,6 +99,11 @@ func syncExporter(t *testing.T) *tracetest.InMemoryExporter { } func sendMessage(t *testing.T, server *A2AServer, request *a2atype.SendMessageRequest) { + t.Helper() + sendMessageWithHeaders(t, server, request, nil) +} + +func sendMessageWithHeaders(t *testing.T, server *A2AServer, request *a2atype.SendMessageRequest, headers map[string]string) { t.Helper() body, err := json.Marshal(map[string]any{ "jsonrpc": "2.0", "id": "1", "method": "SendMessage", "params": request, @@ -108,6 +114,9 @@ func sendMessage(t *testing.T, server *A2AServer, request *a2atype.SendMessageRe httpRequest := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) httpRequest.Header.Set("Content-Type", "application/json") httpRequest.Header.Set(a2atype.SvcParamVersion, string(a2atype.Version)) + for key, value := range headers { + httpRequest.Header.Set(key, value) + } recorder := httptest.NewRecorder() server.httpServer.Handler.ServeHTTP(recorder, httpRequest) if recorder.Code != http.StatusOK { @@ -185,6 +194,39 @@ func TestRequestSpanCarriesStaticIdentityAndTrustedUser(t *testing.T) { } } +// On a turn a share authorizes, x-user-id names the session's owner and +// x-kagent-user the visitor who sent it: the span names the visitor. A turn +// whose caller the gateway did not resolve carries no x-kagent-user and keeps +// x-user-id. +func TestRequestSpanNamesThePersonOfTheTurn(t *testing.T) { + for _, tt := range []struct { + name string + headers map[string]string + want string + }{ + {name: "shared turn", headers: map[string]string{apiadk.UserHeader: "visitor@example.com"}, want: "visitor@example.com"}, + {name: "no resolved person", want: "owner@example.com"}, + } { + t.Run(tt.name, func(t *testing.T) { + exporter := syncExporter(t) + server, err := NewA2AServer(a2atype.AgentCard{}, substrateExecutor{}, slog.New(slog.DiscardHandler), + ServerConfig{Port: "0", Telemetry: tracing.RuntimeTelemetry{Runtime: tracing.RuntimeCodex, AgentName: "reporter-codex"}}, + a2asrv.WithCallInterceptors(staticUserInterceptor{userID: "owner@example.com"})) + if err != nil { + t.Fatal(err) + } + + sendMessageWithHeaders(t, server, &a2atype.SendMessageRequest{ + Message: a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart("hi")), + }, tt.headers) + + if got := spanAttribute(requestSpan(t, exporter), tracing.AttributeUserID); got != tt.want { + t.Errorf("%s = %q, want %q", tracing.AttributeUserID, got, tt.want) + } + }) + } +} + // An ADK runtime emits its own invoke_agent spans, so its request span stays a // transport span that carries the identity but no operation. func TestRequestSpanStaysATransportSpanForTheADK(t *testing.T) { diff --git a/go/adk/pkg/agent/agent.go b/go/adk/pkg/agent/agent.go index 80b3e047a0..d92482354a 100644 --- a/go/adk/pkg/agent/agent.go +++ b/go/adk/pkg/agent/agent.go @@ -12,6 +12,7 @@ import ( "github.com/kagent-dev/kagent/go/adk/pkg/mcp" "github.com/kagent-dev/kagent/go/adk/pkg/models" adkoutputschema "github.com/kagent-dev/kagent/go/adk/pkg/outputschema" + "github.com/kagent-dev/kagent/go/adk/pkg/skillfs" "github.com/kagent-dev/kagent/go/adk/pkg/sts" "github.com/kagent-dev/kagent/go/adk/pkg/tools" "github.com/kagent-dev/kagent/go/api/adk" @@ -65,7 +66,9 @@ func createGoogleADKAgent(ctx context.Context, agentConfig *adk.AgentConfig, age } } if skillsDirectory != "" { - skillsSource := skill.NewFileSystemSource(os.DirFS(skillsDirectory)) + // A SKILL.md written for another harness carries frontmatter fields the + // ADK does not know; skillfs serves it with the agentskills.io fields only. + skillsSource := skill.NewFileSystemSource(skillfs.Lenient(os.DirFS(skillsDirectory))) skills, err := skillsSource.ListFrontmatters(ctx) if err != nil { return nil, fmt.Errorf("failed to load skills: %w", err) @@ -337,6 +340,8 @@ func CreateLLM(ctx context.Context, m adk.Model) (adkmodel.LLM, error) { Temperature: m.Temperature, TopP: m.TopP, TopK: m.TopK, + PromptCaching: m.PromptCaching, + CacheTTL: m.CacheTTL, } return models.NewAnthropicModel(ctx, cfg) @@ -370,6 +375,7 @@ func CreateLLM(ctx context.Context, m adk.Model) (adkmodel.LLM, error) { // The environment holds only the gateway credential placeholder. APIKey: env.OllamaAPIKey.Get(), Options: m.Options, + Think: m.Think, } return models.NewOllamaModel(ctx, cfg) diff --git a/go/adk/pkg/agent/agent_test.go b/go/adk/pkg/agent/agent_test.go index 7c761f9d8f..320dfc0a4a 100644 --- a/go/adk/pkg/agent/agent_test.go +++ b/go/adk/pkg/agent/agent_test.go @@ -3,11 +3,15 @@ package agent import ( "context" "encoding/json" + "errors" + "os" + "path/filepath" "testing" "github.com/kagent-dev/kagent/go/adk/pkg/models" "github.com/kagent-dev/kagent/go/api/adk" "github.com/stretchr/testify/require" + "google.golang.org/adk/v2/tool/skilltoolset/skill" ) // TestConfigDeserialization_OpenAI verifies that a realistic OpenAI config.json @@ -58,7 +62,9 @@ func TestConfigDeserialization_Anthropic(t *testing.T) { "model": { "type": "anthropic", "model": "claude-sonnet-4-20250514", - "base_url": "https://api.anthropic.com" + "base_url": "https://api.anthropic.com", + "prompt_caching": true, + "cache_ttl": "1h" }, "description": "test agent", "instruction": "you are helpful" @@ -77,6 +83,60 @@ func TestConfigDeserialization_Anthropic(t *testing.T) { if anthropic.Model != "claude-sonnet-4-20250514" { t.Errorf("model name = %q, want %q", anthropic.Model, "claude-sonnet-4-20250514") } + if !anthropic.PromptCaching { + t.Error("PromptCaching = false, want true") + } + if anthropic.CacheTTL != "1h" { + t.Errorf("CacheTTL = %q, want %q", anthropic.CacheTTL, "1h") + } +} + +// TestCreateLLM_AnthropicPromptCaching verifies the prompt caching knobs reach +// the Anthropic model, which is where the cache_control markers are set. +func TestCreateLLM_AnthropicPromptCaching(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "test-key") + m := &adk.Anthropic{ + BaseModel: adk.BaseModel{Type: "anthropic", Model: "claude-sonnet-4-6"}, + PromptCaching: true, + CacheTTL: "1h", + } + + llm, err := CreateLLM(context.Background(), m) + if err != nil { + t.Fatalf("CreateLLM: %v", err) + } + am, ok := llm.(*models.AnthropicModel) + if !ok { + t.Fatalf("model is %T, want *models.AnthropicModel", llm) + } + if !am.Config.PromptCaching { + t.Error("Config.PromptCaching = false, want true") + } + if am.Config.CacheTTL != "1h" { + t.Errorf("Config.CacheTTL = %q, want %q", am.Config.CacheTTL, "1h") + } +} + +// TestCreateLLM_OllamaThink verifies the think switch of the config reaches the +// Ollama model, which sends it as the chat request's think field. +func TestCreateLLM_OllamaThink(t *testing.T) { + var cfg adk.AgentConfig + configJSON := `{"model": {"type": "ollama", "model": "qwen3.5:2b", "think": false}}` + if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil { + t.Fatalf("failed to unmarshal config: %v", err) + } + + llm, err := CreateLLM(context.Background(), cfg.Model) + if err != nil { + t.Fatalf("CreateLLM: %v", err) + } + om, ok := llm.(*models.OllamaModel) + if !ok { + t.Fatalf("model is %T, want *models.OllamaModel", llm) + } + if om.Config.Think == nil || *om.Config.Think { + t.Errorf("Config.Think = %v, want false", om.Config.Think) + } } // TestConfigDeserialization_AllTypes verifies every model type deserializes with @@ -538,3 +598,35 @@ func TestUsesRawOutputSchema(t *testing.T) { }) } } + +func TestCreateGoogleADKAgentLoadsSkillsWithFrontmatterFieldsOutsideTheSpec(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "test-key") + writeSkill := func(t *testing.T, frontmatter string) *adk.AgentConfig { + skillsDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(skillsDir, "deploy"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillsDir, "deploy", "SKILL.md"), []byte("---\n"+frontmatter+"---\n# Deploy\n"), 0o644); err != nil { + t.Fatal(err) + } + return &adk.AgentConfig{ + Model: &adk.OpenAI{BaseModel: adk.BaseModel{Type: adk.ModelTypeOpenAI, Model: "gpt-4o"}, BaseUrl: "https://api.openai.com/v1"}, + Description: "root", + Instruction: "deploy", + SkillsDirectory: skillsDir, + } + } + + // Claude Code's fields and a top-level version, as skills written for other + // harnesses carry them. + config := writeSkill(t, "name: deploy\ndescription: Deploys the service.\nuser-invocable: false\nargument-hint: '[environment]'\nversion: 0.1.0\n") + if _, err := CreateGoogleADKAgent(context.Background(), config, "root", nil); err != nil { + t.Fatalf("an agent whose skill carries fields outside the agentskills.io set must load: %v", err) + } + + // The fields the specification requires are still required. + config = writeSkill(t, "name: deploy\nuser-invocable: false\n") + if _, err := CreateGoogleADKAgent(context.Background(), config, "root", nil); !errors.Is(err, skill.ErrInvalidFrontmatter) { + t.Fatalf("a skill without a description must still fail with %v, got %v", skill.ErrInvalidFrontmatter, err) + } +} diff --git a/go/adk/pkg/mcp/registry.go b/go/adk/pkg/mcp/registry.go index 57f5dd9fc7..73e49c4d4e 100644 --- a/go/adk/pkg/mcp/registry.go +++ b/go/adk/pkg/mcp/registry.go @@ -8,6 +8,8 @@ import ( "net/http" "os" "os/exec" + "slices" + "strings" "time" "log/slog" @@ -314,7 +316,12 @@ type headerRoundTripper struct { func (rt *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { req = req.Clone(req.Context()) + rt.setHeaders(req) + return rt.base.RoundTrip(req) +} +// setHeaders sets the headers of all four sources on req, in RoundTrip's order. +func (rt *headerRoundTripper) setHeaders(req *http.Request) { // When KAGENT_PROPAGATE_TOKEN is set, forward Authorization from the incoming // A2A request independently of allowedHeaders. Carry the authenticated user // alongside it so kagent callbacks preserve ownership in insecure and @@ -348,34 +355,70 @@ func (rt *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro for key, value := range rt.headers { req.Header.Set(key, value) } +} - return rt.base.RoundTrip(req) +// awaitsCallerCredential reports whether the server takes its Authorization +// from the caller of a turn (KAGENT_PROPAGATE_TOKEN, or Authorization among +// the allowed headers) and ctx carries none, as at the runtime's boot, which +// has no caller. Listing the server's tools then can only be refused. +func awaitsCallerCredential(ctx context.Context, params mcpServerParams) bool { + if params.ServerType == "stdio" { + return false + } + if !params.PropagateToken && !slices.ContainsFunc(params.AllowedHeaders, func(name string) bool { + return strings.EqualFold(name, constants.AuthorizationHeader) + }) { + return false + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, params.URL, nil) + if err != nil { + return false + } + (&headerRoundTripper{ + headers: params.Headers, + allowedHeaders: params.AllowedHeaders, + propagateToken: params.PropagateToken, + headerProvider: params.HeaderProvider, + }).setHeaders(req) + return req.Header.Get(constants.AuthorizationHeader) == "" +} + +// configuredToolPredicate keeps the tools enabled in the configured filter, or +// every tool when no filter is configured. +func configuredToolPredicate(toolFilter map[string]bool) tool.Predicate { + if len(toolFilter) == 0 { + return nil + } + allowedTools := make([]string, 0, len(toolFilter)) + for name, enabled := range toolFilter { + if enabled { + allowedTools = append(allowedTools, name) + } + } + return tool.StringPredicate(allowedTools) } // initializeToolSet fetches tools from an MCP server using Google ADK's // mcptoolset and wraps the result with any MCP App-capable tool names found -// during classification. +// during classification. A server that awaits the caller's credential is not +// contacted: its tools are discovered lazily, at the first turn, with the +// caller's token. func initializeToolSet(ctx context.Context, params mcpServerParams, toolFilter map[string]bool) (*mcpAppToolset, error) { + log := logging.FromContext(ctx) mcpTransport, err := createTransport(ctx, params) if err != nil { return nil, fmt.Errorf("failed to create transport for %s: %w", params.URL, err) } - toolPredicate, appToolNames, err := agentVisibleToolFilter(ctx, params, toolFilter) - if err != nil { - logging.FromContext(ctx).ErrorContext(ctx, "failed to classify MCP tools; falling back to lazy tool discovery", "error", err, "url", params.URL) + var toolPredicate tool.Predicate + var appToolNames map[string]bool + if awaitsCallerCredential(ctx, params) { + log.DebugContext(ctx, "MCP server takes the caller's credential and there is no caller yet; tool discovery deferred to the first turn", "url", params.URL) + toolPredicate = configuredToolPredicate(toolFilter) + } else if toolPredicate, appToolNames, err = agentVisibleToolFilter(ctx, params, toolFilter); err != nil { + log.ErrorContext(ctx, "failed to classify MCP tools; falling back to lazy tool discovery", "error", err, "url", params.URL) appToolNames = nil - if len(toolFilter) > 0 { - allowedTools := make([]string, 0, len(toolFilter)) - for name, enabled := range toolFilter { - if enabled { - allowedTools = append(allowedTools, name) - } - } - toolPredicate = tool.StringPredicate(allowedTools) - } else { - toolPredicate = nil - } + toolPredicate = configuredToolPredicate(toolFilter) } cfg := mcptoolset.Config{ diff --git a/go/adk/pkg/mcp/registry_test.go b/go/adk/pkg/mcp/registry_test.go index 6697d91466..9bee47b2e2 100644 --- a/go/adk/pkg/mcp/registry_test.go +++ b/go/adk/pkg/mcp/registry_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "sync/atomic" "testing" "github.com/a2aproject/a2a-go/v2/a2asrv" @@ -273,6 +274,88 @@ func TestInitializeToolSetRecoversWhenServerStartsAfterInitialization(t *testing } } +// bearerMCPServer serves one tool to requests that carry Authorization and +// answers 401 to the others, like muster. It counts both. +func bearerMCPServer(t *testing.T) (url string, authorized, unauthorized *atomic.Int32) { + t.Helper() + mcpServer := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "bearer-test", Version: "1.0.0"}, nil) + mcpsdk.AddTool(mcpServer, &mcpsdk.Tool{Name: "getWeather"}, func(context.Context, *mcpsdk.CallToolRequest, map[string]any) (*mcpsdk.CallToolResult, map[string]any, error) { + return nil, map[string]any{"weather": "sunny"}, nil + }) + handler := mcpsdk.NewStreamableHTTPHandler(func(*http.Request) *mcpsdk.Server { return mcpServer }, nil) + authorized, unauthorized = &atomic.Int32{}, &atomic.Int32{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + unauthorized.Add(1) + w.WriteHeader(http.StatusUnauthorized) + return + } + authorized.Add(1) + handler.ServeHTTP(w, r) + })) + t.Cleanup(func() { + // The lazily connected toolset keeps its standalone SSE stream open. + srv.CloseClientConnections() + srv.Close() + }) + return srv.URL, authorized, unauthorized +} + +// TestInitializeToolSetDefersDiscoveryUntilTheCallerCredential covers the +// runtime's boot (a golden boot has no person behind it): a server that takes +// the caller's token is not contacted, and the first turn discovers its tools +// with the caller's token. +func TestInitializeToolSetDefersDiscoveryUntilTheCallerCredential(t *testing.T) { + for name, params := range map[string]mcpServerParams{ + "propagated token": {PropagateToken: true}, + "Authorization allowed header": {AllowedHeaders: []string{"Authorization"}}, + } { + t.Run(name, func(t *testing.T) { + url, authorized, unauthorized := bearerMCPServer(t) + params.URL, params.ServerType = url, "http" + + toolset, err := initializeToolSet(t.Context(), params, nil) + if err != nil { + t.Fatalf("initializeToolSet() error = %v", err) + } + if n := authorized.Load() + unauthorized.Load(); n != 0 { + t.Fatalf("boot sent %d requests to the MCP server, want none", n) + } + + turn := a2aCtx(map[string][]string{"Authorization": {"Bearer person-token"}}) + tools, err := toolset.Tools(testReadonlyContext{Context: turn}) + if err != nil { + t.Fatalf("toolset.Tools() error = %v", err) + } + if len(tools) != 1 || tools[0].Name() != "getWeather" { + t.Fatalf("toolset.Tools() = %#v, want getWeather", tools) + } + if unauthorized.Load() != 0 || authorized.Load() == 0 { + t.Fatalf("turn sent %d authorized and %d unauthorized requests, want only authorized ones", authorized.Load(), unauthorized.Load()) + } + }) + } +} + +// TestInitializeToolSetClassifiesAtBootWithAStaticCredential keeps the eager +// classification for a server whose credential the boot already has. +func TestInitializeToolSetClassifiesAtBootWithAStaticCredential(t *testing.T) { + url, authorized, unauthorized := bearerMCPServer(t) + + _, err := initializeToolSet(t.Context(), mcpServerParams{ + URL: url, + ServerType: "http", + PropagateToken: true, + Headers: map[string]string{"Authorization": "Bearer static-token"}, + }, nil) + if err != nil { + t.Fatalf("initializeToolSet() error = %v", err) + } + if authorized.Load() == 0 || unauthorized.Load() != 0 { + t.Fatalf("boot sent %d authorized and %d unauthorized requests, want the authorized classification", authorized.Load(), unauthorized.Load()) + } +} + type testReadonlyContext struct { context.Context } diff --git a/go/adk/pkg/models/anthropic.go b/go/adk/pkg/models/anthropic.go index 50b8bf11a5..f7f5400e72 100644 --- a/go/adk/pkg/models/anthropic.go +++ b/go/adk/pkg/models/anthropic.go @@ -34,6 +34,15 @@ type AnthropicConfig struct { Temperature *float64 TopP *float64 TopK *int + // PromptCaching, when true, marks the last tool definition, the last system + // prompt block and the last block of the latest conversation turn with a + // cache_control breakpoint so Anthropic bills the stable prefix of an agent + // loop as a cache read instead of fresh input. See markAnthropicCacheBreakpoints. + PromptCaching bool + // CacheTTL selects the cache retention window when PromptCaching is on. + // "" or "5m" uses the API's default 5-minute cache; "1h" opts into the + // 1-hour cache, which is billed at a higher cache-write rate. See anthropicCacheControl. + CacheTTL string } // AnthropicModel implements model.LLM for Anthropic Claude models. diff --git a/go/adk/pkg/models/anthropic_adk.go b/go/adk/pkg/models/anthropic_adk.go index b1e04faa4a..d20d2c7ba0 100644 --- a/go/adk/pkg/models/anthropic_adk.go +++ b/go/adk/pkg/models/anthropic_adk.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "iter" + "slices" "strings" "github.com/anthropics/anthropic-sdk-go" @@ -39,38 +40,7 @@ func (m *AnthropicModel) Name() string { // GenerateContent implements model.LLM. Uses only ADK/genai types. func (m *AnthropicModel) GenerateContent(ctx context.Context, req *model.LLMRequest, stream bool) iter.Seq2[*model.LLMResponse, error] { return func(yield func(*model.LLMResponse, error) bool) { - messages, systemPrompt := genaiContentsToAnthropicMessages(req.Contents, req.Config) - // Always prefer config model - req.Model may contain the model type ("anthropic") instead of model name - modelName := m.Config.Model - if modelName == "" { - modelName = req.Model - } - if modelName == "" || modelName == "anthropic" { - modelName = "claude-sonnet-4-20250514" - } - - // Build request parameters - params := anthropic.MessageNewParams{ - Model: anthropic.Model(modelName), - Messages: messages, - } - - // Set max tokens (required for Anthropic) - maxTokens := int64(defaultAnthropicMaxTokens) - if m.Config.MaxTokens != nil { - maxTokens = int64(*m.Config.MaxTokens) - } - params.MaxTokens = maxTokens - - // Set system prompt if provided - if systemPrompt != "" { - params.System = []anthropic.TextBlockParam{ - {Text: systemPrompt}, - } - } - - // Apply config options - applyAnthropicConfig(¶ms, m.Config) + params := buildAnthropicParams(req, m.Config) if schema, err := structuredOutputSchema(req.Config); err != nil { yield(nil, err) return @@ -78,11 +48,6 @@ func (m *AnthropicModel) GenerateContent(ctx context.Context, req *model.LLMRequ params.OutputConfig.Format = anthropic.JSONOutputFormatParam{Schema: schema} } - // Add tools if provided - if req.Config != nil && len(req.Config.Tools) > 0 { - params.Tools = genaiToolsToAnthropicTools(req.Config.Tools) - } - if stream { runAnthropicStreaming(ctx, m, params, yield) } else { @@ -91,10 +56,46 @@ func (m *AnthropicModel) GenerateContent(ctx context.Context, req *model.LLMRequ } } -func applyAnthropicConfig(params *anthropic.MessageNewParams, cfg *AnthropicConfig) { +// buildAnthropicParams translates an ADK request into a Messages API request: +// model, system prompt, conversation, sampling options and tools, plus the +// prompt-cache breakpoints when cfg enables them. +func buildAnthropicParams(req *model.LLMRequest, cfg *AnthropicConfig) anthropic.MessageNewParams { if cfg == nil { - return + cfg = &AnthropicConfig{} } + messages, systemPrompt := genaiContentsToAnthropicMessages(req.Contents, req.Config) + + // Always prefer config model - req.Model may contain the model type ("anthropic") instead of model name + modelName := cfg.Model + if modelName == "" { + modelName = req.Model + } + if modelName == "" || modelName == "anthropic" { + modelName = "claude-sonnet-4-20250514" + } + + params := anthropic.MessageNewParams{ + Model: anthropic.Model(modelName), + Messages: messages, + MaxTokens: int64(defaultAnthropicMaxTokens), // required by the Messages API + } + if cfg.MaxTokens != nil { + params.MaxTokens = int64(*cfg.MaxTokens) + } + if systemPrompt != "" { + params.System = []anthropic.TextBlockParam{{Text: systemPrompt}} + } + applyAnthropicConfig(¶ms, cfg) + if req.Config != nil && len(req.Config.Tools) > 0 { + params.Tools = genaiToolsToAnthropicTools(req.Config.Tools) + } + if cfg.PromptCaching { + markAnthropicCacheBreakpoints(¶ms, anthropicCacheControl(cfg.CacheTTL)) + } + return params +} + +func applyAnthropicConfig(params *anthropic.MessageNewParams, cfg *AnthropicConfig) { if cfg.Temperature != nil { params.Temperature = anthropic.Float(*cfg.Temperature) } @@ -106,6 +107,69 @@ func applyAnthropicConfig(params *anthropic.MessageNewParams, cfg *AnthropicConf } } +// anthropicCacheControl builds the cache_control marker for the configured +// retention window. "" or "5m" leaves the TTL unset, which is the API's default +// 5-minute cache; "1h" opts into the 1-hour cache, which is billed at a higher +// cache-write rate (see v1alpha3.AnthropicConfig.CacheTTL for the trade-off). +func anthropicCacheControl(cacheTTL string) anthropic.CacheControlEphemeralParam { + control := anthropic.NewCacheControlEphemeralParam() + if cacheTTL == string(anthropic.CacheControlEphemeralTTLTTL1h) { + control.TTL = anthropic.CacheControlEphemeralTTLTTL1h + } + return control +} + +// markAnthropicCacheBreakpoints places the prompt-cache breakpoints on a request. +// +// The Messages API renders tools, then system, then messages, and caches the +// prefix up to each breakpoint, so marking the last tool definition, the last +// system block and the last block of the latest turn keeps the stable head of +// an agent loop cached while the conversation grows: each call reads the +// previous prefix from the cache and writes only the new turn. That uses three +// of the four breakpoints Anthropic allows per request. +// +// The conversation breakpoint walks back from the end because a turn can end +// in a block Anthropic refuses to cache (a thinking block, for which the SDK +// reports no cache_control slot). +func markAnthropicCacheBreakpoints(params *anthropic.MessageNewParams, control anthropic.CacheControlEphemeralParam) { + if n := len(params.Tools); n > 0 { + if slot := params.Tools[n-1].GetCacheControl(); slot != nil { + *slot = control + } + } + if n := len(params.System); n > 0 { + params.System[n-1].CacheControl = control + } + for _, message := range slices.Backward(params.Messages) { + for _, block := range slices.Backward(message.Content) { + if slot := block.GetCacheControl(); slot != nil { + *slot = control + return + } + } + } +} + +// anthropicUsageToGenai folds Anthropic usage into the GenAI shape. +// +// Anthropic reports tokens served from the prompt cache and tokens written to +// it in their own fields, disjoint from input_tokens, whereas GenAI expects one +// prompt count with the cached portion as a breakdown of it. Folding them in +// keeps PromptTokenCount equal to the prompt the model actually saw whether or +// not caching is on, and CachedContentTokenCount says how much of it was a +// cache read. +func anthropicUsageToGenai(usage anthropic.Usage) *genai.GenerateContentResponseUsageMetadata { + prompt := usage.InputTokens + usage.CacheReadInputTokens + usage.CacheCreationInputTokens + if prompt == 0 && usage.OutputTokens == 0 { + return nil + } + return &genai.GenerateContentResponseUsageMetadata{ + PromptTokenCount: int32(prompt), + CachedContentTokenCount: int32(usage.CacheReadInputTokens), + CandidatesTokenCount: int32(usage.OutputTokens), + } +} + func genaiContentsToAnthropicMessages(contents []*genai.Content, config *genai.GenerateContentConfig) ([]anthropic.MessageParam, string) { systemPrompt := mergeSystemInstructionFromConfig("", config) @@ -268,14 +332,16 @@ func runAnthropicStreaming(ctx context.Context, m *AnthropicModel, params anthro inputJSON string }) var stopReason anthropic.StopReason - var inputTokens, outputTokens int64 + // message_start carries the input-side counts (including the prompt-cache + // breakdown); message_delta carries the final output count. + var usage anthropic.Usage for stream.Next() { event := stream.Current() switch e := event.AsAny().(type) { case anthropic.MessageStartEvent: - inputTokens = e.Message.Usage.InputTokens + usage = e.Message.Usage case anthropic.ContentBlockStartEvent: idx := int(e.Index) if e.ContentBlock.Type == "tool_use" { @@ -312,7 +378,7 @@ func runAnthropicStreaming(ctx context.Context, m *AnthropicModel, params anthro } case anthropic.MessageDeltaEvent: stopReason = e.Delta.StopReason - outputTokens = e.Usage.OutputTokens + usage.OutputTokens = e.Usage.OutputTokens } } @@ -342,26 +408,36 @@ func runAnthropicStreaming(ctx context.Context, m *AnthropicModel, params anthro } } - var usage *genai.GenerateContentResponseUsageMetadata - if inputTokens > 0 || outputTokens > 0 { - usage = &genai.GenerateContentResponseUsageMetadata{ - PromptTokenCount: int32(inputTokens), - CandidatesTokenCount: int32(outputTokens), - } - } resp := &model.LLMResponse{ Partial: false, TurnComplete: true, FinishReason: anthropicStopReasonToGenai(stopReason), - UsageMetadata: usage, + UsageMetadata: anthropicUsageToGenai(usage), Content: &genai.Content{Role: string(genai.RoleModel), Parts: finalParts}, } _ = yield(resp, nil) } +// runAnthropicNonStreaming answers with one final response, but the Messages +// API call itself streams and the message is accumulated from its events. The +// SDK refuses a non-streaming call whose max_tokens could take longer than ten +// minutes to generate (more than 21,333 tokens, or a model's own non-streaming +// cap) before sending it, and a ModelConfig's maxTokens is sized for the +// agent's streamed replies: with maxTokens 32000 every non-streaming caller, +// the context compaction summarizer among them, failed with "streaming is +// required for operations that may take longer than 10 minutes". func runAnthropicNonStreaming(ctx context.Context, m *AnthropicModel, params anthropic.MessageNewParams, yield func(*model.LLMResponse, error) bool) { - message, err := m.Client.Messages.New(ctx, params, anthropicPassthroughOpts(ctx, m.Config)...) - if err != nil { + stream := m.Client.Messages.NewStreaming(ctx, params, anthropicPassthroughOpts(ctx, m.Config)...) + defer stream.Close() + + var message anthropic.Message + for stream.Next() { + if err := message.Accumulate(stream.Current()); err != nil { + yield(nil, fmt.Errorf("anthropic API error: %w", err)) + return + } + } + if err := stream.Err(); err != nil { yield(nil, fmt.Errorf("anthropic API error: %w", err)) return } @@ -387,20 +463,11 @@ func runAnthropicNonStreaming(ctx context.Context, m *AnthropicModel, params ant } } - // Build usage metadata - var usage *genai.GenerateContentResponseUsageMetadata - if message.Usage.InputTokens > 0 || message.Usage.OutputTokens > 0 { - usage = &genai.GenerateContentResponseUsageMetadata{ - PromptTokenCount: int32(message.Usage.InputTokens), - CandidatesTokenCount: int32(message.Usage.OutputTokens), - } - } - resp := &model.LLMResponse{ Partial: false, TurnComplete: true, FinishReason: anthropicStopReasonToGenai(message.StopReason), - UsageMetadata: usage, + UsageMetadata: anthropicUsageToGenai(message.Usage), Content: &genai.Content{Role: string(genai.RoleModel), Parts: parts}, } yield(resp, nil) diff --git a/go/adk/pkg/models/anthropic_adk_test.go b/go/adk/pkg/models/anthropic_adk_test.go index b73f53e249..dd08c2c5ff 100644 --- a/go/adk/pkg/models/anthropic_adk_test.go +++ b/go/adk/pkg/models/anthropic_adk_test.go @@ -6,12 +6,261 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" + "github.com/anthropics/anthropic-sdk-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "google.golang.org/adk/v2/model" "google.golang.org/genai" ) +// anthropicAgentLoopRequest is a typical agent-loop request: a system prompt, +// two tools, a completed tool call and a fresh user turn. +func anthropicAgentLoopRequest() *model.LLMRequest { + return &model.LLMRequest{ + Model: "anthropic", + Contents: []*genai.Content{ + {Role: "user", Parts: []*genai.Part{{Text: "list the pods"}}}, + {Role: "model", Parts: []*genai.Part{{FunctionCall: &genai.FunctionCall{ID: "call-1", Name: "list_pods", Args: map[string]any{}}}}}, + {Role: "user", Parts: []*genai.Part{{FunctionResponse: &genai.FunctionResponse{ID: "call-1", Name: "list_pods", Response: map[string]any{"result": "pod-a"}}}}}, + {Role: "user", Parts: []*genai.Part{{Text: "anything else?"}}}, + }, + Config: &genai.GenerateContentConfig{ + SystemInstruction: &genai.Content{Parts: []*genai.Part{{Text: "You are a Kubernetes assistant."}}}, + Tools: []*genai.Tool{{FunctionDeclarations: []*genai.FunctionDeclaration{ + {Name: "get_weather", Description: "lookup weather"}, + {Name: "list_pods", Description: "list pods"}, + }}}, + }, + } +} + +// wireRequest is the Messages API body as the SDK serializes it, so the tests +// assert what Anthropic receives rather than SDK-internal field layout. +type wireRequest struct { + System []map[string]any `json:"system"` + Tools []map[string]any `json:"tools"` + Messages []struct { + Role string `json:"role"` + Content []map[string]any `json:"content"` + } `json:"messages"` +} + +func decodeWireRequest(t *testing.T, body []byte) wireRequest { + t.Helper() + var req wireRequest + require.NoError(t, json.Unmarshal(body, &req)) + return req +} + +func marshalParams(t *testing.T, params anthropic.MessageNewParams) []byte { + t.Helper() + body, err := json.Marshal(params) + require.NoError(t, err) + return body +} + +func lastBlock(t *testing.T, blocks []map[string]any) map[string]any { + t.Helper() + require.NotEmpty(t, blocks) + return blocks[len(blocks)-1] +} + +func TestBuildAnthropicParamsWithoutPromptCachingSendsNoBreakpoints(t *testing.T) { + params := buildAnthropicParams(anthropicAgentLoopRequest(), &AnthropicConfig{Model: "claude-sonnet-4-6"}) + + body := marshalParams(t, params) + assert.NotContains(t, string(body), "cache_control") + wire := decodeWireRequest(t, body) + require.Len(t, wire.System, 1) + require.Len(t, wire.Tools, 2) + require.Len(t, wire.Messages, 4) +} + +func TestBuildAnthropicParamsPromptCachingMarksToolsSystemAndLatestTurn(t *testing.T) { + tests := []struct { + name string + cacheTTL string + want map[string]any + }{ + {name: "default TTL leaves ttl unset", cacheTTL: "", want: map[string]any{"type": "ephemeral"}}, + {name: `"5m" leaves ttl unset`, cacheTTL: "5m", want: map[string]any{"type": "ephemeral"}}, + {name: `"1h" requests the hour-long cache`, cacheTTL: "1h", want: map[string]any{"type": "ephemeral", "ttl": "1h"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + params := buildAnthropicParams(anthropicAgentLoopRequest(), &AnthropicConfig{Model: "claude-sonnet-4-6", PromptCaching: true, CacheTTL: tt.cacheTTL}) + + body := marshalParams(t, params) + assert.Equal(t, 3, strings.Count(string(body), `"cache_control"`), "one breakpoint each for tools, system and the latest turn: %s", body) + wire := decodeWireRequest(t, body) + + // Tools render first: the marker sits on the last definition so the whole list is one cached prefix. + assert.Equal(t, tt.want, lastBlock(t, wire.Tools)["cache_control"]) + assert.NotContains(t, wire.Tools[0], "cache_control", "earlier tools must stay unmarked") + assert.Equal(t, "list_pods", lastBlock(t, wire.Tools)["name"], "tool order must be preserved") + + assert.Equal(t, tt.want, lastBlock(t, wire.System)["cache_control"]) + + last := wire.Messages[len(wire.Messages)-1] + assert.Equal(t, "user", last.Role) + assert.Equal(t, tt.want, lastBlock(t, last.Content)["cache_control"]) + for _, message := range wire.Messages[:len(wire.Messages)-1] { + for _, block := range message.Content { + assert.NotContains(t, block, "cache_control", "only the latest turn carries the moving breakpoint") + } + } + }) + } +} + +func TestBuildAnthropicParamsPromptCachingMarksTrailingToolResult(t *testing.T) { + req := anthropicAgentLoopRequest() + req.Contents = req.Contents[:3] // the turn ends with the tool result + + params := buildAnthropicParams(req, &AnthropicConfig{Model: "claude-sonnet-4-6", PromptCaching: true}) + + wire := decodeWireRequest(t, marshalParams(t, params)) + last := wire.Messages[len(wire.Messages)-1] + block := lastBlock(t, last.Content) + assert.Equal(t, "tool_result", block["type"]) + assert.Equal(t, map[string]any{"type": "ephemeral"}, block["cache_control"]) +} + +func TestBuildAnthropicParamsPromptCachingWithoutToolsOrSystem(t *testing.T) { + req := &model.LLMRequest{Contents: []*genai.Content{{Role: "user", Parts: []*genai.Part{{Text: "hi"}}}}} + + params := buildAnthropicParams(req, &AnthropicConfig{Model: "claude-sonnet-4-6", PromptCaching: true}) + + body := marshalParams(t, params) + assert.Equal(t, 1, strings.Count(string(body), `"cache_control"`), "only the conversation breakpoint applies: %s", body) + wire := decodeWireRequest(t, body) + assert.Empty(t, wire.System) + assert.Empty(t, wire.Tools) + assert.Equal(t, map[string]any{"type": "ephemeral"}, lastBlock(t, wire.Messages[0].Content)["cache_control"]) +} + +func TestAnthropicUsageToGenai(t *testing.T) { + tests := []struct { + name string + usage anthropic.Usage + want *genai.GenerateContentResponseUsageMetadata + }{ + {name: "no usage", usage: anthropic.Usage{}, want: nil}, + { + name: "uncached", + usage: anthropic.Usage{InputTokens: 10, OutputTokens: 5}, + want: &genai.GenerateContentResponseUsageMetadata{PromptTokenCount: 10, CandidatesTokenCount: 5}, + }, + { + name: "cache read and write fold into the prompt count", + usage: anthropic.Usage{InputTokens: 4, CacheReadInputTokens: 900, CacheCreationInputTokens: 96, OutputTokens: 5}, + want: &genai.GenerateContentResponseUsageMetadata{PromptTokenCount: 1000, CachedContentTokenCount: 900, CandidatesTokenCount: 5}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, anthropicUsageToGenai(tt.usage)) + }) + } +} + +// anthropicTestServer serves canned Messages API responses and records the +// request body the SDK sent. +func anthropicTestServer(t *testing.T, handler func(w http.ResponseWriter)) (*AnthropicModel, *[]byte) { + t.Helper() + return anthropicTestServerWithConfig(t, &AnthropicConfig{Model: "claude-sonnet-4-6", PromptCaching: true}, handler) +} + +// anthropicTestServerWithConfig is anthropicTestServer for a model built from +// cfg, pointed at the test server. +func anthropicTestServerWithConfig(t *testing.T, cfg *AnthropicConfig, handler func(w http.ResponseWriter)) (*AnthropicModel, *[]byte) { + t.Helper() + var captured []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + captured = body + handler(w) + })) + t.Cleanup(server.Close) + + cfg.BaseUrl = server.URL + m, err := newAnthropicModelFromConfig(context.Background(), cfg, "test-key") + require.NoError(t, err) + return m, &captured +} + +// anthropicEventStream serves Messages API stream events the way the API does: +// one server-sent event per stream event, named after its type. +func anthropicEventStream(t *testing.T, events []string) func(w http.ResponseWriter) { + return func(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/event-stream") + for _, event := range events { + var envelope struct { + Type string `json:"type"` + } + require.NoError(t, json.Unmarshal([]byte(event), &envelope)) + _, _ = io.WriteString(w, "event: "+envelope.Type+"\ndata: "+event+"\n\n") + } + } +} + +// cachedReplyEvents stream the reply "pod-a" to a request that read 900 prompt +// tokens from the cache and wrote 96. +var cachedReplyEvents = []string{ + `{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-4-6","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":4,"cache_creation_input_tokens":96,"cache_read_input_tokens":900,"output_tokens":1}}}`, + `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, + `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pod-a"}}`, + `{"type":"content_block_stop","index":0}`, + `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":5}}`, + `{"type":"message_stop"}`, +} + +func finalResponse(t *testing.T, m *AnthropicModel, stream bool) *model.LLMResponse { + t.Helper() + var final *model.LLMResponse + for resp, err := range m.GenerateContent(context.Background(), anthropicAgentLoopRequest(), stream) { + require.NoError(t, err) + require.Empty(t, resp.ErrorMessage) + if resp.TurnComplete { + final = resp + } + } + require.NotNil(t, final, "no final response yielded") + return final +} + +func TestAnthropicNonStreamingCarriesBreakpointsAndCacheUsage(t *testing.T) { + m, captured := anthropicTestServer(t, anthropicEventStream(t, cachedReplyEvents)) + + final := finalResponse(t, m, false) + + wire := decodeWireRequest(t, *captured) + assert.Equal(t, map[string]any{"type": "ephemeral"}, lastBlock(t, wire.System)["cache_control"]) + assert.Equal(t, map[string]any{"type": "ephemeral"}, lastBlock(t, wire.Tools)["cache_control"]) + assert.Equal(t, map[string]any{"type": "ephemeral"}, lastBlock(t, wire.Messages[len(wire.Messages)-1].Content)["cache_control"]) + assert.Equal(t, &genai.GenerateContentResponseUsageMetadata{PromptTokenCount: 1000, CachedContentTokenCount: 900, CandidatesTokenCount: 5}, final.UsageMetadata) + require.Len(t, final.Content.Parts, 1) + assert.Equal(t, "pod-a", final.Content.Parts[0].Text) +} + +func TestAnthropicStreamingCarriesBreakpointsAndCacheUsage(t *testing.T) { + m, captured := anthropicTestServer(t, anthropicEventStream(t, cachedReplyEvents)) + + final := finalResponse(t, m, true) + + wire := decodeWireRequest(t, *captured) + assert.Equal(t, map[string]any{"type": "ephemeral"}, lastBlock(t, wire.System)["cache_control"]) + assert.Equal(t, map[string]any{"type": "ephemeral"}, lastBlock(t, wire.Tools)["cache_control"]) + assert.Equal(t, map[string]any{"type": "ephemeral"}, lastBlock(t, wire.Messages[len(wire.Messages)-1].Content)["cache_control"]) + assert.Equal(t, &genai.GenerateContentResponseUsageMetadata{PromptTokenCount: 1000, CachedContentTokenCount: 900, CandidatesTokenCount: 5}, final.UsageMetadata) + require.Len(t, final.Content.Parts, 1) + assert.Equal(t, "pod-a", final.Content.Parts[0].Text) +} + func TestAnthropicModelGenerateContentSendsStructuredOutputWithTools(t *testing.T) { var body map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -70,3 +319,73 @@ func TestAnthropicModelGenerateContentSendsStructuredOutputWithTools(t *testing. t.Fatalf("tools = %#v, want one tool", body["tools"]) } } + +// A ModelConfig's maxTokens is sized for the agent's streamed replies, and the +// SDK refuses a non-streaming call whose max_tokens is above 21,333 before +// sending it. A non-streaming caller, the context compaction summarizer among +// them, still gets its one final response: the call streams and the message is +// accumulated. +func TestAnthropicNonStreamingAnswersAboveTheSDKsNonStreamingMaxTokens(t *testing.T) { + events := []string{ + `{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-5","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":12,"output_tokens":1}}}`, + `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, + `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pod-a is "}}`, + `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"running."}}`, + `{"type":"content_block_stop","index":0}`, + `{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"list_pods","input":{}}}`, + `{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"namespace\":"}}`, + `{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"kagent\"}"}}`, + `{"type":"content_block_stop","index":1}`, + `{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":9}}`, + `{"type":"message_stop"}`, + } + maxTokens := 32000 + m, captured := anthropicTestServerWithConfig(t, &AnthropicConfig{Model: "claude-sonnet-5", MaxTokens: &maxTokens}, anthropicEventStream(t, events)) + + var responses []*model.LLMResponse + for resp, err := range m.GenerateContent(context.Background(), anthropicAgentLoopRequest(), false) { + require.NoError(t, err) + responses = append(responses, resp) + } + + var wire struct { + Stream bool `json:"stream"` + MaxTokens int `json:"max_tokens"` + } + require.NoError(t, json.Unmarshal(*captured, &wire)) + assert.True(t, wire.Stream) + assert.Equal(t, 32000, wire.MaxTokens) + + require.Len(t, responses, 1, "a non-streaming call yields its final response only") + final := responses[0] + assert.False(t, final.Partial) + assert.True(t, final.TurnComplete) + assert.Equal(t, genai.FinishReasonStop, final.FinishReason) + assert.Equal(t, &genai.GenerateContentResponseUsageMetadata{PromptTokenCount: 12, CandidatesTokenCount: 9}, final.UsageMetadata) + require.Len(t, final.Content.Parts, 2) + assert.Equal(t, "pod-a is running.", final.Content.Parts[0].Text) + call := final.Content.Parts[1].FunctionCall + require.NotNil(t, call) + assert.Equal(t, "toolu_1", call.ID) + assert.Equal(t, "list_pods", call.Name) + assert.Equal(t, map[string]any{"namespace": "kagent"}, call.Args) +} + +func TestAnthropicNonStreamingReportsAnAPIError(t *testing.T) { + m, _ := anthropicTestServer(t, func(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"type":"error","error":{"type":"invalid_request_error","message":"prompt is too long"}}`) + }) + + var errs []error + for resp, err := range m.GenerateContent(context.Background(), anthropicAgentLoopRequest(), false) { + assert.Nil(t, resp) + errs = append(errs, err) + } + + require.Len(t, errs, 1) + require.Error(t, errs[0]) + assert.Contains(t, errs[0].Error(), "anthropic API error") + assert.Contains(t, errs[0].Error(), "prompt is too long") +} diff --git a/go/adk/pkg/models/base.go b/go/adk/pkg/models/base.go index b8109db4db..043c4ef008 100644 --- a/go/adk/pkg/models/base.go +++ b/go/adk/pkg/models/base.go @@ -11,6 +11,8 @@ import ( "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/kagent-dev/kagent/go/adk/pkg/constants" + "github.com/kagent-dev/kagent/go/api/adk" + "github.com/kagent-dev/kagent/go/core/pkg/env" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "google.golang.org/genai" ) @@ -30,7 +32,8 @@ type TransportConfig struct { } // BuildHTTPClient creates an http.Client with the full transport stack: -// TLS → custom headers → trace propagation → timeout. +// TLS → headers (the configured defaults, the runtime's identity, the user of +// the turn) → trace propagation → timeout. func BuildHTTPClient(tc TransportConfig) (*http.Client, error) { transport, err := BuildTLSTransport( http.DefaultTransport, @@ -52,9 +55,7 @@ func BuildHTTPClient(tc TransportConfig) (*http.Client, error) { } } - if len(tc.Headers) > 0 { - transport = &headerTransport{base: transport, headers: tc.Headers} - } + transport = &headerTransport{base: transport, headers: tc.Headers, identity: runtimeIdentityHeaders()} // Outermost layer: inject W3C traceparent/tracestate from the active span so // LLM calls stay attached to the invocation trace instead of starting fresh @@ -141,10 +142,73 @@ func BearerTokenFromContext(ctx context.Context) string { return BearerFromCallContext(ctx) } -// headerTransport wraps an http.RoundTripper and adds custom headers to all requests +// userKey is the context key of the authenticated user of a turn: its own +// type, so it can never be equal to another key. A pointer to a zero-size +// struct (BearerTokenKey above is one) is not such a key: the runtime may +// place two zero-size variables at the same address, and the user's slot then +// reads whatever the other key stored — the caller's bearer token. +type userKey struct{} + +// WithUser returns a copy of ctx that carries the authenticated user of the +// turn every model call made under ctx belongs to; the call sends it as the +// x-kagent-user header. The A2A server sets it from the metadata of the same +// name the gateway forwards once it has resolved the caller's identity from a +// validated token; a turn without one carries nothing and its calls send no +// such header. +func WithUser(ctx context.Context, user string) context.Context { + return context.WithValue(ctx, userKey{}, user) +} + +// UserFromContext returns the user set by WithUser, or "" when there is none. +func UserFromContext(ctx context.Context) string { + user, _ := ctx.Value(userKey{}).(string) + return user +} + +// agentInstanceKey is the context key of the AgentInstance a turn runs in, +// its own type for the reason given at userKey. +type agentInstanceKey struct{} + +// WithAgentInstance returns a copy of ctx that carries the id of the +// AgentInstance (the session) the turn every model call made under ctx belongs +// to runs in; the call sends it as the x-kagent-agent-instance-id header. The +// A2A server sets it from the metadata of the same name the gateway forwards. +func WithAgentInstance(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, agentInstanceKey{}, id) +} + +// AgentInstanceFromContext returns the id set by WithAgentInstance, or "" when +// there is none. +func AgentInstanceFromContext(ctx context.Context) string { + id, _ := ctx.Value(agentInstanceKey{}).(string) + return id +} + +// runtimeIdentityHeaders is the identity the controller injected into this +// runtime — the name of the AgentTemplate it executes (KAGENT_AGENT_TEMPLATE) +// and its namespace (KAGENT_NAMESPACE) — as the headers every model call +// carries. A runtime the controller did not start (a BYO build, a test) has no +// identity and sends none; half an identity is none. +func runtimeIdentityHeaders() map[string]string { + agent, hasAgent := env.KagentAgentTemplate.Lookup() + namespace, hasNamespace := env.KagentNamespace.Lookup() + if !hasAgent || !hasNamespace || agent == "" || namespace == "" { + return nil + } + return map[string]string{adk.AgentHeader: agent, adk.AgentNamespaceHeader: namespace} +} + +// headerTransport wraps an http.RoundTripper and sets headers on every request: +// the configured default headers, then the runtime's identity — which therefore +// wins over a configured header of the same name, so a model configuration +// cannot name another agent — then the user of the turn the request belongs to +// and the AgentInstance it runs in. The instance header is the turn's alone: a +// request outside a turn sends none, whatever the configuration sets, so a +// model configuration cannot name a session either. type headerTransport struct { - base http.RoundTripper - headers map[string]string + base http.RoundTripper + headers map[string]string + identity map[string]string } func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) { @@ -152,6 +216,17 @@ func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) { for k, v := range t.headers { req.Header.Set(k, v) } + for k, v := range t.identity { + req.Header.Set(k, v) + } + if user := UserFromContext(req.Context()); user != "" { + req.Header.Set(adk.UserHeader, user) + } + if instance := AgentInstanceFromContext(req.Context()); instance != "" { + req.Header.Set(adk.AgentInstanceHeader, instance) + } else { + req.Header.Del(adk.AgentInstanceHeader) + } return t.base.RoundTrip(req) } diff --git a/go/adk/pkg/models/identity_headers_test.go b/go/adk/pkg/models/identity_headers_test.go new file mode 100644 index 0000000000..a0a62797f9 --- /dev/null +++ b/go/adk/pkg/models/identity_headers_test.go @@ -0,0 +1,161 @@ +// Copyright 2026 The kagent Authors +// SPDX-License-Identifier: Apache-2.0 + +package models + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/kagent-dev/kagent/go/api/adk" + "github.com/kagent-dev/kagent/go/core/pkg/env" +) + +// headersSeenBy sends one request under ctx through a BuildHTTPClient client +// and returns the headers the server received. +func headersSeenBy(t *testing.T, ctx context.Context, tc TransportConfig) http.Header { + t.Helper() + var got http.Header + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Clone() + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + client, err := BuildHTTPClient(tc) + if err != nil { + t.Fatalf("BuildHTTPClient: %v", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL, nil) + if err != nil { + t.Fatal(err) + } + resp, err := client.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + _ = resp.Body.Close() + return got +} + +// unsetenv removes name for the test and restores it afterwards; t.Setenv can +// only set a value, and an empty value is not the same as an absent variable. +func unsetenv(t *testing.T, name string) { + t.Helper() + t.Setenv(name, "") + if err := os.Unsetenv(name); err != nil { + t.Fatal(err) + } +} + +// Every model call names the agent the runtime executes and the authenticated +// user of the turn, whatever the model's own default headers say. +func TestBuildHTTPClient_IdentifiesTheAgentAndTheUser(t *testing.T) { + t.Setenv(env.KagentAgentTemplate.Name(), "assistant") + t.Setenv(env.KagentNamespace.Name(), "team-a") + ctx := WithUser(t.Context(), "alice@example.com") + got := headersSeenBy(t, ctx, TransportConfig{Headers: map[string]string{ + "X-Custom": "1", + adk.AgentHeader: "impostor", // a model configuration cannot name another agent + }}) + want := map[string]string{ + "X-Custom": "1", + adk.AgentHeader: "assistant", + adk.AgentNamespaceHeader: "team-a", + adk.UserHeader: "alice@example.com", + } + for name, value := range want { + if got.Get(name) != value { + t.Errorf("header %s = %q, want %q", name, got.Get(name), value) + } + } +} + +// The user of the turn and the caller's bearer token live in the same context; +// the user's slot must never read the token (a pointer to a zero-size struct +// shared an address with BearerTokenKey once, and the raw id token of the turn +// went out as x-kagent-user). +func TestWithUserDoesNotShareTheBearerTokenSlot(t *testing.T) { + ctx := WithUser(t.Context(), "alice@example.com") + ctx = context.WithValue(ctx, BearerTokenKey, "eyJ.the-callers-token") + if got := UserFromContext(ctx); got != "alice@example.com" { + t.Fatalf("UserFromContext = %q, want the user, not the bearer", got) + } + if token, ok := PassthroughToken(ctx, true); !ok || token != "eyJ.the-callers-token" { + t.Fatalf("PassthroughToken = %q, %v; want the bearer", token, ok) + } + if got := UserFromContext(context.WithValue(t.Context(), BearerTokenKey, "eyJ.only-a-token")); got != "" { + t.Fatalf("UserFromContext with only a bearer = %q, want none", got) + } +} + +// A header the runtime cannot fill is absent, never a stand-in: no identity +// without the controller's variables (both of them), no user outside an +// authenticated turn. +func TestBuildHTTPClient_SendsNoIdentityItDoesNotHave(t *testing.T) { + identity := []string{adk.AgentHeader, adk.AgentNamespaceHeader} + tests := []struct { + name string + agent, ns string + user string + wantAgent bool + wantUserValue string + }{ + {name: "runtime without a controller", wantAgent: false}, + {name: "template without a namespace", agent: "assistant", wantAgent: false}, + {name: "namespace without a template", ns: "team-a", wantAgent: false}, + {name: "turn without an authenticated user", agent: "assistant", ns: "team-a", wantAgent: true}, + {name: "authenticated turn", agent: "assistant", ns: "team-a", user: "alice@example.com", wantAgent: true, wantUserValue: "alice@example.com"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for name, value := range map[string]string{env.KagentAgentTemplate.Name(): tt.agent, env.KagentNamespace.Name(): tt.ns} { + if value == "" { + unsetenv(t, name) + } else { + t.Setenv(name, value) + } + } + ctx := t.Context() + if tt.user != "" { + ctx = WithUser(ctx, tt.user) + } + got := headersSeenBy(t, ctx, TransportConfig{}) + for _, name := range identity { + if _, present := got[http.CanonicalHeaderKey(name)]; present != tt.wantAgent { + t.Errorf("header %s present = %v, want %v (value %q)", name, present, tt.wantAgent, got.Get(name)) + } + } + if got.Get(adk.UserHeader) != tt.wantUserValue { + t.Errorf("header %s = %q, want %q", adk.UserHeader, got.Get(adk.UserHeader), tt.wantUserValue) + } + }) + } +} + +// Every model call of a turn names the AgentInstance the turn runs in; a call +// outside a turn names none, even when the model configuration sets one. +func TestBuildHTTPClient_NamesTheSessionOfTheTurnOnly(t *testing.T) { + configured := TransportConfig{Headers: map[string]string{adk.AgentInstanceHeader: "impostor"}} + tests := []struct { + name string + instance string + }{ + {name: "turn in an instance", instance: "0199a5f0-7c1e-7d4b-9a43-6f1e2b3c4d5e"}, + {name: "call outside a turn"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := t.Context() + if tt.instance != "" { + ctx = WithAgentInstance(ctx, tt.instance) + } + got := headersSeenBy(t, ctx, configured) + if values := got.Values(adk.AgentInstanceHeader); tt.instance == "" && len(values) != 0 || tt.instance != "" && (len(values) != 1 || values[0] != tt.instance) { + t.Errorf("header %s = %q, want %q", adk.AgentInstanceHeader, values, tt.instance) + } + }) + } +} diff --git a/go/adk/pkg/models/ollama.go b/go/adk/pkg/models/ollama.go index 424e7929df..1289871f9a 100644 --- a/go/adk/pkg/models/ollama.go +++ b/go/adk/pkg/models/ollama.go @@ -20,6 +20,7 @@ type OllamaConfig struct { Host string // Ollama server host (e.g., http://localhost:11434) APIKey string // Gateway placeholder for Ollama Cloud; empty for a local daemon Options map[string]string // Ollama-specific options (temperature, top_p, num_ctx, etc.) + Think *bool // The chat request's think field; nil leaves the model's default } // Ollama's two endpoints, mirroring the routing rules in pi-go. Which one a @@ -32,7 +33,7 @@ const ( // nonce, and returns the signing error before the request leaves the // process when that key is absent — as it is in an agent pod. api.ollama.com // misses that special case, so the Bearer token we set is the one used. - ollamaCloudURL = "https://api.ollama.com" + OllamaCloudURL = "https://api.ollama.com" ) // OllamaCloudModels names the models served by api.ollama.com, as returned by @@ -164,7 +165,7 @@ func resolveOllamaEndpoint(config *OllamaConfig, host string) string { return host } if OllamaReachesCloud(config.Model, host, config.APIKey != "") { - return ollamaCloudURL + return OllamaCloudURL } return ollamaLocalURL } diff --git a/go/adk/pkg/models/ollama_adk.go b/go/adk/pkg/models/ollama_adk.go index ecb19dd9b1..1e588aa9ab 100644 --- a/go/adk/pkg/models/ollama_adk.go +++ b/go/adk/pkg/models/ollama_adk.go @@ -48,30 +48,29 @@ func (m *OllamaModel) GenerateContent(ctx context.Context, req *model.LLMRequest tools = convertGenaiToolsToOllama(req.Config.Tools) } + chatReq := &api.ChatRequest{ + Model: modelName, + Messages: messages, + Tools: tools, + Options: options, + Stream: &stream, + Think: ollamaThink(m.Config.Think), + } if stream { - m.generateStreaming(ctx, modelName, messages, tools, options, yield) + m.generateStreaming(ctx, chatReq, yield) } else { - m.generateNonStreaming(ctx, modelName, messages, tools, options, yield) + m.generateNonStreaming(ctx, chatReq, yield) } } } // generateStreaming handles streaming responses from Ollama. -func (m *OllamaModel) generateStreaming(ctx context.Context, modelName string, messages []api.Message, tools []api.Tool, options map[string]any, yield func(*model.LLMResponse, error) bool) { +func (m *OllamaModel) generateStreaming(ctx context.Context, chatReq *api.ChatRequest, yield func(*model.LLMResponse, error) bool) { var aggregatedText strings.Builder // Ollama streams tool calls in intermediate chunks (done=false), not in the // final done=true chunk, so accumulate them across all chunks. var aggregatedToolCalls []api.ToolCall - streamValue := true - chatReq := &api.ChatRequest{ - Model: modelName, - Messages: messages, - Tools: tools, - Options: options, - Stream: &streamValue, - } - err := m.Client.Chat(ctx, chatReq, func(resp api.ChatResponse) error { // Accumulate tool calls from any chunk that carries them. aggregatedToolCalls = append(aggregatedToolCalls, resp.Message.ToolCalls...) @@ -160,16 +159,7 @@ func (m *OllamaModel) generateStreaming(ctx context.Context, modelName string, m } // generateNonStreaming handles non-streaming responses from Ollama. -func (m *OllamaModel) generateNonStreaming(ctx context.Context, modelName string, messages []api.Message, tools []api.Tool, options map[string]any, yield func(*model.LLMResponse, error) bool) { - streamValue := false - chatReq := &api.ChatRequest{ - Model: modelName, - Messages: messages, - Tools: tools, - Options: options, - Stream: &streamValue, - } - +func (m *OllamaModel) generateNonStreaming(ctx context.Context, chatReq *api.ChatRequest, yield func(*model.LLMResponse, error) bool) { var finalResponse api.ChatResponse err := m.Client.Chat(ctx, chatReq, func(resp api.ChatResponse) error { finalResponse = resp @@ -234,6 +224,16 @@ func (m *OllamaModel) generateNonStreaming(ctx context.Context, modelName string yield(response, nil) } +// ollamaThink is the chat request's think field for a configured switch. Nil +// leaves the field out, so Ollama applies its default: a model with the thinking +// capability thinks. +func ollamaThink(think *bool) *api.ThinkValue { + if think == nil { + return nil + } + return &api.ThinkValue{Value: *think} +} + // convertGenaiContentsToOllamaMessages converts genai.Content to Ollama message format. // Returns messages and system instruction (extracted from system role content). func convertGenaiContentsToOllamaMessages(contents []*genai.Content, config *genai.GenerateContentConfig) ([]api.Message, string) { diff --git a/go/adk/pkg/models/ollama_routing_test.go b/go/adk/pkg/models/ollama_routing_test.go index 2b2409ea7e..e036af3a2b 100644 --- a/go/adk/pkg/models/ollama_routing_test.go +++ b/go/adk/pkg/models/ollama_routing_test.go @@ -28,11 +28,11 @@ func TestResolveOllamaEndpoint(t *testing.T) { name: "local model with a key exported stays local", model: "qwen3.8:27b-mlx", apiKey: key, want: ollamaLocalURL, }, - {name: "cloud tag with a key goes to the cloud", model: "minimax-m3:cloud", apiKey: key, want: ollamaCloudURL}, + {name: "cloud tag with a key goes to the cloud", model: "minimax-m3:cloud", apiKey: key, want: OllamaCloudURL}, { // The form most of the cloud catalog uses. name: "sized cloud tag goes to the cloud", model: "deepseek-v4-flash:0731-cloud", - apiKey: key, want: ollamaCloudURL, + apiKey: key, want: OllamaCloudURL, }, { // Unauthenticated api.ollama.com answers 401 before it looks at the @@ -119,7 +119,7 @@ func TestOllamaReachesCloud(t *testing.T) { func TestIsOllamaCloudEndpoint(t *testing.T) { cloud := []string{ - ollamaCloudURL, + OllamaCloudURL, "https://api.ollama.com/", "https://ollama.com", "api.ollama.com", // no scheme, as a host is often written @@ -161,15 +161,15 @@ func TestWithOllamaAuthorization(t *testing.T) { headers map[string]string want string }{ - {name: "cloud endpoint takes the placeholder", host: ollamaCloudURL, apiKey: "placeholder", want: "Bearer placeholder"}, + {name: "cloud endpoint takes the placeholder", host: OllamaCloudURL, apiKey: "placeholder", want: "Bearer placeholder"}, {name: "bare cloud host takes the placeholder", host: "api.ollama.com", apiKey: "placeholder", want: "Bearer placeholder"}, - {name: "cloud endpoint without a key sends nothing", host: ollamaCloudURL}, + {name: "cloud endpoint without a key sends nothing", host: OllamaCloudURL}, {name: "local daemon never takes the key", host: ollamaLocalURL, apiKey: "placeholder"}, {name: "operator host never takes the key", host: "http://gpu-box.lan:11434", apiKey: "placeholder"}, { // The placeholder is the credential; a stale default header would // silently replace it. - name: "placeholder replaces a stale authorization", host: ollamaCloudURL, apiKey: "placeholder", + name: "placeholder replaces a stale authorization", host: OllamaCloudURL, apiKey: "placeholder", headers: map[string]string{"Authorization": "stale"}, want: "Bearer placeholder", }, } @@ -201,7 +201,7 @@ func TestWithOllamaAuthorizationReachesTheWire(t *testing.T) { })) defer server.Close() - client, err := BuildHTTPClient(withOllamaAuthorization(TransportConfig{}, ollamaCloudURL, "placeholder")) + client, err := BuildHTTPClient(withOllamaAuthorization(TransportConfig{}, OllamaCloudURL, "placeholder")) if err != nil { t.Fatalf("BuildHTTPClient: %v", err) } diff --git a/go/adk/pkg/models/ollama_think_test.go b/go/adk/pkg/models/ollama_think_test.go new file mode 100644 index 0000000000..a9ecbb9c9e --- /dev/null +++ b/go/adk/pkg/models/ollama_think_test.go @@ -0,0 +1,97 @@ +package models + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/ollama/ollama/api" + "google.golang.org/adk/v2/model" + "google.golang.org/genai" +) + +// TestGenerateContentSendsThink verifies the configured think switch reaches the +// chat request on both the streaming and the non-streaming path, and that an +// unset switch sends no think field, so Ollama keeps the model's default. +func TestGenerateContentSendsThink(t *testing.T) { + off, on := false, true + tests := []struct { + name string + think *bool + wantThink any // the decoded JSON value; nil when the field must be absent + }{ + {name: "unset sends no think field", think: nil, wantThink: nil}, + {name: "false turns thinking off", think: &off, wantThink: false}, + {name: "true turns thinking on", think: &on, wantThink: true}, + } + + for _, tt := range tests { + for _, stream := range []bool{true, false} { + name := tt.name + "/non-streaming" + if stream { + name = tt.name + "/streaming" + } + t.Run(name, func(t *testing.T) { + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read request body: %v", err) + } + if err := json.Unmarshal(raw, &body); err != nil { + t.Errorf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/x-ndjson") + _, _ = w.Write([]byte(`{"model":"qwen3.5:2b","message":{"role":"assistant","content":"hi"},"done":true,"done_reason":"stop"}` + "\n")) + })) + defer srv.Close() + + baseURL, err := url.Parse(srv.URL) + if err != nil { + t.Fatalf("parse url: %v", err) + } + m := &OllamaModel{ + Config: &OllamaConfig{Model: "qwen3.5:2b", Think: tt.think}, + Client: api.NewClient(baseURL, http.DefaultClient), + } + req := &model.LLMRequest{ + Contents: []*genai.Content{{Role: "user", Parts: []*genai.Part{{Text: "hello"}}}}, + } + + var text string + for resp, err := range m.GenerateContent(context.Background(), req, stream) { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.ErrorMessage != "" { + t.Fatalf("unexpected response error: %s", resp.ErrorMessage) + } + if resp.TurnComplete && resp.Content != nil && len(resp.Content.Parts) > 0 { + text = resp.Content.Parts[0].Text + } + } + if text != "hi" { + t.Errorf("final text = %q, want %q", text, "hi") + } + + got, present := body["think"] + if tt.wantThink == nil { + if present { + t.Errorf("request carries think = %v, want no think field", got) + } + return + } + if got != tt.wantThink { + t.Errorf("request think = %v (present %v), want %v", got, present, tt.wantThink) + } + if body["stream"] != stream { + t.Errorf("request stream = %v, want %v", body["stream"], stream) + } + }) + } + } +} diff --git a/go/adk/pkg/skillfs/lenient.go b/go/adk/pkg/skillfs/lenient.go new file mode 100644 index 0000000000..87afe2202a --- /dev/null +++ b/go/adk/pkg/skillfs/lenient.go @@ -0,0 +1,180 @@ +// Package skillfs adapts a skills directory for the Go ADK's skill loader. +// +// The ADK's skill.Parse decodes a SKILL.md frontmatter with KnownFields and +// knows the agentskills.io fields only: name, description, license, +// compatibility, metadata and allowed-tools. Skills written for other harnesses +// carry more — Claude Code's user-invocable, argument-hint and +// disable-model-invocation, a top-level version — and one such field failed the +// whole agent ("field user-invocable not found in type skill.Frontmatter"), +// while the same skill loaded on the Python runtime (whose Frontmatter model +// allows extra fields) and in Claude Code. Lenient serves every SKILL.md with +// its frontmatter reduced to the fields the ADK knows, so the ADK's parser, +// validation and resource rules stay the single authority on what a skill is. +package skillfs + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "io/fs" + "path" + "strings" + + "gopkg.in/yaml.v3" +) + +// skillFile is the skill definition the ADK reads from every skill directory. +const skillFile = "SKILL.md" + +// knownFields are the frontmatter fields skill.Frontmatter decodes: +// https://agentskills.io/specification#frontmatter. +var knownFields = map[string]bool{ + "name": true, "description": true, "license": true, "compatibility": true, "metadata": true, "allowed-tools": true, +} + +// Lenient returns a filesystem that serves fsys unchanged, except that the +// SKILL.md at the root of every skill directory carries only the frontmatter +// fields the ADK knows. A SKILL.md the filter cannot read as frontmatter (no +// opening or closing separator, YAML that does not parse, a document that is +// not a mapping) is served unchanged, so the ADK reports it the way it always +// did. +func Lenient(fsys fs.FS) fs.FS { + return &lenientFS{fsys: fsys} +} + +type lenientFS struct { + fsys fs.FS +} + +// Open serves a skill's SKILL.md with its frontmatter filtered and every other +// path as fsys does. Directory listings come from fsys, so a listed SKILL.md +// entry reports the source file's size; the ADK reads sizes from neither. +func (l *lenientFS) Open(name string) (fs.File, error) { + file, err := l.fsys.Open(name) + if err != nil || !isSkillFile(name) { + return file, err + } + info, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, err + } + if info.IsDir() { + return file, nil + } + content, err := io.ReadAll(file) + _ = file.Close() + if err != nil { + return nil, fmt.Errorf("read %q: %w", name, err) + } + filtered, err := DropUnknownFields(content) + if err != nil { + // Not frontmatter the filter understands: the ADK's own parser + // reports it. + filtered = content + } + return &memFile{Reader: bytes.NewReader(filtered), info: fileInfo{FileInfo: info, size: int64(len(filtered))}}, nil +} + +// isSkillFile reports whether name is the SKILL.md at the root of a skill +// directory (an immediate subdirectory of the filesystem root). +func isSkillFile(name string) bool { + dir, base := path.Split(name) + dir = path.Clean(dir) + return base == skillFile && dir != "." && dir != "/" && !strings.Contains(dir, "/") +} + +var ( + separator = []byte("---\n") + separatorWin = []byte("---\r\n") + + errNotFrontmatter = errors.New("not a SKILL.md frontmatter") +) + +func isSeparator(line []byte) bool { + return bytes.Equal(line, separator) || bytes.Equal(line, separatorWin) +} + +// DropUnknownFields returns content with every top-level frontmatter field the +// ADK does not know removed. Content whose frontmatter has no unknown field is +// returned as is. The frontmatter must be a YAML mapping between two separator +// lines, the way skill.Parse reads it; anything else is an error and the +// caller serves the content unchanged. +func DropUnknownFields(content []byte) ([]byte, error) { + reader := bufio.NewReader(bytes.NewReader(content)) + line, err := reader.ReadBytes('\n') + if err != nil || !isSeparator(line) { + return nil, errNotFrontmatter + } + opening := line + var frontmatter bytes.Buffer + for { + line, err = reader.ReadBytes('\n') + if err != nil { + return nil, errNotFrontmatter + } + if isSeparator(line) { + break + } + frontmatter.Write(line) + } + closing := line + + var document yaml.Node + if err := yaml.Unmarshal(frontmatter.Bytes(), &document); err != nil { + return nil, fmt.Errorf("%w: %v", errNotFrontmatter, err) + } + if document.Kind != yaml.DocumentNode || len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode { + return nil, errNotFrontmatter + } + mapping := document.Content[0] + kept := mapping.Content[:0:0] + for i := 0; i+1 < len(mapping.Content); i += 2 { + if knownFields[mapping.Content[i].Value] { + kept = append(kept, mapping.Content[i], mapping.Content[i+1]) + } + } + if len(kept) == len(mapping.Content) { + return content, nil + } + mapping.Content = kept + + body, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + var out bytes.Buffer + out.Write(opening) + if len(kept) > 0 { + encoder := yaml.NewEncoder(&out) + encoder.SetIndent(2) + if err := encoder.Encode(&document); err != nil { + return nil, fmt.Errorf("encode frontmatter: %w", err) + } + if err := encoder.Close(); err != nil { + return nil, fmt.Errorf("encode frontmatter: %w", err) + } + } + out.Write(closing) + out.Write(body) + return out.Bytes(), nil +} + +// memFile is a read-only fs.File over filtered content. +type memFile struct { + *bytes.Reader + info fileInfo +} + +func (f *memFile) Stat() (fs.FileInfo, error) { return f.info, nil } +func (f *memFile) Close() error { return nil } + +// fileInfo reports the source file's metadata with the filtered size. +type fileInfo struct { + fs.FileInfo + size int64 +} + +func (i fileInfo) Size() int64 { return i.size } diff --git a/go/adk/pkg/skillfs/lenient_test.go b/go/adk/pkg/skillfs/lenient_test.go new file mode 100644 index 0000000000..2924da5797 --- /dev/null +++ b/go/adk/pkg/skillfs/lenient_test.go @@ -0,0 +1,163 @@ +package skillfs + +import ( + "context" + "errors" + "io/fs" + "strings" + "testing" + "testing/fstest" + + "google.golang.org/adk/v2/tool/skilltoolset/skill" +) + +const body = "\n# Deploy\n\nRun the deploy script.\n" + +func skillMD(frontmatter string) *fstest.MapFile { + return &fstest.MapFile{Data: []byte("---\n" + frontmatter + "---\n" + body)} +} + +func TestLenientLoadsSkillsWithFieldsOutsideTheSpec(t *testing.T) { + fsys := Lenient(fstest.MapFS{ + "deploy/SKILL.md": skillMD("name: deploy\ndescription: Deploys the service.\nuser-invocable: false\nargument-hint: '[environment]'\nversion: 0.1.0\nmetadata:\n author: platform\n"), + "review/SKILL.md": skillMD("name: review\ndescription: Reviews a change.\nallowed-tools:\n - Read\n"), + "deploy/scripts/run.sh": {Data: []byte("#!/bin/sh\n")}, + "README.md": {Data: []byte("not a skill\n")}, + }) + source := skill.NewFileSystemSource(fsys) + + frontmatters, err := source.ListFrontmatters(context.Background()) + if err != nil { + t.Fatalf("ListFrontmatters: %v", err) + } + if len(frontmatters) != 2 { + t.Fatalf("got %d skills, want 2", len(frontmatters)) + } + deploy, err := source.LoadFrontmatter(context.Background(), "deploy") + if err != nil { + t.Fatalf("LoadFrontmatter(deploy): %v", err) + } + if deploy.Name != "deploy" || deploy.Description != "Deploys the service." || deploy.Metadata["author"] != "platform" { + t.Errorf("known fields lost: %+v", deploy) + } + instructions, err := source.LoadInstructions(context.Background(), "deploy") + if err != nil { + t.Fatalf("LoadInstructions(deploy): %v", err) + } + if instructions != body { + t.Errorf("instructions = %q, want %q", instructions, body) + } + resources, err := source.ListResources(context.Background(), "deploy", "") + if err != nil || len(resources) != 1 || resources[0] != "scripts/run.sh" { + t.Errorf("ListResources = %v, %v", resources, err) + } +} + +func TestLenientKeepsTheADKsValidation(t *testing.T) { + for name, tc := range map[string]struct { + files fstest.MapFS + wantErr error + }{ + "missing description": { + files: fstest.MapFS{"deploy/SKILL.md": skillMD("name: deploy\nuser-invocable: false\n")}, + wantErr: skill.ErrInvalidFrontmatter, + }, + "only unknown fields": { + files: fstest.MapFS{"deploy/SKILL.md": skillMD("user-invocable: false\nversion: 1\n")}, + wantErr: skill.ErrInvalidFrontmatter, + }, + "name does not match the directory": { + files: fstest.MapFS{"deploy/SKILL.md": skillMD("name: ship\ndescription: Ships.\nversion: 1\n")}, + wantErr: skill.ErrInvalidSkillName, + }, + "no closing separator": { + files: fstest.MapFS{"deploy/SKILL.md": {Data: []byte("---\nname: deploy\ndescription: Deploys.\nversion: 1\n")}}, + wantErr: skill.ErrInvalidFrontmatter, + }, + "frontmatter is not a mapping": { + files: fstest.MapFS{"deploy/SKILL.md": skillMD("- name: deploy\n")}, + wantErr: skill.ErrInvalidFrontmatter, + }, + } { + t.Run(name, func(t *testing.T) { + _, err := skill.NewFileSystemSource(Lenient(tc.files)).ListFrontmatters(context.Background()) + if !errors.Is(err, tc.wantErr) { + t.Fatalf("err = %v, want %v", err, tc.wantErr) + } + }) + } +} + +func TestDropUnknownFields(t *testing.T) { + for name, tc := range map[string]struct { + in, want string + }{ + "unknown fields are dropped, the rest is kept in order": { + in: "---\nname: deploy\nuser-invocable: false\ndescription: Deploys.\nversion: 0.1.0\n---\nbody\n", + want: "---\nname: deploy\ndescription: Deploys.\n---\nbody\n", + }, + "nothing to drop is returned as is": { + in: "---\nname: deploy\ndescription: Deploys.\n---\nbody\n", + want: "---\nname: deploy\ndescription: Deploys.\n---\nbody\n", + }, + "windows line endings": { + in: "---\r\nname: deploy\r\ndescription: Deploys.\r\nversion: 1\r\n---\r\nbody\r\n", + want: "---\r\nname: deploy\ndescription: Deploys.\n---\r\nbody\r\n", + }, + "the body keeps its own separators": { + in: "---\nname: deploy\ndescription: Deploys.\nversion: 1\n---\n\n---\nnot frontmatter\n", + want: "---\nname: deploy\ndescription: Deploys.\n---\n\n---\nnot frontmatter\n", + }, + "nested unknown keys under metadata are kept": { + in: "---\nname: deploy\ndescription: Deploys.\nmetadata:\n version: 0.1.0\nversion: 1\n---\n", + want: "---\nname: deploy\ndescription: Deploys.\nmetadata:\n version: 0.1.0\n---\n", + }, + } { + t.Run(name, func(t *testing.T) { + got, err := DropUnknownFields([]byte(tc.in)) + if err != nil { + t.Fatal(err) + } + if string(got) != tc.want { + t.Fatalf("got\n%s\nwant\n%s", got, tc.want) + } + }) + } +} + +func TestDropUnknownFieldsRefusesWhatIsNotFrontmatter(t *testing.T) { + for name, in := range map[string]string{ + "no opening separator": "name: deploy\n---\n", + "no closing separator": "---\nname: deploy\n", + "not a mapping": "---\n- deploy\n---\n", + "yaml that does not parse": "---\nname: [\n---\n", + } { + t.Run(name, func(t *testing.T) { + if _, err := DropUnknownFields([]byte(in)); !errors.Is(err, errNotFrontmatter) { + t.Fatalf("err = %v, want %v", err, errNotFrontmatter) + } + }) + } +} + +func TestLenientServesOtherFilesUnchanged(t *testing.T) { + fsys := Lenient(fstest.MapFS{ + "deploy/SKILL.md": skillMD("name: deploy\ndescription: Deploys.\nversion: 1\n"), + "deploy/references/SKILL.md": {Data: []byte("---\nversion: 1\n---\n")}, + "deploy/references/notes.md": {Data: []byte("---\nversion: 1\n---\n")}, + }) + for _, name := range []string{"deploy/references/SKILL.md", "deploy/references/notes.md"} { + got, err := fs.ReadFile(fsys, name) + if err != nil || string(got) != "---\nversion: 1\n---\n" { + t.Errorf("%s = %q, %v; want unchanged", name, got, err) + } + } + got, err := fs.ReadFile(fsys, "deploy/SKILL.md") + if err != nil || strings.Contains(string(got), "version") { + t.Errorf("deploy/SKILL.md = %q, %v; want the version field dropped", got, err) + } + info, err := fs.Stat(fsys, "deploy/SKILL.md") + if err != nil || info.Size() != int64(len(got)) { + t.Errorf("Stat size = %d, %v; want %d", info.Size(), err, len(got)) + } +} diff --git a/go/adk/pkg/taskstore/executor_test.go b/go/adk/pkg/taskstore/executor_test.go index f24ef11127..afd5c3f61f 100644 --- a/go/adk/pkg/taskstore/executor_test.go +++ b/go/adk/pkg/taskstore/executor_test.go @@ -247,3 +247,51 @@ func TestSettlementFlushesFinalSaveAndSettlement(t *testing.T) { }) } } + +// cancelProbe is a native executor that records whether it was asked to run or +// to cancel. +type cancelProbe struct { + executes atomic.Int32 + cancels atomic.Int32 +} + +func (p *cancelProbe) Execute(context.Context, *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { + p.executes.Add(1) + return func(func(a2a.Event, error) bool) {} +} + +func (p *cancelProbe) Cancel(_ context.Context, input *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { + p.cancels.Add(1) + return func(yield func(a2a.Event, error) bool) { + yield(a2a.NewStatusUpdateEvent(input, a2a.TaskStateCanceled, nil), nil) + } +} + +// A cancel that reaches the runtime before native execution starts stops that +// start and goes to the native executor, whose cancel yields the one CANCELED +// boundary for the task saved as SUBMITTED; the execution itself ends silently. +func TestCancelBeforeNativeStartLeavesTheBoundaryToTheNativeCancel(t *testing.T) { + native := &cancelProbe{} + wrapper := (&Store{}).WrapExecutor(native, tracing.RuntimeClaude, nil) + input := &a2asrv.ExecutorContext{TaskID: "task", ContextID: "conversation"} + state := &execution{ready: make(chan struct{})} + ctx := context.WithValue(t.Context(), executionKey{}, state) + cancelCtx := context.WithValue(t.Context(), executionKey{}, &execution{ready: make(chan struct{})}) + + var events, cancelEvents []a2a.Event + for event, err := range wrapper.Execute(ctx, input) { + require.NoError(t, err) + events = append(events, event) + for cancelEvent, cancelErr := range wrapper.Cancel(cancelCtx, input) { + require.NoError(t, cancelErr) + cancelEvents = append(cancelEvents, cancelEvent) + } + recordSave(ctx, &a2a.Task{Status: a2a.TaskStatus{State: a2a.TaskStateSubmitted}}, 1) + } + require.Len(t, events, 1) + require.Equal(t, a2a.TaskStateSubmitted, events[0].(*a2a.Task).Status.State) + require.Len(t, cancelEvents, 1) + require.Equal(t, a2a.TaskStateCanceled, cancelEvents[0].(*a2a.TaskStatusUpdateEvent).Status.State) + require.Zero(t, native.executes.Load(), "native execution must not start after the cancel") + require.Equal(t, int32(1), native.cancels.Load()) +} diff --git a/go/api/a2a/runtime_lost.go b/go/api/a2a/runtime_lost.go new file mode 100644 index 0000000000..f4afb8ccb5 --- /dev/null +++ b/go/api/a2a/runtime_lost.go @@ -0,0 +1,13 @@ +package a2a + +const ( + // RuntimeLostMessagePrefix opens the failure message of a Session whose + // runtime is gone for good: its Actor crashed or no longer exists. The + // text after the prefix names the cause. A client that reads it knows the + // conversation cannot continue, as opposed to a turn that may be retried. + RuntimeLostMessagePrefix = "runtime lost: " + + // FailureReasonRuntimeLost is the Session failure reason recorded with a + // message that opens with RuntimeLostMessagePrefix. + FailureReasonRuntimeLost = "RuntimeLost" +) diff --git a/go/api/adk/headers.go b/go/api/adk/headers.go new file mode 100644 index 0000000000..cf45f28e01 --- /dev/null +++ b/go/api/adk/headers.go @@ -0,0 +1,31 @@ +// Copyright 2026 The kagent Authors +// SPDX-License-Identifier: Apache-2.0 + +package adk + +// Request headers an agent runtime sets on every call it makes to a model +// provider. They name the agent the call is made for, so a gateway or proxy +// between the runtime and the provider can attribute the call to the agent and +// the user without reading the runtime's network identity: a runtime that +// shares a pod, a ServiceAccount or an egress with other agents is otherwise +// indistinguishable from them. The headers are an accounting identity the +// runtime asserts about itself, never an authorization input. +const ( + // AgentHeader carries the name of the AgentTemplate the runtime executes. + AgentHeader = "x-kagent-agent" + // AgentNamespaceHeader carries the namespace of that AgentTemplate. + AgentNamespaceHeader = "x-kagent-agent-namespace" + // UserHeader carries the authenticated user of the turn the call belongs + // to: the identity the controller resolved from the caller's validated + // token, which its gateway forwards to the runtime as request metadata of + // the same name. A call outside a turn, or in a turn whose caller the + // controller did not resolve, carries no such header. + UserHeader = "x-kagent-user" + // AgentInstanceHeader carries the id of the AgentInstance (the session: a + // conversation, a thread) the turn the call belongs to runs in, which the + // controller's gateway forwards to the runtime as request metadata of the + // same name. It is the name of the header that routes a call to an + // AgentInstance (a2a.AgentInstanceIDHeader). A call outside a turn carries + // no such header. + AgentInstanceHeader = "x-kagent-agent-instance-id" +) diff --git a/go/api/adk/types.go b/go/api/adk/types.go index 648a390cf1..11fff442f5 100644 --- a/go/api/adk/types.go +++ b/go/api/adk/types.go @@ -176,6 +176,15 @@ type Anthropic struct { TopP *float64 `json:"top_p,omitempty"` TopK *int `json:"top_k,omitempty"` Timeout *int `json:"timeout,omitempty"` + // PromptCaching enables Anthropic prompt caching by marking the last tool + // definition, the last system prompt block and the last block of the latest + // conversation turn with a cache_control breakpoint. See the + // v1alpha3.AnthropicConfig CRD doc for context. + PromptCaching bool `json:"prompt_caching,omitempty"` + // CacheTTL selects the cache retention window when PromptCaching is on: + // "5m" (default) or "1h". See the v1alpha3.AnthropicConfig CRD doc for the + // cost trade-offs of "1h". + CacheTTL string `json:"cache_ttl,omitempty"` } func (a *Anthropic) MarshalJSON() ([]byte, error) { @@ -259,6 +268,8 @@ func (g *GeminiAnthropic) GetType() string { type Ollama struct { BaseModel Options map[string]string `json:"options,omitempty"` + // Think is sent as the chat request's top-level `think` field; nil sends none. + Think *bool `json:"think,omitempty"` } func (o *Ollama) MarshalJSON() ([]byte, error) { diff --git a/go/api/adk/types_test.go b/go/api/adk/types_test.go index f0305e7bf5..d7cd3a0eb1 100644 --- a/go/api/adk/types_test.go +++ b/go/api/adk/types_test.go @@ -96,7 +96,7 @@ func TestMarshalJSON_OmitemptyFields(t *testing.T) { { name: "Ollama zero-valued omitempty fields omitted", model: &Ollama{BaseModel: BaseModel{Model: "llama3"}}, - wantAbsent: []string{"headers", "options", "api_key_passthrough"}, + wantAbsent: []string{"headers", "options", "think", "api_key_passthrough"}, }, } @@ -256,6 +256,20 @@ func TestMarshalJSON_TypeSpecificFields(t *testing.T) { } }) + t.Run("Ollama think", func(t *testing.T) { + data, err := json.Marshal(&Ollama{BaseModel: BaseModel{Model: "qwen3.5:2b"}, Think: new(false)}) + if err != nil { + t.Fatalf("MarshalJSON() error = %v", err) + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if think, ok := raw["think"]; !ok || think != false { + t.Errorf("think = %v (present %v), want false", think, ok) + } + }) + t.Run("Bedrock region", func(t *testing.T) { m := &Bedrock{ BaseModel: BaseModel{Model: "claude-v2"}, @@ -404,6 +418,7 @@ func TestParseModel_Roundtrip(t *testing.T) { model: &Ollama{ BaseModel: BaseModel{Model: "llama3", Headers: map[string]string{"User-Agent": "test"}}, Options: map[string]string{"num_ctx": "2048", "temperature": "0.8"}, + Think: new(false), }, wantType: ModelTypeOllama, }, diff --git a/go/api/agentplugin/types.go b/go/api/agentplugin/types.go index 87d7f1cb73..6b5677634c 100644 --- a/go/api/agentplugin/types.go +++ b/go/api/agentplugin/types.go @@ -33,6 +33,10 @@ type Source struct { type GitSource struct { URL string `json:"url"` Commit string `json:"commit"` + // Authenticated marks a source whose credential the egress gateway + // supplies: the gateway replaces an Authorization header the request + // carries, so git must send one with a placeholder value. + Authenticated bool `json:"authenticated,omitempty"` } // S3Source identifies one immutable S3 object version. diff --git a/go/api/client/a2a.go b/go/api/client/a2a.go index f99952b8c6..d5bc1821ae 100644 --- a/go/api/client/a2a.go +++ b/go/api/client/a2a.go @@ -12,7 +12,11 @@ import ( apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" ) -const userIDHeader = "x-user-id" +const ( + userIDHeader = "x-user-id" + authorizationHeader = "authorization" + bearerPrefix = "Bearer " +) // A2AClient creates upstream A2A clients routed to an Agent. type A2AClient struct { @@ -60,9 +64,10 @@ func (c *A2AClient) forAgent(ctx context.Context, agent *apiv1alpha1.ResourceRef }, )), a2aclient.WithCallInterceptors(&agentRoutingInterceptor{ - contextID: contextID, - userID: c.client.userID, - timeout: c.client.transport.timeout, + contextID: contextID, + userID: c.client.userID, + bearerToken: c.client.bearerToken, + timeout: c.client.transport.timeout, }), ) } @@ -71,9 +76,10 @@ type cancelCallContextKey struct{} type agentRoutingInterceptor struct { a2aclient.PassthroughInterceptor - contextID string - userID string - timeout time.Duration + contextID string + userID string + bearerToken string + timeout time.Duration } func (i *agentRoutingInterceptor) Before(ctx context.Context, request *a2aclient.Request) (context.Context, any, error) { @@ -92,6 +98,12 @@ func (i *agentRoutingInterceptor) Before(ctx context.Context, request *a2aclient if i.userID != "" { request.ServiceParams.Append(userIDHeader, i.userID) } + // The server reads one authorization value. A bearer the caller attached + // to the context (a model key passed through to the agent) keeps it; the + // caller's own token authenticates every other call. + if i.bearerToken != "" && len(request.ServiceParams.Get(authorizationHeader)) == 0 { + request.ServiceParams.Append(authorizationHeader, bearerPrefix+i.bearerToken) + } if i.timeout <= 0 || isStreamingA2AMethod(request.Method) { return ctx, nil, nil } diff --git a/go/api/client/base.go b/go/api/client/base.go index ac01319e60..2142517d5d 100644 --- a/go/api/client/base.go +++ b/go/api/client/base.go @@ -10,9 +10,19 @@ func WithUserID(userID string) ClientOption { } } +// WithBearerToken sets the caller's bearer token, sent as the authorization +// metadata of every call. A server in trusted-proxy mode identifies the caller +// by it; the user ID alone only selects the data partition of an insecure one. +func WithBearerToken(token string) ClientOption { + return func(c *baseClient) { + c.bearerToken = token + } +} + type baseClient struct { - userID string - transport *grpcTransport + userID string + bearerToken string + transport *grpcTransport } func newBaseClient(rawURL string, options ...ClientOption) (*baseClient, error) { diff --git a/go/api/client/grpc.go b/go/api/client/grpc.go index d6a46b5de8..b05491fd0f 100644 --- a/go/api/client/grpc.go +++ b/go/api/client/grpc.go @@ -201,7 +201,10 @@ func (c *baseClient) grpcCallContext(ctx context.Context) (context.Context, cont func (c *baseClient) grpcCallContextForUser(ctx context.Context, userID string) (context.Context, context.CancelFunc) { if userID != "" { - ctx = metadata.AppendToOutgoingContext(ctx, "x-user-id", userID) + ctx = metadata.AppendToOutgoingContext(ctx, userIDHeader, userID) + } + if c.bearerToken != "" { + ctx = metadata.AppendToOutgoingContext(ctx, authorizationHeader, bearerPrefix+c.bearerToken) } if c.transport.timeout <= 0 { return ctx, func() {} diff --git a/go/api/client/grpc_test.go b/go/api/client/grpc_test.go index 3f7c28174c..964bda3a3a 100644 --- a/go/api/client/grpc_test.go +++ b/go/api/client/grpc_test.go @@ -8,8 +8,9 @@ import ( ) type callObservation struct { - userID string - hasDeadline bool + userID string + authorization string + hasDeadline bool } func first(values []string) string { diff --git a/go/api/client/sessiongrpc_test.go b/go/api/client/sessiongrpc_test.go index 2390a8f375..f38c78704a 100644 --- a/go/api/client/sessiongrpc_test.go +++ b/go/api/client/sessiongrpc_test.go @@ -3,6 +3,7 @@ package client import ( "context" "net" + "strings" "sync" "sync/atomic" "testing" @@ -28,18 +29,27 @@ type recordingSessionService struct { } func (s *recordingSessionService) CreateSession(ctx context.Context, _ *apiv1alpha1.CreateSessionRequest) (*apiv1alpha1.CreateSessionResponse, error) { - values, _ := metadata.FromIncomingContext(ctx) - _, hasDeadline := ctx.Deadline() - s.observation = callObservation{userID: first(values.Get(userIDHeader)), hasDeadline: hasDeadline} + s.observation = observeCall(ctx) return &apiv1alpha1.CreateSessionResponse{}, nil } -func (s *recordingSessionService) GetSession(_ context.Context, request *apiv1alpha1.GetSessionRequest) (*apiv1alpha1.GetSessionResponse, error) { +func (s *recordingSessionService) GetSession(ctx context.Context, request *apiv1alpha1.GetSessionRequest) (*apiv1alpha1.GetSessionResponse, error) { + s.observation = observeCall(ctx) return &apiv1alpha1.GetSessionResponse{Session: &apiv1alpha1.Session{ Id: request.SessionId, Agent: &apiv1alpha1.ResourceReference{Namespace: "team-a", Name: "assistant"}, }}, nil } +func observeCall(ctx context.Context) callObservation { + values, _ := metadata.FromIncomingContext(ctx) + _, hasDeadline := ctx.Deadline() + return callObservation{ + userID: first(values.Get(userIDHeader)), + authorization: strings.Join(values.Get(authorizationHeader), ","), + hasDeadline: hasDeadline, + } +} + type a2aCallObservation struct { contextID string id string @@ -86,15 +96,16 @@ func (s *recordingA2AService) observe(ctx context.Context, tenant, contextID str id: tenant, contextID: contextID, userID: first(values.Get(userIDHeader)), - authorization: first(values.Get("authorization")), + authorization: strings.Join(values.Get(authorizationHeader), ","), hasDeadline: hasDeadline, }) } -func TestSessionAndA2AClientsUseTheirEndpoints(t *testing.T) { +// serveRecordingServices runs both recording services on one in-process +// listener and returns the dialer that reaches it, counting the dials. +func serveRecordingServices(t *testing.T, sessionService *recordingSessionService, a2aService *recordingA2AService) (grpc.DialOption, *atomic.Int32) { + t.Helper() listener := bufconn.Listen(1024 * 1024) - sessionService := &recordingSessionService{} - a2aService := &recordingA2AService{} server := grpc.NewServer() apiv1alpha1.RegisterSessionServiceServer(server, sessionService) a2apb.RegisterA2AServiceServer(server, a2aService) @@ -104,14 +115,22 @@ func TestSessionAndA2AClientsUseTheirEndpoints(t *testing.T) { _ = listener.Close() }) - var dialCount atomic.Int32 + dialCount := &atomic.Int32{} + return grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + dialCount.Add(1) + return listener.Dial() + }), dialCount +} + +func TestSessionAndA2AClientsUseTheirEndpoints(t *testing.T) { + sessionService := &recordingSessionService{} + a2aService := &recordingA2AService{} + dialer, dialCount := serveRecordingServices(t, sessionService, a2aService) + options := []ClientOption{ WithUserID("caller"), WithGRPCTimeout(5 * time.Second), - WithGRPCDialOptions(grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { - dialCount.Add(1) - return listener.Dial() - })), + WithGRPCDialOptions(dialer), } apiClient, err := NewAPI( "http://api.invalid:80", @@ -160,6 +179,59 @@ func TestSessionAndA2AClientsUseTheirEndpoints(t *testing.T) { assert.Equal(t, int32(2), dialCount.Load()) } +func TestBearerTokenAuthenticatesEveryCall(t *testing.T) { + sessionService := &recordingSessionService{} + a2aService := &recordingA2AService{} + dialer, _ := serveRecordingServices(t, sessionService, a2aService) + + options := []ClientOption{ + WithUserID("caller"), + WithBearerToken("caller-jwt"), + WithGRPCTimeout(5 * time.Second), + WithGRPCDialOptions(dialer), + } + apiClient, err := NewAPI("http://api.invalid:80", options...) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, apiClient.Close()) }) + gatewayClient, err := NewGateway("http://gateway.invalid:80", options...) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, gatewayClient.Close()) }) + + // Control plane: the bearer rides beside the user ID on every call. + _, err = apiClient.Session.CreateSession(context.Background(), &apiv1alpha1.CreateSessionRequest{}) + require.NoError(t, err) + assert.Equal(t, callObservation{userID: "caller", authorization: "Bearer caller-jwt", hasDeadline: true}, sessionService.observation) + + // Agent traffic: the Session lookup and the A2A calls carry it too, and a + // bearer attached to the context (a model key passed through) keeps the + // slot to itself, since the server reads one value. + a2aClient, err := gatewayClient.A2A.ForSession(context.Background(), sessionClientTestID) + require.NoError(t, err) + assert.Equal(t, "Bearer caller-jwt", sessionService.observation.authorization) + + request := &a2atype.SendMessageRequest{Message: a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart("hi"))} + _, err = a2aClient.SendMessage(context.Background(), request) + require.NoError(t, err) + for _, streamErr := range a2aClient.SendStreamingMessage(context.Background(), request) { + require.NoError(t, streamErr) + } + modelKeyCtx := a2aclient.AttachServiceParams(context.Background(), a2aclient.ServiceParams{ + authorizationHeader: {"Bearer model-key"}, + }) + _, err = a2aClient.SendMessage(modelKeyCtx, request) + require.NoError(t, err) + + a2aService.mu.Lock() + defer a2aService.mu.Unlock() + require.Len(t, a2aService.observations, 3) + assert.Equal(t, "Bearer caller-jwt", a2aService.observations[0].authorization) + assert.Equal(t, "Bearer caller-jwt", a2aService.observations[1].authorization) + assert.Equal(t, "Bearer model-key", a2aService.observations[2].authorization) + for _, observation := range a2aService.observations { + assert.Equal(t, "caller", observation.userID) + } +} + func TestStreamingA2AMethodsMatchUpstreamService(t *testing.T) { methods := make([]string, 0, len(a2apb.A2AService_ServiceDesc.Streams)) for _, stream := range a2apb.A2AService_ServiceDesc.Streams { diff --git a/go/api/config/crd/bases/api.kagent.dev_agents.yaml b/go/api/config/crd/bases/api.kagent.dev_agents.yaml index 84023a3624..a989124820 100644 --- a/go/api/config/crd/bases/api.kagent.dev_agents.yaml +++ b/go/api/config/crd/bases/api.kagent.dev_agents.yaml @@ -4,6 +4,7 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.19.0 + helm.sh/resource-policy: keep name: agents.api.kagent.dev spec: group: api.kagent.dev @@ -51,6 +52,21 @@ spec: AgentSpec pairs portable behavior with a runner. References are local to the Agent's namespace, including references nested in inline specs. properties: + egress: + description: |- + Egress lists HTTP(S) origins the Agent may reach besides the destinations + its revision compiles (the model, its MCP servers, its skill and plugin + sources, telemetry), such as "https://proxy.golang.org". The host may + have "*" as its leftmost label: "https://*.githubusercontent.com" matches + one label, such as raw.githubusercontent.com. A wildcard needs two labels + under it, so nothing opens a whole top-level domain or every host. + items: + maxLength: 270 + pattern: ^https?://(\*\.([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)+|([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)*)[a-z]([-a-z0-9]{0,61}[a-z0-9])?(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?$ + type: string + maxItems: 64 + type: array + x-kubernetes-list-type: set harness: description: HarnessSpec defines a reusable runtime and its infrastructure policy. @@ -61,6 +77,31 @@ spec: type: object claude: description: ClaudeHarness selects the Claude runtime adapter. + properties: + limits: + description: |- + Limits bounds every turn of every agent that runs on the Harness + (Claude Code's --max-budget-usd and --max-turns). A turn that reaches a + limit ends completed, with the reason and the usage on the task, and a + follow-up continues the conversation. + properties: + budgetUSD: + description: |- + BudgetUSD is the most one turn may spend, in US dollars, as a decimal + string such as "2.50". + pattern: ^(0|[1-9][0-9]{0,5})(\.[0-9]{1,4})?$ + type: string + maxTurns: + description: MaxTurns is the most model round-trips one + turn may take. + format: int32 + maximum: 10000 + minimum: 1 + type: integer + type: object + x-kubernetes-validations: + - message: at least one of budgetUSD or maxTurns must be set + rule: has(self.budgetUSD) || has(self.maxTurns) type: object codex: description: CodexHarness selects the Codex runtime adapter. @@ -389,6 +430,37 @@ spec: commit: pattern: ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ type: string + credentialRef: + description: |- + CredentialRef references a key in a same-namespace Secret whose value is + the base64 of ":" for the URL's host (GitHub takes + "x-access-token:"). The egress gateway replaces the Authorization + header of a request the actor sends to that host with "Basic ", + and the fetch of this source sends a placeholder for it to replace. A + request without the header gets no credential, and the actor never + holds the value. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic url: minLength: 1 pattern: ^https?://[^[:space:]]+$ @@ -397,6 +469,12 @@ spec: - commit - url type: object + x-kubernetes-validations: + - message: credentialRef name and key must not be empty + rule: '!has(self.credentialRef) || (self.credentialRef.name.size() + > 0 && self.credentialRef.key.size() > 0)' + - message: credentialRef requires an https URL + rule: '!has(self.credentialRef) || self.url.startsWith(''https://'')' oci: description: OCI is a digest-pinned image reference. pattern: ^[^[:space:]@]+@sha256:[0-9a-f]{64}$ @@ -502,6 +580,37 @@ spec: commit: pattern: ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ type: string + credentialRef: + description: |- + CredentialRef references a key in a same-namespace Secret whose value is + the base64 of ":" for the URL's host (GitHub takes + "x-access-token:"). The egress gateway replaces the Authorization + header of a request the actor sends to that host with "Basic ", + and the fetch of this source sends a placeholder for it to replace. A + request without the header gets no credential, and the actor never + holds the value. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic url: minLength: 1 pattern: ^https?://[^[:space:]]+$ @@ -510,6 +619,12 @@ spec: - commit - url type: object + x-kubernetes-validations: + - message: credentialRef name and key must not be empty + rule: '!has(self.credentialRef) || (self.credentialRef.name.size() + > 0 && self.credentialRef.key.size() > 0)' + - message: credentialRef requires an https URL + rule: '!has(self.credentialRef) || self.url.startsWith(''https://'')' oci: description: OCI is a digest-pinned image reference. pattern: ^[^[:space:]@]+@sha256:[0-9a-f]{64}$ diff --git a/go/api/config/crd/bases/api.kagent.dev_agenttemplates.yaml b/go/api/config/crd/bases/api.kagent.dev_agenttemplates.yaml index ebe57de679..f5e57b8a64 100644 --- a/go/api/config/crd/bases/api.kagent.dev_agenttemplates.yaml +++ b/go/api/config/crd/bases/api.kagent.dev_agenttemplates.yaml @@ -4,6 +4,7 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.19.0 + helm.sh/resource-policy: keep name: agenttemplates.api.kagent.dev spec: group: api.kagent.dev @@ -144,6 +145,37 @@ spec: commit: pattern: ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ type: string + credentialRef: + description: |- + CredentialRef references a key in a same-namespace Secret whose value is + the base64 of ":" for the URL's host (GitHub takes + "x-access-token:"). The egress gateway replaces the Authorization + header of a request the actor sends to that host with "Basic ", + and the fetch of this source sends a placeholder for it to replace. A + request without the header gets no credential, and the actor never + holds the value. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic url: minLength: 1 pattern: ^https?://[^[:space:]]+$ @@ -152,6 +184,12 @@ spec: - commit - url type: object + x-kubernetes-validations: + - message: credentialRef name and key must not be empty + rule: '!has(self.credentialRef) || (self.credentialRef.name.size() + > 0 && self.credentialRef.key.size() > 0)' + - message: credentialRef requires an https URL + rule: '!has(self.credentialRef) || self.url.startsWith(''https://'')' oci: description: OCI is a digest-pinned image reference. pattern: ^[^[:space:]@]+@sha256:[0-9a-f]{64}$ @@ -255,6 +293,37 @@ spec: commit: pattern: ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ type: string + credentialRef: + description: |- + CredentialRef references a key in a same-namespace Secret whose value is + the base64 of ":" for the URL's host (GitHub takes + "x-access-token:"). The egress gateway replaces the Authorization + header of a request the actor sends to that host with "Basic ", + and the fetch of this source sends a placeholder for it to replace. A + request without the header gets no credential, and the actor never + holds the value. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic url: minLength: 1 pattern: ^https?://[^[:space:]]+$ @@ -263,6 +332,12 @@ spec: - commit - url type: object + x-kubernetes-validations: + - message: credentialRef name and key must not be empty + rule: '!has(self.credentialRef) || (self.credentialRef.name.size() + > 0 && self.credentialRef.key.size() > 0)' + - message: credentialRef requires an https URL + rule: '!has(self.credentialRef) || self.url.startsWith(''https://'')' oci: description: OCI is a digest-pinned image reference. pattern: ^[^[:space:]@]+@sha256:[0-9a-f]{64}$ diff --git a/go/api/config/crd/bases/api.kagent.dev_harnesses.yaml b/go/api/config/crd/bases/api.kagent.dev_harnesses.yaml index 50d59e5113..667e2eb0d6 100644 --- a/go/api/config/crd/bases/api.kagent.dev_harnesses.yaml +++ b/go/api/config/crd/bases/api.kagent.dev_harnesses.yaml @@ -4,6 +4,7 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.19.0 + helm.sh/resource-policy: keep name: harnesses.api.kagent.dev spec: group: api.kagent.dev @@ -55,6 +56,31 @@ spec: type: object claude: description: ClaudeHarness selects the Claude runtime adapter. + properties: + limits: + description: |- + Limits bounds every turn of every agent that runs on the Harness + (Claude Code's --max-budget-usd and --max-turns). A turn that reaches a + limit ends completed, with the reason and the usage on the task, and a + follow-up continues the conversation. + properties: + budgetUSD: + description: |- + BudgetUSD is the most one turn may spend, in US dollars, as a decimal + string such as "2.50". + pattern: ^(0|[1-9][0-9]{0,5})(\.[0-9]{1,4})?$ + type: string + maxTurns: + description: MaxTurns is the most model round-trips one turn + may take. + format: int32 + maximum: 10000 + minimum: 1 + type: integer + type: object + x-kubernetes-validations: + - message: at least one of budgetUSD or maxTurns must be set + rule: has(self.budgetUSD) || has(self.maxTurns) type: object codex: description: CodexHarness selects the Codex runtime adapter. diff --git a/go/api/config/crd/bases/api.kagent.dev_modelconfigs.yaml b/go/api/config/crd/bases/api.kagent.dev_modelconfigs.yaml index 4432c5055d..6c22f07da8 100644 --- a/go/api/config/crd/bases/api.kagent.dev_modelconfigs.yaml +++ b/go/api/config/crd/bases/api.kagent.dev_modelconfigs.yaml @@ -4,6 +4,7 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.19.0 + helm.sh/resource-policy: keep name: modelconfigs.api.kagent.dev spec: group: api.kagent.dev @@ -56,9 +57,55 @@ spec: baseUrl: description: Base URL for the Anthropic API (overrides default) type: string + cacheTTL: + default: 5m + description: |- + CacheTTL controls how long Anthropic retains a cached prefix when + PromptCaching is enabled. Only meaningful when PromptCaching is true. + + - "5m" (default): the standard 5-minute cache. Each cache hit refreshes + the window, so an agent loop whose calls are less than 5 minutes + apart keeps its prefix cached for the whole task. + - "1h": the extended 1-hour cache, useful for tasks whose model calls + are spaced more than 5 minutes apart. + + NOTE: "1h" is NOT strictly better than "5m". 1-hour cache writes are + billed at a higher per-token rate than 5-minute writes. Only choose + "1h" when calls are spaced far enough apart that a 5-minute cache would + expire between them; otherwise the higher write cost is wasted. See the + Anthropic prompt-caching docs above. + enum: + - 5m + - 1h + type: string maxTokens: description: Maximum tokens to generate type: integer + promptCaching: + default: false + description: |- + PromptCaching enables Anthropic prompt caching by marking the reusable + prefix of every Messages API request with `cache_control` breakpoints: + the last tool definition, the last system prompt block and the last + content block of the most recent conversation turn. Anthropic caches the + prefix up to each breakpoint and bills a cache hit at a fraction of the + normal input price on later requests within the TTL. Because the + conversation breakpoint moves with every turn, each call of an agent + loop reads the whole previous history from the cache and writes only + the new turn. + + Recommended for tool-using agents that make many model calls per task + with a stable system prompt and tool set — without it the full history + is billed as fresh input on every call. Cache writes are billed at a + premium over normal input, so a prefix has to be read at least once to + pay off, and each model has a minimum cacheable prefix (1024–4096 + tokens depending on the model) below which the markers are silently + ignored. + + See https://platform.claude.com/docs/en/build-with-claude/prompt-caching + for the current list of supported models, minimum prefix sizes and + pricing. + type: boolean temperature: description: Temperature for sampling type: string @@ -400,6 +447,15 @@ spec: type: string description: Options for the Ollama API type: object + think: + description: |- + Think switches a thinking model's reasoning on or off. It is sent as the + top-level `think` field of every chat request, not as an option. Unset + leaves Ollama's default, under which a model with the thinking capability + (Qwen3, DeepSeek-R1, Granite 4.2, ...) thinks before every answer. Set it + to false for an agent whose model should answer directly. Ollama refuses + true for a model without the thinking capability. + type: boolean type: object openAI: description: OpenAI-specific configuration diff --git a/go/api/config/crd/bases/api.kagent.dev_modelproviderconfigs.yaml b/go/api/config/crd/bases/api.kagent.dev_modelproviderconfigs.yaml index f802a8598e..e2c15b363f 100644 --- a/go/api/config/crd/bases/api.kagent.dev_modelproviderconfigs.yaml +++ b/go/api/config/crd/bases/api.kagent.dev_modelproviderconfigs.yaml @@ -4,6 +4,7 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.19.0 + helm.sh/resource-policy: keep name: modelproviderconfigs.api.kagent.dev spec: group: api.kagent.dev diff --git a/go/api/config/crd/bases/api.kagent.dev_remotemcpservers.yaml b/go/api/config/crd/bases/api.kagent.dev_remotemcpservers.yaml index 3c2f5c21e5..2696d71178 100644 --- a/go/api/config/crd/bases/api.kagent.dev_remotemcpservers.yaml +++ b/go/api/config/crd/bases/api.kagent.dev_remotemcpservers.yaml @@ -4,6 +4,7 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.19.0 + helm.sh/resource-policy: keep name: remotemcpservers.api.kagent.dev spec: group: api.kagent.dev diff --git a/go/api/config/crd/bases/api.kagent.dev_sandboxtemplates.yaml b/go/api/config/crd/bases/api.kagent.dev_sandboxtemplates.yaml index 80b1a4b595..f122b5e135 100644 --- a/go/api/config/crd/bases/api.kagent.dev_sandboxtemplates.yaml +++ b/go/api/config/crd/bases/api.kagent.dev_sandboxtemplates.yaml @@ -4,6 +4,7 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.19.0 + helm.sh/resource-policy: keep name: sandboxtemplates.api.kagent.dev spec: group: api.kagent.dev diff --git a/go/api/gen/kagent/api/v1alpha1/sessions.pb.go b/go/api/gen/kagent/api/v1alpha1/sessions.pb.go index acc64171fa..7f27588554 100644 --- a/go/api/gen/kagent/api/v1alpha1/sessions.pb.go +++ b/go/api/gen/kagent/api/v1alpha1/sessions.pb.go @@ -144,9 +144,13 @@ type Session struct { Name string `protobuf:"bytes,13,opt,name=name,proto3" json:"name,omitempty"` // Output only. A2A conversation ID, equal to this session's ID. // Forks receive a new conversation ID and new task IDs. - ContextId string `protobuf:"bytes,14,opt,name=context_id,json=contextId,proto3" json:"context_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + ContextId string `protobuf:"bytes,14,opt,name=context_id,json=contextId,proto3" json:"context_id,omitempty"` + // Output only. Why the runtime boundary after a turn was released without a + // pause or a snapshot, once the suspend or pause and its retry failed or the + // runtime was gone. A later turn's snapshot clears it. + LastQuiescenceFailure *QuiescenceFailure `protobuf:"bytes,16,opt,name=last_quiescence_failure,json=lastQuiescenceFailure,proto3" json:"last_quiescence_failure,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Session) Reset() { @@ -263,6 +267,86 @@ func (x *Session) GetContextId() string { return "" } +func (x *Session) GetLastQuiescenceFailure() *QuiescenceFailure { + if x != nil { + return x.LastQuiescenceFailure + } + return nil +} + +// QuiescenceFailure explains a turn whose runtime could not be paused or +// suspended after it settled. The turn is stored; without a snapshot it cannot +// be checkpointed, and the next turn takes the runtime as it is. +type QuiescenceFailure struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The turn whose boundary was released. + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + // SuspendFailed, PauseFailed or RuntimeUnavailable. + Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` + Message string `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"` + FailedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=failed_at,json=failedAt,proto3" json:"failed_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *QuiescenceFailure) Reset() { + *x = QuiescenceFailure{} + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *QuiescenceFailure) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QuiescenceFailure) ProtoMessage() {} + +func (x *QuiescenceFailure) ProtoReflect() protoreflect.Message { + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QuiescenceFailure.ProtoReflect.Descriptor instead. +func (*QuiescenceFailure) Descriptor() ([]byte, []int) { + return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{2} +} + +func (x *QuiescenceFailure) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *QuiescenceFailure) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +func (x *QuiescenceFailure) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *QuiescenceFailure) GetFailedAt() *timestamppb.Timestamp { + if x != nil { + return x.FailedAt + } + return nil +} + type CreateSessionRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Agent *ResourceReference `protobuf:"bytes,5,opt,name=agent,proto3" json:"agent,omitempty"` @@ -275,7 +359,7 @@ type CreateSessionRequest struct { func (x *CreateSessionRequest) Reset() { *x = CreateSessionRequest{} - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[2] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -287,7 +371,7 @@ func (x *CreateSessionRequest) String() string { func (*CreateSessionRequest) ProtoMessage() {} func (x *CreateSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[2] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -300,7 +384,7 @@ func (x *CreateSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSessionRequest.ProtoReflect.Descriptor instead. func (*CreateSessionRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{2} + return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{3} } func (x *CreateSessionRequest) GetAgent() *ResourceReference { @@ -333,7 +417,7 @@ type CreateSessionResponse struct { func (x *CreateSessionResponse) Reset() { *x = CreateSessionResponse{} - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[3] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -345,7 +429,7 @@ func (x *CreateSessionResponse) String() string { func (*CreateSessionResponse) ProtoMessage() {} func (x *CreateSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[3] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -358,7 +442,7 @@ func (x *CreateSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSessionResponse.ProtoReflect.Descriptor instead. func (*CreateSessionResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{3} + return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{4} } func (x *CreateSessionResponse) GetSession() *Session { @@ -377,7 +461,7 @@ type GetSessionRequest struct { func (x *GetSessionRequest) Reset() { *x = GetSessionRequest{} - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[4] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -389,7 +473,7 @@ func (x *GetSessionRequest) String() string { func (*GetSessionRequest) ProtoMessage() {} func (x *GetSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[4] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -402,7 +486,7 @@ func (x *GetSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSessionRequest.ProtoReflect.Descriptor instead. func (*GetSessionRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{4} + return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{5} } func (x *GetSessionRequest) GetSessionId() string { @@ -421,7 +505,7 @@ type GetSessionResponse struct { func (x *GetSessionResponse) Reset() { *x = GetSessionResponse{} - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[5] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -433,7 +517,7 @@ func (x *GetSessionResponse) String() string { func (*GetSessionResponse) ProtoMessage() {} func (x *GetSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[5] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -446,7 +530,7 @@ func (x *GetSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSessionResponse.ProtoReflect.Descriptor instead. func (*GetSessionResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{5} + return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{6} } func (x *GetSessionResponse) GetSession() *Session { @@ -469,7 +553,7 @@ type ListSessionsRequest struct { func (x *ListSessionsRequest) Reset() { *x = ListSessionsRequest{} - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[6] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -481,7 +565,7 @@ func (x *ListSessionsRequest) String() string { func (*ListSessionsRequest) ProtoMessage() {} func (x *ListSessionsRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[6] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -494,7 +578,7 @@ func (x *ListSessionsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionsRequest.ProtoReflect.Descriptor instead. func (*ListSessionsRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{6} + return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{7} } func (x *ListSessionsRequest) GetAllCreators() bool { @@ -528,7 +612,7 @@ type ListSessionsResponse struct { func (x *ListSessionsResponse) Reset() { *x = ListSessionsResponse{} - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[7] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -540,7 +624,7 @@ func (x *ListSessionsResponse) String() string { func (*ListSessionsResponse) ProtoMessage() {} func (x *ListSessionsResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[7] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -553,7 +637,7 @@ func (x *ListSessionsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionsResponse.ProtoReflect.Descriptor instead. func (*ListSessionsResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{7} + return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{8} } func (x *ListSessionsResponse) GetSessions() []*Session { @@ -582,7 +666,7 @@ type UpdateSessionNameRequest struct { func (x *UpdateSessionNameRequest) Reset() { *x = UpdateSessionNameRequest{} - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[8] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -594,7 +678,7 @@ func (x *UpdateSessionNameRequest) String() string { func (*UpdateSessionNameRequest) ProtoMessage() {} func (x *UpdateSessionNameRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[8] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -607,7 +691,7 @@ func (x *UpdateSessionNameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateSessionNameRequest.ProtoReflect.Descriptor instead. func (*UpdateSessionNameRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{8} + return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{9} } func (x *UpdateSessionNameRequest) GetSessionId() string { @@ -633,7 +717,7 @@ type UpdateSessionNameResponse struct { func (x *UpdateSessionNameResponse) Reset() { *x = UpdateSessionNameResponse{} - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[9] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -645,7 +729,7 @@ func (x *UpdateSessionNameResponse) String() string { func (*UpdateSessionNameResponse) ProtoMessage() {} func (x *UpdateSessionNameResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[9] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -658,7 +742,7 @@ func (x *UpdateSessionNameResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateSessionNameResponse.ProtoReflect.Descriptor instead. func (*UpdateSessionNameResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{9} + return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{10} } func (x *UpdateSessionNameResponse) GetSession() *Session { @@ -677,7 +761,7 @@ type SuspendSessionRequest struct { func (x *SuspendSessionRequest) Reset() { *x = SuspendSessionRequest{} - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[10] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -689,7 +773,7 @@ func (x *SuspendSessionRequest) String() string { func (*SuspendSessionRequest) ProtoMessage() {} func (x *SuspendSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[10] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -702,7 +786,7 @@ func (x *SuspendSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SuspendSessionRequest.ProtoReflect.Descriptor instead. func (*SuspendSessionRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{10} + return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{11} } func (x *SuspendSessionRequest) GetSessionId() string { @@ -721,7 +805,7 @@ type SuspendSessionResponse struct { func (x *SuspendSessionResponse) Reset() { *x = SuspendSessionResponse{} - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[11] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -733,7 +817,7 @@ func (x *SuspendSessionResponse) String() string { func (*SuspendSessionResponse) ProtoMessage() {} func (x *SuspendSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[11] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -746,7 +830,7 @@ func (x *SuspendSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SuspendSessionResponse.ProtoReflect.Descriptor instead. func (*SuspendSessionResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{11} + return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{12} } func (x *SuspendSessionResponse) GetSession() *Session { @@ -765,7 +849,7 @@ type ResumeSessionRequest struct { func (x *ResumeSessionRequest) Reset() { *x = ResumeSessionRequest{} - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[12] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -777,7 +861,7 @@ func (x *ResumeSessionRequest) String() string { func (*ResumeSessionRequest) ProtoMessage() {} func (x *ResumeSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[12] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -790,7 +874,7 @@ func (x *ResumeSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ResumeSessionRequest.ProtoReflect.Descriptor instead. func (*ResumeSessionRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{12} + return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{13} } func (x *ResumeSessionRequest) GetSessionId() string { @@ -809,7 +893,7 @@ type ResumeSessionResponse struct { func (x *ResumeSessionResponse) Reset() { *x = ResumeSessionResponse{} - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[13] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -821,7 +905,7 @@ func (x *ResumeSessionResponse) String() string { func (*ResumeSessionResponse) ProtoMessage() {} func (x *ResumeSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[13] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -834,7 +918,7 @@ func (x *ResumeSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ResumeSessionResponse.ProtoReflect.Descriptor instead. func (*ResumeSessionResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{13} + return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{14} } func (x *ResumeSessionResponse) GetSession() *Session { @@ -853,7 +937,7 @@ type DeleteSessionRequest struct { func (x *DeleteSessionRequest) Reset() { *x = DeleteSessionRequest{} - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[14] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -865,7 +949,7 @@ func (x *DeleteSessionRequest) String() string { func (*DeleteSessionRequest) ProtoMessage() {} func (x *DeleteSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[14] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -878,7 +962,7 @@ func (x *DeleteSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteSessionRequest.ProtoReflect.Descriptor instead. func (*DeleteSessionRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{14} + return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{15} } func (x *DeleteSessionRequest) GetSessionId() string { @@ -897,7 +981,7 @@ type DeleteSessionResponse struct { func (x *DeleteSessionResponse) Reset() { *x = DeleteSessionResponse{} - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[15] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -909,7 +993,7 @@ func (x *DeleteSessionResponse) String() string { func (*DeleteSessionResponse) ProtoMessage() {} func (x *DeleteSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[15] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -922,7 +1006,7 @@ func (x *DeleteSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteSessionResponse.ProtoReflect.Descriptor instead. func (*DeleteSessionResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{15} + return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{16} } func (x *DeleteSessionResponse) GetSession() *Session { @@ -946,7 +1030,7 @@ type SessionShare struct { func (x *SessionShare) Reset() { *x = SessionShare{} - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[16] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -958,7 +1042,7 @@ func (x *SessionShare) String() string { func (*SessionShare) ProtoMessage() {} func (x *SessionShare) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[16] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -971,7 +1055,7 @@ func (x *SessionShare) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionShare.ProtoReflect.Descriptor instead. func (*SessionShare) Descriptor() ([]byte, []int) { - return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{16} + return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{17} } func (x *SessionShare) GetId() string { @@ -1023,7 +1107,7 @@ type CreateSessionShareRequest struct { func (x *CreateSessionShareRequest) Reset() { *x = CreateSessionShareRequest{} - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[17] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1035,7 +1119,7 @@ func (x *CreateSessionShareRequest) String() string { func (*CreateSessionShareRequest) ProtoMessage() {} func (x *CreateSessionShareRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[17] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1048,7 +1132,7 @@ func (x *CreateSessionShareRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSessionShareRequest.ProtoReflect.Descriptor instead. func (*CreateSessionShareRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{17} + return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{18} } func (x *CreateSessionShareRequest) GetSessionId() string { @@ -1083,7 +1167,7 @@ type CreateSessionShareResponse struct { func (x *CreateSessionShareResponse) Reset() { *x = CreateSessionShareResponse{} - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[18] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1095,7 +1179,7 @@ func (x *CreateSessionShareResponse) String() string { func (*CreateSessionShareResponse) ProtoMessage() {} func (x *CreateSessionShareResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[18] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1108,7 +1192,7 @@ func (x *CreateSessionShareResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateSessionShareResponse.ProtoReflect.Descriptor instead. func (*CreateSessionShareResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{18} + return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{19} } func (x *CreateSessionShareResponse) GetShare() *SessionShare { @@ -1135,7 +1219,7 @@ type ListSessionSharesRequest struct { func (x *ListSessionSharesRequest) Reset() { *x = ListSessionSharesRequest{} - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[19] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1147,7 +1231,7 @@ func (x *ListSessionSharesRequest) String() string { func (*ListSessionSharesRequest) ProtoMessage() {} func (x *ListSessionSharesRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[19] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1160,7 +1244,7 @@ func (x *ListSessionSharesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionSharesRequest.ProtoReflect.Descriptor instead. func (*ListSessionSharesRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{19} + return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{20} } func (x *ListSessionSharesRequest) GetSessionId() string { @@ -1187,7 +1271,7 @@ type ListSessionSharesResponse struct { func (x *ListSessionSharesResponse) Reset() { *x = ListSessionSharesResponse{} - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[20] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1199,7 +1283,7 @@ func (x *ListSessionSharesResponse) String() string { func (*ListSessionSharesResponse) ProtoMessage() {} func (x *ListSessionSharesResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[20] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1212,7 +1296,7 @@ func (x *ListSessionSharesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSessionSharesResponse.ProtoReflect.Descriptor instead. func (*ListSessionSharesResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{20} + return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{21} } func (x *ListSessionSharesResponse) GetShares() []*SessionShare { @@ -1238,7 +1322,7 @@ type RevokeSessionShareRequest struct { func (x *RevokeSessionShareRequest) Reset() { *x = RevokeSessionShareRequest{} - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[21] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1250,7 +1334,7 @@ func (x *RevokeSessionShareRequest) String() string { func (*RevokeSessionShareRequest) ProtoMessage() {} func (x *RevokeSessionShareRequest) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[21] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1263,7 +1347,7 @@ func (x *RevokeSessionShareRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeSessionShareRequest.ProtoReflect.Descriptor instead. func (*RevokeSessionShareRequest) Descriptor() ([]byte, []int) { - return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{21} + return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{22} } func (x *RevokeSessionShareRequest) GetShareId() string { @@ -1281,7 +1365,7 @@ type RevokeSessionShareResponse struct { func (x *RevokeSessionShareResponse) Reset() { *x = RevokeSessionShareResponse{} - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[22] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1293,7 +1377,7 @@ func (x *RevokeSessionShareResponse) String() string { func (*RevokeSessionShareResponse) ProtoMessage() {} func (x *RevokeSessionShareResponse) ProtoReflect() protoreflect.Message { - mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[22] + mi := &file_kagent_api_v1alpha1_sessions_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1306,7 +1390,7 @@ func (x *RevokeSessionShareResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeSessionShareResponse.ProtoReflect.Descriptor instead. func (*RevokeSessionShareResponse) Descriptor() ([]byte, []int) { - return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{22} + return file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP(), []int{23} } var File_kagent_api_v1alpha1_sessions_proto protoreflect.FileDescriptor @@ -1316,7 +1400,7 @@ const file_kagent_api_v1alpha1_sessions_proto_rawDesc = "" + "\"kagent/api/v1alpha1/sessions.proto\x12\x13kagent.api.v1alpha1\x1a\x1bbuf/validate/validate.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a kagent/api/v1alpha1/common.proto\x1a!kagent/api/v1alpha1/runtime.proto\";\n" + "\aFailure\x12\x16\n" + "\x06reason\x18\x01 \x01(\tR\x06reason\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage\"\xd5\x04\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"\xb5\x05\n" + "\aSession\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x18\n" + "\acreator\x18\x02 \x01(\tR\acreator\x12<\n" + @@ -1333,7 +1417,13 @@ const file_kagent_api_v1alpha1_sessions_proto_rawDesc = "" + "updated_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12\x12\n" + "\x04name\x18\r \x01(\tR\x04name\x12\x1d\n" + "\n" + - "context_id\x18\x0e \x01(\tR\tcontextIdJ\x04\b\x03\x10\x04J\x04\b\x04\x10\x05J\x04\b\f\x10\rR\aharnessR\x0eagent_templateR\x06labels\"\xff\x01\n" + + "context_id\x18\x0e \x01(\tR\tcontextId\x12^\n" + + "\x17last_quiescence_failure\x18\x10 \x01(\v2&.kagent.api.v1alpha1.QuiescenceFailureR\x15lastQuiescenceFailureJ\x04\b\x03\x10\x04J\x04\b\x04\x10\x05J\x04\b\f\x10\rR\aharnessR\x0eagent_templateR\x06labels\"\x97\x01\n" + + "\x11QuiescenceFailure\x12\x17\n" + + "\atask_id\x18\x01 \x01(\tR\x06taskId\x12\x16\n" + + "\x06reason\x18\x02 \x01(\tR\x06reason\x12\x18\n" + + "\amessage\x18\x03 \x01(\tR\amessage\x127\n" + + "\tfailed_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\bfailedAt\"\xff\x01\n" + "\x14CreateSessionRequest\x12D\n" + "\x05agent\x18\x05 \x01(\v2&.kagent.api.v1alpha1.ResourceReferenceB\x06\xbaH\x03\xc8\x01\x01R\x05agent\x12)\n" + "\n" + @@ -1437,92 +1527,95 @@ func file_kagent_api_v1alpha1_sessions_proto_rawDescGZIP() []byte { } var file_kagent_api_v1alpha1_sessions_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_kagent_api_v1alpha1_sessions_proto_msgTypes = make([]protoimpl.MessageInfo, 23) +var file_kagent_api_v1alpha1_sessions_proto_msgTypes = make([]protoimpl.MessageInfo, 24) var file_kagent_api_v1alpha1_sessions_proto_goTypes = []any{ (SessionSharePermission)(0), // 0: kagent.api.v1alpha1.SessionSharePermission (*Failure)(nil), // 1: kagent.api.v1alpha1.Failure (*Session)(nil), // 2: kagent.api.v1alpha1.Session - (*CreateSessionRequest)(nil), // 3: kagent.api.v1alpha1.CreateSessionRequest - (*CreateSessionResponse)(nil), // 4: kagent.api.v1alpha1.CreateSessionResponse - (*GetSessionRequest)(nil), // 5: kagent.api.v1alpha1.GetSessionRequest - (*GetSessionResponse)(nil), // 6: kagent.api.v1alpha1.GetSessionResponse - (*ListSessionsRequest)(nil), // 7: kagent.api.v1alpha1.ListSessionsRequest - (*ListSessionsResponse)(nil), // 8: kagent.api.v1alpha1.ListSessionsResponse - (*UpdateSessionNameRequest)(nil), // 9: kagent.api.v1alpha1.UpdateSessionNameRequest - (*UpdateSessionNameResponse)(nil), // 10: kagent.api.v1alpha1.UpdateSessionNameResponse - (*SuspendSessionRequest)(nil), // 11: kagent.api.v1alpha1.SuspendSessionRequest - (*SuspendSessionResponse)(nil), // 12: kagent.api.v1alpha1.SuspendSessionResponse - (*ResumeSessionRequest)(nil), // 13: kagent.api.v1alpha1.ResumeSessionRequest - (*ResumeSessionResponse)(nil), // 14: kagent.api.v1alpha1.ResumeSessionResponse - (*DeleteSessionRequest)(nil), // 15: kagent.api.v1alpha1.DeleteSessionRequest - (*DeleteSessionResponse)(nil), // 16: kagent.api.v1alpha1.DeleteSessionResponse - (*SessionShare)(nil), // 17: kagent.api.v1alpha1.SessionShare - (*CreateSessionShareRequest)(nil), // 18: kagent.api.v1alpha1.CreateSessionShareRequest - (*CreateSessionShareResponse)(nil), // 19: kagent.api.v1alpha1.CreateSessionShareResponse - (*ListSessionSharesRequest)(nil), // 20: kagent.api.v1alpha1.ListSessionSharesRequest - (*ListSessionSharesResponse)(nil), // 21: kagent.api.v1alpha1.ListSessionSharesResponse - (*RevokeSessionShareRequest)(nil), // 22: kagent.api.v1alpha1.RevokeSessionShareRequest - (*RevokeSessionShareResponse)(nil), // 23: kagent.api.v1alpha1.RevokeSessionShareResponse - (*ResourceReference)(nil), // 24: kagent.api.v1alpha1.ResourceReference - (RuntimeState)(0), // 25: kagent.api.v1alpha1.RuntimeState - (RuntimeOperation)(0), // 26: kagent.api.v1alpha1.RuntimeOperation - (*timestamppb.Timestamp)(nil), // 27: google.protobuf.Timestamp - (*PageRequest)(nil), // 28: kagent.api.v1alpha1.PageRequest - (*PageResponse)(nil), // 29: kagent.api.v1alpha1.PageResponse - (*durationpb.Duration)(nil), // 30: google.protobuf.Duration + (*QuiescenceFailure)(nil), // 3: kagent.api.v1alpha1.QuiescenceFailure + (*CreateSessionRequest)(nil), // 4: kagent.api.v1alpha1.CreateSessionRequest + (*CreateSessionResponse)(nil), // 5: kagent.api.v1alpha1.CreateSessionResponse + (*GetSessionRequest)(nil), // 6: kagent.api.v1alpha1.GetSessionRequest + (*GetSessionResponse)(nil), // 7: kagent.api.v1alpha1.GetSessionResponse + (*ListSessionsRequest)(nil), // 8: kagent.api.v1alpha1.ListSessionsRequest + (*ListSessionsResponse)(nil), // 9: kagent.api.v1alpha1.ListSessionsResponse + (*UpdateSessionNameRequest)(nil), // 10: kagent.api.v1alpha1.UpdateSessionNameRequest + (*UpdateSessionNameResponse)(nil), // 11: kagent.api.v1alpha1.UpdateSessionNameResponse + (*SuspendSessionRequest)(nil), // 12: kagent.api.v1alpha1.SuspendSessionRequest + (*SuspendSessionResponse)(nil), // 13: kagent.api.v1alpha1.SuspendSessionResponse + (*ResumeSessionRequest)(nil), // 14: kagent.api.v1alpha1.ResumeSessionRequest + (*ResumeSessionResponse)(nil), // 15: kagent.api.v1alpha1.ResumeSessionResponse + (*DeleteSessionRequest)(nil), // 16: kagent.api.v1alpha1.DeleteSessionRequest + (*DeleteSessionResponse)(nil), // 17: kagent.api.v1alpha1.DeleteSessionResponse + (*SessionShare)(nil), // 18: kagent.api.v1alpha1.SessionShare + (*CreateSessionShareRequest)(nil), // 19: kagent.api.v1alpha1.CreateSessionShareRequest + (*CreateSessionShareResponse)(nil), // 20: kagent.api.v1alpha1.CreateSessionShareResponse + (*ListSessionSharesRequest)(nil), // 21: kagent.api.v1alpha1.ListSessionSharesRequest + (*ListSessionSharesResponse)(nil), // 22: kagent.api.v1alpha1.ListSessionSharesResponse + (*RevokeSessionShareRequest)(nil), // 23: kagent.api.v1alpha1.RevokeSessionShareRequest + (*RevokeSessionShareResponse)(nil), // 24: kagent.api.v1alpha1.RevokeSessionShareResponse + (*ResourceReference)(nil), // 25: kagent.api.v1alpha1.ResourceReference + (RuntimeState)(0), // 26: kagent.api.v1alpha1.RuntimeState + (RuntimeOperation)(0), // 27: kagent.api.v1alpha1.RuntimeOperation + (*timestamppb.Timestamp)(nil), // 28: google.protobuf.Timestamp + (*PageRequest)(nil), // 29: kagent.api.v1alpha1.PageRequest + (*PageResponse)(nil), // 30: kagent.api.v1alpha1.PageResponse + (*durationpb.Duration)(nil), // 31: google.protobuf.Duration } var file_kagent_api_v1alpha1_sessions_proto_depIdxs = []int32{ - 24, // 0: kagent.api.v1alpha1.Session.agent:type_name -> kagent.api.v1alpha1.ResourceReference - 25, // 1: kagent.api.v1alpha1.Session.state:type_name -> kagent.api.v1alpha1.RuntimeState - 26, // 2: kagent.api.v1alpha1.Session.operation:type_name -> kagent.api.v1alpha1.RuntimeOperation + 25, // 0: kagent.api.v1alpha1.Session.agent:type_name -> kagent.api.v1alpha1.ResourceReference + 26, // 1: kagent.api.v1alpha1.Session.state:type_name -> kagent.api.v1alpha1.RuntimeState + 27, // 2: kagent.api.v1alpha1.Session.operation:type_name -> kagent.api.v1alpha1.RuntimeOperation 1, // 3: kagent.api.v1alpha1.Session.failure:type_name -> kagent.api.v1alpha1.Failure - 27, // 4: kagent.api.v1alpha1.Session.created_at:type_name -> google.protobuf.Timestamp - 27, // 5: kagent.api.v1alpha1.Session.updated_at:type_name -> google.protobuf.Timestamp - 24, // 6: kagent.api.v1alpha1.CreateSessionRequest.agent:type_name -> kagent.api.v1alpha1.ResourceReference - 2, // 7: kagent.api.v1alpha1.CreateSessionResponse.session:type_name -> kagent.api.v1alpha1.Session - 2, // 8: kagent.api.v1alpha1.GetSessionResponse.session:type_name -> kagent.api.v1alpha1.Session - 28, // 9: kagent.api.v1alpha1.ListSessionsRequest.page:type_name -> kagent.api.v1alpha1.PageRequest - 24, // 10: kagent.api.v1alpha1.ListSessionsRequest.agent:type_name -> kagent.api.v1alpha1.ResourceReference - 2, // 11: kagent.api.v1alpha1.ListSessionsResponse.sessions:type_name -> kagent.api.v1alpha1.Session - 29, // 12: kagent.api.v1alpha1.ListSessionsResponse.page:type_name -> kagent.api.v1alpha1.PageResponse - 2, // 13: kagent.api.v1alpha1.UpdateSessionNameResponse.session:type_name -> kagent.api.v1alpha1.Session - 2, // 14: kagent.api.v1alpha1.SuspendSessionResponse.session:type_name -> kagent.api.v1alpha1.Session - 2, // 15: kagent.api.v1alpha1.ResumeSessionResponse.session:type_name -> kagent.api.v1alpha1.Session - 2, // 16: kagent.api.v1alpha1.DeleteSessionResponse.session:type_name -> kagent.api.v1alpha1.Session - 0, // 17: kagent.api.v1alpha1.SessionShare.permission:type_name -> kagent.api.v1alpha1.SessionSharePermission - 27, // 18: kagent.api.v1alpha1.SessionShare.created_at:type_name -> google.protobuf.Timestamp - 27, // 19: kagent.api.v1alpha1.SessionShare.expires_at:type_name -> google.protobuf.Timestamp - 0, // 20: kagent.api.v1alpha1.CreateSessionShareRequest.permission:type_name -> kagent.api.v1alpha1.SessionSharePermission - 30, // 21: kagent.api.v1alpha1.CreateSessionShareRequest.ttl:type_name -> google.protobuf.Duration - 17, // 22: kagent.api.v1alpha1.CreateSessionShareResponse.share:type_name -> kagent.api.v1alpha1.SessionShare - 28, // 23: kagent.api.v1alpha1.ListSessionSharesRequest.page:type_name -> kagent.api.v1alpha1.PageRequest - 17, // 24: kagent.api.v1alpha1.ListSessionSharesResponse.shares:type_name -> kagent.api.v1alpha1.SessionShare - 29, // 25: kagent.api.v1alpha1.ListSessionSharesResponse.page:type_name -> kagent.api.v1alpha1.PageResponse - 3, // 26: kagent.api.v1alpha1.SessionService.CreateSession:input_type -> kagent.api.v1alpha1.CreateSessionRequest - 5, // 27: kagent.api.v1alpha1.SessionService.GetSession:input_type -> kagent.api.v1alpha1.GetSessionRequest - 7, // 28: kagent.api.v1alpha1.SessionService.ListSessions:input_type -> kagent.api.v1alpha1.ListSessionsRequest - 9, // 29: kagent.api.v1alpha1.SessionService.UpdateSessionName:input_type -> kagent.api.v1alpha1.UpdateSessionNameRequest - 11, // 30: kagent.api.v1alpha1.SessionService.SuspendSession:input_type -> kagent.api.v1alpha1.SuspendSessionRequest - 13, // 31: kagent.api.v1alpha1.SessionService.ResumeSession:input_type -> kagent.api.v1alpha1.ResumeSessionRequest - 15, // 32: kagent.api.v1alpha1.SessionService.DeleteSession:input_type -> kagent.api.v1alpha1.DeleteSessionRequest - 18, // 33: kagent.api.v1alpha1.SessionService.CreateSessionShare:input_type -> kagent.api.v1alpha1.CreateSessionShareRequest - 20, // 34: kagent.api.v1alpha1.SessionService.ListSessionShares:input_type -> kagent.api.v1alpha1.ListSessionSharesRequest - 22, // 35: kagent.api.v1alpha1.SessionService.RevokeSessionShare:input_type -> kagent.api.v1alpha1.RevokeSessionShareRequest - 4, // 36: kagent.api.v1alpha1.SessionService.CreateSession:output_type -> kagent.api.v1alpha1.CreateSessionResponse - 6, // 37: kagent.api.v1alpha1.SessionService.GetSession:output_type -> kagent.api.v1alpha1.GetSessionResponse - 8, // 38: kagent.api.v1alpha1.SessionService.ListSessions:output_type -> kagent.api.v1alpha1.ListSessionsResponse - 10, // 39: kagent.api.v1alpha1.SessionService.UpdateSessionName:output_type -> kagent.api.v1alpha1.UpdateSessionNameResponse - 12, // 40: kagent.api.v1alpha1.SessionService.SuspendSession:output_type -> kagent.api.v1alpha1.SuspendSessionResponse - 14, // 41: kagent.api.v1alpha1.SessionService.ResumeSession:output_type -> kagent.api.v1alpha1.ResumeSessionResponse - 16, // 42: kagent.api.v1alpha1.SessionService.DeleteSession:output_type -> kagent.api.v1alpha1.DeleteSessionResponse - 19, // 43: kagent.api.v1alpha1.SessionService.CreateSessionShare:output_type -> kagent.api.v1alpha1.CreateSessionShareResponse - 21, // 44: kagent.api.v1alpha1.SessionService.ListSessionShares:output_type -> kagent.api.v1alpha1.ListSessionSharesResponse - 23, // 45: kagent.api.v1alpha1.SessionService.RevokeSessionShare:output_type -> kagent.api.v1alpha1.RevokeSessionShareResponse - 36, // [36:46] is the sub-list for method output_type - 26, // [26:36] is the sub-list for method input_type - 26, // [26:26] is the sub-list for extension type_name - 26, // [26:26] is the sub-list for extension extendee - 0, // [0:26] is the sub-list for field type_name + 28, // 4: kagent.api.v1alpha1.Session.created_at:type_name -> google.protobuf.Timestamp + 28, // 5: kagent.api.v1alpha1.Session.updated_at:type_name -> google.protobuf.Timestamp + 3, // 6: kagent.api.v1alpha1.Session.last_quiescence_failure:type_name -> kagent.api.v1alpha1.QuiescenceFailure + 28, // 7: kagent.api.v1alpha1.QuiescenceFailure.failed_at:type_name -> google.protobuf.Timestamp + 25, // 8: kagent.api.v1alpha1.CreateSessionRequest.agent:type_name -> kagent.api.v1alpha1.ResourceReference + 2, // 9: kagent.api.v1alpha1.CreateSessionResponse.session:type_name -> kagent.api.v1alpha1.Session + 2, // 10: kagent.api.v1alpha1.GetSessionResponse.session:type_name -> kagent.api.v1alpha1.Session + 29, // 11: kagent.api.v1alpha1.ListSessionsRequest.page:type_name -> kagent.api.v1alpha1.PageRequest + 25, // 12: kagent.api.v1alpha1.ListSessionsRequest.agent:type_name -> kagent.api.v1alpha1.ResourceReference + 2, // 13: kagent.api.v1alpha1.ListSessionsResponse.sessions:type_name -> kagent.api.v1alpha1.Session + 30, // 14: kagent.api.v1alpha1.ListSessionsResponse.page:type_name -> kagent.api.v1alpha1.PageResponse + 2, // 15: kagent.api.v1alpha1.UpdateSessionNameResponse.session:type_name -> kagent.api.v1alpha1.Session + 2, // 16: kagent.api.v1alpha1.SuspendSessionResponse.session:type_name -> kagent.api.v1alpha1.Session + 2, // 17: kagent.api.v1alpha1.ResumeSessionResponse.session:type_name -> kagent.api.v1alpha1.Session + 2, // 18: kagent.api.v1alpha1.DeleteSessionResponse.session:type_name -> kagent.api.v1alpha1.Session + 0, // 19: kagent.api.v1alpha1.SessionShare.permission:type_name -> kagent.api.v1alpha1.SessionSharePermission + 28, // 20: kagent.api.v1alpha1.SessionShare.created_at:type_name -> google.protobuf.Timestamp + 28, // 21: kagent.api.v1alpha1.SessionShare.expires_at:type_name -> google.protobuf.Timestamp + 0, // 22: kagent.api.v1alpha1.CreateSessionShareRequest.permission:type_name -> kagent.api.v1alpha1.SessionSharePermission + 31, // 23: kagent.api.v1alpha1.CreateSessionShareRequest.ttl:type_name -> google.protobuf.Duration + 18, // 24: kagent.api.v1alpha1.CreateSessionShareResponse.share:type_name -> kagent.api.v1alpha1.SessionShare + 29, // 25: kagent.api.v1alpha1.ListSessionSharesRequest.page:type_name -> kagent.api.v1alpha1.PageRequest + 18, // 26: kagent.api.v1alpha1.ListSessionSharesResponse.shares:type_name -> kagent.api.v1alpha1.SessionShare + 30, // 27: kagent.api.v1alpha1.ListSessionSharesResponse.page:type_name -> kagent.api.v1alpha1.PageResponse + 4, // 28: kagent.api.v1alpha1.SessionService.CreateSession:input_type -> kagent.api.v1alpha1.CreateSessionRequest + 6, // 29: kagent.api.v1alpha1.SessionService.GetSession:input_type -> kagent.api.v1alpha1.GetSessionRequest + 8, // 30: kagent.api.v1alpha1.SessionService.ListSessions:input_type -> kagent.api.v1alpha1.ListSessionsRequest + 10, // 31: kagent.api.v1alpha1.SessionService.UpdateSessionName:input_type -> kagent.api.v1alpha1.UpdateSessionNameRequest + 12, // 32: kagent.api.v1alpha1.SessionService.SuspendSession:input_type -> kagent.api.v1alpha1.SuspendSessionRequest + 14, // 33: kagent.api.v1alpha1.SessionService.ResumeSession:input_type -> kagent.api.v1alpha1.ResumeSessionRequest + 16, // 34: kagent.api.v1alpha1.SessionService.DeleteSession:input_type -> kagent.api.v1alpha1.DeleteSessionRequest + 19, // 35: kagent.api.v1alpha1.SessionService.CreateSessionShare:input_type -> kagent.api.v1alpha1.CreateSessionShareRequest + 21, // 36: kagent.api.v1alpha1.SessionService.ListSessionShares:input_type -> kagent.api.v1alpha1.ListSessionSharesRequest + 23, // 37: kagent.api.v1alpha1.SessionService.RevokeSessionShare:input_type -> kagent.api.v1alpha1.RevokeSessionShareRequest + 5, // 38: kagent.api.v1alpha1.SessionService.CreateSession:output_type -> kagent.api.v1alpha1.CreateSessionResponse + 7, // 39: kagent.api.v1alpha1.SessionService.GetSession:output_type -> kagent.api.v1alpha1.GetSessionResponse + 9, // 40: kagent.api.v1alpha1.SessionService.ListSessions:output_type -> kagent.api.v1alpha1.ListSessionsResponse + 11, // 41: kagent.api.v1alpha1.SessionService.UpdateSessionName:output_type -> kagent.api.v1alpha1.UpdateSessionNameResponse + 13, // 42: kagent.api.v1alpha1.SessionService.SuspendSession:output_type -> kagent.api.v1alpha1.SuspendSessionResponse + 15, // 43: kagent.api.v1alpha1.SessionService.ResumeSession:output_type -> kagent.api.v1alpha1.ResumeSessionResponse + 17, // 44: kagent.api.v1alpha1.SessionService.DeleteSession:output_type -> kagent.api.v1alpha1.DeleteSessionResponse + 20, // 45: kagent.api.v1alpha1.SessionService.CreateSessionShare:output_type -> kagent.api.v1alpha1.CreateSessionShareResponse + 22, // 46: kagent.api.v1alpha1.SessionService.ListSessionShares:output_type -> kagent.api.v1alpha1.ListSessionSharesResponse + 24, // 47: kagent.api.v1alpha1.SessionService.RevokeSessionShare:output_type -> kagent.api.v1alpha1.RevokeSessionShareResponse + 38, // [38:48] is the sub-list for method output_type + 28, // [28:38] is the sub-list for method input_type + 28, // [28:28] is the sub-list for extension type_name + 28, // [28:28] is the sub-list for extension extendee + 0, // [0:28] is the sub-list for field type_name } func init() { file_kagent_api_v1alpha1_sessions_proto_init() } @@ -1538,7 +1631,7 @@ func file_kagent_api_v1alpha1_sessions_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_kagent_api_v1alpha1_sessions_proto_rawDesc), len(file_kagent_api_v1alpha1_sessions_proto_rawDesc)), NumEnums: 1, - NumMessages: 23, + NumMessages: 24, NumExtensions: 0, NumServices: 1, }, diff --git a/go/api/v1alpha3/agent_types.go b/go/api/v1alpha3/agent_types.go index 95fd057d5a..7080988ddb 100644 --- a/go/api/v1alpha3/agent_types.go +++ b/go/api/v1alpha3/agent_types.go @@ -37,6 +37,18 @@ type AgentSpec struct { // +kubebuilder:validation:XValidation:rule="has(self.name) && self.name != ''",message="harnessRef.name must not be empty" // +optional HarnessRef *corev1.LocalObjectReference `json:"harnessRef,omitempty"` + // Egress lists HTTP(S) origins the Agent may reach besides the destinations + // its revision compiles (the model, its MCP servers, its skill and plugin + // sources, telemetry), such as "https://proxy.golang.org". The host may + // have "*" as its leftmost label: "https://*.githubusercontent.com" matches + // one label, such as raw.githubusercontent.com. A wildcard needs two labels + // under it, so nothing opens a whole top-level domain or every host. + // +optional + // +listType=set + // +kubebuilder:validation:MaxItems=64 + // +kubebuilder:validation:items:MaxLength=270 + // +kubebuilder:validation:items:Pattern=`^https?://(\*\.([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)+|([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)*)[a-z]([-a-z0-9]{0,61}[a-z0-9])?(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?$` + Egress []string `json:"egress,omitempty"` } const ( @@ -72,6 +84,7 @@ type AgentStatus struct { // +genclient // +kubebuilder:object:root=true // +kubebuilder:resource:path=agents,singular=agent,categories=kagent +// +kubebuilder:metadata:annotations="helm.sh/resource-policy=keep" // +kubebuilder:subresource:status // +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" diff --git a/go/api/v1alpha3/agenttemplate_types.go b/go/api/v1alpha3/agenttemplate_types.go index b306126311..4e9146e7cb 100644 --- a/go/api/v1alpha3/agenttemplate_types.go +++ b/go/api/v1alpha3/agenttemplate_types.go @@ -102,6 +102,8 @@ type AgentTemplateSkill struct { } // GitArtifact identifies immutable content at a full Git commit ID. +// +kubebuilder:validation:XValidation:rule="!has(self.credentialRef) || (self.credentialRef.name.size() > 0 && self.credentialRef.key.size() > 0)",message="credentialRef name and key must not be empty" +// +kubebuilder:validation:XValidation:rule="!has(self.credentialRef) || self.url.startsWith('https://')",message="credentialRef requires an https URL" type GitArtifact struct { // +kubebuilder:validation:Pattern=`^https?://[^[:space:]]+$` // +kubebuilder:validation:MinLength=1 @@ -110,6 +112,15 @@ type GitArtifact struct { // +kubebuilder:validation:Pattern=`^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$` // +required Commit string `json:"commit"` + // CredentialRef references a key in a same-namespace Secret whose value is + // the base64 of ":" for the URL's host (GitHub takes + // "x-access-token:"). The egress gateway replaces the Authorization + // header of a request the actor sends to that host with "Basic ", + // and the fetch of this source sends a placeholder for it to replace. A + // request without the header gets no credential, and the actor never + // holds the value. + // +optional + CredentialRef *corev1.SecretKeySelector `json:"credentialRef,omitempty"` } // S3Object identifies one immutable S3 object version. @@ -211,6 +222,7 @@ type AgentTemplateSpec struct { // +genclient // +kubebuilder:object:root=true // +kubebuilder:resource:path=agenttemplates,singular=agenttemplate,categories=kagent +// +kubebuilder:metadata:annotations="helm.sh/resource-policy=keep" // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" // AgentTemplate defines portable agent behavior. diff --git a/go/api/v1alpha3/configuration_crd_cel_test.go b/go/api/v1alpha3/configuration_crd_cel_test.go index be23e3a308..1a0f63d1b1 100644 --- a/go/api/v1alpha3/configuration_crd_cel_test.go +++ b/go/api/v1alpha3/configuration_crd_cel_test.go @@ -95,6 +95,20 @@ func TestConfigurationCRDValidation(t *testing.T) { }), wantReject: "spec.workload.image", }, + { + name: "Harness accepts Claude turn limits", + object: validHarness(namespace, "harness-claude-limits", HarnessSpec{Claude: &ClaudeHarness{Limits: &ClaudeHarnessLimits{BudgetUSD: "2.50", MaxTurns: 40}}}), + }, + { + name: "Harness Claude limits require a bound", + object: validHarness(namespace, "harness-claude-unbounded", HarnessSpec{Claude: &ClaudeHarness{Limits: &ClaudeHarnessLimits{}}}), + wantReject: "at least one of budgetUSD or maxTurns must be set", + }, + { + name: "Harness Claude limits reject a budget that is not a decimal", + object: validHarness(namespace, "harness-claude-negative-budget", HarnessSpec{Claude: &ClaudeHarness{Limits: &ClaudeHarnessLimits{BudgetUSD: "-1"}}}), + wantReject: "spec.claude.limits.budgetUSD", + }, { name: "Harness memory requires a model reference", object: validHarness(namespace, "harness-empty-memory-model", HarnessSpec{ @@ -341,6 +355,21 @@ func TestConfigurationCRDValidation(t *testing.T) { } require.ErrorContains(t, cl.Create(ctx, &Agent{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "empty-" + strings.ToLower(field)}, Spec: spec}), field+".name must not be empty") } + t.Run("Agent egress accepts HTTP(S) origins and leftmost-label wildcards", func(t *testing.T) { + spec := AgentSpec{TemplateRef: &corev1.LocalObjectReference{Name: "behavior"}, HarnessRef: &corev1.LocalObjectReference{Name: "runner"}, + Egress: []string{"https://proxy.golang.org", "http://mirror.internal:8080", "https://git.internal:65535", "https://*.githubusercontent.com"}} + require.NoError(t, cl.Create(ctx, &Agent{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "egress"}, Spec: spec})) + }) + for i, origin := range []string{ + "proxy.golang.org", "ftp://proxy.golang.org", "https://*", "https://*.com", "https://a.*.example.com", "https://192.0.2.1", "https://[2001:db8::1]", + "https://proxy.golang.org/path", "https://Proxy.Golang.org", "https://user@proxy.golang.org", "https://proxy.golang.org?x=1", + "https://proxy.golang.org#f", "https://proxy.golang.org:0", "https://proxy.golang.org:70000", + } { + t.Run("Agent egress rejects "+origin, func(t *testing.T) { + spec := AgentSpec{TemplateRef: &corev1.LocalObjectReference{Name: "behavior"}, HarnessRef: &corev1.LocalObjectReference{Name: "runner"}, Egress: []string{origin}} + require.ErrorContains(t, cl.Create(ctx, &Agent{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: fmt.Sprintf("egress-invalid-%d", i)}, Spec: spec}), "spec.egress[0]") + }) + } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/go/api/v1alpha3/harness_types.go b/go/api/v1alpha3/harness_types.go index 3408165968..7f19ad6a5e 100644 --- a/go/api/v1alpha3/harness_types.go +++ b/go/api/v1alpha3/harness_types.go @@ -105,7 +105,29 @@ type KagentHarnessMemory struct { type CodexHarness struct{} // ClaudeHarness selects the Claude runtime adapter. -type ClaudeHarness struct{} +type ClaudeHarness struct { + // Limits bounds every turn of every agent that runs on the Harness + // (Claude Code's --max-budget-usd and --max-turns). A turn that reaches a + // limit ends completed, with the reason and the usage on the task, and a + // follow-up continues the conversation. + // +optional + Limits *ClaudeHarnessLimits `json:"limits,omitempty"` +} + +// ClaudeHarnessLimits bounds one turn. At least one bound is set. +// +kubebuilder:validation:XValidation:rule="has(self.budgetUSD) || has(self.maxTurns)",message="at least one of budgetUSD or maxTurns must be set" +type ClaudeHarnessLimits struct { + // BudgetUSD is the most one turn may spend, in US dollars, as a decimal + // string such as "2.50". + // +kubebuilder:validation:Pattern=`^(0|[1-9][0-9]{0,5})(\.[0-9]{1,4})?$` + // +optional + BudgetUSD string `json:"budgetUSD,omitempty"` + // MaxTurns is the most model round-trips one turn may take. + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=10000 + // +optional + MaxTurns int32 `json:"maxTurns,omitempty"` +} // BYOHarness selects an image that implements kagent's private A2A contract. type BYOHarness struct{} @@ -232,6 +254,7 @@ type HarnessStatus struct { // +genclient // +kubebuilder:object:root=true // +kubebuilder:resource:path=harnesses,singular=harness,categories=kagent +// +kubebuilder:metadata:annotations="helm.sh/resource-policy=keep" // +kubebuilder:subresource:status // +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" diff --git a/go/api/v1alpha3/modelconfig_types.go b/go/api/v1alpha3/modelconfig_types.go index 77a4e546e5..efd44ef7f1 100644 --- a/go/api/v1alpha3/modelconfig_types.go +++ b/go/api/v1alpha3/modelconfig_types.go @@ -146,6 +146,50 @@ type AnthropicConfig struct { // Top-k sampling parameter // +optional TopK int `json:"topK,omitempty"` + + // PromptCaching enables Anthropic prompt caching by marking the reusable + // prefix of every Messages API request with `cache_control` breakpoints: + // the last tool definition, the last system prompt block and the last + // content block of the most recent conversation turn. Anthropic caches the + // prefix up to each breakpoint and bills a cache hit at a fraction of the + // normal input price on later requests within the TTL. Because the + // conversation breakpoint moves with every turn, each call of an agent + // loop reads the whole previous history from the cache and writes only + // the new turn. + // + // Recommended for tool-using agents that make many model calls per task + // with a stable system prompt and tool set — without it the full history + // is billed as fresh input on every call. Cache writes are billed at a + // premium over normal input, so a prefix has to be read at least once to + // pay off, and each model has a minimum cacheable prefix (1024–4096 + // tokens depending on the model) below which the markers are silently + // ignored. + // + // See https://platform.claude.com/docs/en/build-with-claude/prompt-caching + // for the current list of supported models, minimum prefix sizes and + // pricing. + // +optional + // +kubebuilder:default=false + PromptCaching bool `json:"promptCaching,omitempty"` + + // CacheTTL controls how long Anthropic retains a cached prefix when + // PromptCaching is enabled. Only meaningful when PromptCaching is true. + // + // - "5m" (default): the standard 5-minute cache. Each cache hit refreshes + // the window, so an agent loop whose calls are less than 5 minutes + // apart keeps its prefix cached for the whole task. + // - "1h": the extended 1-hour cache, useful for tasks whose model calls + // are spaced more than 5 minutes apart. + // + // NOTE: "1h" is NOT strictly better than "5m". 1-hour cache writes are + // billed at a higher per-token rate than 5-minute writes. Only choose + // "1h" when calls are spaced far enough apart that a 5-minute cache would + // expire between them; otherwise the higher write cost is wasted. See the + // Anthropic prompt-caching docs above. + // +optional + // +kubebuilder:validation:Enum="5m";"1h" + // +kubebuilder:default="5m" + CacheTTL string `json:"cacheTTL,omitempty"` } // Deferred until service-account credentials can be provided to the runtime: @@ -293,6 +337,15 @@ type OllamaConfig struct { // Options for the Ollama API // +optional Options map[string]string `json:"options,omitempty"` + + // Think switches a thinking model's reasoning on or off. It is sent as the + // top-level `think` field of every chat request, not as an option. Unset + // leaves Ollama's default, under which a model with the thinking capability + // (Qwen3, DeepSeek-R1, Granite 4.2, ...) thinks before every answer. Set it + // to false for an agent whose model should answer directly. Ollama refuses + // true for a model without the thinking capability. + // +optional + Think *bool `json:"think,omitempty"` } // GeminiConfig contains Gemini (AI Studio, API-key) specific configuration options @@ -618,6 +671,7 @@ type ModelConfigStatus struct { // +kubebuilder:object:root=true // +kubebuilder:storageversion // +kubebuilder:resource:categories=kagent,shortName=mc +// +kubebuilder:metadata:annotations="helm.sh/resource-policy=keep" // +kubebuilder:subresource:status // +kubebuilder:printcolumn:name="Provider",type="string",JSONPath=".spec.provider" // +kubebuilder:printcolumn:name="Model",type="string",JSONPath=".spec.model" diff --git a/go/api/v1alpha3/modelproviderconfig_types.go b/go/api/v1alpha3/modelproviderconfig_types.go index 13183fa961..bc41b4edef 100644 --- a/go/api/v1alpha3/modelproviderconfig_types.go +++ b/go/api/v1alpha3/modelproviderconfig_types.go @@ -126,6 +126,7 @@ type ModelProviderConfigStatus struct { // +kubebuilder:object:root=true // +kubebuilder:storageversion // +kubebuilder:resource:categories=kagent,shortName=mprov +// +kubebuilder:metadata:annotations="helm.sh/resource-policy=keep" // +kubebuilder:subresource:status // +kubebuilder:printcolumn:name="Type",type="string",JSONPath=".spec.type" // +kubebuilder:printcolumn:name="Endpoint",type="string",JSONPath=".spec.endpoint" diff --git a/go/api/v1alpha3/remotemcpserver_types.go b/go/api/v1alpha3/remotemcpserver_types.go index 8fa83f3a0f..079d0b3458 100644 --- a/go/api/v1alpha3/remotemcpserver_types.go +++ b/go/api/v1alpha3/remotemcpserver_types.go @@ -114,6 +114,7 @@ type MCPTool struct { // +kubebuilder:object:root=true // +kubebuilder:storageversion // +kubebuilder:resource:shortName=rmcps,categories=kagent +// +kubebuilder:metadata:annotations="helm.sh/resource-policy=keep" // +kubebuilder:subresource:status // +kubebuilder:printcolumn:name="Protocol",type="string",JSONPath=".spec.protocol" // +kubebuilder:printcolumn:name="URL",type="string",JSONPath=".spec.url" diff --git a/go/api/v1alpha3/sandboxtemplate_types.go b/go/api/v1alpha3/sandboxtemplate_types.go index 9687b688a9..a57a00c5dc 100644 --- a/go/api/v1alpha3/sandboxtemplate_types.go +++ b/go/api/v1alpha3/sandboxtemplate_types.go @@ -55,6 +55,7 @@ type SandboxTemplateStatus struct { // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:resource:path=sandboxtemplates,singular=sandboxtemplate,scope=Namespaced,categories=kagent +// +kubebuilder:metadata:annotations="helm.sh/resource-policy=keep" // +kubebuilder:printcolumn:name="Image",type="string",JSONPath=".spec.workload.image" // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" diff --git a/go/api/v1alpha3/zz_generated.deepcopy.go b/go/api/v1alpha3/zz_generated.deepcopy.go index 59a3d9ddf2..6b1d6c92f6 100644 --- a/go/api/v1alpha3/zz_generated.deepcopy.go +++ b/go/api/v1alpha3/zz_generated.deepcopy.go @@ -109,6 +109,11 @@ func (in *AgentSpec) DeepCopyInto(out *AgentSpec) { *out = new(v1.LocalObjectReference) **out = **in } + if in.Egress != nil { + in, out := &in.Egress, &out.Egress + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentSpec. @@ -375,7 +380,7 @@ func (in *ArtifactSource) DeepCopyInto(out *ArtifactSource) { if in.Git != nil { in, out := &in.Git, &out.Git *out = new(GitArtifact) - **out = **in + (*in).DeepCopyInto(*out) } if in.Bucket != nil { in, out := &in.Bucket, &out.Bucket @@ -518,6 +523,11 @@ func (in *BucketArtifact) DeepCopy() *BucketArtifact { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClaudeHarness) DeepCopyInto(out *ClaudeHarness) { *out = *in + if in.Limits != nil { + in, out := &in.Limits, &out.Limits + *out = new(ClaudeHarnessLimits) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClaudeHarness. @@ -530,6 +540,21 @@ func (in *ClaudeHarness) DeepCopy() *ClaudeHarness { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClaudeHarnessLimits) DeepCopyInto(out *ClaudeHarnessLimits) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClaudeHarnessLimits. +func (in *ClaudeHarnessLimits) DeepCopy() *ClaudeHarnessLimits { + if in == nil { + return nil + } + out := new(ClaudeHarnessLimits) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CodexHarness) DeepCopyInto(out *CodexHarness) { *out = *in @@ -614,6 +639,11 @@ func (in *GeminiVertexAIConfig) DeepCopy() *GeminiVertexAIConfig { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GitArtifact) DeepCopyInto(out *GitArtifact) { *out = *in + if in.CredentialRef != nil { + in, out := &in.CredentialRef, &out.CredentialRef + *out = new(v1.SecretKeySelector) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitArtifact. @@ -726,7 +756,7 @@ func (in *HarnessSpec) DeepCopyInto(out *HarnessSpec) { if in.Claude != nil { in, out := &in.Claude, &out.Claude *out = new(ClaudeHarness) - **out = **in + (*in).DeepCopyInto(*out) } if in.BYO != nil { in, out := &in.BYO, &out.BYO @@ -1269,6 +1299,11 @@ func (in *OllamaConfig) DeepCopyInto(out *OllamaConfig) { (*out)[key] = val } } + if in.Think != nil { + in, out := &in.Think, &out.Think + *out = new(bool) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OllamaConfig. diff --git a/go/core/cli/internal/connection/connection.go b/go/core/cli/internal/connection/connection.go index 5ec43e524d..1a4d81efbe 100644 --- a/go/core/cli/internal/connection/connection.go +++ b/go/core/cli/internal/connection/connection.go @@ -14,6 +14,7 @@ import ( "unicode" "github.com/kagent-dev/kagent/go/api/client" + "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -25,6 +26,8 @@ const ( defaultGatewayURL = client.DefaultGatewayURL defaultUserID = "admin@kagent.dev" + authorizationMetadataKey = "authorization" + portForwardReadyTimeout = 15 * time.Second portForwardRetryDelay = 100 * time.Millisecond kubectlErrorLimit = 8 << 10 @@ -41,6 +44,11 @@ type Options struct { Verbose bool Timeout time.Duration UserID string + // CallerToken is the bearer given on the command line; Connect resolves the + // one the calls carry from it, KAGENT_TOKEN or the kubeconfig. + CallerToken string + + token callerToken } func DefaultOptions() Options { @@ -54,7 +62,16 @@ func DefaultOptions() Options { } func (o *Options) clientOptions() []client.ClientOption { - clientOptions := []client.ClientOption{client.WithUserID(o.UserID)} + clientOptions := []client.ClientOption{ + client.WithUserID(o.UserID), + client.WithGRPCDialOptions( + grpc.WithChainUnaryInterceptor(o.unaryInterceptor), + grpc.WithChainStreamInterceptor(o.streamInterceptor), + ), + } + if o.token.isSet() { + clientOptions = append(clientOptions, client.WithBearerToken(o.token.value)) + } if o.Timeout > 0 { clientOptions = append(clientOptions, client.WithGRPCTimeout(o.Timeout)) } @@ -82,20 +99,32 @@ func (o *Options) validate() error { if strings.IndexFunc(o.UserID, unicode.IsSpace) >= 0 { return errors.New("caller identity must not contain whitespace") } + if strings.IndexFunc(o.CallerToken, unicode.IsSpace) >= 0 { + return errors.New("caller token must not contain whitespace") + } return nil } // Connect checks the configured server and starts a port-forward only for an -// unreachable default local endpoint. +// unreachable default local endpoint. It resolves the caller token the +// session's clients send. func Connect(ctx context.Context, cfg *Options, endpoint string) (*PortForward, error) { if err := cfg.validate(); err != nil { return nil, err } + token, err := resolveCallerToken(cfg.CallerToken) + if err != nil { + return nil, err + } + cfg.token = token if cfg.Verbose { fmt.Fprintf(os.Stderr, "Using caller identity %q\n", cfg.UserID) + if token.isSet() { + fmt.Fprintf(os.Stderr, "Using caller token from %s\n", token.source) + } } - err := checkConfiguredServer(ctx, cfg, endpoint) + err = checkConfiguredServer(ctx, cfg, endpoint) if err == nil { return nil, nil } diff --git a/go/core/cli/internal/connection/connection_test.go b/go/core/cli/internal/connection/connection_test.go index 45f46903fc..7eb6d3338b 100644 --- a/go/core/cli/internal/connection/connection_test.go +++ b/go/core/cli/internal/connection/connection_test.go @@ -13,8 +13,10 @@ import ( func TestOptionsValidate(t *testing.T) { assert.NoError(t, (&Options{UserID: "user@example.com"}).validate()) + assert.NoError(t, (&Options{UserID: "user@example.com", CallerToken: "token"}).validate()) assert.Error(t, (&Options{}).validate()) assert.Error(t, (&Options{UserID: "invalid user"}).validate()) + assert.Error(t, (&Options{UserID: "user@example.com", CallerToken: "invalid token"}).validate()) } func TestOptionsClientsValidateOnlyTheirEndpoint(t *testing.T) { diff --git a/go/core/cli/internal/connection/flags.go b/go/core/cli/internal/connection/flags.go index a0cecbd571..3bad73ed37 100644 --- a/go/core/cli/internal/connection/flags.go +++ b/go/core/cli/internal/connection/flags.go @@ -8,14 +8,15 @@ import ( // Flag names are unexported so RegisterFlags and OptionsFromCommand are the // only things that can disagree about them, and they cannot. const ( - flagAPIURL = "api-url" - flagGatewayURL = "gateway-url" - flagCAFile = "ca-file" - flagServerName = "server-name" - flagNamespace = "namespace" - flagVerbose = "verbose" - flagTimeout = "timeout" - flagUserID = "user-id" + flagAPIURL = "api-url" + flagGatewayURL = "gateway-url" + flagCAFile = "ca-file" + flagServerName = "server-name" + flagNamespace = "namespace" + flagVerbose = "verbose" + flagTimeout = "timeout" + flagUserID = "user-id" + flagCallerToken = "caller-token" ) // RegisterFlags declares the CLI-wide connection flags, defaulted from DefaultOptions. @@ -29,6 +30,7 @@ func RegisterFlags(flags *pflag.FlagSet) { flags.BoolP(flagVerbose, "v", defaults.Verbose, "Verbose output") flags.Duration(flagTimeout, defaults.Timeout, "Timeout") flags.String(flagUserID, defaults.UserID, "Caller identity used to select the server-side data partition") + flags.String(flagCallerToken, defaults.CallerToken, "Caller token sent as the bearer of every call; defaults to KAGENT_TOKEN, then the current kubeconfig context's credential") } // OptionsFromCommand resolves connection options from the flags a command was @@ -61,5 +63,8 @@ func OptionsFromCommand(cmd *cobra.Command) (Options, error) { if options.UserID, err = flags.GetString(flagUserID); err != nil { return Options{}, err } + if options.CallerToken, err = flags.GetString(flagCallerToken); err != nil { + return Options{}, err + } return options, nil } diff --git a/go/core/cli/internal/connection/flags_test.go b/go/core/cli/internal/connection/flags_test.go index 4489125932..63836c2dac 100644 --- a/go/core/cli/internal/connection/flags_test.go +++ b/go/core/cli/internal/connection/flags_test.go @@ -31,17 +31,19 @@ func TestOptionsFromCommandReadsInheritedFlags(t *testing.T) { "--verbose", "--timeout", "12s", "--user-id", "reviewer@example.test", + "--caller-token", "reviewer-token", }) require.NoError(t, root.ExecuteContext(t.Context())) assert.Equal(t, Options{ - APIURL: "https://api.example.test", - GatewayURL: "https://gateway.example.test", - CAFile: "/tmp/ca.pem", - ServerName: "api.example.test", - Namespace: "agents", - Verbose: true, - Timeout: 12 * time.Second, - UserID: "reviewer@example.test", + APIURL: "https://api.example.test", + GatewayURL: "https://gateway.example.test", + CAFile: "/tmp/ca.pem", + ServerName: "api.example.test", + Namespace: "agents", + Verbose: true, + Timeout: 12 * time.Second, + UserID: "reviewer@example.test", + CallerToken: "reviewer-token", }, got) } diff --git a/go/core/cli/internal/connection/token.go b/go/core/cli/internal/connection/token.go new file mode 100644 index 0000000000..21fb89d224 --- /dev/null +++ b/go/core/cli/internal/connection/token.go @@ -0,0 +1,107 @@ +package connection + +import ( + "fmt" + "net/http" + "strings" + + "github.com/kagent-dev/kagent/go/core/pkg/env" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/transport" + + // The kubeconfig's oidc auth-provider, so an id-token it holds is read, and + // refreshed when expired, the way kubectl does. + _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" +) + +const bearerPrefix = "Bearer " + +// callerTokenAdvice names every way to supply a caller token, for a refusal +// the person can act on. +var callerTokenAdvice = "pass --" + flagCallerToken + ", set " + env.KagentToken.Name() + + ", or select a kubeconfig context whose credential is a token (an exec plugin or an id-token)" + +// callerToken is the bearer the CLI sends with every controller call, and +// where it came from: messages name the source, never the value. +type callerToken struct { + value string + source string +} + +func (t callerToken) isSet() bool { + return t.value != "" +} + +// resolveCallerToken picks the caller token: the flag, then KAGENT_TOKEN, then +// the credential of the current kubeconfig context. An empty token with no +// error means no source offered one. +func resolveCallerToken(flagValue string) (callerToken, error) { + if flagValue != "" { + return callerToken{value: flagValue, source: "--" + flagCallerToken}, nil + } + if value := env.KagentToken.Get(); value != "" { + return callerToken{value: value, source: env.KagentToken.Name()}, nil + } + return kubeconfigCallerToken() +} + +// kubeconfigCallerToken is the bearer kubectl would send for the current +// context: a static token, an exec plugin's credential or an oidc id-token. +// client-go's transport wrappers resolve all three, an exec plugin's run and an +// expired id-token's refresh included, so the credential is read off a request +// that never leaves the process instead of being resolved a second way. +func kubeconfigCallerToken() (callerToken, error) { + loader := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + clientcmd.NewDefaultClientConfigLoadingRules(), + &clientcmd.ConfigOverrides{}, + ) + rawConfig, err := loader.RawConfig() + if err != nil { + return callerToken{}, fmt.Errorf("failed to load kubeconfig: %w", err) + } + if rawConfig.CurrentContext == "" { + return callerToken{}, nil + } + source := "kubeconfig context " + rawConfig.CurrentContext + + restConfig, err := loader.ClientConfig() + if err != nil { + return callerToken{}, fmt.Errorf("failed to load %s: %w", source, err) + } + transportConfig, err := restConfig.TransportConfig() + if err != nil { + return callerToken{}, fmt.Errorf("failed to read the credential of %s: %w", source, err) + } + capture := &authorizationCapture{} + roundTripper, err := transport.HTTPWrappersForConfig(transportConfig, capture) + if err != nil { + return callerToken{}, fmt.Errorf("failed to read the credential of %s: %w", source, err) + } + request, err := http.NewRequest(http.MethodGet, "https://kubernetes.invalid/", nil) + if err != nil { + return callerToken{}, fmt.Errorf("failed to read the credential of %s: %w", source, err) + } + if _, err := roundTripper.RoundTrip(request); err != nil { + return callerToken{}, fmt.Errorf("failed to read the credential of %s: %w", source, err) + } + token, isBearer := strings.CutPrefix(capture.authorization, bearerPrefix) + if !isBearer || token == "" { + // A client certificate, or no credential at all. + return callerToken{}, nil + } + return callerToken{value: token, source: source}, nil +} + +// authorizationCapture is the innermost round tripper under client-go's +// credential wrappers: it records the Authorization header they set and +// answers without sending anything. +type authorizationCapture struct { + authorization string +} + +var _ http.RoundTripper = (*authorizationCapture)(nil) + +func (c *authorizationCapture) RoundTrip(request *http.Request) (*http.Response, error) { + c.authorization = request.Header.Get("Authorization") + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody, Request: request}, nil +} diff --git a/go/core/cli/internal/connection/token_test.go b/go/core/cli/internal/connection/token_test.go new file mode 100644 index 0000000000..ea475eb8e8 --- /dev/null +++ b/go/core/cli/internal/connection/token_test.go @@ -0,0 +1,135 @@ +package connection + +import ( + "encoding/base64" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// kubeconfigWithUser writes a kubeconfig whose current context authenticates +// with the given user block and points KUBECONFIG at it. +func kubeconfigWithUser(t *testing.T, user string) { + t.Helper() + path := filepath.Join(t.TempDir(), "kubeconfig") + require.NoError(t, os.WriteFile(path, []byte(`apiVersion: v1 +kind: Config +current-context: lab +contexts: +- name: lab + context: + cluster: lab + user: person +clusters: +- name: lab + cluster: + server: https://lab.example.test +users: +- name: person + user: +`+user), 0o600)) + t.Setenv("KUBECONFIG", path) +} + +// unexpiredJWT is a JWT whose only claim is an expiry far ahead; the oidc +// auth-provider hands an id-token out without a refresh while it is valid. +func unexpiredJWT() string { + segment := func(json string) string { return base64.RawURLEncoding.EncodeToString([]byte(json)) } + return segment(`{"alg":"none"}`) + "." + segment(`{"exp":4102444800}`) + ".signature" +} + +func TestResolveCallerToken(t *testing.T) { + idToken := unexpiredJWT() + tests := []struct { + name string + flag string + env string + user string + want callerToken + }{ + { + name: "flag wins over every other source", + flag: "flag-token", + env: "env-token", + user: " token: kubeconfig-token\n", + want: callerToken{value: "flag-token", source: "--caller-token"}, + }, + { + name: "environment wins over the kubeconfig", + env: "env-token", + user: " token: kubeconfig-token\n", + want: callerToken{value: "env-token", source: "KAGENT_TOKEN"}, + }, + { + name: "kubeconfig static token", + user: " token: kubeconfig-token\n", + want: callerToken{value: "kubeconfig-token", source: "kubeconfig context lab"}, + }, + { + name: "kubeconfig exec plugin", + user: ` exec: + apiVersion: client.authentication.k8s.io/v1 + command: sh + args: + - -c + - 'echo ''{"apiVersion":"client.authentication.k8s.io/v1","kind":"ExecCredential","status":{"token":"exec-token"}}''' + interactiveMode: Never +`, + want: callerToken{value: "exec-token", source: "kubeconfig context lab"}, + }, + { + name: "kubeconfig oidc id-token", + user: ` auth-provider: + name: oidc + config: + idp-issuer-url: https://issuer.example.test + client-id: kubernetes + id-token: ` + idToken + "\n", + want: callerToken{value: idToken, source: "kubeconfig context lab"}, + }, + { + name: "kubeconfig client certificate is no bearer", + user: " client-certificate-data: Y2VydA==\n client-key-data: a2V5\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + kubeconfigWithUser(t, tt.user) + t.Setenv("KAGENT_TOKEN", tt.env) + + got, err := resolveCallerToken(tt.flag) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestResolveCallerTokenWithoutKubeconfig(t *testing.T) { + t.Setenv("KUBECONFIG", filepath.Join(t.TempDir(), "absent")) + t.Setenv("KAGENT_TOKEN", "") + + got, err := resolveCallerToken("") + require.NoError(t, err) + assert.Equal(t, callerToken{}, got) + assert.False(t, got.isSet()) +} + +func TestResolveCallerTokenReportsAFailingExecPlugin(t *testing.T) { + kubeconfigWithUser(t, ` exec: + apiVersion: client.authentication.k8s.io/v1 + command: sh + args: + - -c + - 'exit 3' + interactiveMode: Never +`) + t.Setenv("KAGENT_TOKEN", "") + + _, err := resolveCallerToken("") + require.Error(t, err) + assert.Contains(t, err.Error(), "kubeconfig context lab") +} diff --git a/go/core/cli/internal/connection/unauthenticated.go b/go/core/cli/internal/connection/unauthenticated.go new file mode 100644 index 0000000000..55311b14c0 --- /dev/null +++ b/go/core/cli/internal/connection/unauthenticated.go @@ -0,0 +1,57 @@ +package connection + +import ( + "context" + "fmt" + "slices" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +// The controller answers a bearer it cannot identify the caller by with a bare +// "invalid credentials". These interceptors add what the person can do about +// it, which depends on the token this CLI sent, or did not send. + +func (o *Options) unaryInterceptor(ctx context.Context, method string, request, reply any, connection *grpc.ClientConn, invoker grpc.UnaryInvoker, callOptions ...grpc.CallOption) error { + return o.explainUnauthenticated(ctx, invoker(ctx, method, request, reply, connection, callOptions...)) +} + +func (o *Options) streamInterceptor(ctx context.Context, desc *grpc.StreamDesc, connection *grpc.ClientConn, method string, streamer grpc.Streamer, callOptions ...grpc.CallOption) (grpc.ClientStream, error) { + stream, err := streamer(ctx, desc, connection, method, callOptions...) + if err != nil { + return nil, o.explainUnauthenticated(ctx, err) + } + return &explainedStream{ClientStream: stream, ctx: ctx, options: o}, nil +} + +// explainedStream explains the refusal where a stream reports it: on receive. +type explainedStream struct { + grpc.ClientStream + ctx context.Context + options *Options +} + +func (s *explainedStream) RecvMsg(message any) error { + return s.options.explainUnauthenticated(s.ctx, s.ClientStream.RecvMsg(message)) +} + +func (o *Options) explainUnauthenticated(ctx context.Context, err error) error { + if status.Code(err) != codes.Unauthenticated { + return err + } + return status.Error(codes.Unauthenticated, status.Convert(err).Message()+": "+o.explainCallerToken(ctx)) +} + +func (o *Options) explainCallerToken(ctx context.Context) string { + if !o.token.isSet() { + return "no caller token was sent; " + callerTokenAdvice + } + outgoing, _ := metadata.FromOutgoingContext(ctx) + if !slices.Contains(outgoing.Get(authorizationMetadataKey), bearerPrefix+o.token.value) { + return fmt.Sprintf("the call carried the model key of --token as its bearer in place of the caller token from %s; a controller that identifies the caller by the bearer takes no model key there, so drop --token", o.token.source) + } + return fmt.Sprintf("the caller token from %s was refused", o.token.source) +} diff --git a/go/core/cli/internal/connection/unauthenticated_test.go b/go/core/cli/internal/connection/unauthenticated_test.go new file mode 100644 index 0000000000..27e276d391 --- /dev/null +++ b/go/core/cli/internal/connection/unauthenticated_test.go @@ -0,0 +1,96 @@ +package connection + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +var errInvalidCredentials = status.Error(codes.Unauthenticated, "invalid credentials") + +func TestExplainUnauthenticated(t *testing.T) { + resolved := callerToken{value: "caller-jwt", source: "KAGENT_TOKEN"} + tests := []struct { + name string + token callerToken + outgoing metadata.MD + err error + want string + }{ + { + name: "no token names every source", + err: errInvalidCredentials, + want: "invalid credentials: no caller token was sent; pass --caller-token, set KAGENT_TOKEN, or select a kubeconfig context whose credential is a token (an exec plugin or an id-token)", + }, + { + name: "a sent token names its source", + token: resolved, + outgoing: metadata.Pairs("authorization", "Bearer caller-jwt"), + err: errInvalidCredentials, + want: "invalid credentials: the caller token from KAGENT_TOKEN was refused", + }, + { + name: "a model key in the slot names the conflict", + token: resolved, + outgoing: metadata.Pairs("authorization", "Bearer model-key"), + err: errInvalidCredentials, + want: "invalid credentials: the call carried the model key of --token as its bearer in place of the caller token from KAGENT_TOKEN; a controller that identifies the caller by the bearer takes no model key there, so drop --token", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + options := &Options{token: tt.token} + ctx := context.Background() + if tt.outgoing != nil { + ctx = metadata.NewOutgoingContext(ctx, tt.outgoing) + } + + got := options.explainUnauthenticated(ctx, tt.err) + require.Equal(t, codes.Unauthenticated, status.Code(got)) + assert.Equal(t, tt.want, status.Convert(got).Message()) + }) + } +} + +func TestExplainUnauthenticatedLeavesOtherErrorsAlone(t *testing.T) { + options := &Options{} + denied := status.Error(codes.PermissionDenied, "denied") + assert.Same(t, denied, options.explainUnauthenticated(context.Background(), denied)) + assert.NoError(t, options.explainUnauthenticated(context.Background(), nil)) + plain := errors.New("plain") + assert.Same(t, plain, options.explainUnauthenticated(context.Background(), plain)) +} + +type refusingStream struct { + grpc.ClientStream +} + +func (refusingStream) RecvMsg(any) error { + return errInvalidCredentials +} + +func TestInterceptorsExplainOnTheCall(t *testing.T) { + options := &Options{} + ctx := context.Background() + + invoker := func(context.Context, string, any, any, *grpc.ClientConn, ...grpc.CallOption) error { + return errInvalidCredentials + } + err := options.unaryInterceptor(ctx, "/kagent.api.v1alpha1.SessionService/CreateSession", nil, nil, nil, invoker) + assert.Contains(t, status.Convert(err).Message(), "no caller token was sent") + + streamer := func(context.Context, *grpc.StreamDesc, *grpc.ClientConn, string, ...grpc.CallOption) (grpc.ClientStream, error) { + return refusingStream{}, nil + } + stream, err := options.streamInterceptor(ctx, &grpc.StreamDesc{}, nil, "/a2a.v1.A2AService/SendStreamingMessage", streamer) + require.NoError(t, err) + assert.Contains(t, status.Convert(stream.RecvMsg(nil)).Message(), "no caller token was sent") +} diff --git a/go/core/cli/internal/tui/workspace.go b/go/core/cli/internal/tui/workspace.go index d6fe78a2e3..68f50fbfea 100644 --- a/go/core/cli/internal/tui/workspace.go +++ b/go/core/cli/internal/tui/workspace.go @@ -6,6 +6,7 @@ import ( "fmt" "slices" "strings" + "time" a2atype "github.com/a2aproject/a2a-go/v2/a2a" "github.com/charmbracelet/bubbles/list" @@ -702,6 +703,10 @@ func (m *workspaceModel) renderDetails() { if failure := m.current.GetFailure(); failure != nil { fmt.Fprintf(&b, "\nFailure\n%s\n", failure.GetMessage()) } + if failure := m.current.GetLastQuiescenceFailure(); failure != nil { + fmt.Fprintf(&b, "\nNo snapshot of turn %s\n%s: %s\n%s\n", failure.GetTaskId(), failure.GetReason(), failure.GetMessage(), + failure.GetFailedAt().AsTime().Format(time.RFC3339)) + } m.details = b.String() } diff --git a/go/core/cli/internal/tui/workspace_test.go b/go/core/cli/internal/tui/workspace_test.go index f91dfb1f1a..cd64083dc6 100644 --- a/go/core/cli/internal/tui/workspace_test.go +++ b/go/core/cli/internal/tui/workspace_test.go @@ -464,6 +464,23 @@ func TestWorkspaceRenders(t *testing.T) { }, want: []string{id, "reporter", "READY"}, }, + { + name: "details explain a turn released without a snapshot", + sessions: []*apiv1alpha1.Session{func() *apiv1alpha1.Session { + session := readySession(id, "reporter") + session.LastQuiescenceFailure = &apiv1alpha1.QuiescenceFailure{ + TaskId: "task-7", Reason: "SuspendFailed", Message: "retry after a failed request: deadline exceeded", + FailedAt: timestamppb.New(time.Date(2026, 10, 7, 7, 22, 0, 0, time.UTC)), + } + return session + }()}, + render: func(m *workspaceModel) string { + m.current = m.all[0] + m.renderDetails() + return m.details + }, + want: []string{"No snapshot of turn task-7", "SuspendFailed: retry after a failed request: deadline exceeded", "2026-10-07T07:22:00Z"}, + }, } for _, tt := range tests { diff --git a/go/core/internal/a2agateway/gateway.go b/go/core/internal/a2agateway/gateway.go index 4acd9ceab1..dca961ea80 100644 --- a/go/core/internal/a2agateway/gateway.go +++ b/go/core/internal/a2agateway/gateway.go @@ -23,6 +23,10 @@ type interactionService interface { PrepareCancelTask(context.Context, types.NamespacedName, *a2a.CancelTaskRequest) (*apiv1alpha1.Session, *a2a.Task, error) PrepareTaskSubscription(context.Context, types.NamespacedName, *a2a.SubscribeToTaskRequest) (*apiv1alpha1.Session, *a2a.Task, error) RevokeSend(context.Context, types.NamespacedName, *a2a.Message, uuid.UUID) (bool, error) + FailLostRuntime(context.Context, *apiv1alpha1.Session) (*apiv1alpha1.Failure, error) + FailLostTurn(context.Context, *apiv1alpha1.Session, a2a.TaskID) (*apiv1alpha1.Failure, error) + RepointQuiesced(context.Context, *apiv1alpha1.Session) (*apiv1alpha1.Session, error) + AwaitRuntime(context.Context, *apiv1alpha1.Session) error GetTaskByMessage(context.Context, types.NamespacedName, *a2a.Message) (*a2a.Task, error) GetSendResult(context.Context, types.NamespacedName, *a2a.Message, a2a.TaskID, *int) (*a2a.Task, error) GetCancelResult(context.Context, types.NamespacedName, a2a.TaskID) (*a2a.Task, error) diff --git a/go/core/internal/a2agateway/gateway_test.go b/go/core/internal/a2agateway/gateway_test.go index 1486b187cb..91dc96c2eb 100644 --- a/go/core/internal/a2agateway/gateway_test.go +++ b/go/core/internal/a2agateway/gateway_test.go @@ -20,6 +20,7 @@ import ( a2agrpc "github.com/a2aproject/a2a-go/v2/a2agrpc/v1" a2apb "github.com/a2aproject/a2a-go/v2/a2apb/v1" "github.com/a2aproject/a2a-go/v2/a2apb/v1/pbconv" + "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/google/uuid" apia2a "github.com/kagent-dev/kagent/go/api/a2a" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" @@ -66,6 +67,44 @@ type gatewayTestStore struct { unscoped bool settledRead func() error created map[string]*apiv1alpha1.Session + // lost is what the workflow's RuntimeLost answers, with lostCause; lostErr + // makes it fail instead. failed is the failure FailSession recorded. + lost bool + lostCause string + lostErr error + lostCalls int + // repointCalls counts the gateway's repoints before a turn; repointErr is + // what each answers. + repointCalls int + repointErr error + failed *apiv1alpha1.Failure + // awaitCalls counts the waits for a runtime that refused a send without + // being lost; awaitErr is what each answers. + awaitCalls int + awaitErr error + // interruptedTask is the turn InterruptSessionTask ended with + // interruptionMessage; interruptErr is its answer instead. + interruptedTask string + interruptionMessage string + interruptErr error +} + +func (s *gatewayTestStore) InterruptSessionTask(_ context.Context, _, taskID string, _ time.Time, message string) (*database.SessionTaskInterruption, error) { + s.interruptedTask, s.interruptionMessage = taskID, message + if s.interruptErr != nil { + return nil, s.interruptErr + } + return &database.SessionTaskInterruption{State: a2atype.TaskStateFailed}, nil +} + +func (s *gatewayTestStore) FailSession(_ context.Context, id string, failure *apiv1alpha1.Failure) (*apiv1alpha1.Session, error) { + if s.session == nil || id != s.session.Id { + return nil, database.ErrNotFound + } + s.failed = failure + s.session.State = apiv1alpha1.RuntimeState_RUNTIME_STATE_FAILED + s.session.Failure = failure + return s.session, nil } func (s *gatewayTestStore) ReserveSessionDispatch(_ context.Context, _ string, _ uuid.UUID, initialID string) error { @@ -234,6 +273,12 @@ func (r *gatewayTestRuntime) SendMessage(_ context.Context, _ a2aclient.ServiceP func (r *gatewayTestRuntime) SendStreamingMessage(_ context.Context, _ a2aclient.ServiceParams, req *a2atype.SendMessageRequest) iter.Seq2[a2atype.Event, error] { return func(yield func(a2atype.Event, error) bool) { + if r.onSend != nil { + if err := r.onSend(); err != nil { + yield(nil, err) + return + } + } if r.task != nil { yield(r.task, nil) return @@ -787,7 +832,7 @@ func TestGatewayRecoversUnaryResponseLostDuringFinalization(t *testing.T) { func TestGatewayReportsOnlyRevokedAttemptsAsNotAccepted(t *testing.T) { for _, revoked := range []bool{false, true} { - store := &gatewayTestStore{session: gatewayTestSession(), revoked: revoked, taskErr: database.ErrNotFound} + store := &gatewayTestStore{session: gatewayTestSession(), revoked: revoked, taskErr: database.ErrNotFound, awaitErr: errors.New("runtime unavailable")} failure := a2atype.NewError(a2atype.ErrInternalError, "connection lost before response") runtime := &gatewayTestRuntime{onSend: func() error { return failure }} gateway := newTestGateway(store, &gatewayTestAuthorizer{}, &gatewayTestDialer{client: gatewayTestClient(t, runtime)}, gatewayTestURL) @@ -968,3 +1013,278 @@ func TestGatewayUnusedStreamDoesNotReserveDispatch(t *testing.T) { require.Zero(t, store.reserveCalls) require.Nil(t, dialer.session) } + +// refusingRuntime refuses every send with its error, the way Substrate's +// ingress answers for an Actor it cannot bring back. +type refusingRuntime struct { + gatewayTestRuntime + err error +} + +func (r *refusingRuntime) SendMessage(context.Context, a2aclient.ServiceParams, *a2atype.SendMessageRequest) (a2atype.SendMessageResult, error) { + r.sendCalls++ + return nil, r.err +} + +func (r *refusingRuntime) SendStreamingMessage(context.Context, a2aclient.ServiceParams, *a2atype.SendMessageRequest) iter.Seq2[a2atype.Event, error] { + r.sendCalls++ + return func(yield func(a2atype.Event, error) bool) { yield(nil, r.err) } +} + +// heldRuntime refuses its first held sends with err, the way Substrate's +// ingress answers while another operation holds the Actor past its parking +// budget, and takes every later one. +type heldRuntime struct { + gatewayTestRuntime + held int + err error +} + +func (r *heldRuntime) SendMessage(ctx context.Context, params a2aclient.ServiceParams, req *a2atype.SendMessageRequest) (a2atype.SendMessageResult, error) { + if r.held > 0 { + r.held-- + r.sendCalls++ + return nil, r.err + } + return r.gatewayTestRuntime.SendMessage(ctx, params, req) +} + +func (r *heldRuntime) SendStreamingMessage(ctx context.Context, params a2aclient.ServiceParams, req *a2atype.SendMessageRequest) iter.Seq2[a2atype.Event, error] { + r.sendCalls++ + if r.held > 0 { + r.held-- + return func(yield func(a2atype.Event, error) bool) { yield(nil, r.err) } + } + return r.gatewayTestRuntime.SendStreamingMessage(ctx, params, req) +} + +// A send the runtime refused without taking it, while another operation held +// its Actor (a repoint right after an upgrade), is answered once the gateway +// waited the operation out: the turn runs again instead of failing. +func TestGatewayWaitsOutAnOperationHoldingTheRuntime(t *testing.T) { + for _, streaming := range []bool{false, true} { + t.Run(map[bool]string{false: "unary", true: "streaming"}[streaming], func(t *testing.T) { + answered := &a2atype.Task{ID: "runtime-task", ContextID: gatewayTestContextID, Status: a2atype.TaskStatus{State: a2atype.TaskStateCompleted}} + store := &gatewayTestStore{session: gatewayTestSession(), revoked: true, task: answered} + runtime := &heldRuntime{held: 1, err: a2atype.NewError(a2atype.ErrInternalError, "error resuming actor team-a/session-8bd650a8")} + runtime.task = answered + gateway := newTestGateway(store, &gatewayTestAuthorizer{}, &gatewayTestDialer{client: gatewayTestClient(t, runtime)}, gatewayTestURL) + var err error + if streaming { + for _, err = range gateway.SendStreamingMessage(gatewayTestContext(), gatewayTestRequest()) { + if err != nil { + break + } + } + } else { + _, err = gateway.SendMessage(gatewayTestContext(), gatewayTestRequest()) + } + require.NoError(t, err) + require.Equal(t, 1, store.awaitCalls) + require.Equal(t, 2, runtime.sendCalls) + require.Equal(t, 2, store.reserveCalls) + require.Nil(t, store.failed) + }) + } +} + +// The gateway waits once per turn: a runtime that refuses again after the +// wait, or that the wait could not make available, keeps the retryable refusal. +func TestGatewayWaitsForARefusingRuntimeOnlyOnce(t *testing.T) { + for name, awaitErr := range map[string]error{ + "refused again": nil, + "still held": errors.New("resume Actor team-a/session-8bd650a8: still held by another operation after 2m0s"), + "resume refused": errors.New("resume Actor team-a/session-8bd650a8: rpc error: code = PermissionDenied"), + } { + t.Run(name, func(t *testing.T) { + store := &gatewayTestStore{session: gatewayTestSession(), revoked: true, taskErr: database.ErrNotFound, awaitErr: awaitErr} + runtime := &refusingRuntime{err: a2atype.NewError(a2atype.ErrInternalError, "error resuming actor team-a/session-8bd650a8")} + gateway := newTestGateway(store, &gatewayTestAuthorizer{}, &gatewayTestDialer{client: gatewayTestClient(t, runtime)}, gatewayTestURL) + _, err := gateway.SendMessage(gatewayTestContext(), gatewayTestRequest()) + var protocolError *a2atype.Error + require.ErrorAs(t, err, &protocolError) + require.Equal(t, "KAGENT_SEND_NOT_ACCEPTED", protocolError.ErrorInfo().Value["metadata"].(map[string]string)["reason"]) + require.Equal(t, 1, store.awaitCalls) + require.Equal(t, map[bool]int{true: 2, false: 1}[awaitErr == nil], runtime.sendCalls) + require.Nil(t, store.failed) + }) + } +} + +// A send the runtime provably did not take, on a session whose Actor Substrate +// reports crashed or gone, ends with the loss instead of a retry: the session +// records it, and the next send is refused with the same message without a dial. +func TestGatewayFailsSessionWhenRuntimeIsLost(t *testing.T) { + for _, streaming := range []bool{false, true} { + t.Run(map[bool]string{false: "unary", true: "streaming"}[streaming], func(t *testing.T) { + store := &gatewayTestStore{session: gatewayTestSession(), revoked: true, taskErr: database.ErrNotFound, lost: true, lostCause: "Actor team-a/session-8bd650a8 crashed"} + runtime := &refusingRuntime{err: a2atype.NewError(a2atype.ErrInternalError, "actor team-a/session-8bd650a8 unavailable: actor crashed")} + gateway := newTestGateway(store, &gatewayTestAuthorizer{}, &gatewayTestDialer{client: gatewayTestClient(t, runtime)}, gatewayTestURL) + want := apia2a.RuntimeLostMessagePrefix + "Actor team-a/session-8bd650a8 crashed; start a new conversation" + var err error + if streaming { + for _, err = range gateway.SendStreamingMessage(gatewayTestContext(), gatewayTestRequest()) { + if err != nil { + break + } + } + } else { + _, err = gateway.SendMessage(gatewayTestContext(), gatewayTestRequest()) + } + require.ErrorIs(t, err, a2atype.ErrUnsupportedOperation) + require.EqualError(t, err, want) + require.Equal(t, 1, store.lostCalls) + require.Equal(t, apiv1alpha1.RuntimeState_RUNTIME_STATE_FAILED, store.session.State) + require.Equal(t, apia2a.FailureReasonRuntimeLost, store.failed.GetReason()) + require.Equal(t, want, store.failed.GetMessage()) + + _, err = gateway.SendMessage(gatewayTestContext(), gatewayTestRequest()) + require.ErrorIs(t, err, a2atype.ErrUnsupportedOperation) + require.EqualError(t, err, want) + require.Equal(t, 1, runtime.sendCalls, "a failed session is refused without a dial") + require.Equal(t, 1, store.reserveCalls, "a failed session reserves no dispatch") + }) + } +} + +// A runtime that takes the input and then loses its stream, because its worker +// went away with the turn in flight, saves nothing more for the turn: the +// gateway ends the turn with the loss and answers it at once instead of +// waiting for a boundary until the caller's deadline. A runtime that is intact +// leaves the turn to its runtime, and a boundary the runtime saved before the +// stream broke is recovered as the response. +func TestGatewayFailsTheTurnWhenRuntimeIsLostAfterTakingTheInput(t *testing.T) { + lost := a2atype.NewError(a2atype.ErrInternalError, "upstream call failed: broken pipe") + want := apia2a.RuntimeLostMessagePrefix + "Actor team-a/session-8bd650a8 crashed; start a new conversation" + send := func(t *testing.T, gateway a2asrv.RequestHandler, streaming bool) (*a2atype.Task, error) { + t.Helper() + var last a2atype.Event + if streaming { + for event, err := range gateway.SendStreamingMessage(gatewayTestContext(), gatewayTestRequest()) { + if err != nil { + return nil, err + } + last = event + } + } else { + result, err := gateway.SendMessage(gatewayTestContext(), gatewayTestRequest()) + if err != nil { + return nil, err + } + last = result.(a2atype.Event) + } + task, _ := last.(*a2atype.Task) + return task, nil + } + for _, streaming := range []bool{false, true} { + t.Run(map[bool]string{false: "unary", true: "streaming"}[streaming], func(t *testing.T) { + t.Run("lost", func(t *testing.T) { + session := gatewayTestSession() + taken := &a2atype.Task{ID: "task", ContextID: session.ContextId, Status: a2atype.TaskStatus{State: a2atype.TaskStateSubmitted}} + store := &gatewayTestStore{session: session, task: taken, lost: true, lostCause: "Actor team-a/session-8bd650a8 crashed"} + runtime := &gatewayTestRuntime{onSend: func() error { + store.replay = taken + return lost + }} + gateway := newTestGateway(store, &gatewayTestAuthorizer{}, &gatewayTestDialer{client: gatewayTestClient(t, runtime)}, gatewayTestURL) + result, err := send(t, gateway, streaming) + require.Nil(t, result) + require.ErrorIs(t, err, a2atype.ErrUnsupportedOperation) + require.EqualError(t, err, want) + require.Equal(t, 1, store.lostCalls) + require.Equal(t, "task", store.interruptedTask, "the taken turn is ended") + require.Equal(t, want, store.interruptionMessage) + require.Equal(t, apiv1alpha1.RuntimeState_RUNTIME_STATE_FAILED, store.session.State) + require.Equal(t, apia2a.FailureReasonRuntimeLost, store.failed.GetReason()) + require.True(t, runtime.destroyed, "the broken runtime connection is released") + + _, err = send(t, gateway, streaming) + require.ErrorIs(t, err, a2atype.ErrUnsupportedOperation) + require.EqualError(t, err, want) + require.Equal(t, 1, store.reserveCalls, "a failed session reserves no dispatch") + }) + t.Run("intact", func(t *testing.T) { + session := gatewayTestSession() + taken := &a2atype.Task{ID: "task", ContextID: session.ContextId, Status: a2atype.TaskStatus{State: a2atype.TaskStateWorking}} + store := &gatewayTestStore{session: session, task: taken} + runtime := &gatewayTestRuntime{onSend: func() error { + store.replay = taken + return lost + }} + gateway := newTestGateway(store, &gatewayTestAuthorizer{}, &gatewayTestDialer{client: gatewayTestClient(t, runtime)}, gatewayTestURL) + result, err := send(t, gateway, streaming) + require.Nil(t, result) + require.ErrorIs(t, err, lost, "an incomplete turn of an intact runtime stays an error") + require.Equal(t, 1, store.lostCalls) + require.Empty(t, store.interruptedTask, "an intact runtime's turn is left to it") + require.Nil(t, store.failed) + require.Equal(t, apiv1alpha1.RuntimeState_RUNTIME_STATE_READY, store.session.State) + }) + t.Run("boundary saved", func(t *testing.T) { + session := gatewayTestSession() + saved := &a2atype.Task{ID: "task", ContextID: session.ContextId, Status: a2atype.TaskStatus{State: a2atype.TaskStateInputRequired}} + store := &gatewayTestStore{session: session, task: saved, lost: true, lostCause: "Actor team-a/session-8bd650a8 crashed"} + runtime := &gatewayTestRuntime{onSend: func() error { + store.replay = saved + return lost + }} + gateway := newTestGateway(store, &gatewayTestAuthorizer{}, &gatewayTestDialer{client: gatewayTestClient(t, runtime)}, gatewayTestURL) + result, err := send(t, gateway, streaming) + require.NoError(t, err) + require.Equal(t, saved, result, "the boundary the runtime saved is the response") + require.Zero(t, store.lostCalls, "a saved boundary asks nothing of the runtime's state") + require.Empty(t, store.interruptedTask) + require.Equal(t, apiv1alpha1.RuntimeState_RUNTIME_STATE_READY, store.session.State) + }) + }) + } +} + +// A runtime whose state cannot be read, or that is intact, keeps the retryable +// refusal: the session is not failed on a guess. +func TestGatewayKeepsRetryableRefusalWhenRuntimeIsNotKnownLost(t *testing.T) { + for name, store := range map[string]*gatewayTestStore{ + "unknown": {session: gatewayTestSession(), revoked: true, taskErr: database.ErrNotFound, lostErr: errors.New("ate-api is rolling")}, + "intact": {session: gatewayTestSession(), revoked: true, taskErr: database.ErrNotFound, awaitErr: errors.New("runtime unavailable")}, + } { + t.Run(name, func(t *testing.T) { + runtime := &refusingRuntime{err: a2atype.NewError(a2atype.ErrInternalError, "actor request timed out")} + gateway := newTestGateway(store, &gatewayTestAuthorizer{}, &gatewayTestDialer{client: gatewayTestClient(t, runtime)}, gatewayTestURL) + _, err := gateway.SendMessage(gatewayTestContext(), gatewayTestRequest()) + var protocolError *a2atype.Error + require.ErrorAs(t, err, &protocolError) + require.Equal(t, "KAGENT_SEND_NOT_ACCEPTED", protocolError.ErrorInfo().Value["metadata"].(map[string]string)["reason"]) + require.Equal(t, 1, store.lostCalls) + require.Nil(t, store.failed) + require.Equal(t, apiv1alpha1.RuntimeState_RUNTIME_STATE_READY, store.session.State) + }) + } +} + +// After the stalled turn sweep ended a silent turn, the next message is taken +// when the runtime was alive and refused with the recorded loss, without a +// dial, when the sweep found the runtime lost. +func TestGatewaySendAfterStalledTurnSweep(t *testing.T) { + t.Run("runtime alive", func(t *testing.T) { + store := &gatewayTestStore{session: gatewayTestSession()} + runtime := &gatewayTestRuntime{} + gateway := newTestGateway(store, &gatewayTestAuthorizer{}, &gatewayTestDialer{client: gatewayTestClient(t, runtime)}, gatewayTestURL) + _, err := gateway.SendMessage(gatewayTestContext(), gatewayTestRequest()) + require.NoError(t, err) + require.Equal(t, 1, store.reserveCalls) + require.Equal(t, 1, runtime.sendCalls) + }) + t.Run("runtime lost", func(t *testing.T) { + session := gatewayTestSession() + session.State = apiv1alpha1.RuntimeState_RUNTIME_STATE_FAILED + session.Failure = &apiv1alpha1.Failure{Reason: apia2a.FailureReasonRuntimeLost, Message: apia2a.RuntimeLostMessagePrefix + "Actor team-a/session-8bd650a8 crashed; start a new conversation"} + store := &gatewayTestStore{session: session} + runtime := &gatewayTestRuntime{} + gateway := newTestGateway(store, &gatewayTestAuthorizer{}, &gatewayTestDialer{client: gatewayTestClient(t, runtime)}, gatewayTestURL) + _, err := gateway.SendMessage(gatewayTestContext(), gatewayTestRequest()) + require.ErrorIs(t, err, a2atype.ErrUnsupportedOperation) + require.EqualError(t, err, session.Failure.Message) + require.Zero(t, store.reserveCalls) + require.Zero(t, runtime.sendCalls) + }) +} diff --git a/go/core/internal/a2agateway/http_test.go b/go/core/internal/a2agateway/http_test.go index 3c829f7899..cac1dd34d0 100644 --- a/go/core/internal/a2agateway/http_test.go +++ b/go/core/internal/a2agateway/http_test.go @@ -49,7 +49,7 @@ type httpTestShares struct { digest []byte } -func (s *httpTestShares) GetSessionShareByTokenHash(_ context.Context, digest []byte) (*apiv1alpha1.SessionShare, string, error) { +func (s *httpTestShares) GetSessionShareByTokenHash(_ context.Context, digest []byte, _ time.Time) (*apiv1alpha1.SessionShare, string, error) { s.digest = digest return &apiv1alpha1.SessionShare{SessionId: s.sessionID, Permission: s.permission}, "owner", s.err } diff --git a/go/core/internal/a2agateway/interactions.go b/go/core/internal/a2agateway/interactions.go index 0b9a093328..7fd550997e 100644 --- a/go/core/internal/a2agateway/interactions.go +++ b/go/core/internal/a2agateway/interactions.go @@ -58,7 +58,17 @@ func (g *Gateway) cancelTask(ctx context.Context, agent types.NamespacedName, re return result, nil } +// sendMessage runs the send once more after a refusal the gateway waited out: +// the runtime refused it without taking its input while an operation held it. func (g *Gateway) sendMessage(ctx context.Context, agent types.NamespacedName, req *a2atype.SendMessageRequest) (a2atype.SendMessageResult, error) { + result, err := g.sendMessageOnce(ctx, agent, req, true) + if errors.Is(err, errRuntimeAwaited) { + return g.sendMessageOnce(ctx, agent, req, false) + } + return result, err +} + +func (g *Gateway) sendMessageOnce(ctx context.Context, agent types.NamespacedName, req *a2atype.SendMessageRequest, await bool) (a2atype.SendMessageResult, error) { var historyLength *int if req != nil && req.Config != nil { historyLength = req.Config.HistoryLength @@ -76,6 +86,7 @@ func (g *Gateway) sendMessage(ctx context.Context, agent types.NamespacedName, r } ctx = a2aclient.AttachServiceParams(ctx, a2aclient.ServiceParams{apia2a.DispatchHeader: {dispatchID.String()}}) defer g.finishSend(ctx, agent, req.Message, dispatchID, nil) + session = g.repointQuiesced(ctx, session) client, err := g.dial(ctx, session) if err != nil { return nil, err @@ -92,12 +103,15 @@ func (g *Gateway) sendMessage(ctx context.Context, agent types.NamespacedName, r // Preserve explicit protocol rejections, including unwrapped sentinels. if ctx.Err() == nil && a2atype.ErrorReason(err) == a2atype.ErrorReason(a2atype.ErrInternalError) { if task, readErr := g.interactions.GetTaskByMessage(ctx, agent, req.Message); readErr == nil { + if lostErr := g.failLostTurn(ctx, session, task); lostErr != nil { + return nil, lostErr + } if recovered, recoverErr := recoverBoundary(g.interactions.GetSendResult(ctx, agent, req.Message, task.ID, historyLength)); recovered != nil || recoverErr != nil { return recovered, recoverErr } } } - return nil, err + return nil, g.refusedSend(ctx, session, err, await) } if task, ok := result.(*a2atype.Task); ok && isQuiescent(task.Status.State) { if err := closeRuntime(); err != nil { @@ -132,7 +146,29 @@ func (g *Gateway) subscribeToTask(ctx context.Context, agent types.NamespacedNam return g.observe(ctx, agent, session, req.ID, nil, nil, client, client.SubscribeToTask(ctx, req)) } +// sendStreamingMessage runs the send once more after a refusal the gateway +// waited out, as sendMessage does, when the refused attempt yielded nothing. func (g *Gateway) sendStreamingMessage(ctx context.Context, agent types.NamespacedName, req *a2atype.SendMessageRequest) iter.Seq2[a2atype.Event, error] { + return func(yield func(a2atype.Event, error) bool) { + yielded, awaited := false, false + g.streamMessageOnce(ctx, agent, req, true)(func(event a2atype.Event, err error) bool { + if errors.Is(err, errRuntimeAwaited) { + if !yielded { + awaited = true + return false + } + err = sessionsvc.ErrSendNotAccepted + } + yielded = true + return yield(event, err) + }) + if awaited { + g.streamMessageOnce(ctx, agent, req, false)(yield) + } + } +} + +func (g *Gateway) streamMessageOnce(ctx context.Context, agent types.NamespacedName, req *a2atype.SendMessageRequest, await bool) iter.Seq2[a2atype.Event, error] { return func(yield func(a2atype.Event, error) bool) { // Reserve only when the caller starts consuming: an unused iterator must // not block lifecycle work with an undispatched reservation. @@ -156,6 +192,7 @@ func (g *Gateway) sendStreamingMessage(ctx context.Context, agent types.Namespac } ctx := a2aclient.AttachServiceParams(ctx, a2aclient.ServiceParams{apia2a.DispatchHeader: {dispatchID.String()}}) defer g.finishSend(ctx, agent, req.Message, dispatchID, nil) + session = g.repointQuiesced(ctx, session) client, err := g.dial(ctx, session) if err != nil { yield(nil, err) @@ -164,19 +201,35 @@ func (g *Gateway) sendStreamingMessage(ctx context.Context, agent types.Namespac events := func(next func(a2atype.Event, error) bool) { for event, err := range client.SendStreamingMessage(ctx, req) { if err != nil { - next(nil, g.finishSend(ctx, agent, req.Message, dispatchID, err)) + next(nil, g.refusedSend(ctx, session, g.finishSend(ctx, agent, req.Message, dispatchID, err), await)) return } if !next(event, nil) { return } } - next(nil, g.finishSend(ctx, agent, req.Message, dispatchID, a2atype.ErrInternalError)) + next(nil, g.refusedSend(ctx, session, g.finishSend(ctx, agent, req.Message, dispatchID, a2atype.ErrInternalError), await)) } g.observe(ctx, agent, session, req.Message.TaskID, req.Message, historyLength, client, events)(yield) } } +// repointQuiesced moves a runtime quiesced on a superseded revision onto its +// agent's current one before the turn wakes it: a conversation started before +// its agent was re-rendered by a newer release otherwise keeps the old +// revision's configuration for good. The dispatch the caller holds keeps +// lifecycle and idle work off the session meanwhile. A failed repoint is logged +// and the turn runs on the prepared revision; the next turn tries again. +func (g *Gateway) repointQuiesced(ctx context.Context, session *apiv1alpha1.Session) *apiv1alpha1.Session { + moved, err := g.interactions.RepointQuiesced(ctx, session) + if err != nil { + logging.FromContext(ctx).ErrorContext(ctx, "failed to move the session runtime to its agent's current revision", + "session_id", session.GetId(), "revision", session.GetPreparedRevision(), "error", err) + return session + } + return moved +} + // finishSend releases the attempt even after the caller disconnects. A failed // actor call is retryable only if persistence proves the input was not accepted. func (g *Gateway) finishSend(ctx context.Context, agent types.NamespacedName, message *a2atype.Message, id uuid.UUID, sendErr error) error { @@ -191,6 +244,68 @@ func (g *Gateway) finishSend(ctx context.Context, agent types.NamespacedName, me return sendErr } +// errRuntimeAwaited reports a send the runtime refused without taking its +// input, after the gateway waited until the runtime could take it: the send +// runs once more. It never reaches the caller. +var errRuntimeAwaited = errors.New("session runtime awaited after a refused send") + +// refusedSend turns an input the runtime provably did not take into the loss +// it is due to, when the session's runtime is gone. A crashed or missing +// Actor makes the runtime refuse every send the same way, after Substrate's +// parking budget; a retry would only wait it out again. The session records +// the loss once, and this and every later send is refused with its message, +// without a dial. A runtime whose state cannot be read keeps the retryable +// refusal: not knowing is not the same as lost. +// +// A runtime that is not lost may have refused because another operation held +// its Actor beyond Substrate's parking budget, such as a repoint right after +// an upgrade. With await, the gateway waits, bounded, until the runtime can +// take the send and answers errRuntimeAwaited for the caller to send again; +// a runtime still unable to take it keeps the retryable refusal. +func (g *Gateway) refusedSend(ctx context.Context, session *apiv1alpha1.Session, sendErr error, await bool) error { + if !errors.Is(sendErr, sessionsvc.ErrSendNotAccepted) || ctx.Err() != nil { + return sendErr + } + failure, err := g.interactions.FailLostRuntime(ctx, session) + if err != nil { + logging.FromContext(ctx).WarnContext(ctx, "check session runtime after a refused send", "session_id", session.GetId(), "error", err) + return sendErr + } + if failure != nil { + return a2atype.NewError(a2atype.ErrUnsupportedOperation, failure.GetMessage()) + } + if !await { + return sendErr + } + if err := g.interactions.AwaitRuntime(ctx, session); err != nil { + logging.FromContext(ctx).WarnContext(ctx, "wait for session runtime after a refused send", "session_id", session.GetId(), "error", err) + return sendErr + } + return errRuntimeAwaited +} + +// failLostTurn ends a turn the runtime took before its stream broke, when the +// runtime is lost, and answers the loss: the runtime saves nothing more for +// that turn, so waiting for its boundary would only wait out the caller's +// deadline, and the next send would be refused with the same loss. A turn +// whose boundary the runtime did save is recovered by the caller instead. A +// runtime that is not lost, or whose state cannot be read, returns nil: the +// turn's outcome is still the runtime's. +func (g *Gateway) failLostTurn(ctx context.Context, session *apiv1alpha1.Session, task *a2atype.Task) error { + if isQuiescent(task.Status.State) { + return nil + } + failure, err := g.interactions.FailLostTurn(ctx, session, task.ID) + if err != nil { + logging.FromContext(ctx).WarnContext(ctx, "check session runtime after a broken turn", "session_id", session.GetId(), "task_id", task.ID, "error", err) + return nil + } + if failure == nil { + return nil + } + return a2atype.NewError(a2atype.ErrUnsupportedOperation, failure.GetMessage()) +} + // observe owns only this observer's actor connection. Disconnecting cannot // cancel execution. Final results come from the service after native cleanup // acknowledges their saved version, independently of actor pause or suspend. @@ -261,6 +376,11 @@ func (g *Gateway) observe(ctx context.Context, agent types.NamespacedName, sessi task, err := g.interactions.GetTaskByMessage(ctx, agent, message) if err == nil { taskID = task.ID + if lostErr := g.failLostTurn(ctx, session, task); lostErr != nil { + _ = closeRuntime() + yield(nil, lostErr) + return + } } else { taskID = "" } diff --git a/go/core/internal/a2agateway/routing_test.go b/go/core/internal/a2agateway/routing_test.go index f9b8b3eeee..0313912053 100644 --- a/go/core/internal/a2agateway/routing_test.go +++ b/go/core/internal/a2agateway/routing_test.go @@ -43,14 +43,32 @@ func (s gatewayTestAgents) Get(ctx context.Context, ref types.NamespacedName) (* return &v1alpha3.Agent{ObjectMeta: metav1.ObjectMeta{Namespace: ref.Namespace, Name: ref.Name}, Status: v1alpha3.AgentStatus{LatestSuccessfulRevision: "revision-1"}}, nil } -type gatewayTestWorkflow struct{ *sessionsvc.ActorWorkflow } +type gatewayTestWorkflow struct { + *sessionsvc.ActorWorkflow + store *gatewayTestStore +} func (gatewayTestWorkflow) Create(_ context.Context, session *apiv1alpha1.Session) (*apiv1alpha1.Session, error) { return session, nil } +func (w gatewayTestWorkflow) RuntimeLost(context.Context, *apiv1alpha1.Session) (string, bool, error) { + w.store.lostCalls++ + return w.store.lostCause, w.store.lost, w.store.lostErr +} + +func (w gatewayTestWorkflow) AwaitRuntime(context.Context, *apiv1alpha1.Session) error { + w.store.awaitCalls++ + return w.store.awaitErr +} + +func (w gatewayTestWorkflow) RepointQuiesced(_ context.Context, session *apiv1alpha1.Session) (*apiv1alpha1.Session, error) { + w.store.repointCalls++ + return session, w.store.repointErr +} + func newTestSessions(store *gatewayTestStore, authorizer auth.Authorizer) *sessionsvc.Service { - return sessionsvc.NewService(store, authorizer, gatewayTestWorkflow{}) + return sessionsvc.NewService(store, authorizer, gatewayTestWorkflow{store: store}) } func newTestGateway(store *gatewayTestStore, authorizer auth.Authorizer, dialer *gatewayTestDialer, url string) a2asrv.RequestHandler { diff --git a/go/core/internal/a2agateway/runtime.go b/go/core/internal/a2agateway/runtime.go index ba583e34e3..300f5e323b 100644 --- a/go/core/internal/a2agateway/runtime.go +++ b/go/core/internal/a2agateway/runtime.go @@ -11,6 +11,7 @@ import ( "github.com/a2aproject/a2a-go/v2/a2aclient" "github.com/a2aproject/a2a-go/v2/a2aext" a2agrpc "github.com/a2aproject/a2a-go/v2/a2agrpc/v1" + "github.com/kagent-dev/kagent/go/api/adk" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/core/internal/substrate" "github.com/kagent-dev/kagent/go/core/pkg/auth" @@ -96,7 +97,30 @@ func (u *upstreamAuthInterceptor) Before(ctx context.Context, req *a2aclient.Req if err := u.authenticator.UpstreamAuth(httpRequest, session, principal); err != nil { return ctx, nil, err } + // The runtime keys its session on x-user-id and the context id. On a + // turn a share of this Session authorizes, the conversation is the + // owner's: under the visitor's id the runtime finds no session and + // starts an empty one in the same context. The visitor stays the + // person of the turn below and keeps their own Authorization. + if share, ok := auth.ShareContextFrom(ctx); ok && share.IsForSession(u.session.GetId()) { + httpRequest.Header.Set("X-User-Id", share.UserID) + } + // The person of the turn, for the runtime to name on its model calls + // (x-kagent-user): only an identity the authenticator resolved from the + // caller's validated token — a session carrying claims. A session + // without claims (the unsecure mode's X-User-Id and its default, an + // agent's propagated caller id, the control plane) resolves nobody and + // forwards nothing, so a raw credential or a stand-in never becomes an + // accounting identity. + if caller := session.Principal(); caller.Claims != nil && caller.User.ID != "" { + httpRequest.Header.Set(adk.UserHeader, caller.User.ID) + } } + // The Session the turn runs in, for the runtime to name on its model calls + // (x-kagent-agent-instance-id, the header's name since the AgentInstance + // days): the Session the gateway routed this call to, never a value the + // caller sent. + httpRequest.Header.Set(adk.AgentInstanceHeader, u.session.GetId()) for key, values := range httpRequest.Header { for _, value := range values { req.ServiceParams.Append(key, value) diff --git a/go/core/internal/a2agateway/runtime_test.go b/go/core/internal/a2agateway/runtime_test.go index 018b80eba6..67119ab570 100644 --- a/go/core/internal/a2agateway/runtime_test.go +++ b/go/core/internal/a2agateway/runtime_test.go @@ -9,6 +9,7 @@ import ( a2atype "github.com/a2aproject/a2a-go/v2/a2a" a2apb "github.com/a2aproject/a2a-go/v2/a2apb/v1" + "github.com/kagent-dev/kagent/go/api/adk" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/core/internal/substrate" "github.com/kagent-dev/kagent/go/core/pkg/auth" @@ -27,6 +28,125 @@ func (runtimeTestAuth) UpstreamAuth(req *http.Request, _ auth.Session, _ auth.Pr return nil } +// claimsSession is a caller whose identity the authenticator resolved from a +// validated token; the control-plane session and an unsecure-mode session +// carry no claims. +type claimsSession struct{ user string } + +func (s claimsSession) Principal() auth.Principal { + return auth.Principal{User: auth.User{ID: s.user}, Claims: map[string]any{"email": s.user}} +} + +// The runtime learns the person of the turn only when the gateway resolved +// one: a session with claims forwards x-kagent-user, any other forwards none. +// Every turn names the Session the gateway routed it to. +func TestRuntimeDialerForwardsTheResolvedPersonOnly(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + received := make(chan metadata.MD, 2) + server := grpc.NewServer(grpc.UnaryInterceptor(func(ctx context.Context, _ any, _ *grpc.UnaryServerInfo, _ grpc.UnaryHandler) (any, error) { + md, _ := metadata.FromIncomingContext(ctx) + received <- md + return nil, status.Error(codes.Unimplemented, "observed") + })) + a2apb.RegisterA2AServiceServer(server, &a2apb.UnimplementedA2AServiceServer{}) + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + dialer, err := NewRuntimeDialer("http://"+listener.Addr().String(), runtimeTestAuth{}) + require.NoError(t, err) + for _, tt := range []struct { + name string + session auth.Session + want []string + }{ + {name: "resolved person", session: claimsSession{user: "alice@example.com"}, want: []string{"alice@example.com"}}, + {name: "control plane", session: auth.ControlPlaneSession{}, want: nil}, + } { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + ctx = auth.AuthSessionTo(ctx, tt.session) + client, err := dialer.Dial(ctx, &apiv1alpha1.Session{Id: "session", A2AAuthority: substrate.ActorHost("team", "session-session", "")}) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, client.Destroy()) }) + _, err = client.GetTask(ctx, &a2atype.GetTaskRequest{ID: "task"}) + require.Error(t, err) + select { + case md := <-received: + require.Equal(t, tt.want, md.Get(adk.UserHeader)) + require.Equal(t, []string{"session"}, md.Get(adk.AgentInstanceHeader)) + require.Equal(t, []string{"Bearer runtime-test"}, md.Get("authorization")) + case <-ctx.Done(): + t.Fatal("runtime did not receive the call") + } + }) + } +} + +// runtimeCallerAuth forwards the caller as X-User-Id and a fixed bearer, like +// the installed authenticators. +type runtimeCallerAuth struct{ auth.AuthProvider } + +func (runtimeCallerAuth) UpstreamAuth(req *http.Request, session auth.Session, _ auth.Principal) error { + req.Header.Set("Authorization", "Bearer visitor") + req.Header.Set("X-User-Id", session.Principal().User.ID) + return nil +} + +// The runtime keys its session on x-user-id, so a turn a share of the routed +// Session authorizes names the share's owner there, while the visitor stays +// the person of the turn and keeps their own credentials. +func TestRuntimeDialerRunsASharedTurnInTheOwnersSession(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + received := make(chan metadata.MD, 1) + // A turn reaches the runtime as a streaming call. + server := grpc.NewServer(grpc.StreamInterceptor(func(_ any, stream grpc.ServerStream, _ *grpc.StreamServerInfo, _ grpc.StreamHandler) error { + md, _ := metadata.FromIncomingContext(stream.Context()) + received <- md + return status.Error(codes.Unimplemented, "observed") + })) + a2apb.RegisterA2AServiceServer(server, &a2apb.UnimplementedA2AServiceServer{}) + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + dialer, err := NewRuntimeDialer("http://"+listener.Addr().String(), runtimeCallerAuth{}) + require.NoError(t, err) + for _, tt := range []struct { + name string + share *auth.ShareContext + want string + }{ + {name: "no share", want: "visitor@example.com"}, + {name: "share of the session", share: &auth.ShareContext{UserID: "owner@example.com", SessionID: "session"}, want: "owner@example.com"}, + {name: "share of another session", share: &auth.ShareContext{UserID: "owner@example.com", SessionID: "other"}, want: "visitor@example.com"}, + } { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + ctx = auth.AuthSessionTo(ctx, claimsSession{user: "visitor@example.com"}) + if tt.share != nil { + ctx = auth.ShareContextTo(ctx, tt.share) + } + client, err := dialer.Dial(ctx, &apiv1alpha1.Session{Id: "session", A2AAuthority: substrate.ActorHost("team", "session-session", "")}) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, client.Destroy()) }) + for _, err := range client.SendStreamingMessage(ctx, &a2atype.SendMessageRequest{ + Message: a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart("hello")), + }) { + require.Error(t, err) + } + select { + case md := <-received: + require.Equal(t, []string{tt.want}, md.Get("x-user-id")) + require.Equal(t, []string{"visitor@example.com"}, md.Get(adk.UserHeader)) + require.Equal(t, []string{"Bearer visitor"}, md.Get("authorization")) + case <-ctx.Done(): + t.Fatal("runtime did not receive the call") + } + }) + } +} + func TestRuntimeDialerRoutesUnaryAndStreamingCalls(t *testing.T) { listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) diff --git a/go/core/internal/controller/collections.go b/go/core/internal/controller/collections.go index 1b359d804e..26f390dc27 100644 --- a/go/core/internal/controller/collections.go +++ b/go/core/internal/controller/collections.go @@ -2,6 +2,7 @@ package controller import ( "reflect" + "time" atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1" "github.com/agent-substrate/substrate/pkg/proto/ateapipb" @@ -31,6 +32,7 @@ type Collections struct { ModelConfigStatuses krt.StatusCollection[*kagentv1alpha3.ModelConfig, kagentv1alpha3.ModelConfigStatus] ResolvedModelConfigs krt.Collection[v2translator.ResolvedModelConfig] AgentStatuses krt.StatusCollection[*kagentv1alpha3.Agent, kagentv1alpha3.AgentStatus] + HarnessStatuses krt.StatusCollection[*kagentv1alpha3.Harness, kagentv1alpha3.HarnessStatus] } // AgentRuntimeObservation records a Agent's preparation. The revision prevents a @@ -41,6 +43,12 @@ type AgentRuntimeObservation struct { RevisionID v2translator.RevisionID Template *ateapipb.ActorTemplate Failure *ReconciliationFailure + // GoldenBootRetries counts the golden boots of this revision that were + // started over after a crash; Template is the boot after them. + GoldenBootRetries int + // RetryGoldenBootAt is when the crashed golden boot Template carries may + // be started over. Zero while Template has not crashed. + RetryGoldenBootAt time.Time } func (p AgentRuntimeObservation) ResourceName() string { @@ -51,10 +59,11 @@ var _ krt.Equaler[AgentRuntimeObservation] = AgentRuntimeObservation{} // Equals compares runtime contents rather than protobuf's mutable caches. func (p AgentRuntimeObservation) Equals(other AgentRuntimeObservation) bool { - if !proto.Equal(p.Template, other.Template) { + if !proto.Equal(p.Template, other.Template) || !p.RetryGoldenBootAt.Equal(other.RetryGoldenBootAt) { return false } p.Template, other.Template = nil, nil + p.RetryGoldenBootAt, other.RetryGoldenBootAt = time.Time{}, time.Time{} return reflect.DeepEqual(p, other) } @@ -78,6 +87,7 @@ func NewCollections(client kube.Client, watchNamespaces []string, opts krt.Optio } reconciliations := newAgentReconciliations(agents, compilerCollections, agentRuntimeObservations, opts) statuses := newAgentStatuses(agents, reconciliations, opts) + harnessStatuses := newHarnessStatuses(harnesses, workerPools, reconciliations, opts) return Collections{ SandboxTemplates: sandboxTemplates, @@ -94,6 +104,7 @@ func NewCollections(client kube.Client, watchNamespaces []string, opts krt.Optio ModelConfigStatuses: modelConfigStatuses, ResolvedModelConfigs: resolvedModelConfigs, AgentStatuses: statuses, + HarnessStatuses: harnessStatuses, } } diff --git a/go/core/internal/controller/equality_test.go b/go/core/internal/controller/equality_test.go index 813b7f1af2..b148a55478 100644 --- a/go/core/internal/controller/equality_test.go +++ b/go/core/internal/controller/equality_test.go @@ -3,6 +3,7 @@ package controller import ( "sync" "testing" + "time" a2apb "github.com/a2aproject/a2a-go/v2/a2apb/v1" "github.com/agent-substrate/substrate/pkg/proto/ateapipb" @@ -31,6 +32,7 @@ func equalityTestReconciliation() AgentReconciliation { Metadata: &ateapipb.ResourceMetadata{Atespace: "test", Name: "runtime", Uid: "runtime-uid"}, Status: &ateapipb.ActorTemplateStatus{GoldenSnapshotStatus: &ateapipb.GoldenSnapshotStatus{ErrorMessage: "preparing"}}, }, + GoldenBootRetry: &GoldenBootRetry{Attempt: 1, Message: "GoldenActorCrashed: golden actor crashed before its snapshot was taken"}, PreparationFailure: &ReconciliationFailure{Condition: "Ready", Reason: "RuntimePreparationFailed", Message: "retry", Retryable: true}, } } @@ -69,6 +71,8 @@ func TestAgentReconciliationEquality(t *testing.T) { {name: "failure", change: func(r *AgentReconciliation) { r.PreparationFailure.Message = "changed" }}, {name: "retryability", change: func(r *AgentReconciliation) { r.PreparationFailure.Retryable = false }}, {name: "failure cleared", change: func(r *AgentReconciliation) { r.PreparationFailure = nil }}, + {name: "golden boot retry", change: func(r *AgentReconciliation) { r.GoldenBootRetry.Attempt = 2 }}, + {name: "golden boot retry cleared", change: func(r *AgentReconciliation) { r.GoldenBootRetry = nil }}, {name: "compilation failure", change: func(r *AgentReconciliation) { r.CompilationFailure = &ReconciliationFailure{Reason: "ActorTemplateInvalid"} }}, @@ -90,6 +94,7 @@ func equalityTestObservation() AgentRuntimeObservation { return AgentRuntimeObservation{ Namespace: "test", AgentName: "agent", RevisionID: state.Target.RevisionID, Template: state.ObservedActorTemplate, Failure: state.PreparationFailure, + GoldenBootRetries: 1, RetryGoldenBootAt: time.Unix(1_700_000_000, 0), } } @@ -112,6 +117,10 @@ func TestAgentRuntimeObservationEquality(t *testing.T) { {name: "failure", change: func(o *AgentRuntimeObservation) { o.Failure.Message = "changed" }}, {name: "retryability", change: func(o *AgentRuntimeObservation) { o.Failure.Retryable = false }}, {name: "failure cleared", change: func(o *AgentRuntimeObservation) { o.Failure = nil }}, + {name: "golden boot retries", change: func(o *AgentRuntimeObservation) { o.GoldenBootRetries = 2 }}, + {name: "retry deadline", change: func(o *AgentRuntimeObservation) { o.RetryGoldenBootAt = o.RetryGoldenBootAt.Add(time.Second) }}, + {name: "retry deadline cleared", change: func(o *AgentRuntimeObservation) { o.RetryGoldenBootAt = time.Time{} }}, + {name: "retry deadline in another location", change: func(o *AgentRuntimeObservation) { o.RetryGoldenBootAt = o.RetryGoldenBootAt.UTC() }, equal: true}, } { t.Run(tt.name, func(t *testing.T) { left, right := equalityTestObservation(), equalityTestObservation() diff --git a/go/core/internal/controller/preparation_test.go b/go/core/internal/controller/preparation_test.go index fdde79c673..d98efdd0ad 100644 --- a/go/core/internal/controller/preparation_test.go +++ b/go/core/internal/controller/preparation_test.go @@ -13,6 +13,7 @@ import ( "github.com/agent-substrate/substrate/pkg/proto/ateapipb" "github.com/google/uuid" kagentfake "github.com/kagent-dev/kagent/go/api/clientset/versioned/fake" + kagentclient "github.com/kagent-dev/kagent/go/api/clientset/versioned/typed/api/v1alpha3" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" kagentv1alpha3 "github.com/kagent-dev/kagent/go/api/v1alpha3" "github.com/kagent-dev/kagent/go/core/internal/database" @@ -379,6 +380,12 @@ func (p *preparationTestBackend) CreateActorTemplate(ctx context.Context, templa return p.templates.CreateActorTemplate(ctx, template) } +func (p *preparationTestBackend) DeleteActorTemplate(ctx context.Context, atespace, name string) error { + p.mu.Lock() + defer p.mu.Unlock() + return p.templates.DeleteActorTemplate(ctx, atespace, name) +} + func (p *preparationTestBackend) UpsertAgentDefinition(ctx context.Context, definition database.AgentDefinition) error { p.mu.Lock() defer p.mu.Unlock() @@ -501,6 +508,270 @@ func TestPreparationTerminalFailures(t *testing.T) { } } +const unattributedGoldenCrash = "GoldenActorCrashed: golden actor crashed before its snapshot was taken" + +func crashedGoldenBoot(message string) *ateapipb.ActorTemplateStatus { + return &ateapipb.ActorTemplateStatus{GoldenSnapshotStatus: &ateapipb.GoldenSnapshotStatus{ErrorMessage: message}} +} + +func readyGoldenBoot() *ateapipb.ActorTemplateStatus { + return &ateapipb.ActorTemplateStatus{GoldenSnapshotStatus: &ateapipb.GoldenSnapshotStatus{ + GoldenTag: &ateapipb.ObjectRef{Atespace: "ate-golden", Name: "golden"}, + }} +} + +// goldenBootTest runs the reconciler over the fakes inside a synctest bubble, +// so the poller's ticks and the retry deadlines share one clock. +type goldenBootTest struct { + t *testing.T + collections Collections + harnesses krt.StaticCollection[*kagentv1alpha3.Harness] + backend *preparationTestBackend + templates *fakeActorTemplates + store *fakeRuntimeRevisionStore + reconciler *Reconciler + initial AgentReconciliation + statusClient kagentclient.ApiV1alpha3Interface +} + +func startGoldenBootTest(t *testing.T) *goldenBootTest { + t.Helper() + collections, harnesses := newPreparationTestCollections(t, "microvm") + initial := collections.Reconciliations.List()[0] + g := &goldenBootTest{ + t: t, collections: collections, harnesses: harnesses, initial: initial, + templates: &fakeActorTemplates{}, store: &fakeRuntimeRevisionStore{}, + statusClient: kagentfake.NewSimpleClientset(initial.Agent.DeepCopy()).ApiV1alpha3(), + } + g.backend = &preparationTestBackend{templates: g.templates, store: g.store} + g.reconciler = newReconciler(collections, g.backend, g.backend, g.statusClient) + ctx, cancel := context.WithCancel(t.Context()) + t.Cleanup(func() { + cancel() + synctest.Wait() + }) + go g.reconciler.Run(ctx.Done()) + synctest.Wait() + g.backend.inspect(func() { + require.Equal(t, 1, g.templates.created) + require.Equal(t, "actor-uid", g.store.revision.ActorTemplateUID) + require.False(t, g.store.markedSuccessful) + }) + return g +} + +// setGoldenBoot replaces the observed template's status the way Substrate's +// template reconciler writes it, and lets the next poller tick observe it. +func (g *goldenBootTest) setGoldenBoot(status *ateapipb.ActorTemplateStatus) { + g.t.Helper() + g.backend.inspect(func() { + require.NotNil(g.t, g.templates.template) + g.templates.template = proto.CloneOf(g.templates.template) + g.templates.template.Status = status + }) + time.Sleep(time.Second) + synctest.Wait() +} + +func (g *goldenBootTest) observation() AgentRuntimeObservation { + g.t.Helper() + observed := g.collections.AgentRuntimeObservations.GetKey(g.initial.ResourceName()) + require.NotNil(g.t, observed, "the Agent must stay observed") + return *observed +} + +func (g *goldenBootTest) readyCondition() metav1.Condition { + g.t.Helper() + published, err := g.statusClient.Agents(g.initial.Agent.Namespace).Get(g.t.Context(), g.initial.Agent.Name, metav1.GetOptions{}) + require.NoError(g.t, err) + ready := apimeta.FindStatusCondition(published.Status.Conditions, kagentv1alpha3.AgentConditionReady) + require.NotNil(g.t, ready) + return *ready +} + +func TestGoldenBootCrashIsStartedOver(t *testing.T) { + for name, message := range map[string]string{ + "worker replaced under the golden actor": unattributedGoldenCrash, + "registry refused the image pull": "GoldenActorCrashed: actor ate-golden/assistant crashed: CallAteletRestore failed: " + + "while creating \"agent\" OCI bundle: FAILED_GET_EXTERNAL_OBJECT: 503 Service Unavailable", + } { + t.Run(name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := startGoldenBootTest(t) + g.setGoldenBoot(crashedGoldenBoot(message)) + observed := g.observation() + require.Equal(t, 0, observed.GoldenBootRetries) + require.Equal(t, time.Now().Add(goldenBootRetryBaseDelay), observed.RetryGoldenBootAt, "the first sight of a crash schedules its retry") + g.backend.inspect(func() { require.Empty(t, g.templates.deleted, "a crash is not started over before its backoff") }) + ready := g.readyCondition() + require.Equal(t, metav1.ConditionFalse, ready.Status) + require.Equal(t, "ActorTemplateRetrying", ready.Reason) + require.Equal(t, "golden boot 1 of 6 failed ("+message+"); starting it over", ready.Message) + + time.Sleep(goldenBootRetryBaseDelay - time.Second) + synctest.Wait() + g.backend.inspect(func() { require.Empty(t, g.templates.deleted) }) + require.Equal(t, observed.RetryGoldenBootAt, g.observation().RetryGoldenBootAt, "the deadline holds across observations") + + time.Sleep(time.Second) + synctest.Wait() + g.backend.inspect(func() { + require.Equal(t, []string{"actor-uid"}, g.templates.deleted, "the crashed template and its golden actor are removed") + require.Equal(t, 2, g.templates.created, "the desired template is created again") + require.Equal(t, "actor-uid-2", g.templates.template.GetMetadata().GetUid()) + require.Nil(t, g.templates.template.GetStatus(), "the new boot starts clean") + require.Equal(t, "actor-uid-2", g.store.revision.ActorTemplateUID, "the revision follows the new template so GC can tell them apart") + require.False(t, g.store.markedSuccessful) + }) + observed = g.observation() + require.Equal(t, 1, observed.GoldenBootRetries) + require.True(t, observed.RetryGoldenBootAt.IsZero()) + require.Equal(t, "ActorTemplatePending", g.readyCondition().Reason) + + g.setGoldenBoot(readyGoldenBoot()) + g.backend.inspect(func() { require.True(t, g.store.markedSuccessful) }) + require.Equal(t, 1, g.observation().GoldenBootRetries, "the count stays with the revision") + ready = g.readyCondition() + require.Equal(t, metav1.ConditionTrue, ready.Status) + require.Equal(t, "Ready", ready.Reason) + }) + }) + } +} + +func TestGoldenBootRetryBudget(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := startGoldenBootTest(t) + var delays []time.Duration + for retry := range maxGoldenBootRetries { + g.setGoldenBoot(crashedGoldenBoot(unattributedGoldenCrash)) + observed := g.observation() + require.Equal(t, retry, observed.GoldenBootRetries) + delays = append(delays, time.Until(observed.RetryGoldenBootAt)) + require.Equal(t, "ActorTemplateRetrying", g.readyCondition().Reason) + time.Sleep(time.Until(observed.RetryGoldenBootAt)) + synctest.Wait() + g.backend.inspect(func() { + require.Len(t, g.templates.deleted, retry+1) + require.Equal(t, retry+2, g.templates.created) + }) + } + require.Equal(t, []time.Duration{20 * time.Second, 40 * time.Second, 80 * time.Second, 2 * time.Minute, 2 * time.Minute}, delays, "the backoff doubles up to its cap") + + g.setGoldenBoot(crashedGoldenBoot(unattributedGoldenCrash)) + observed := g.observation() + require.Equal(t, maxGoldenBootRetries, observed.GoldenBootRetries) + require.True(t, observed.RetryGoldenBootAt.IsZero()) + state := g.collections.Reconciliations.GetKey(g.initial.ResourceName()) + require.Nil(t, state.GoldenBootRetry) + require.Equal(t, "ActorTemplateFailed", state.PreparationFailure.Reason) + require.Equal(t, "golden boot 6 of 6 failed ("+unattributedGoldenCrash+"); no retries left", state.PreparationFailure.Message) + require.False(t, state.PreparationFailure.Retryable) + ready := g.readyCondition() + require.Equal(t, metav1.ConditionFalse, ready.Status) + require.Equal(t, "ActorTemplateFailed", ready.Reason) + var reconciles int + g.backend.inspect(func() { reconciles = g.store.pairCalls }) + time.Sleep(time.Hour) + synctest.Wait() + g.backend.inspect(func() { + require.Len(t, g.templates.deleted, maxGoldenBootRetries, "the budget is spent: the last crash stands") + require.Equal(t, reconciles, g.store.pairCalls, "neither the queue nor the poller retries a spent budget") + }) + }) +} + +// An invalid template and an actor someone else paused or deleted are final at +// once: no retry repairs them. +func TestGoldenBootFailuresWithoutACrashStayFinal(t *testing.T) { + for _, message := range []string{ + "GoldenActorInvalid: rpc error: code = InvalidArgument desc = containers[0].image: unsupported reference", + "GoldenActorUnexpectedState: golden actor in state ACTOR_STATE_PAUSED", + } { + synctest.Test(t, func(t *testing.T) { + g := startGoldenBootTest(t) + g.setGoldenBoot(crashedGoldenBoot(message)) + time.Sleep(time.Hour) + synctest.Wait() + g.backend.inspect(func() { + require.Empty(t, g.templates.deleted, "a failure no retry can repair is not started over: %s", message) + require.Equal(t, 1, g.templates.created) + }) + observed := g.observation() + require.Equal(t, 0, observed.GoldenBootRetries) + require.True(t, observed.RetryGoldenBootAt.IsZero()) + state := g.collections.Reconciliations.GetKey(g.initial.ResourceName()) + require.Nil(t, state.GoldenBootRetry) + require.Equal(t, "ActorTemplateFailed", state.PreparationFailure.Reason) + require.Equal(t, message, state.PreparationFailure.Message) + require.Equal(t, "ActorTemplateFailed", g.readyCondition().Reason) + }) + } +} + +// A crashed template replaced outside the reconciler's own restart (a restart +// that failed after its delete, an operator's deletion) still counts against +// the revision's budget; a new revision starts with a fresh one. +func TestGoldenBootReplacedTemplateCountsAgainstTheRevision(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := startGoldenBootTest(t) + g.setGoldenBoot(crashedGoldenBoot(unattributedGoldenCrash)) + g.backend.inspect(func() { g.templates.template = nil }) + time.Sleep(time.Second) + synctest.Wait() + g.backend.inspect(func() { + require.Empty(t, g.templates.deleted) + require.Equal(t, 2, g.templates.created) + }) + observed := g.observation() + require.Equal(t, 1, observed.GoldenBootRetries) + require.True(t, observed.RetryGoldenBootAt.IsZero()) + + g.backend.inspect(func() { g.templates.template = nil }) + updated := g.harnesses.List()[0].DeepCopy() + updated.Spec.Workload.Args = []string{"changed"} + g.harnesses.UpdateObject(updated) + synctest.Wait() + fresh := g.observation() + require.NotEqual(t, observed.RevisionID, fresh.RevisionID) + require.Equal(t, 0, fresh.GoldenBootRetries, "a new revision brings a fresh budget") + g.backend.inspect(func() { require.Equal(t, 3, g.templates.created) }) + }) +} + +// A restart Substrate refuses keeps the crashed template, its deadline and the +// count observed, so the queue's backoff retries the restart itself. +func TestGoldenBootRestartFailureIsRetried(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := startGoldenBootTest(t) + g.setGoldenBoot(crashedGoldenBoot(unattributedGoldenCrash)) + deadline := g.observation().RetryGoldenBootAt + g.backend.inspect(func() { g.templates.deleteErr = status.Error(codes.Unavailable, "private-endpoint") }) + time.Sleep(goldenBootRetryBaseDelay) + synctest.Wait() + observed := g.observation() + require.NotNil(t, observed.Template) + require.Equal(t, deadline, observed.RetryGoldenBootAt) + require.Equal(t, 0, observed.GoldenBootRetries) + state := g.collections.Reconciliations.GetKey(g.initial.ResourceName()) + require.Equal(t, "RuntimePreparationFailed", state.PreparationFailure.Reason) + require.True(t, state.PreparationFailure.Retryable) + require.NotContains(t, state.PreparationFailure.Message, "private-endpoint") + + g.backend.inspect(func() { g.templates.deleteErr = nil }) + time.Sleep(time.Second) + synctest.Wait() + g.backend.inspect(func() { + require.Equal(t, []string{"actor-uid"}, g.templates.deleted) + require.Equal(t, 2, g.templates.created) + }) + observed = g.observation() + require.Nil(t, observed.Failure) + require.Equal(t, 1, observed.GoldenBootRetries) + require.Equal(t, "ActorTemplatePending", g.readyCondition().Reason) + }) +} + func TestInvalidActorTemplateHasNoCompiledTarget(t *testing.T) { collections, harnesses := newPreparationTestCollections(t, "microvm") initial := collections.Reconciliations.List()[0] @@ -556,6 +827,7 @@ func newPreparationTestCollections(t *testing.T, workerPool string) (Collections WorkerPools: krttest.GetMockCollection[*atev1alpha1.WorkerPool](mock), AgentRuntimeObservations: krt.NewStaticCollection[AgentRuntimeObservation](nil, nil, opts.WithName("AgentRuntimeObservations")...), ModelConfigStatuses: krttest.GetMockCollection[krt.ObjectWithStatus[*kagentv1alpha3.ModelConfig, kagentv1alpha3.ModelConfigStatus]](mock), + HarnessStatuses: krttest.GetMockCollection[krt.ObjectWithStatus[*kagentv1alpha3.Harness, kagentv1alpha3.HarnessStatus]](mock), } collections.Agents = krt.NewStaticCollection(nil, []*kagentv1alpha3.Agent{testAgent(template, runtimeHarness)}, opts.WithName("Agents")...) collections.Reconciliations = newAgentReconciliations(collections.Agents, v2translator.Collections{ diff --git a/go/core/internal/controller/reconciler.go b/go/core/internal/controller/reconciler.go index 1b8ef3e33f..88662a1334 100644 --- a/go/core/internal/controller/reconciler.go +++ b/go/core/internal/controller/reconciler.go @@ -2,6 +2,7 @@ package controller import ( "context" + "encoding/json" "errors" "fmt" "math" @@ -20,6 +21,7 @@ import ( claudetranslator "github.com/kagent-dev/kagent/go/core/internal/translator/claude" codextranslator "github.com/kagent-dev/kagent/go/core/internal/translator/codex" kagenttranslator "github.com/kagent-dev/kagent/go/core/internal/translator/kagent" + "github.com/kagent-dev/kagent/go/pkg/logging" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" @@ -28,6 +30,7 @@ import ( apiequality "k8s.io/apimachinery/pkg/api/equality" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/rest" "k8s.io/client-go/util/workqueue" ) @@ -41,7 +44,68 @@ type AgentReconciliation struct { Warnings []string CompilationFailure *ReconciliationFailure ObservedActorTemplate *ateapipb.ActorTemplate - PreparationFailure *ReconciliationFailure + // GoldenBootRetry is set while the observed golden boot crashed and will + // be started over; the Agent stays pending rather than failed. + GoldenBootRetry *GoldenBootRetry + PreparationFailure *ReconciliationFailure +} + +// GoldenBootRetry describes a crashed golden boot the reconciler starts over. +type GoldenBootRetry struct { + // Attempt counts the golden boots of the revision that crashed so far. + Attempt int + Message string +} + +// A golden boot whose actor crashed is started over. Substrate fails the +// ActorTemplate once and never touches it again, and the crash it records does +// not tell a transient cause (a registry that refused the image pull, a worker +// pod replaced under the golden actor) from a workload that dies on every +// boot, so the controller retries every crash and lets the budget tell them +// apart. An invalid template or an actor someone else paused or deleted stays +// final: no retry repairs it. +const ( + goldenActorCrashedPrefix = "GoldenActorCrashed: " + maxGoldenBootRetries = 5 + goldenBootRetryBaseDelay = 20 * time.Second + goldenBootRetryMaxDelay = 2 * time.Minute +) + +func goldenBootCrashed(golden *ateapipb.GoldenSnapshotStatus) bool { + return strings.HasPrefix(golden.GetErrorMessage(), goldenActorCrashedPrefix) +} + +// goldenBootRetryDelay doubles with every retry of a revision, from the base +// delay up to the cap. +func goldenBootRetryDelay(retries int) time.Duration { + delay := goldenBootRetryBaseDelay << retries + if delay <= 0 || delay > goldenBootRetryMaxDelay { + return goldenBootRetryMaxDelay + } + return delay +} + +// goldenBootFailedMessage places a failed golden boot in the revision's retry +// budget, followed by what the reconciler does next. +func goldenBootFailedMessage(attempt int, message, next string) string { + return fmt.Sprintf("golden boot %d of %d failed (%s); %s", attempt, maxGoldenBootRetries+1, message, next) +} + +// goldenBootOutcome reads a failed golden boot from the Agent's observation: a +// crash within the retry budget is retried, anything else fails the Agent. +func goldenBootOutcome(observed AgentRuntimeObservation) (*GoldenBootRetry, *ReconciliationFailure) { + golden := observed.Template.GetStatus().GetGoldenSnapshotStatus() + message := golden.GetErrorMessage() + if message == "" { + return nil, nil + } + if goldenBootCrashed(golden) { + if observed.GoldenBootRetries < maxGoldenBootRetries { + return &GoldenBootRetry{Attempt: observed.GoldenBootRetries + 1, Message: message}, nil + } + message = goldenBootFailedMessage(observed.GoldenBootRetries+1, message, "no retries left") + } + return nil, &ReconciliationFailure{Condition: kagentv1alpha3.AgentConditionReady, Reason: "ActorTemplateFailed", Message: message} } // compiledTarget is published only after compilation, hashing, and ActorTemplate @@ -147,8 +211,8 @@ func newAgentReconciliations( Reason: "ActorTemplateConflict", Message: "existing immutable ActorTemplate differs from the compiled revision", } - } else if message := state.ObservedActorTemplate.GetStatus().GetGoldenSnapshotStatus().GetErrorMessage(); message != "" { - state.PreparationFailure = &ReconciliationFailure{Condition: kagentv1alpha3.AgentConditionReady, Reason: "ActorTemplateFailed", Message: message} + } else { + state.GoldenBootRetry, state.PreparationFailure = goldenBootOutcome(*observed) } return state }, opts.WithName("AgentReconciliations")...) @@ -167,6 +231,7 @@ type actorTemplateClient interface { EnsureAtespace(context.Context, string) error GetActorTemplate(context.Context, string, string) (*ateapipb.ActorTemplate, error) CreateActorTemplate(context.Context, *ateapipb.ActorTemplate) (*ateapipb.ActorTemplate, error) + DeleteActorTemplate(context.Context, string, string) error } // Reconciler is the side-effect boundary for the pure KRT graph. Collection @@ -180,9 +245,11 @@ type Reconciler struct { agents controllers.Queue agentStatuses controllers.Queue modelConfigStatuses controllers.Queue + harnessStatuses controllers.Queue agentHandler krt.HandlerRegistration agentStatusHandler krt.HandlerRegistration modelConfigStatusHandler krt.HandlerRegistration + harnessStatusHandler krt.HandlerRegistration } // NewReconciler creates the Kubernetes and database write boundary. Run starts @@ -216,6 +283,9 @@ func newReconciler( r.modelConfigStatuses = newReconciliationQueue("v2-model-config-status", func(item any) error { return r.reconcileModelConfigStatus(context.Background(), item.(string)) }) + r.harnessStatuses = newReconciliationQueue("v2-harness-status", func(item any) error { + return r.reconcileHarnessStatus(context.Background(), item.(string)) + }) r.agentHandler = collections.Reconciliations.Register(func(event krt.Event[AgentReconciliation]) { // Observations and status writes must not bypass failure backoff. Only @@ -240,6 +310,13 @@ func newReconciler( } r.modelConfigStatuses.Add(status.ResourceName()) }) + r.harnessStatusHandler = collections.HarnessStatuses.Register(func(event krt.Event[krt.ObjectWithStatus[*kagentv1alpha3.Harness, kagentv1alpha3.HarnessStatus]]) { + status := event.Latest() + if apiequality.Semantic.DeepEqual(harnessStatusWithTransitionTimes(status.Status, status.Obj.Status), status.Obj.Status) { + return + } + r.harnessStatuses.Add(status.ResourceName()) + }) return r } @@ -256,15 +333,18 @@ func newReconciliationQueue(name string, reconcile func(any) error) controllers. // Run waits for the graph boundary to observe initial state, then processes // Agent and status writes until stop closes. func (r *Reconciler) Run(stop <-chan struct{}) { - if !r.agentHandler.WaitUntilSynced(stop) || !r.agentStatusHandler.WaitUntilSynced(stop) || !r.modelConfigStatusHandler.WaitUntilSynced(stop) { + if !r.agentHandler.WaitUntilSynced(stop) || !r.agentStatusHandler.WaitUntilSynced(stop) || + !r.modelConfigStatusHandler.WaitUntilSynced(stop) || !r.harnessStatusHandler.WaitUntilSynced(stop) { r.agents.ShutDownEarly() r.agentStatuses.ShutDownEarly() r.modelConfigStatuses.ShutDownEarly() + r.harnessStatuses.ShutDownEarly() return } go r.pollPendingTemplates(stop) go r.agentStatuses.Run(stop) go r.modelConfigStatuses.Run(stop) + go r.harnessStatuses.Run(stop) r.agents.Run(stop) } @@ -342,7 +422,7 @@ func (r *Reconciler) reconcileAgent(ctx context.Context, key string) error { Condition: kagentv1alpha3.AgentConditionReady, Reason: "ActorTemplateRejected", Message: "Substrate rejected the compiled ActorTemplate (InvalidArgument); verify the Agent configuration against Substrate's validation requirements", - }) + }, goldenBootSchedule{}) return nil } if status.Code(err) == codes.AlreadyExists { @@ -353,9 +433,22 @@ func (r *Reconciler) reconcileAgent(ctx context.Context, key string) error { return r.observePreparationError(*state, fmt.Errorf("reconcile ActorTemplate %s/%s: %w", desiredRef.GetAtespace(), desiredRef.GetName(), err)) } if !substrate.ActorTemplateSpecEqual(observed, target.ActorTemplate) { - r.observePreparation(*state, observed, nil) + r.observePreparation(*state, observed, nil, goldenBootSchedule{}) return nil } + schedule := r.goldenBootSchedule(*state, observed) + if schedule.due { + restarted, err := r.restartGoldenBoot(ctx, *state, observed) + if err != nil { + // The crashed template and its deadline stay observed: the queue's + // backoff retries the restart, and a restart that got as far as the + // delete is counted when its replacement is observed. + r.observePreparation(*state, observed, runtimePreparationFailure(err, target.ActorTemplate.GetSandboxConfig().GetConfigName(), target.Revision.SandboxClass), schedule) + return err + } + observed = restarted + schedule = goldenBootSchedule{retries: schedule.retries + 1} + } revision := database.RuntimeRevision{ Revision: target.RevisionID.String(), Namespace: definition.Namespace, @@ -371,10 +464,75 @@ func (r *Reconciler) reconcileAgent(ctx context.Context, key string) error { } // This observation drives Kubernetes Ready status on a separate queue. // Publish it only after session creation can select the persisted revision. - r.observePreparation(*state, observed, nil) + r.observePreparation(*state, observed, nil, schedule) return nil } +// goldenBootSchedule is a revision's retry bookkeeping for the next +// observation, how many golden boots were started over and when the observed +// crash may be, and whether that crash is due to be started over now. +type goldenBootSchedule struct { + retries int + retryAt time.Time + due bool +} + +// goldenBootSchedule derives the retry bookkeeping of the observed +// ActorTemplate from the Agent's previous observation of the same revision. A +// crashed boot replaced by another template counts as one retry, however it +// was replaced; a crash seen for the first time gets its backoff deadline; a +// crash past its deadline is due, unless the budget is spent. +func (r *Reconciler) goldenBootSchedule(state AgentReconciliation, observed *ateapipb.ActorTemplate) goldenBootSchedule { + var schedule goldenBootSchedule + if previous := r.previousObservation(state); previous != nil { + schedule.retries = previous.GoldenBootRetries + if previous.Template.GetMetadata().GetUid() == observed.GetMetadata().GetUid() { + schedule.retryAt = previous.RetryGoldenBootAt + } else if goldenBootCrashed(previous.Template.GetStatus().GetGoldenSnapshotStatus()) { + schedule.retries++ + } + } + if !goldenBootCrashed(observed.GetStatus().GetGoldenSnapshotStatus()) || schedule.retries >= maxGoldenBootRetries { + schedule.retryAt = time.Time{} + return schedule + } + now := time.Now() + if schedule.retryAt.IsZero() { + schedule.retryAt = now.Add(goldenBootRetryDelay(schedule.retries)) + return schedule + } + schedule.due = !now.Before(schedule.retryAt) + return schedule +} + +func (r *Reconciler) previousObservation(state AgentReconciliation) *AgentRuntimeObservation { + previous := r.collections.AgentRuntimeObservations.GetKey(state.ResourceName()) + if previous == nil || state.Target == nil || previous.RevisionID != state.Target.RevisionID { + return nil + } + return previous +} + +// restartGoldenBoot replaces a crashed ActorTemplate with a fresh copy of the +// desired one. ActorTemplates are immutable and a failed golden snapshot is +// terminal in Substrate, so starting the boot over means deleting the +// template (and its golden actor) and creating it again; nothing runs on a +// revision without a golden snapshot, so no session loses its template. +func (r *Reconciler) restartGoldenBoot(ctx context.Context, state AgentReconciliation, crashed *ateapipb.ActorTemplate) (*ateapipb.ActorTemplate, error) { + ref := crashed.GetMetadata() + logging.FromContext(ctx).InfoContext(ctx, "starting a crashed golden boot over", + "agent", state.ResourceName(), "revision", state.Target.RevisionID.String(), + "actor_template", ref.GetAtespace()+"/"+ref.GetName(), "error", crashed.GetStatus().GetGoldenSnapshotStatus().GetErrorMessage()) + if err := r.templates.DeleteActorTemplate(ctx, ref.GetAtespace(), ref.GetName()); err != nil { + return nil, fmt.Errorf("delete crashed ActorTemplate %s/%s: %w", ref.GetAtespace(), ref.GetName(), err) + } + created, err := r.templates.CreateActorTemplate(ctx, state.Target.ActorTemplate) + if err != nil { + return nil, fmt.Errorf("recreate ActorTemplate %s/%s: %w", ref.GetAtespace(), ref.GetName(), err) + } + return created, nil +} + func (r *Reconciler) observePreparationError(state AgentReconciliation, err error) error { if state.Target == nil { return err @@ -383,7 +541,13 @@ func (r *Reconciler) observePreparationError(state AgentReconciliation, err erro if state.PreparationFailure != nil && !state.PreparationFailure.Retryable { return err } - r.observePreparation(state, nil, runtimePreparationFailure(err, state.Target.ActorTemplate.GetSandboxConfig().GetConfigName(), state.Target.Revision.SandboxClass)) + // The retry count of the revision outlives a transient error; the crash + // deadline belongs to the template, which this observation no longer holds. + var schedule goldenBootSchedule + if previous := r.previousObservation(state); previous != nil { + schedule.retries = previous.GoldenBootRetries + } + r.observePreparation(state, nil, runtimePreparationFailure(err, state.Target.ActorTemplate.GetSandboxConfig().GetConfigName(), state.Target.Revision.SandboxClass), schedule) return err } @@ -407,13 +571,16 @@ func runtimePreparationFailure(err error, configName string, class atev1alpha1.S } // Observations belong to the Agent's current preparation, independently of how -// long sessions or checkpoints keep its old runtime alive in the database. -func (r *Reconciler) observePreparation(state AgentReconciliation, template *ateapipb.ActorTemplate, failure *ReconciliationFailure) { +// long sessions or checkpoints keep its old runtime alive in the database. The +// golden-boot retry bookkeeping travels with the template it describes. +func (r *Reconciler) observePreparation(state AgentReconciliation, template *ateapipb.ActorTemplate, failure *ReconciliationFailure, schedule goldenBootSchedule) { r.collections.AgentRuntimeObservations.ConditionalUpdateObject(AgentRuntimeObservation{ Namespace: state.Agent.Namespace, AgentName: state.Agent.Name, - RevisionID: state.Target.RevisionID, - Template: template, - Failure: failure, + RevisionID: state.Target.RevisionID, + Template: template, + Failure: failure, + GoldenBootRetries: schedule.retries, + RetryGoldenBootAt: schedule.retryAt, }) } @@ -440,17 +607,59 @@ func (r *Reconciler) reconcileModelConfigStatus(ctx context.Context, key string) if desired == nil || modelConfig == nil { return nil } - updated := (*modelConfig).DeepCopy() - updated.Status = modelConfigStatusWithTransitionTimes(desired.Status, updated.Status) - if apiequality.Semantic.DeepEqual(updated.Status, (*modelConfig).Status) { + current := (*modelConfig).Status + status := modelConfigStatusWithTransitionTimes(desired.Status, current) + if apiequality.Semantic.DeepEqual(status, current) { return nil } - if _, err := r.status.ModelConfigs(updated.Namespace).UpdateStatus(ctx, updated, metav1.UpdateOptions{}); err != nil { - return fmt.Errorf("update ModelConfig %s status: %w", key, err) + patch, err := statusMergePatch(map[string]any{ + "observedGeneration": status.ObservedGeneration, + "secretHash": status.SecretHash, + "conditions": status.Conditions, + }) + if err != nil { + return fmt.Errorf("patch ModelConfig %s status: %w", key, err) + } + if _, err := r.status.ModelConfigs((*modelConfig).Namespace).Patch(ctx, (*modelConfig).Name, types.MergePatchType, patch, metav1.PatchOptions{}, "status"); err != nil { + return fmt.Errorf("patch ModelConfig %s status: %w", key, err) } return nil } +func (r *Reconciler) reconcileHarnessStatus(ctx context.Context, key string) error { + desired := r.collections.HarnessStatuses.GetKey(key) + harness := r.collections.Harnesses.GetKey(key) + if desired == nil || harness == nil { + return nil + } + current := (*harness).Status + status := harnessStatusWithTransitionTimes(desired.Status, current) + if apiequality.Semantic.DeepEqual(status, current) { + return nil + } + // status.capabilities is not the controller's to write here, so the patch + // never carries it. + patch, err := statusMergePatch(map[string]any{ + "observedGeneration": status.ObservedGeneration, + "conditions": status.Conditions, + }) + if err != nil { + return fmt.Errorf("patch Harness %s status: %w", key, err) + } + if _, err := r.status.Harnesses((*harness).Namespace).Patch(ctx, (*harness).Name, types.MergePatchType, patch, metav1.PatchOptions{}, "status"); err != nil { + return fmt.Errorf("patch Harness %s status: %w", key, err) + } + return nil +} + +// statusMergePatch builds a JSON merge patch of the status subresource that +// carries no resourceVersion: the controller's fields replace the live ones +// whatever else changed on the object since the cache last observed it, and +// fields the patch omits keep their live values. +func statusMergePatch(fields map[string]any) ([]byte, error) { + return json.Marshal(map[string]any{"status": fields}) +} + func statusWithTransitionTimes(desired, current kagentv1alpha3.AgentStatus) kagentv1alpha3.AgentStatus { desired.Conditions = append([]metav1.Condition(nil), desired.Conditions...) for i := range desired.Conditions { @@ -466,10 +675,23 @@ func statusWithTransitionTimes(desired, current kagentv1alpha3.AgentStatus) kage } func modelConfigStatusWithTransitionTimes(desired, current kagentv1alpha3.ModelConfigStatus) kagentv1alpha3.ModelConfigStatus { - desired.Conditions = append([]metav1.Condition(nil), desired.Conditions...) - for conditionIndex := range desired.Conditions { - condition := &desired.Conditions[conditionIndex] - if previous := apimeta.FindStatusCondition(current.Conditions, condition.Type); previous != nil && + desired.Conditions = conditionsWithTransitionTimes(desired.Conditions, current.Conditions) + return desired +} + +func harnessStatusWithTransitionTimes(desired, current kagentv1alpha3.HarnessStatus) kagentv1alpha3.HarnessStatus { + desired.Capabilities = current.Capabilities + desired.Conditions = conditionsWithTransitionTimes(desired.Conditions, current.Conditions) + return desired +} + +// conditionsWithTransitionTimes keeps a condition's transition time while +// nothing about it changed and stamps the current time otherwise. +func conditionsWithTransitionTimes(desired, current []metav1.Condition) []metav1.Condition { + desired = append([]metav1.Condition(nil), desired...) + for conditionIndex := range desired { + condition := &desired[conditionIndex] + if previous := apimeta.FindStatusCondition(current, condition.Type); previous != nil && previous.Status == condition.Status && previous.Reason == condition.Reason && previous.Message == condition.Message && previous.ObservedGeneration == condition.ObservedGeneration { condition.LastTransitionTime = previous.LastTransitionTime diff --git a/go/core/internal/controller/reconciler_test.go b/go/core/internal/controller/reconciler_test.go index 8b0dc5bfa2..2998ab4e15 100644 --- a/go/core/internal/controller/reconciler_test.go +++ b/go/core/internal/controller/reconciler_test.go @@ -3,6 +3,7 @@ package controller import ( "context" "errors" + "fmt" "sync/atomic" "testing" "testing/synctest" @@ -261,6 +262,8 @@ type fakeActorTemplates struct { createErr error deleteErr error deletedBeforeError bool + created int + deleted []string } func (f *fakeActorTemplates) EnsureAtespace(context.Context, string) error { return f.ensureErr } @@ -279,8 +282,12 @@ func (f *fakeActorTemplates) CreateActorTemplate(_ context.Context, template *at if f.createErr != nil { return nil, f.createErr } + f.created++ f.template = proto.CloneOf(template) f.template.Metadata.Uid = "actor-uid" + if f.created > 1 { + f.template.Metadata.Uid = fmt.Sprintf("actor-uid-%d", f.created) + } return f.template, nil } @@ -291,6 +298,9 @@ func (f *fakeActorTemplates) DeleteActorTemplate(context.Context, string, string } return f.deleteErr } + if f.template != nil { + f.deleted = append(f.deleted, f.template.GetMetadata().GetUid()) + } f.template = nil return nil } @@ -360,6 +370,7 @@ func TestReconcilerUpdatesModelConfigStatusOnSecretHashChange(t *testing.T) { Agents: krttest.GetMockCollection[*kagentv1alpha3.Agent](mock), Reconciliations: krttest.GetMockCollection[AgentReconciliation](mock), AgentStatuses: krttest.GetMockCollection[krt.ObjectWithStatus[*kagentv1alpha3.Agent, kagentv1alpha3.AgentStatus]](mock), + HarnessStatuses: krttest.GetMockCollection[krt.ObjectWithStatus[*kagentv1alpha3.Harness, kagentv1alpha3.HarnessStatus]](mock), } statusClient := kagentfake.NewSimpleClientset(modelConfig.DeepCopy()).ApiV1alpha3() diff --git a/go/core/internal/controller/sandbox_integration_test.go b/go/core/internal/controller/sandbox_integration_test.go index 9778f4ac22..e9e16638e9 100644 --- a/go/core/internal/controller/sandbox_integration_test.go +++ b/go/core/internal/controller/sandbox_integration_test.go @@ -3,6 +3,7 @@ package controller import ( "context" "errors" + "os" "strings" "sync" "testing" @@ -26,6 +27,9 @@ import ( // Exercise the real registered SandboxTemplate informer, API resource versions, // status writes, and restart cleanup. Only the external DB/Substrate work is fake. func TestSandboxKRTInformerQueueAndRestartCleanup(t *testing.T) { + if os.Getenv("KUBEBUILDER_ASSETS") == "" { + t.Skip("KUBEBUILDER_ASSETS is not set: run `make test` in go/, or export KUBEBUILDER_ASSETS=\"$(make -s envtest-path)\"") + } testEnv := &envtest.Environment{CRDDirectoryPaths: []string{"../../../api/config/crd/bases/api.kagent.dev_sandboxtemplates.yaml"}, ErrorIfCRDPathMissing: true} config, err := testEnv.Start() require.NoError(t, err) diff --git a/go/core/internal/controller/sandbox_reconciler_test.go b/go/core/internal/controller/sandbox_reconciler_test.go index 500ac371f6..8e2e672da1 100644 --- a/go/core/internal/controller/sandbox_reconciler_test.go +++ b/go/core/internal/controller/sandbox_reconciler_test.go @@ -103,6 +103,13 @@ func (s *sandboxTestActors) CreateActorTemplate(_ context.Context, template *ate return proto.CloneOf(template), nil } +func (s *sandboxTestActors) DeleteActorTemplate(_ context.Context, atespace, name string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.templates, atespace+"/"+name) + return nil +} + func sandboxGoldenStatus() *ateapipb.ActorTemplateStatus { return &ateapipb.ActorTemplateStatus{GoldenSnapshotStatus: &ateapipb.GoldenSnapshotStatus{GoldenTag: &ateapipb.ObjectRef{Atespace: "ate-golden", Name: "golden"}}} } diff --git a/go/core/internal/controller/status.go b/go/core/internal/controller/status.go index caff9b9e1e..357f070b28 100644 --- a/go/core/internal/controller/status.go +++ b/go/core/internal/controller/status.go @@ -1,9 +1,15 @@ package controller import ( + "fmt" + "slices" + "strings" + + atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1" kagentv1alpha3 "github.com/kagent-dev/kagent/go/api/v1alpha3" "istio.io/istio/pkg/kube/krt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" ) func newAgentStatuses(agents krt.Collection[*kagentv1alpha3.Agent], states krt.Collection[AgentReconciliation], opts krt.OptionsBuilder) krt.StatusCollection[*kagentv1alpha3.Agent, kagentv1alpha3.AgentStatus] { @@ -40,6 +46,10 @@ func statusForAgent(state AgentReconciliation, generation int64, latestSuccessfu } setAgentCondition(&status, generation, kagentv1alpha3.AgentConditionResolvedRefs, metav1.ConditionTrue, "Resolved", "All runtime references resolved") setAgentCondition(&status, generation, kagentv1alpha3.AgentConditionCompatible, metav1.ConditionTrue, "Compatible", "Resolved configuration is compatible with the Harness") + if retry := state.GoldenBootRetry; retry != nil { + setAgentCondition(&status, generation, kagentv1alpha3.AgentConditionReady, metav1.ConditionFalse, "ActorTemplateRetrying", goldenBootFailedMessage(retry.Attempt, retry.Message, "starting it over")) + return status + } if state.ObservedActorTemplate.GetStatus().GetGoldenSnapshotStatus().GetGoldenTag() == nil { setAgentCondition(&status, generation, kagentv1alpha3.AgentConditionReady, metav1.ConditionFalse, "ActorTemplatePending", "waiting for the ActorTemplate golden snapshot") return status @@ -69,3 +79,93 @@ func setAgentCondition(status *kagentv1alpha3.AgentStatus, generation int64, con Type: conditionType, Status: conditionStatus, Reason: reason, Message: message, ObservedGeneration: generation, }) } + +// harnessKey is the Harness an Agent runs on, or "" for an Agent that +// declares its harness inline. +func harnessKey(agent *kagentv1alpha3.Agent) string { + if agent.Spec.HarnessRef == nil { + return "" + } + return agent.Namespace + "/" + agent.Spec.HarnessRef.Name +} + +func newHarnessStatuses( + harnesses krt.Collection[*kagentv1alpha3.Harness], + workerPools krt.Collection[*atev1alpha1.WorkerPool], + states krt.Collection[AgentReconciliation], + opts krt.OptionsBuilder, +) krt.StatusCollection[*kagentv1alpha3.Harness, kagentv1alpha3.HarnessStatus] { + byHarness := krt.NewIndex(states, "harness", func(state AgentReconciliation) []string { + if key := harnessKey(state.Agent); key != "" { + return []string{key} + } + return nil + }) + statuses, _ := krt.NewStatusManyCollection(harnesses, func(ctx krt.HandlerContext, harness *kagentv1alpha3.Harness) (*kagentv1alpha3.HarnessStatus, []AgentReconciliation) { + pool := types.NamespacedName{Namespace: harness.Namespace, Name: harness.Spec.Substrate.WorkerPoolRef.Name} + poolFound := krt.FetchOne(ctx, workerPools, krt.FilterObjectName(pool)) != nil + agents := krt.Fetch(ctx, states, krt.FilterIndex(byHarness, harness.Namespace+"/"+harness.Name)) + return &kagentv1alpha3.HarnessStatus{ + ObservedGeneration: harness.Generation, + Conditions: []metav1.Condition{harnessReadyCondition(harness.Generation, pool, poolFound, agents)}, + }, nil + }, opts.WithName("HarnessStatuses")...) + return statuses +} + +// harnessReadyCondition reads a Harness's readiness from its WorkerPool and +// the golden boots of the Agents that run on it. Every Agent revision boots +// the Harness's workload, so one successful golden boot proves the Harness +// boots; a failed boot fails it only while no Agent booted, since a single +// Agent's own configuration can fail its boot too. Before any golden boot +// finished the resolved WorkerPool keeps it True: Helm and Flux read a Ready +// condition other than True as a rollout in progress, so a Harness without +// Agents would hold every release of its chart. +func harnessReadyCondition(generation int64, pool types.NamespacedName, poolFound bool, agents []AgentReconciliation) metav1.Condition { + condition := func(status metav1.ConditionStatus, reason, message string) metav1.Condition { + return metav1.Condition{ + Type: kagentv1alpha3.HarnessConditionTypeReady, Status: status, Reason: reason, Message: message, ObservedGeneration: generation, + } + } + if !poolFound { + return condition(metav1.ConditionFalse, "WorkerPoolNotFound", fmt.Sprintf("WorkerPool %q not found", pool.String())) + } + slices.SortFunc(agents, func(a, b AgentReconciliation) int { return strings.Compare(a.ResourceName(), b.ResourceName()) }) + var failed, retrying *metav1.Condition + var pending string + for _, state := range agents { + if state.Target == nil || state.CompilationFailure != nil { + continue + } + agent := state.ResourceName() + switch failure := state.PreparationFailure; { + case failure != nil && failure.Condition == kagentv1alpha3.AgentConditionReady: + if failed == nil { + c := condition(metav1.ConditionFalse, failure.Reason, fmt.Sprintf("Agent %s: %s", agent, failure.Message)) + failed = &c + } + case failure != nil: + case state.GoldenBootRetry != nil: + if retrying == nil { + retry := state.GoldenBootRetry + c := condition(metav1.ConditionFalse, "ActorTemplateRetrying", fmt.Sprintf("Agent %s: %s", agent, goldenBootFailedMessage(retry.Attempt, retry.Message, "starting it over"))) + retrying = &c + } + case state.ObservedActorTemplate.GetStatus().GetGoldenSnapshotStatus().GetGoldenTag() != nil: + return condition(metav1.ConditionTrue, "Booted", fmt.Sprintf("golden boot of Agent %s succeeded", agent)) + default: + if pending == "" { + pending = agent + } + } + } + for _, c := range []*metav1.Condition{failed, retrying} { + if c != nil { + return *c + } + } + if pending != "" { + return condition(metav1.ConditionTrue, "WorkerPoolResolved", fmt.Sprintf("WorkerPool %q resolves; waiting for the golden boot of Agent %s", pool.String(), pending)) + } + return condition(metav1.ConditionTrue, "WorkerPoolResolved", fmt.Sprintf("WorkerPool %q resolves; no Agent has booted on the Harness yet", pool.String())) +} diff --git a/go/core/internal/controller/status_test.go b/go/core/internal/controller/status_test.go index bb5024eb8b..90cb9c9d55 100644 --- a/go/core/internal/controller/status_test.go +++ b/go/core/internal/controller/status_test.go @@ -1,10 +1,26 @@ package controller import ( + "context" + "errors" "testing" + "time" + atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + kagentfake "github.com/kagent-dev/kagent/go/api/clientset/versioned/fake" kagentv1alpha3 "github.com/kagent-dev/kagent/go/api/v1alpha3" v2translator "github.com/kagent-dev/kagent/go/core/internal/translator" + "github.com/stretchr/testify/require" + "istio.io/istio/pkg/kube/krt" + "istio.io/istio/pkg/kube/krt/krttest" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + k8stesting "k8s.io/client-go/testing" ) func TestStatusForPairPublishesCompilationWarnings(t *testing.T) { @@ -23,3 +39,220 @@ func TestStatusForPairPublishesCompilationWarnings(t *testing.T) { t.Fatal("status aliases mutable compilation warnings") } } + +func harnessAgentState(name string, mutate func(*AgentReconciliation)) AgentReconciliation { + state := AgentReconciliation{ + Agent: &kagentv1alpha3.Agent{ + ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: name}, + Spec: kagentv1alpha3.AgentSpec{HarnessRef: &corev1.LocalObjectReference{Name: "claude"}}, + }, + Target: &compiledTarget{RevisionID: v2translator.RevisionID{1}}, + ObservedActorTemplate: &ateapipb.ActorTemplate{}, + } + if mutate != nil { + mutate(&state) + } + return state +} + +func booted(state *AgentReconciliation) { + state.ObservedActorTemplate = &ateapipb.ActorTemplate{Status: &ateapipb.ActorTemplateStatus{ + GoldenSnapshotStatus: &ateapipb.GoldenSnapshotStatus{GoldenTag: &ateapipb.ObjectRef{Atespace: "ate-golden", Name: "golden"}}, + }} +} + +func bootFailed(state *AgentReconciliation) { + state.PreparationFailure = &ReconciliationFailure{Condition: kagentv1alpha3.AgentConditionReady, Reason: "ActorTemplateFailed", Message: "ImagePullBackOff"} +} + +func TestHarnessReadyCondition(t *testing.T) { + pool := types.NamespacedName{Namespace: "team-a", Name: "gvisor"} + tests := []struct { + name string + poolFound bool + agents []AgentReconciliation + wantStatus metav1.ConditionStatus + wantReason string + wantMessage string + }{ + {name: "missing worker pool", agents: []AgentReconciliation{harnessAgentState("a", booted)}, + wantStatus: metav1.ConditionFalse, wantReason: "WorkerPoolNotFound", wantMessage: `WorkerPool "team-a/gvisor" not found`}, + {name: "no agents yet", poolFound: true, + wantStatus: metav1.ConditionTrue, wantReason: "WorkerPoolResolved", wantMessage: "WorkerPool \"team-a/gvisor\" resolves; no Agent has booted on the Harness yet"}, + {name: "boot pending", poolFound: true, agents: []AgentReconciliation{harnessAgentState("a", nil)}, + wantStatus: metav1.ConditionTrue, wantReason: "WorkerPoolResolved", wantMessage: "WorkerPool \"team-a/gvisor\" resolves; waiting for the golden boot of Agent team-a/a"}, + {name: "booted", poolFound: true, agents: []AgentReconciliation{harnessAgentState("a", booted)}, + wantStatus: metav1.ConditionTrue, wantReason: "Booted", wantMessage: "golden boot of Agent team-a/a succeeded"}, + {name: "failed boot", poolFound: true, agents: []AgentReconciliation{harnessAgentState("a", bootFailed)}, + wantStatus: metav1.ConditionFalse, wantReason: "ActorTemplateFailed", wantMessage: "Agent team-a/a: ImagePullBackOff"}, + {name: "crashed boot retrying", poolFound: true, agents: []AgentReconciliation{harnessAgentState("a", func(s *AgentReconciliation) { + s.GoldenBootRetry = &GoldenBootRetry{Attempt: 1, Message: "GoldenActorCrashed: exit 1"} + })}, + wantStatus: metav1.ConditionFalse, wantReason: "ActorTemplateRetrying", wantMessage: "Agent team-a/a: golden boot 1 of 6 failed (GoldenActorCrashed: exit 1); starting it over"}, + {name: "one booted agent outweighs another's failure", poolFound: true, agents: []AgentReconciliation{harnessAgentState("a", bootFailed), harnessAgentState("b", booted)}, + wantStatus: metav1.ConditionTrue, wantReason: "Booted", wantMessage: "golden boot of Agent team-a/b succeeded"}, + {name: "a failure outweighs a pending boot", poolFound: true, agents: []AgentReconciliation{harnessAgentState("a", nil), harnessAgentState("b", bootFailed)}, + wantStatus: metav1.ConditionFalse, wantReason: "ActorTemplateFailed", wantMessage: "Agent team-a/b: ImagePullBackOff"}, + {name: "agent failures before the boot say nothing about the harness", poolFound: true, agents: []AgentReconciliation{ + harnessAgentState("a", func(s *AgentReconciliation) { + s.Target = nil + s.CompilationFailure = &ReconciliationFailure{Condition: kagentv1alpha3.AgentConditionCompatible, Reason: "UnsupportedConfiguration"} + }), + harnessAgentState("b", func(s *AgentReconciliation) { + s.PreparationFailure = &ReconciliationFailure{Condition: kagentv1alpha3.AgentConditionCompatible, Reason: "ActorTemplateInvalid"} + }), + }, + wantStatus: metav1.ConditionTrue, wantReason: "WorkerPoolResolved", wantMessage: "WorkerPool \"team-a/gvisor\" resolves; no Agent has booted on the Harness yet"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := harnessReadyCondition(3, pool, tt.poolFound, tt.agents) + want := metav1.Condition{ + Type: kagentv1alpha3.HarnessConditionTypeReady, Status: tt.wantStatus, Reason: tt.wantReason, Message: tt.wantMessage, ObservedGeneration: 3, + } + require.Equal(t, want, got) + }) + } +} + +func TestReconcilerWritesHarnessReady(t *testing.T) { + stop := make(chan struct{}) + t.Cleanup(func() { close(stop) }) + opts := krt.NewOptionsBuilder(stop, "test", nil) + + harness := &kagentv1alpha3.Harness{ + ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "claude", Generation: 2}, + Spec: kagentv1alpha3.HarnessSpec{Substrate: kagentv1alpha3.RuntimeSubstratePolicy{ + WorkerPoolRef: corev1.LocalObjectReference{Name: "gvisor"}, + }}, + Status: kagentv1alpha3.HarnessStatus{Capabilities: &kagentv1alpha3.HarnessCapabilities{Version: "v1"}}, + } + mock := krttest.NewMock(t, []any{ + harness, + &atev1alpha1.WorkerPool{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "gvisor"}}, + harnessAgentState("assistant", booted), + harnessAgentState("elsewhere", func(s *AgentReconciliation) { + s.Agent.Spec.HarnessRef.Name = "codex" + bootFailed(s) + }), + }) + harnesses := krttest.GetMockCollection[*kagentv1alpha3.Harness](mock) + states := krttest.GetMockCollection[AgentReconciliation](mock) + collections := Collections{ + Harnesses: harnesses, + Reconciliations: krt.NewStaticCollection[AgentReconciliation](nil, nil, opts.WithName("NoReconciliations")...), + HarnessStatuses: newHarnessStatuses(harnesses, krttest.GetMockCollection[*atev1alpha1.WorkerPool](mock), states, opts), + AgentStatuses: krttest.GetMockCollection[krt.ObjectWithStatus[*kagentv1alpha3.Agent, kagentv1alpha3.AgentStatus]](mock), + ModelConfigStatuses: krttest.GetMockCollection[krt.ObjectWithStatus[*kagentv1alpha3.ModelConfig, kagentv1alpha3.ModelConfigStatus]](mock), + } + statusClient := kagentfake.NewSimpleClientset(harness.DeepCopy()).ApiV1alpha3() + go newReconciler(collections, &fakeActorTemplates{}, &fakeRuntimeRevisionStore{}, statusClient).Run(stop) + + var updated *kagentv1alpha3.Harness + require.Eventually(t, func() bool { + var err error + updated, err = statusClient.Harnesses("team-a").Get(context.Background(), "claude", metav1.GetOptions{}) + return err == nil && apimeta.IsStatusConditionTrue(updated.Status.Conditions, kagentv1alpha3.HarnessConditionTypeReady) + }, 3*time.Second, 10*time.Millisecond) + ready := apimeta.FindStatusCondition(updated.Status.Conditions, kagentv1alpha3.HarnessConditionTypeReady) + require.Equal(t, "Booted", ready.Reason) + require.False(t, ready.LastTransitionTime.IsZero()) + require.Equal(t, int64(2), updated.Status.ObservedGeneration) + require.Equal(t, harness.Status.Capabilities, updated.Status.Capabilities, "the status write keeps the capabilities") +} + +func TestReconcilerStatusWriteAfterConcurrentHarnessUpdate(t *testing.T) { + stop := make(chan struct{}) + t.Cleanup(func() { close(stop) }) + opts := krt.NewOptionsBuilder(stop, "test", nil) + + cached := &kagentv1alpha3.Harness{ + ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "claude", Generation: 1, ResourceVersion: "1"}, + Spec: kagentv1alpha3.HarnessSpec{Substrate: kagentv1alpha3.RuntimeSubstratePolicy{ + WorkerPoolRef: corev1.LocalObjectReference{Name: "gvisor"}, + }}, + Status: kagentv1alpha3.HarnessStatus{Capabilities: &kagentv1alpha3.HarnessCapabilities{Version: "v1"}}, + } + live := cached.DeepCopy() + live.ResourceVersion = "2" + live.Status.Capabilities = &kagentv1alpha3.HarnessCapabilities{Version: "v2", Streaming: true} + + mock := krttest.NewMock(t, []any{ + cached, + &atev1alpha1.WorkerPool{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "gvisor"}}, + harnessAgentState("assistant", booted), + }) + harnesses := krttest.GetMockCollection[*kagentv1alpha3.Harness](mock) + collections := Collections{ + Harnesses: harnesses, + HarnessStatuses: newHarnessStatuses(harnesses, krttest.GetMockCollection[*atev1alpha1.WorkerPool](mock), krttest.GetMockCollection[AgentReconciliation](mock), opts), + } + require.Eventually(t, func() bool { return collections.HarnessStatuses.GetKey("team-a/claude") != nil }, 3*time.Second, 10*time.Millisecond) + clientset := kagentfake.NewSimpleClientset(live) + conflictOnStaleUpdate(clientset) + statusClient := clientset.ApiV1alpha3() + reconciler := &Reconciler{collections: collections, status: statusClient} + + require.NoError(t, reconciler.reconcileHarnessStatus(t.Context(), "team-a/claude")) + + updated, err := statusClient.Harnesses("team-a").Get(t.Context(), "claude", metav1.GetOptions{}) + require.NoError(t, err) + require.True(t, apimeta.IsStatusConditionTrue(updated.Status.Conditions, kagentv1alpha3.HarnessConditionTypeReady)) + require.Equal(t, int64(1), updated.Status.ObservedGeneration) + require.Equal(t, live.Status.Capabilities, updated.Status.Capabilities, "the status write keeps the live capabilities") +} + +func TestReconcilerStatusWriteAfterConcurrentModelConfigUpdate(t *testing.T) { + cached := &kagentv1alpha3.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "sonnet", Generation: 1, ResourceVersion: "1"}, + } + live := cached.DeepCopy() + live.ResourceVersion = "2" + live.Annotations = map[string]string{"example.com/touched": "true"} + desired := kagentv1alpha3.ModelConfigStatus{ + ObservedGeneration: 1, + SecretHash: "hash", + Conditions: []metav1.Condition{{ + Type: kagentv1alpha3.ModelConfigConditionTypeResolvedRefs, Status: metav1.ConditionTrue, Reason: "Resolved", ObservedGeneration: 1, + }}, + } + + mock := krttest.NewMock(t, []any{ + cached, + krt.ObjectWithStatus[*kagentv1alpha3.ModelConfig, kagentv1alpha3.ModelConfigStatus]{Obj: cached, Status: desired}, + }) + collections := Collections{ + ModelConfigs: krttest.GetMockCollection[*kagentv1alpha3.ModelConfig](mock), + ModelConfigStatuses: krttest.GetMockCollection[krt.ObjectWithStatus[*kagentv1alpha3.ModelConfig, kagentv1alpha3.ModelConfigStatus]](mock), + } + clientset := kagentfake.NewSimpleClientset(live) + conflictOnStaleUpdate(clientset) + statusClient := clientset.ApiV1alpha3() + reconciler := &Reconciler{collections: collections, status: statusClient} + + require.NoError(t, reconciler.reconcileModelConfigStatus(t.Context(), "team-a/sonnet")) + + updated, err := statusClient.ModelConfigs("team-a").Get(t.Context(), "sonnet", metav1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, "hash", updated.Status.SecretHash) + require.Equal(t, int64(1), updated.Status.ObservedGeneration) + require.True(t, apimeta.IsStatusConditionTrue(updated.Status.Conditions, kagentv1alpha3.ModelConfigConditionTypeResolvedRefs)) + require.Equal(t, live.Annotations, updated.Annotations) +} + +// conflictOnStaleUpdate makes the fake reject an update whose resourceVersion +// is not the stored one, as the API server does. +func conflictOnStaleUpdate(clientset *kagentfake.Clientset) { + clientset.PrependReactor("update", "*", func(action k8stesting.Action) (bool, runtime.Object, error) { + update := action.(k8stesting.UpdateAction) + object := update.GetObject().(metav1.Object) + stored, err := clientset.Tracker().Get(update.GetResource(), update.GetNamespace(), object.GetName()) + if err != nil { + return false, nil, nil + } + if stored.(metav1.Object).GetResourceVersion() != object.GetResourceVersion() { + return true, nil, apierrors.NewConflict(update.GetResource().GroupResource(), object.GetName(), errors.New("the object has been modified")) + } + return false, nil, nil + }) +} diff --git a/go/core/internal/database/client_session_test.go b/go/core/internal/database/client_session_test.go index 82ae53f6b9..815c888615 100644 --- a/go/core/internal/database/client_session_test.go +++ b/go/core/internal/database/client_session_test.go @@ -189,7 +189,7 @@ func TestSessionReplyArchivesStatusMessageAtomically(t *testing.T) { version, err := client.CreateRuntimeTask(ctx, sessionID, taskMutationHash("request-1"), parked, "") require.NoError(t, err) require.NoError(t, client.SettleSessionTask(ctx, sessionID, string(parked.ID), version)) - boundary, err := client.ClaimSessionQuiescence(ctx) + boundary, err := client.ClaimSessionQuiescence(ctx, testClaimLease, 0, nil) require.NoError(t, err) require.NoError(t, client.FinishSessionQuiescence(ctx, boundary, nil)) @@ -856,7 +856,7 @@ func TestSessionShareCreationRequiresOwner(t *testing.T) { } _, err = client.CreateSessionShare(ctx, share, []byte("token"), "bob") require.ErrorIs(t, err, ErrNotFound) - _, _, err = client.GetSessionShareByTokenHash(ctx, []byte("token")) + _, _, err = client.GetSessionShareByTokenHash(ctx, []byte("token"), time.Now()) require.ErrorIs(t, err, ErrNotFound) created, err := client.CreateSessionShare(ctx, share, []byte("token"), "alice") @@ -887,14 +887,19 @@ func TestSessionShareExpiry(t *testing.T) { live, err := client.CreateSessionShare(ctx, newShare(time.Now().Add(time.Hour)), []byte("live"), "alice") require.NoError(t, err) - resolved, owner, err := client.GetSessionShareByTokenHash(ctx, []byte("live")) + resolved, owner, err := client.GetSessionShareByTokenHash(ctx, []byte("live"), time.Now()) require.NoError(t, err) require.Equal(t, "alice", owner) require.True(t, proto.Equal(live, resolved), "the expiry survives the round trip") + liveUntil := live.GetExpiresAt().AsTime() + _, _, err = client.GetSessionShareByTokenHash(ctx, []byte("live"), liveUntil.Add(-time.Microsecond)) + require.NoError(t, err, "the caller's clock decides expiry, not the database's") + _, _, err = client.GetSessionShareByTokenHash(ctx, []byte("live"), liveUntil) + require.ErrorIs(t, err, ErrNotFound, "a share stops granting at its expires_at") expired, err := client.CreateSessionShare(ctx, newShare(time.Now().Add(-time.Second)), []byte("expired"), "alice") require.NoError(t, err) - _, _, err = client.GetSessionShareByTokenHash(ctx, []byte("expired")) + _, _, err = client.GetSessionShareByTokenHash(ctx, []byte("expired"), time.Now()) require.ErrorIs(t, err, ErrNotFound, "an expired share's token grants nothing") listed, err := client.ListSessionShares(ctx, session.Id, "alice", "", 10) @@ -904,6 +909,53 @@ func TestSessionShareExpiry(t *testing.T) { require.NoError(t, client.DeleteSessionShare(ctx, expired.GetId(), "alice")) } +func TestDeleteExpiredSessionShares(t *testing.T) { + client := NewClient(setupTestDB(t)) + ctx := t.Context() + sessionFixture(t, client, ctx, "team-a", "revision", "assistant", "kagent") + session, _, err := client.CreateSession(ctx, newSessionRequest(uuid.NewString(), "assistant", "kagent", ""), "create") + require.NoError(t, err) + // Postgres keeps microseconds; a clock the column can hold exactly makes + // "expired at now" a precise claim. + now := time.Now().Truncate(time.Microsecond) + share := func(expiresAt *timestamppb.Timestamp, token string) string { + t.Helper() + created, err := client.CreateSessionShare(ctx, &apiv1alpha1.SessionShare{ + Id: uuid.NewString(), SessionId: session.Id, + Permission: apiv1alpha1.SessionSharePermission_SESSION_SHARE_PERMISSION_READ_ONLY, + ExpiresAt: expiresAt, + }, []byte(token), "alice") + require.NoError(t, err) + return created.GetId() + } + share(timestamppb.New(now.Add(-time.Second)), "expired") + share(timestamppb.New(now), "expired at now") + live := share(timestamppb.New(now.Add(time.Hour)), "live") + unbounded := share(nil, "unbounded") + + count, err := client.DeleteExpiredSessionShares(ctx, now) + require.NoError(t, err) + require.EqualValues(t, 2, count, "a share expired at now is gone, as its token grants nothing at now") + listed, err := client.ListSessionShares(ctx, session.Id, "alice", "", 10) + require.NoError(t, err) + require.Len(t, listed, 2) + require.ElementsMatch(t, []string{live, unbounded}, []string{listed[0].GetId(), listed[1].GetId()}) + + count, err = client.DeleteExpiredSessionShares(ctx, now) + require.NoError(t, err) + require.Zero(t, count, "the same clock finds nothing more") + share(nil, "expired") + _, _, err = client.GetSessionShareByTokenHash(ctx, []byte("expired"), now) + require.NoError(t, err, "a deleted share's token hash is free again") + + count, err = client.DeleteExpiredSessionShares(ctx, now.Add(2*time.Hour)) + require.NoError(t, err) + require.EqualValues(t, 1, count, "the live share goes once the clock passes its expiry") + listed, err = client.ListSessionShares(ctx, session.Id, "alice", "", 10) + require.NoError(t, err) + require.Len(t, listed, 2, "shares without expiry stay") +} + func TestDeletedSessionPreservesRequestIdentityAndHidesAccess(t *testing.T) { client := NewClient(setupTestDB(t)) ctx := t.Context() @@ -933,7 +985,7 @@ func TestDeletedSessionPreservesRequestIdentityAndHidesAccess(t *testing.T) { require.Empty(t, sessions) _, err = client.UpdateSessionName(ctx, session.Id, "alice", "resurrect") require.ErrorIs(t, err, ErrNotFound) - _, _, err = client.GetSessionShareByTokenHash(ctx, []byte("token")) + _, _, err = client.GetSessionShareByTokenHash(ctx, []byte("token"), time.Now()) require.ErrorIs(t, err, ErrNotFound) _, err = client.CreateSessionShare(ctx, share, []byte("new token"), "alice") require.ErrorIs(t, err, ErrNotFound) @@ -987,7 +1039,7 @@ func TestShareCreationRacesSessionDeletion(t *testing.T) { if shareErr != nil { require.ErrorIs(t, shareErr, ErrNotFound) } - _, _, err = client.GetSessionShareByTokenHash(ctx, token) + _, _, err = client.GetSessionShareByTokenHash(ctx, token, time.Now()) require.ErrorIs(t, err, ErrNotFound) // A revoked share must actually be removed, releasing its unique token hash. other, _, err := client.CreateSession(ctx, newSessionRequest(uuid.NewString(), "assistant", "kagent", ""), uuid.NewString()) diff --git a/go/core/internal/database/protobuf_test.go b/go/core/internal/database/protobuf_test.go index 1c996871b0..7262780630 100644 --- a/go/core/internal/database/protobuf_test.go +++ b/go/core/internal/database/protobuf_test.go @@ -3,6 +3,7 @@ package database import ( "crypto/sha256" "testing" + "time" "github.com/a2aproject/a2a-go/v2/a2a" "github.com/a2aproject/a2a-go/v2/a2aevent" @@ -312,7 +313,7 @@ func TestShareAndAgentCardProtobufPersistence(t *testing.T) { share, err := client.CreateSessionShare(ctx, value, digest[:], "alice") require.NoError(t, err) require.Equal(t, value.ProtoReflect().GetUnknown(), share.ProtoReflect().GetUnknown()) - resolved, ownerUserID, err := client.GetSessionShareByTokenHash(ctx, digest[:]) + resolved, ownerUserID, err := client.GetSessionShareByTokenHash(ctx, digest[:], time.Now()) require.NoError(t, err) require.True(t, proto.Equal(share, resolved)) require.Equal(t, "alice", ownerUserID) @@ -325,7 +326,7 @@ func TestShareAndAgentCardProtobufPersistence(t *testing.T) { require.Empty(t, listed) require.ErrorIs(t, client.DeleteSessionShare(ctx, share.Id, "mallory"), ErrNotFound) require.NoError(t, client.DeleteSessionShare(ctx, share.Id, "alice")) - _, _, err = client.GetSessionShareByTokenHash(ctx, digest[:]) + _, _, err = client.GetSessionShareByTokenHash(ctx, digest[:], time.Now()) require.ErrorIs(t, err, ErrNotFound) } } diff --git a/go/core/internal/database/runtime_revisions.go b/go/core/internal/database/runtime_revisions.go index 94b280f79a..939e6d16dd 100644 --- a/go/core/internal/database/runtime_revisions.go +++ b/go/core/internal/database/runtime_revisions.go @@ -136,6 +136,29 @@ func (c *Client) GetRuntimeRevision(ctx context.Context, revision string) (*Runt return toRuntimeRevision(row) } +// GetCurrentRuntimeRevision returns the latest successful revision of the +// agent a revision was prepared for: the same Agent identity, by UID, while it +// is not retired. ErrNotFound means the agent has no current revision, or it +// is being deleted. +func (c *Client) GetCurrentRuntimeRevision(ctx context.Context, revision string) (*RuntimeRevision, error) { + row, err := queryOne(ctx, c.db, ` + SELECT r.revision, r.namespace, r.agent_name, r.agent_uid, + r.source_snapshot, r.egress_destinations, r.credentials, r.actor_template_atespace, r.actor_template_name, + r.actor_template_uid, r.agent_card, r.deleted_at + FROM agent_runtime_revision prepared + JOIN agent_definition p + ON p.namespace = prepared.namespace + AND p.agent_uid = prepared.agent_uid + AND p.retired_at IS NULL + JOIN agent_runtime_revision r ON r.revision = p.latest_successful_revision AND r.deleted_at IS NULL + WHERE prepared.revision = $1 + `, pgx.RowToStructByName[runtimeRevisionRow], revision) + if err != nil { + return nil, fmt.Errorf("get current runtime revision of %s: %w", revision, notFoundOr(err)) + } + return toRuntimeRevision(row) +} + // toRuntimeRevision decodes the agent card and canonicalizes credential bindings, // returning an error for malformed stored data. func toRuntimeRevision(row runtimeRevisionRow) (*RuntimeRevision, error) { diff --git a/go/core/internal/database/session_agent_deleted.go b/go/core/internal/database/session_agent_deleted.go new file mode 100644 index 0000000000..14c66c0d4c --- /dev/null +++ b/go/core/internal/database/session_agent_deleted.go @@ -0,0 +1,68 @@ +package database + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" +) + +// ListSessionsOfDeletedAgents returns the sessions whose Agent is gone — the +// name their prepared revision was rendered for has no active definition — +// and the deletions of that reason still awaiting runtime cleanup, by id after +// afterID, up to limit. A session of an Agent replaced under the same name is +// not listed: it keeps its pinned revision until it idles out. Admission +// rechecks the Agent under the session lock. +func (c *Client) ListSessionsOfDeletedAgents(ctx context.Context, afterID string, limit int) ([]string, error) { + return queryMany(ctx, c.db, ` + SELECT s.id::text FROM session s + JOIN runtime_instance r USING (id) + JOIN agent_runtime_revision prepared ON prepared.revision = r.prepared_revision + WHERE (s.deletion_reason = 'agent_deleted' + OR (r.state <> 'RUNTIME_STATE_DELETED' AND NOT EXISTS ( + SELECT 1 FROM agent_definition p + WHERE p.namespace = prepared.namespace AND p.agent_name = prepared.agent_name + AND p.retired_at IS NULL))) + AND ($1::text = '' OR s.id > $1::uuid) + ORDER BY s.id LIMIT $2 + `, pgx.RowTo[string], afterID, limit) +} + +// BeginDeletedAgentSessionDeletion admits the deletion of a session whose Agent +// is gone, or joins the deletion already admitted for it, under the session +// lock. An Agent active again under the name since the listing leaves the +// session alone with ErrConflict; a deleted session answers ErrNotFound. The +// admitted reason survives retries, so a later sweep finishes a deletion whose +// runtime work failed. +func (c *Client) BeginDeletedAgentSessionDeletion(ctx context.Context, id string) (*SessionOperation, error) { + var operation *SessionOperation + err := c.withTx(ctx, func(tx pgx.Tx) error { + row, err := lockSession(ctx, tx, id) + if err != nil { + return notFoundOr(err) + } + if row.State == apiv1alpha1.RuntimeState_RUNTIME_STATE_DELETED.String() { + return ErrNotFound + } + gone, err := queryOne(ctx, tx, ` + SELECT COALESCE(deletion_reason, '') = 'agent_deleted' OR NOT EXISTS ( + SELECT 1 FROM agent_runtime_revision prepared + JOIN agent_definition p ON p.namespace = prepared.namespace AND p.agent_name = prepared.agent_name + WHERE prepared.revision = $2 AND p.retired_at IS NULL) + FROM session WHERE id = $1 + `, pgx.RowTo[bool], row.ID, row.PreparedRevision) + if err != nil { + return err + } + if !gone { + return fmt.Errorf("the session's Agent is active: %w", ErrConflict) + } + operation, err = beginSessionDeletion(ctx, tx, row, sessionDeletionAgentDeleted) + return err + }) + if err != nil { + return nil, fmt.Errorf("begin deleted-Agent Session deletion: %w", err) + } + return operation, nil +} diff --git a/go/core/internal/database/session_deletion.go b/go/core/internal/database/session_deletion.go index 8df75a7043..b2298af580 100644 --- a/go/core/internal/database/session_deletion.go +++ b/go/core/internal/database/session_deletion.go @@ -13,6 +13,7 @@ type sessionDeletionReason string const ( sessionDeletionUserRequested sessionDeletionReason = "user_requested" sessionDeletionIdleTimeout sessionDeletionReason = "idle_timeout" + sessionDeletionAgentDeleted sessionDeletionReason = "agent_deleted" ) // beginSessionDeletion attaches the reason to ordinary lifecycle admission. @@ -28,11 +29,12 @@ func beginSessionDeletion(ctx context.Context, tx pgx.Tx, row sessionRow, reason return operation, nil } -// finishSessionDeletion releases expired creation receipts. Explicit deletion -// retains its tombstone so the original request cannot recreate compute. +// finishSessionDeletion releases the creation receipts of the sessions a sweep +// deleted, idle ones and those of a deleted Agent. Explicit deletion retains +// its tombstone so the original request cannot recreate compute. func finishSessionDeletion(ctx context.Context, tx pgx.Tx, id uuid.UUID) error { return execSQL(ctx, tx, ` DELETE FROM runtime_instance r USING session s - WHERE r.id = $1 AND s.id = r.id AND s.deletion_reason = 'idle_timeout' + WHERE r.id = $1 AND s.id = r.id AND s.deletion_reason IN ('idle_timeout', 'agent_deleted') `, id) } diff --git a/go/core/internal/database/session_expiration_test.go b/go/core/internal/database/session_expiration_test.go index 6f19bdd288..6ddd416cbb 100644 --- a/go/core/internal/database/session_expiration_test.go +++ b/go/core/internal/database/session_expiration_test.go @@ -67,7 +67,7 @@ func TestSessionExpirationRechecksActivity(t *testing.T) { _, err = client.BeginIdleSessionDeletion(ctx, session.Id, time.Now()) require.ErrorIs(t, err, ErrConflict, "unpublished runtime cleanup blocks expiration") require.NoError(t, client.SettleSessionTask(ctx, session.Id, string(task.ID), version)) - work, err := client.ClaimSessionQuiescence(ctx) + work, err := client.ClaimSessionQuiescence(ctx, testClaimLease, 0, nil) require.NoError(t, err) _, err = client.BeginIdleSessionDeletion(ctx, session.Id, time.Now()) require.ErrorIs(t, err, ErrFailedPrecondition, "a claimed suspension blocks expiration") diff --git a/go/core/internal/database/session_operations.go b/go/core/internal/database/session_operations.go index c1a4a382ee..c3b27437d5 100644 --- a/go/core/internal/database/session_operations.go +++ b/go/core/internal/database/session_operations.go @@ -194,6 +194,60 @@ func (c *Client) FinishSessionOperation(ctx context.Context, sessionID string, i return result, nil } +// FailSessionOperation publishes a known failure of issued work for the +// claiming executor: the runtime answered that the operation cannot succeed, so +// nothing about it is uncertain. The session leaves the operation FAILED with +// the failure recorded, readable and deletable. Stale completion returns +// ErrConflict. +func (c *Client) FailSessionOperation(ctx context.Context, sessionID string, id, executorID uuid.UUID, failure *apiv1alpha1.Failure) (*apiv1alpha1.Session, error) { + if failure.GetReason() == "" { + return nil, fmt.Errorf("fail Session operation: a failure reason is required") + } + var result *apiv1alpha1.Session + err := c.withTx(ctx, func(tx pgx.Tx) error { + row, err := lockSession(ctx, tx, sessionID) + if errors.Is(err, pgx.ErrNoRows) { + return ErrConflict + } + if err != nil { + return err + } + _, operation, err := row.lifecycle() + if err != nil { + return err + } + if id == uuid.Nil || executorID == uuid.Nil || row.OperationID == nil || *row.OperationID != id || + row.ExecutorID == nil || *row.ExecutorID != executorID || operation == apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_NONE { + return fmt.Errorf("lifecycle operation no longer belongs to this executor: %w", ErrConflict) + } + next := row.runtimeInstanceRow + next.State, next.Operation = apiv1alpha1.RuntimeState_RUNTIME_STATE_FAILED.String(), apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_NONE.String() + next.OperationID, next.ExecutorID = nil, nil + result, err = toSession(row) + if err != nil { + return err + } + result.State, result.Operation, err = next.lifecycle() + if err != nil { + return err + } + result.Failure = failure + result.UpdatedAt = timestamppb.Now() + row.Data, err = marshalSession(result) + if err != nil { + return err + } + if err := saveRuntimeLifecycle(ctx, tx, next, runtimeKindAgent); err != nil { + return err + } + return execSQL(ctx, tx, `UPDATE session SET data = $2 WHERE id = $1`, row.ID, row.Data) + }) + if err != nil { + return nil, fmt.Errorf("fail Session operation: %w", err) + } + return result, nil +} + // GetSessionOperation observes this generation only while it remains current, // including its deletion tombstone. A superseded or missing generation returns // ErrConflict. Callers must have authorized the session at admission. It never @@ -222,3 +276,66 @@ func toSessionOperation(row sessionRow) (*SessionOperation, error) { } return &SessionOperation{RuntimeOperation: runtimeOperationFor(row.runtimeInstanceRow, session), SourceCheckpointID: row.SourceCheckpointID}, nil } + +// FailSession moves a READY session with no lifecycle operation to FAILED with +// the failure, outside any operation and without a claim. A session that +// already records a failure with the same reason is returned as it is; any +// other state returns ErrConflict. Runtime writes and lifecycle admission read +// the state under the same row lock, so a failed session refuses new work but +// stays readable and deletable: its idle work, claimed or not, is released +// with the failure, since nothing settles it for a runtime that is gone. +func (c *Client) FailSession(ctx context.Context, sessionID string, failure *apiv1alpha1.Failure) (*apiv1alpha1.Session, error) { + if failure.GetReason() == "" || failure.GetMessage() == "" { + return nil, fmt.Errorf("session failure requires a reason and a message: %w", ErrFailedPrecondition) + } + var result *apiv1alpha1.Session + err := c.withTx(ctx, func(tx pgx.Tx) error { + row, err := lockSession(ctx, tx, sessionID) + if err != nil { + return notFoundOr(err) + } + session, err := toSession(row) + if err != nil { + return err + } + switch { + case session.State == apiv1alpha1.RuntimeState_RUNTIME_STATE_DELETED: + return ErrNotFound + case session.State == apiv1alpha1.RuntimeState_RUNTIME_STATE_FAILED && session.GetFailure().GetReason() == failure.GetReason(): + result = session + return nil + case session.State != apiv1alpha1.RuntimeState_RUNTIME_STATE_READY || session.Operation != apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_NONE: + return fmt.Errorf("session %s is %s with operation %s: %w", sessionID, session.State, session.Operation, ErrConflict) + } + row.State = apiv1alpha1.RuntimeState_RUNTIME_STATE_FAILED.String() + session.State = apiv1alpha1.RuntimeState_RUNTIME_STATE_FAILED + session.Failure = &apiv1alpha1.Failure{Reason: failure.GetReason(), Message: failure.GetMessage()} + session.UpdatedAt = timestamppb.Now() + row.Data, err = marshalSession(session) + if err != nil { + return err + } + if err := saveRuntimeLifecycle(ctx, tx, row.runtimeInstanceRow, runtimeKindAgent); err != nil { + return err + } + // A failed session's runtime takes no pause or suspend any more, and a + // failed session takes no turn: its idle work, claimed or not, would + // only hold the delete. A holder's late finish or release finds the + // boundary settled and records nothing. + if err := execSQL(ctx, tx, ` + UPDATE session_task_event SET quiescence_pending = FALSE + WHERE history_id = $1 AND quiescence_pending + `, row.HistoryID); err != nil { + return err + } + if err := execSQL(ctx, tx, `UPDATE session SET data = $2 WHERE id = $1`, row.ID, row.Data); err != nil { + return err + } + result = session + return nil + }) + if err != nil { + return nil, fmt.Errorf("fail Session %s: %w", sessionID, err) + } + return result, nil +} diff --git a/go/core/internal/database/session_operations_test.go b/go/core/internal/database/session_operations_test.go index e2a43c8d47..423104c747 100644 --- a/go/core/internal/database/session_operations_test.go +++ b/go/core/internal/database/session_operations_test.go @@ -240,3 +240,72 @@ func TestLifecycleObservationUsesCurrentFields(t *testing.T) { require.NoError(t, err) require.False(t, claimed, "completion must not become executable when its claim is cleared") } + +func TestFailSessionRecordsRuntimeLoss(t *testing.T) { + client := NewClient(setupTestDB(t)) + sessionFixture(t, client, t.Context(), "team-a", "revision", "assistant", "kagent") + session, _, err := client.CreateSession(t.Context(), newSessionRequest(uuid.NewString(), "assistant", "kagent", ""), uuid.NewString()) + require.NoError(t, err) + failure := &apiv1alpha1.Failure{Reason: "RuntimeLost", Message: "runtime lost: Actor team-a/session-1 crashed; start a new conversation"} + _, err = client.FailSession(t.Context(), session.Id, failure) + require.ErrorIs(t, err, ErrConflict, "a session still being created is not READY") + _, err = client.FailSession(t.Context(), uuid.NewString(), failure) + require.ErrorIs(t, err, ErrNotFound) + _, err = client.FailSession(t.Context(), session.Id, &apiv1alpha1.Failure{Reason: "RuntimeLost"}) + require.ErrorIs(t, err, ErrFailedPrecondition) + _, err = markSessionReady(t.Context(), client, session.Id, "runtime.example") + require.NoError(t, err) + + failed, err := client.FailSession(t.Context(), session.Id, failure) + require.NoError(t, err) + require.Equal(t, apiv1alpha1.RuntimeState_RUNTIME_STATE_FAILED, failed.State) + require.Equal(t, apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_NONE, failed.Operation) + require.Equal(t, failure.Reason, failed.GetFailure().GetReason()) + require.Equal(t, failure.Message, failed.GetFailure().GetMessage()) + stored, err := client.GetSessionByID(t.Context(), session.Id) + require.NoError(t, err) + require.Equal(t, apiv1alpha1.RuntimeState_RUNTIME_STATE_FAILED, stored.State) + require.Equal(t, failure.Message, stored.GetFailure().GetMessage()) + + again, err := client.FailSession(t.Context(), session.Id, &apiv1alpha1.Failure{Reason: "RuntimeLost", Message: "runtime lost: later"}) + require.NoError(t, err) + require.Equal(t, failure.Message, again.GetFailure().GetMessage(), "the first recorded loss stands") + _, err = client.FailSession(t.Context(), session.Id, &apiv1alpha1.Failure{Reason: "Other", Message: "other"}) + require.ErrorIs(t, err, ErrConflict) + + _, err = client.BeginSessionOperation(t.Context(), session.Id, apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_SUSPEND) + require.ErrorIs(t, err, ErrConflict) + _, err = client.BeginSessionOperation(t.Context(), session.Id, apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_RESUME) + require.ErrorIs(t, err, ErrConflict) + deletion, err := client.BeginSessionOperation(t.Context(), session.Id, apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_DELETE) + require.NoError(t, err, "a failed session stays deletable") + require.Equal(t, apiv1alpha1.RuntimeState_RUNTIME_STATE_DELETING, deletion.Instance.State) +} + +// A session that fails while a worker holds its idle boundary, because the +// runtime the worker was pausing crashed, releases the claim with the failure: +// nothing settles it for a runtime that is gone, and a held claim would refuse +// the delete for good. The holder's late outcome records nothing. +func TestFailSessionReleasesHeldIdleWork(t *testing.T) { + client := NewClient(setupTestDB(t)) + session, _ := completedBoundaryFixture(t, client) + work, err := client.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) + require.NoError(t, err) + _, err = client.BeginSessionOperation(t.Context(), session.Id, apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_DELETE) + require.ErrorIs(t, err, ErrFailedPrecondition, "a held claim refuses the delete of a ready session") + + failure := &apiv1alpha1.Failure{Reason: "RuntimeLost", Message: "runtime lost: Actor team-a/session-1 crashed; start a new conversation"} + failed, err := client.FailSession(t.Context(), session.Id, failure) + require.NoError(t, err) + require.Equal(t, apiv1alpha1.RuntimeState_RUNTIME_STATE_FAILED, failed.State) + require.ErrorIs(t, client.RenewSessionQuiescence(t.Context(), work, testClaimLease), ErrNotFound, "the claim is released") + require.NoError(t, client.ReleaseSessionQuiescence(t.Context(), work, testQuiescenceFailure), "the holder's late release records nothing") + snapshot := &SessionTaskSnapshot{Atespace: "team-a", URI: "s3://snapshot/late", ContentScope: "DATA"} + require.NoError(t, client.FinishSessionQuiescence(t.Context(), work, snapshot), "the holder's late finish records nothing") + _, err = client.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) + require.ErrorIs(t, err, ErrNotFound, "a failed session's boundary is not claimed again") + + deletion, err := client.BeginSessionOperation(t.Context(), session.Id, apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_DELETE) + require.NoError(t, err, "the failed session is deletable at once") + require.Equal(t, apiv1alpha1.RuntimeState_RUNTIME_STATE_DELETING, deletion.Instance.State) +} diff --git a/go/core/internal/database/session_pause_ttl_test.go b/go/core/internal/database/session_pause_ttl_test.go new file mode 100644 index 0000000000..56de8546da --- /dev/null +++ b/go/core/internal/database/session_pause_ttl_test.go @@ -0,0 +1,105 @@ +package database + +import ( + "context" + "testing" + "time" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "github.com/stretchr/testify/require" +) + +// pausedTaskFixture leaves a session paused on a waiting task, its pause done +// and its boundary recorded age ago. +func pausedTaskFixture(t *testing.T, age time.Duration) (*Client, *pgxpool.Pool, *apiv1alpha1.Session, *a2a.Task) { + t.Helper() + pool := setupTestDB(t) + client := NewClient(pool) + sessionFixture(t, client, t.Context(), "team-a", "revision", "assistant", "kagent") + session, task := waitingTaskFixture(t, client) + agePause(t, pool, session, age) + return client, pool, session, task +} + +func agePause(t *testing.T, pool *pgxpool.Pool, session *apiv1alpha1.Session, age time.Duration) { + t.Helper() + _, err := pool.Exec(context.WithoutCancel(t.Context()), ` + UPDATE session_task_event SET created_at = created_at - $2::interval + WHERE history_id = (SELECT history_id FROM session WHERE id = $1) + `, session.Id, age) + require.NoError(t, err) +} + +func TestExpiredPauseIsClaimedForASuspend(t *testing.T) { + client, _, session, task := pausedTaskFixture(t, time.Hour) + ctx := t.Context() + _, err := client.ClaimSessionQuiescence(ctx, testClaimLease, 0, nil) + require.ErrorIs(t, err, ErrNotFound, "without a TTL a pause stays in place") + _, err = client.ClaimSessionQuiescence(ctx, testClaimLease, 2*time.Hour, nil) + require.ErrorIs(t, err, ErrNotFound, "a pause younger than the TTL stays in place") + + work, err := client.ClaimSessionQuiescence(ctx, testClaimLease, 2*time.Minute, nil) + require.NoError(t, err) + require.True(t, work.Suspend) + require.Equal(t, session.Id, work.Session.Id) + require.Equal(t, string(task.ID), work.TaskID) + require.Equal(t, a2a.TaskStateInputRequired, work.State) + _, err = client.ClaimSessionQuiescence(ctx, testClaimLease, 2*time.Minute, nil) + require.ErrorIs(t, err, ErrNotFound, "a claimed suspend is not handed out twice") + require.ErrorIs(t, client.ReserveSessionDispatch(ctx, session.Id, uuid.New(), "reply"), ErrDispatchBusy, "a reply waits for the suspend") + + snapshot := &SessionTaskSnapshot{Atespace: "team-a", URI: "s3://snapshots/paused", ContentScope: "FULL"} + require.NoError(t, client.FinishSessionQuiescence(ctx, work, snapshot)) + _, err = client.ClaimSessionQuiescence(ctx, testClaimLease, 2*time.Minute, nil) + require.ErrorIs(t, err, ErrNotFound, "a suspended runtime has its snapshot") + require.NoError(t, client.ReserveSessionDispatch(ctx, session.Id, uuid.New(), "reply")) + _, _, err = client.ReserveSessionCheckpoint(ctx, &apiv1alpha1.Checkpoint{Id: uuid.NewString(), SessionId: session.Id, HeadTaskId: string(task.ID)}, "alice", uuid.NewString()) + require.ErrorIs(t, err, ErrFailedPrecondition, "a waiting head is still no checkpoint") + stored, err := client.GetSessionTask(ctx, session.Id, string(task.ID), nil) + require.NoError(t, err) + require.Equal(t, a2a.TaskStateInputRequired, stored.Status.State) +} + +func TestExpiredPauseIsNotClaimedAfterAReply(t *testing.T) { + client, pool, session, task := pausedTaskFixture(t, time.Hour) + reply := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("PostgreSQL")) + reply.TaskID, reply.ContextID = task.ID, task.ContextID + resumeRuntimeTask(t, client, session.Id, reply) + agePause(t, pool, session, time.Hour) + _, err := client.ClaimSessionQuiescence(t.Context(), testClaimLease, 2*time.Minute, nil) + require.ErrorIs(t, err, ErrNotFound, "a task that took its reply is not paused") +} + +func TestExpiredPauseWaitsForALiveDispatch(t *testing.T) { + client, _, session, _ := pausedTaskFixture(t, time.Hour) + require.NoError(t, client.ReserveSessionDispatch(t.Context(), session.Id, uuid.New(), "reply")) + _, err := client.ClaimSessionQuiescence(t.Context(), testClaimLease, 2*time.Minute, nil) + require.ErrorIs(t, err, ErrNotFound, "a reply being dispatched wins over the suspend") +} + +func TestReleasedSuspendClaimCanBeClaimedAgain(t *testing.T) { + client, _, session, _ := pausedTaskFixture(t, time.Hour) + work, err := client.ClaimSessionQuiescence(t.Context(), testClaimLease, 2*time.Minute, nil) + require.NoError(t, err) + require.NoError(t, client.FinishSessionQuiescence(t.Context(), work, nil), "a suspend that did not happen releases the claim") + dispatch := uuid.New() + require.NoError(t, client.ReserveSessionDispatch(t.Context(), session.Id, dispatch, "reply")) + _, err = client.RevokeSessionDispatch(t.Context(), session.Id, dispatch, "reply") + require.NoError(t, err) + again, err := client.ClaimSessionQuiescence(t.Context(), testClaimLease, 2*time.Minute, nil) + require.NoError(t, err) + require.True(t, again.Suspend) + require.Equal(t, work.Version, again.Version) +} + +func TestExpiredPauseSkipsDeferredSessions(t *testing.T) { + client, _, session, _ := pausedTaskFixture(t, time.Hour) + _, err := client.ClaimSessionQuiescence(t.Context(), testClaimLease, 2*time.Minute, []string{session.Id}) + require.ErrorIs(t, err, ErrNotFound) + work, err := client.ClaimSessionQuiescence(t.Context(), testClaimLease, 2*time.Minute, []string{uuid.NewString()}) + require.NoError(t, err) + require.Equal(t, session.Id, work.Session.Id) +} diff --git a/go/core/internal/database/session_quiescence.go b/go/core/internal/database/session_quiescence.go index 46fd78a5a8..cca7e9cd6c 100644 --- a/go/core/internal/database/session_quiescence.go +++ b/go/core/internal/database/session_quiescence.go @@ -2,12 +2,16 @@ package database import ( "context" + "errors" "fmt" + "time" "github.com/a2aproject/a2a-go/v2/a2a" "github.com/google/uuid" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "google.golang.org/protobuf/types/known/timestamppb" ) // SessionQuiescence grants one worker permission to pause an idle session or @@ -18,20 +22,46 @@ type SessionQuiescence struct { State a2a.TaskState Version int64 ExecutorID uuid.UUID + // Suspend asks for a durable suspend of a waiting task's runtime whose + // pause is older than the claim's TTL, in place of a second pause. + Suspend bool + // TakenOver marks a claim whose previous holder stopped renewing its lease + // while its runtime request's outcome was unknown. The new holder settles + // the boundary from the runtime's state and repeats the request, fenced, + // only on a running runtime. + TakenOver bool +} + +type quiescenceCandidate struct { + SessionID string + TaskID string + Sequence int64 } // ClaimSessionQuiescence claims an idle session at its latest settled task // version. A new turn supersedes unclaimed idle work; a claim blocks task writes, // checkpoints, and explicit lifecycle operations until it finishes. Missing work -// returns ErrNotFound. Uncertain issued work is never reassigned after a timeout. -func (c *Client) ClaimSessionQuiescence(ctx context.Context) (*SessionQuiescence, error) { +// returns ErrNotFound. +// +// A claim holds a lease of the given length, which its holder extends with +// RenewSessionQuiescence while it works. A claim whose lease ran out, because +// its holder stopped, is claimed again as TakenOver: the runtime request it +// issued is uncertain, so it is settled from the runtime's state, never issued +// a second time. A claim without a lease predates leases and is taken over too. +// +// With a positive pauseTTL, a waiting task whose pause finished longer ago than +// the TTL and whose runtime has no recorded snapshot is claimed again, for a +// suspend: the pause checkpoint lives on one node, and a reply that takes longer +// than the TTL may outlive that node. A reply re-arms nothing: it clears the +// boundary's idle marker and the task leaves the waiting state. Sessions in +// skip are left out of that second claim, for a caller that found their suspend +// impossible for now. +func (c *Client) ClaimSessionQuiescence(ctx context.Context, lease, pauseTTL time.Duration, skip []string) (*SessionQuiescence, error) { + if lease <= 0 { + return nil, fmt.Errorf("a quiescence claim requires a positive lease") + } var result *SessionQuiescence err := c.withTx(ctx, func(tx pgx.Tx) error { - type candidate struct { - SessionID string - TaskID string - Sequence int64 - } row, err := queryOne(ctx, tx, ` SELECT i.id::text AS session_id, e.task_id, e.sequence FROM session_task_event e JOIN session_record i ON i.history_id = e.history_id @@ -42,7 +72,17 @@ func (c *Client) ClaimSessionQuiescence(ctx context.Context) (*SessionQuiescence AND (i.dispatch_expires_at IS NULL OR i.dispatch_expires_at <= clock_timestamp()) AND NOT EXISTS (SELECT 1 FROM session_checkpoint WHERE source_session_id = i.id AND state = 'CREATING') ORDER BY e.sequence LIMIT 1 FOR UPDATE OF i SKIP LOCKED - `, pgx.RowToStructByName[candidate]) + `, pgx.RowToStructByName[quiescenceCandidate]) + suspend, takeOver := false, false + if errors.Is(err, pgx.ErrNoRows) { + row, err = expiredClaimCandidate(ctx, tx) + takeOver = true + } + if errors.Is(err, pgx.ErrNoRows) && pauseTTL > 0 { + takeOver = false + row, err = expiredPauseCandidate(ctx, tx, pauseTTL, skip) + suspend = true + } if err != nil { return notFoundOr(err) } @@ -65,16 +105,39 @@ func (c *Client) ClaimSessionQuiescence(ctx context.Context) (*SessionQuiescence if err != nil { return err } - result = &SessionQuiescence{Session: value, TaskID: row.TaskID, State: task.Status.State, Version: row.Sequence, ExecutorID: uuid.New()} + result = &SessionQuiescence{Session: value, TaskID: row.TaskID, State: task.Status.State, Version: row.Sequence, ExecutorID: uuid.New(), Suspend: suspend, TakenOver: takeOver} // Recheck after locking: the candidate query's snapshot may predate a // new turn that committed just before we acquired the session lock. - tag, err := tx.Exec(ctx, ` - UPDATE session_task_event SET quiescence_executor_id = $2 - WHERE sequence = $1 AND published AND quiescence_pending - AND quiescence_executor_id IS NULL - AND NOT EXISTS (SELECT 1 FROM session WHERE id = $3 AND dispatch_expires_at > clock_timestamp()) - AND NOT EXISTS (SELECT 1 FROM session_checkpoint WHERE source_session_id = $3 AND state = 'CREATING') - `, result.Version, result.ExecutorID, session.ID) + var tag pgconn.CommandTag + switch { + case takeOver: + tag, err = tx.Exec(ctx, ` + UPDATE session_task_event SET quiescence_executor_id = $2, quiescence_claimed_until = clock_timestamp() + $3::interval + WHERE sequence = $1 AND published AND quiescence_pending + AND quiescence_executor_id IS NOT NULL + AND (quiescence_claimed_until IS NULL OR quiescence_claimed_until <= clock_timestamp()) + `, result.Version, result.ExecutorID, lease) + case suspend: + tag, err = tx.Exec(ctx, ` + UPDATE session_task_event SET quiescence_pending = TRUE, quiescence_executor_id = $2, + quiescence_claimed_until = clock_timestamp() + $6::interval + WHERE sequence = $1 AND published AND quiescence_pending = FALSE + AND NOT EXISTS (SELECT 1 FROM session WHERE id = $3 AND dispatch_expires_at > clock_timestamp()) + AND NOT EXISTS (SELECT 1 FROM session_checkpoint WHERE source_session_id = $3 AND state = 'CREATING') + AND NOT EXISTS (SELECT 1 FROM session_task_event WHERE history_id = $4 AND (quiescence_pending OR NOT published)) + AND NOT EXISTS (SELECT 1 FROM session_task_event WHERE history_id = $4 AND task_id = $5 AND sequence > $1) + AND EXISTS (SELECT 1 FROM session_task WHERE history_id = $4 AND id = $5 AND snapshot_uri IS NULL + AND state IN ('TASK_STATE_INPUT_REQUIRED', 'TASK_STATE_AUTH_REQUIRED')) + `, result.Version, result.ExecutorID, session.ID, session.HistoryID, row.TaskID, lease) + default: + tag, err = tx.Exec(ctx, ` + UPDATE session_task_event SET quiescence_executor_id = $2, quiescence_claimed_until = clock_timestamp() + $4::interval + WHERE sequence = $1 AND published AND quiescence_pending + AND quiescence_executor_id IS NULL + AND NOT EXISTS (SELECT 1 FROM session WHERE id = $3 AND dispatch_expires_at > clock_timestamp()) + AND NOT EXISTS (SELECT 1 FROM session_checkpoint WHERE source_session_id = $3 AND state = 'CREATING') + `, result.Version, result.ExecutorID, session.ID, lease) + } if err != nil { return err } @@ -86,17 +149,107 @@ func (c *Client) ClaimSessionQuiescence(ctx context.Context) (*SessionQuiescence return result, err } +// expiredClaimCandidate finds the oldest held claim whose lease ran out or +// that has none. A held claim blocks every lifecycle operation, so its session +// is still ready. +func expiredClaimCandidate(ctx context.Context, tx pgx.Tx) (quiescenceCandidate, error) { + return queryOne(ctx, tx, ` + SELECT i.id::text AS session_id, e.task_id, e.sequence + FROM session_task_event e JOIN session_record i ON i.history_id = e.history_id + WHERE e.published AND e.quiescence_pending = TRUE + AND e.quiescence_executor_id IS NOT NULL + AND (e.quiescence_claimed_until IS NULL OR e.quiescence_claimed_until <= clock_timestamp()) + AND i.state = 'RUNTIME_STATE_READY' + AND i.operation = 'RUNTIME_OPERATION_NONE' + ORDER BY e.sequence LIMIT 1 FOR UPDATE OF i SKIP LOCKED + `, pgx.RowToStructByName[quiescenceCandidate]) +} + +// expiredPauseCandidate finds the oldest waiting task whose pause finished +// longer ago than ttl and whose runtime has no recorded snapshot. The boundary +// is the task's latest event: a reply appends to the task and leaves the +// waiting state, and a superseded boundary is never a waiting task's latest. +func expiredPauseCandidate(ctx context.Context, tx pgx.Tx, ttl time.Duration, skip []string) (quiescenceCandidate, error) { + if skip == nil { + skip = []string{} + } + return queryOne(ctx, tx, ` + SELECT i.id::text AS session_id, e.task_id, e.sequence + FROM session_task_event e + JOIN session_task t ON t.history_id = e.history_id AND t.id = e.task_id + JOIN session_record i ON i.history_id = e.history_id + WHERE e.published AND e.quiescence_pending = FALSE + AND e.created_at <= clock_timestamp() - $1::interval + AND t.state IN ('TASK_STATE_INPUT_REQUIRED', 'TASK_STATE_AUTH_REQUIRED') + AND t.snapshot_uri IS NULL + AND NOT EXISTS (SELECT 1 FROM session_task_event WHERE history_id = e.history_id AND task_id = e.task_id AND sequence > e.sequence) + AND i.state = 'RUNTIME_STATE_READY' + AND i.operation = 'RUNTIME_OPERATION_NONE' + AND (i.dispatch_expires_at IS NULL OR i.dispatch_expires_at <= clock_timestamp()) + AND NOT EXISTS (SELECT 1 FROM session_checkpoint WHERE source_session_id = i.id AND state = 'CREATING') + AND NOT (i.id::text = ANY($2::text[])) + ORDER BY e.created_at, e.sequence LIMIT 1 FOR UPDATE OF i SKIP LOCKED + `, pgx.RowToStructByName[quiescenceCandidate], ttl, skip) +} + +// RenewSessionQuiescence extends a held claim's lease to the given length from +// now. It returns ErrNotFound once the claim is no longer this executor's: it +// was settled, superseded, or taken over after its lease ran out. +func (c *Client) RenewSessionQuiescence(ctx context.Context, work *SessionQuiescence, lease time.Duration) error { + if work == nil || work.ExecutorID == uuid.Nil { + return fmt.Errorf("claimed task boundary is required") + } + return c.withTx(ctx, func(tx pgx.Tx) error { + tag, err := tx.Exec(ctx, ` + UPDATE session_task_event SET quiescence_claimed_until = clock_timestamp() + $3::interval + WHERE sequence = $1 AND quiescence_executor_id = $2 AND quiescence_pending + `, work.Version, work.ExecutorID, lease) + if err != nil { + return err + } + if tag.RowsAffected() != 1 { + return ErrNotFound + } + return nil + }) +} + +// NextQuiescenceFencingGeneration returns the generation of a quiescence +// holder's next fencing token. Generations only grow, across sessions and +// replicas: a request sent after another always carries a newer token. +func (c *Client) NextQuiescenceFencingGeneration(ctx context.Context) (int64, error) { + return queryOne(ctx, c.db, `SELECT nextval('quiescence_fencing_generation')`, pgx.RowTo[int64]) +} + // FinishSessionQuiescence records a claimed pause/suspend outcome and releases // the session for new execution. Terminal tasks require the matching snapshot. // Retries are harmless, and a stale claim cannot release another operation. // Task state and history remain readable throughout lifecycle work. func (c *Client) FinishSessionQuiescence(ctx context.Context, work *SessionQuiescence, snapshot *SessionTaskSnapshot) error { - if work == nil || work.ExecutorID == uuid.Nil { - return fmt.Errorf("claimed task boundary is required") - } if work.State.Terminal() && (snapshot == nil || snapshot.URI == "" || snapshot.Atespace == "" || snapshot.ContentScope == "") { return fmt.Errorf("terminal task quiescence requires a runtime snapshot") } + return c.settleSessionQuiescence(ctx, work, snapshot, nil) +} + +// ReleaseSessionQuiescence settles a claimed boundary whose runtime was neither +// suspended nor paused, for a caller that found the Actor's state after a failed +// Quiesce or Pause: the task keeps no snapshot, admission reopens, and the next +// turn takes the runtime as it is. The session records the failure as its last +// quiescence failure, so its status explains the missing snapshot. A terminal +// task released this way cannot be checkpointed until a later turn records a +// snapshot, which clears the failure. +func (c *Client) ReleaseSessionQuiescence(ctx context.Context, work *SessionQuiescence, failure *apiv1alpha1.QuiescenceFailure) error { + if failure.GetReason() == "" || failure.GetMessage() == "" { + return fmt.Errorf("quiescence failure requires a reason and a message: %w", ErrFailedPrecondition) + } + return c.settleSessionQuiescence(ctx, work, nil, failure) +} + +func (c *Client) settleSessionQuiescence(ctx context.Context, work *SessionQuiescence, snapshot *SessionTaskSnapshot, failure *apiv1alpha1.QuiescenceFailure) error { + if work == nil || work.ExecutorID == uuid.Nil { + return fmt.Errorf("claimed task boundary is required") + } return c.withTx(ctx, func(tx pgx.Tx) error { session, err := lockSession(ctx, tx, work.Session.Id) if err != nil { @@ -137,8 +290,39 @@ func (c *Client) FinishSessionQuiescence(ctx context.Context, work *SessionQuies return err } } + if err := recordQuiescenceFailure(ctx, tx, session, work.TaskID, snapshot, failure); err != nil { + return err + } return execSQL(ctx, tx, ` UPDATE session_task_event SET quiescence_pending = FALSE WHERE sequence = $1 `, work.Version) }) } + +// recordQuiescenceFailure stores a released boundary's failure on the locked +// session, or clears an earlier one once a boundary records a snapshot. A pause +// leaves an earlier failure in place: the turn it explains still has none. +func recordQuiescenceFailure(ctx context.Context, tx pgx.Tx, row sessionRow, taskID string, snapshot *SessionTaskSnapshot, failure *apiv1alpha1.QuiescenceFailure) error { + if failure == nil && snapshot == nil { + return nil + } + session, err := toSession(row) + if err != nil { + return err + } + switch { + case failure != nil: + session.LastQuiescenceFailure = &apiv1alpha1.QuiescenceFailure{ + TaskId: taskID, Reason: failure.GetReason(), Message: failure.GetMessage(), FailedAt: timestamppb.Now(), + } + case session.LastQuiescenceFailure == nil: + return nil + default: + session.LastQuiescenceFailure = nil + } + data, err := marshalSession(session) + if err != nil { + return err + } + return execSQL(ctx, tx, `UPDATE session SET data = $2 WHERE id = $1`, row.ID, data) +} diff --git a/go/core/internal/database/session_quiescence_test.go b/go/core/internal/database/session_quiescence_test.go new file mode 100644 index 0000000000..629d1674a8 --- /dev/null +++ b/go/core/internal/database/session_quiescence_test.go @@ -0,0 +1,112 @@ +package database + +import ( + "crypto/sha256" + "testing" + "time" + + "github.com/a2aproject/a2a-go/v2/a2a" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "github.com/stretchr/testify/require" +) + +// testClaimLease outlives every test that does not wait for a claim to expire. +const testClaimLease = time.Hour + +var testQuiescenceFailure = &apiv1alpha1.QuiescenceFailure{Reason: "RuntimeUnavailable", Message: "the runtime's Actor is ACTOR_STATE_CRASHED"} + +// completedBoundaryFixture settles a completed turn whose boundary waits for +// its idle suspend. +func completedBoundaryFixture(t *testing.T, client *Client) (*apiv1alpha1.Session, *a2a.Task) { + t.Helper() + sessionFixture(t, client, t.Context(), "team-a", "revision", "assistant", "kagent") + session, waiting := waitingTaskFixture(t, client) + reply := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("PostgreSQL")) + reply.TaskID, reply.ContextID = waiting.ID, waiting.ContextID + current, initialVersion := resumeRuntimeTask(t, client, session.Id, reply) + current.Status = a2a.TaskStatus{State: a2a.TaskStateCompleted} + hash := sha256.Sum256([]byte("completed")) + version, err := client.UpdateSessionTask(t.Context(), session.Id, initialVersion, hash[:], current, current, "") + require.NoError(t, err) + require.NoError(t, client.SettleSessionTask(t.Context(), session.Id, string(current.ID), version)) + return session, current +} + +// A claim whose holder stops renewing its lease is taken over to be settled, +// and the stopped holder can no longer renew, finish or release it. +func TestExpiredQuiescenceClaimIsTakenOver(t *testing.T) { + client := NewClient(setupTestDB(t)) + session, _ := completedBoundaryFixture(t, client) + work, err := client.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) + require.NoError(t, err) + require.False(t, work.TakenOver) + _, err = client.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) + require.ErrorIs(t, err, ErrNotFound, "a renewed claim is not taken over") + + require.NoError(t, client.RenewSessionQuiescence(t.Context(), work, 100*time.Millisecond)) + var taken *SessionQuiescence + require.Eventually(t, func() bool { + taken, err = client.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) + return err == nil + }, 5*time.Second, 20*time.Millisecond) + require.True(t, taken.TakenOver) + require.Equal(t, work.Version, taken.Version) + require.Equal(t, work.TaskID, taken.TaskID) + require.Equal(t, a2a.TaskStateCompleted, taken.State) + require.NotEqual(t, work.ExecutorID, taken.ExecutorID) + _, err = client.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) + require.ErrorIs(t, err, ErrNotFound, "a taken-over claim holds a lease of its own") + + snapshot := &SessionTaskSnapshot{Atespace: "team-a", URI: "s3://snapshot/stale", ContentScope: "DATA"} + require.ErrorIs(t, client.RenewSessionQuiescence(t.Context(), work, testClaimLease), ErrNotFound) + require.ErrorIs(t, client.FinishSessionQuiescence(t.Context(), work, snapshot), ErrNotFound) + require.ErrorIs(t, client.ReleaseSessionQuiescence(t.Context(), work, testQuiescenceFailure), ErrNotFound) + fresh := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("next")) + fresh.ContextID = session.ContextId + hash := sha256.Sum256([]byte("next")) + _, err = client.CreateRuntimeTask(t.Context(), session.Id, hash[:], a2a.NewSubmittedTask(fresh, fresh), "") + require.ErrorIs(t, err, ErrFailedPrecondition, "a taken-over claim still refuses the next turn") + + require.NoError(t, client.ReleaseSessionQuiescence(t.Context(), taken, testQuiescenceFailure)) + require.ErrorIs(t, client.RenewSessionQuiescence(t.Context(), taken, testClaimLease), ErrNotFound, "a settled claim is not renewed") + _, err = client.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) + require.ErrorIs(t, err, ErrNotFound) + _, err = client.CreateRuntimeTask(t.Context(), session.Id, hash[:], a2a.NewSubmittedTask(fresh, fresh), "") + require.NoError(t, err) +} + +// A claim made before claims held leases has none and is taken over at once. +func TestQuiescenceClaimWithoutLeaseIsTakenOver(t *testing.T) { + client := NewClient(setupTestDB(t)) + _, task := completedBoundaryFixture(t, client) + work, err := client.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) + require.NoError(t, err) + _, err = client.db.Exec(t.Context(), "UPDATE session_task_event SET quiescence_claimed_until = NULL WHERE sequence = $1", work.Version) + require.NoError(t, err) + + taken, err := client.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) + require.NoError(t, err) + require.True(t, taken.TakenOver) + require.Equal(t, string(task.ID), taken.TaskID) +} + +func TestQuiescenceLeaseRequiresAHolder(t *testing.T) { + client := NewClient(setupTestDB(t)) + completedBoundaryFixture(t, client) + _, err := client.db.Exec(t.Context(), "UPDATE session_task_event SET quiescence_claimed_until = clock_timestamp() WHERE quiescence_pending") + require.ErrorContains(t, err, "session_task_event_quiescence_lease_held") + _, err = client.ClaimSessionQuiescence(t.Context(), 0, 0, nil) + require.Error(t, err, "a claim without a lease is refused") +} + +// Every fencing token a quiescence holder takes outranks the ones before it, so +// a holder that took a claim over outranks the holder it replaced. +func TestQuiescenceFencingGenerationGrows(t *testing.T) { + client := NewClient(setupTestDB(t)) + first, err := client.NextQuiescenceFencingGeneration(t.Context()) + require.NoError(t, err) + require.Positive(t, first, "Substrate requires a generation of at least 1") + second, err := client.NextQuiescenceFencingGeneration(t.Context()) + require.NoError(t, err) + require.Greater(t, second, first) +} diff --git a/go/core/internal/database/session_stalled.go b/go/core/internal/database/session_stalled.go new file mode 100644 index 0000000000..5c17c94d30 --- /dev/null +++ b/go/core/internal/database/session_stalled.go @@ -0,0 +1,141 @@ +package database + +import ( + "context" + "fmt" + "time" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/jackc/pgx/v5" +) + +// StalledSessionTask is a submitted or working task whose last recorded event +// is older than a cutoff: the turn started and nothing has been saved for it since. +type StalledSessionTask struct { + SessionID string + TaskID string + State a2a.TaskState + LastEventAt time.Time +} + +// ListStalledSessionTasks returns active tasks whose last event was recorded +// before cutoff, oldest first, up to limit. Tasks of a session in a lifecycle +// operation and tasks whose dispatch reservation has not expired are left out. +// Admission is rechecked under the session lock by InterruptSessionTask. +func (c *Client) ListStalledSessionTasks(ctx context.Context, cutoff time.Time, limit int) ([]StalledSessionTask, error) { + tasks, err := queryMany(ctx, c.db, ` + SELECT i.id::text AS session_id, t.id AS task_id, t.state, latest.created_at AS last_event_at + FROM session_task t JOIN session_record i ON i.history_id = t.history_id + JOIN LATERAL ( + SELECT created_at FROM session_task_event + WHERE history_id = t.history_id AND task_id = t.id ORDER BY sequence DESC LIMIT 1 + ) latest ON TRUE + WHERE t.state IN ('TASK_STATE_SUBMITTED', 'TASK_STATE_WORKING') + AND latest.created_at < $1 + AND i.state = 'RUNTIME_STATE_READY' + AND i.operation = 'RUNTIME_OPERATION_NONE' + AND (i.dispatch_expires_at IS NULL OR i.dispatch_expires_at <= clock_timestamp()) + ORDER BY latest.created_at, i.id LIMIT $2 + `, pgx.RowToStructByName[StalledSessionTask], cutoff, int32(limit)) + if err != nil { + return nil, fmt.Errorf("list stalled Session tasks: %w", err) + } + return tasks, nil +} + +// SessionTaskInterruption is how a stalled task ended: Settled when the +// runtime's own saved boundary was published, otherwise a failure was appended. +type SessionTaskInterruption struct { + State a2a.TaskState + Settled bool +} + +// InterruptSessionTask ends a submitted or working task that recorded no event +// since cutoff; a zero cutoff ends the task whatever it recorded last, for a +// caller that knows its runtime is gone. A boundary the runtime saved but never +// settled is published as it is: the runtime's outcome wins over an +// interruption. Otherwise a published +// FAILED status update carrying message ends the task, so a late runtime save +// fails on the terminal task and the session takes its next message. The +// boundary carries no idle work: a suspend of a runtime that may be gone would +// leave a claim that never expires. A task that moved on, a session in a +// lifecycle operation, or an unexpired dispatch returns ErrConflict; a claimed +// idle boundary returns ErrFailedPrecondition. +func (c *Client) InterruptSessionTask(ctx context.Context, sessionID, taskID string, cutoff time.Time, message string) (*SessionTaskInterruption, error) { + if message == "" { + return nil, fmt.Errorf("task interruption requires a message") + } + var result *SessionTaskInterruption + err := c.withTx(ctx, func(tx pgx.Tx) error { + session, err := lockSession(ctx, tx, sessionID) + if err != nil { + return notFoundOr(err) + } + if session.State == "RUNTIME_STATE_DELETED" { + return ErrNotFound + } + if session.State != "RUNTIME_STATE_READY" || session.Operation != "RUNTIME_OPERATION_NONE" { + return fmt.Errorf("session %s is %s with operation %s: %w", sessionID, session.State, session.Operation, ErrConflict) + } + row, err := readSessionTask(ctx, tx, session.HistoryID, taskID) + if err != nil { + return notFoundOr(err) + } + if row.State != string(a2a.TaskStateSubmitted) && row.State != string(a2a.TaskStateWorking) { + return fmt.Errorf("task %s is %s: %w", taskID, row.State, ErrConflict) + } + type latestEvent struct { + Sequence int64 + CreatedAt time.Time + Published bool + Boundary bool + } + latest, err := queryOne(ctx, tx, ` + SELECT sequence, created_at, published, + (expected_version IS NOT NULL AND quiescence_pending IS NOT NULL) AS boundary + FROM session_task_event WHERE history_id = $1 AND task_id = $2 + ORDER BY sequence DESC LIMIT 1 + `, pgx.RowToStructByName[latestEvent], session.HistoryID, taskID) + if err != nil { + return notFoundOr(err) + } + if !cutoff.IsZero() && !latest.CreatedAt.Before(cutoff) { + return fmt.Errorf("task %s recorded an event at %s: %w", taskID, latest.CreatedAt.Format(time.RFC3339), ErrConflict) + } + if !latest.Published && latest.Boundary { + if err := settleSessionTask(ctx, tx, session, taskID, latest.Sequence); err != nil { + return err + } + settled, err := readSessionTask(ctx, tx, session.HistoryID, taskID) + if err != nil { + return err + } + result = &SessionTaskInterruption{State: a2a.TaskState(settled.State), Settled: true} + return nil + } + if err := requireSettledRuntime(ctx, tx, session.HistoryID, ""); err != nil { + return err + } + if err := execSQL(ctx, tx, ` + UPDATE session_task_event SET quiescence_pending = FALSE + WHERE history_id = $1 AND quiescence_pending + `, session.HistoryID); err != nil { + return err + } + task, err := unmarshalSessionTask(row.Data) + if err != nil { + return err + } + event := a2a.NewStatusUpdateEvent(task, a2a.TaskStateFailed, a2a.NewMessageForTask(a2a.MessageRoleAgent, task, a2a.NewTextPart(message))) + task.Status = event.Status + if err := storeSessionTaskEvent(ctx, tx, session, task, event, false); err != nil { + return err + } + result = &SessionTaskInterruption{State: a2a.TaskStateFailed} + return nil + }) + if err != nil { + return nil, fmt.Errorf("interrupt Session task %s: %w", taskID, err) + } + return result, nil +} diff --git a/go/core/internal/database/session_stalled_test.go b/go/core/internal/database/session_stalled_test.go new file mode 100644 index 0000000000..0cd86bf5ef --- /dev/null +++ b/go/core/internal/database/session_stalled_test.go @@ -0,0 +1,128 @@ +package database + +import ( + "testing" + "time" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/google/uuid" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "github.com/stretchr/testify/require" +) + +func stalledTaskFixture(t *testing.T, state a2a.TaskState) (*Client, *apiv1alpha1.Session, *a2a.Task, int64) { + t.Helper() + client := NewClient(setupTestDB(t)) + sessionFixture(t, client, t.Context(), "team-a", "revision-1", "assistant", "kagent") + session, _, err := client.CreateSession(t.Context(), newSessionRequest(uuid.NewString(), "assistant", "kagent", ""), uuid.NewString()) + require.NoError(t, err) + session, err = markSessionReady(t.Context(), client, session.Id, "agent.example") + require.NoError(t, err) + task := newSessionTask(uuid.NewString(), "initial") + task.ContextID, task.Status.State = session.ContextId, state + version, err := client.CreateRuntimeTask(t.Context(), session.Id, taskMutationHash("initial request"), task, "") + require.NoError(t, err) + return client, session, task, version +} + +func TestStalledSessionTaskIsListedOnlyPastTheCutoff(t *testing.T) { + for _, state := range []a2a.TaskState{a2a.TaskStateSubmitted, a2a.TaskStateWorking} { + t.Run(string(state), func(t *testing.T) { + client, session, task, _ := stalledTaskFixture(t, state) + stalled, err := client.ListStalledSessionTasks(t.Context(), time.Now().Add(-time.Hour), 100) + require.NoError(t, err) + require.Empty(t, stalled, "a turn younger than the cutoff is not stalled") + stalled, err = client.ListStalledSessionTasks(t.Context(), time.Now().Add(time.Hour), 100) + require.NoError(t, err) + require.Len(t, stalled, 1) + require.Equal(t, session.Id, stalled[0].SessionID) + require.Equal(t, string(task.ID), stalled[0].TaskID) + require.Equal(t, state, stalled[0].State) + _, err = client.InterruptSessionTask(t.Context(), session.Id, string(task.ID), time.Now().Add(-time.Hour), "interrupted") + require.ErrorIs(t, err, ErrConflict, "the cutoff is rechecked under the lock") + }) + } +} + +func TestInterruptSessionTaskFailsTheTurnAndReopensTheSession(t *testing.T) { + client, session, task, version := stalledTaskFixture(t, a2a.TaskStateWorking) + require.ErrorIs(t, client.ReserveSessionDispatch(t.Context(), session.Id, uuid.New(), "next"), ErrConflict) + + outcome, err := client.InterruptSessionTask(t.Context(), session.Id, string(task.ID), time.Now().Add(time.Hour), "turn interrupted: no progress for 1h0m0s") + require.NoError(t, err) + require.Equal(t, &SessionTaskInterruption{State: a2a.TaskStateFailed}, outcome) + failed, err := client.GetSettledSessionTask(t.Context(), session.Id, string(task.ID), nil) + require.NoError(t, err) + require.Equal(t, a2a.TaskStateFailed, failed.Status.State) + require.Equal(t, a2a.MessageRoleAgent, failed.Status.Message.Role) + require.Equal(t, a2a.NewTextPart("turn interrupted: no progress for 1h0m0s"), failed.Status.Message.Parts[0]) + require.Equal(t, task.ID, failed.Status.Message.TaskID) + + // A late save from the runtime that stalled finds a terminal task. + late := *task + late.Status = a2a.TaskStatus{State: a2a.TaskStateCompleted} + _, err = client.UpdateSessionTask(t.Context(), session.Id, version, taskMutationHash("late"), &late, &late, "") + require.ErrorIs(t, err, ErrConflict, "the interruption advanced the version") + _, current, err := client.GetVersionedSessionTask(t.Context(), session.Id, string(task.ID)) + require.NoError(t, err) + _, err = client.UpdateSessionTask(t.Context(), session.Id, current, taskMutationHash("late"), &late, &late, "") + require.ErrorIs(t, err, ErrFailedPrecondition) + + // The interruption is no idle boundary: nothing to pause or suspend. + _, err = client.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) + require.ErrorIs(t, err, ErrNotFound) + stalled, err := client.ListStalledSessionTasks(t.Context(), time.Now().Add(time.Hour), 100) + require.NoError(t, err) + require.Empty(t, stalled) + require.NoError(t, client.ReserveSessionDispatch(t.Context(), session.Id, uuid.New(), "next")) + _, err = client.InterruptSessionTask(t.Context(), session.Id, string(task.ID), time.Now().Add(time.Hour), "again") + require.ErrorIs(t, err, ErrConflict) +} + +// A caller that knows the turn's runtime is gone ends the turn without a +// cutoff, whatever the turn recorded last. +func TestInterruptSessionTaskWithoutACutoffEndsAFreshTurn(t *testing.T) { + client, session, task, _ := stalledTaskFixture(t, a2a.TaskStateSubmitted) + outcome, err := client.InterruptSessionTask(t.Context(), session.Id, string(task.ID), time.Time{}, "runtime lost: Actor team-a/session-1 crashed; start a new conversation") + require.NoError(t, err) + require.Equal(t, &SessionTaskInterruption{State: a2a.TaskStateFailed}, outcome) + failed, err := client.GetSettledSessionTask(t.Context(), session.Id, string(task.ID), nil) + require.NoError(t, err) + require.Equal(t, a2a.TaskStateFailed, failed.Status.State) + require.Equal(t, a2a.NewTextPart("runtime lost: Actor team-a/session-1 crashed; start a new conversation"), failed.Status.Message.Parts[0]) + _, err = client.InterruptSessionTask(t.Context(), session.Id, string(task.ID), time.Time{}, "again") + require.ErrorIs(t, err, ErrConflict, "an ended turn is not interrupted again") +} + +func TestInterruptSessionTaskSettlesTheRuntimesOwnBoundary(t *testing.T) { + client, session, task, version := stalledTaskFixture(t, a2a.TaskStateWorking) + done := *task + done.Status = a2a.TaskStatus{State: a2a.TaskStateCompleted, Message: a2a.NewMessageForTask(a2a.MessageRoleAgent, task, a2a.NewTextPart("done"))} + boundary, err := client.UpdateSessionTask(t.Context(), session.Id, version, taskMutationHash("complete"), &done, &done, "") + require.NoError(t, err) + stalled, err := client.ListStalledSessionTasks(t.Context(), time.Now().Add(time.Hour), 100) + require.NoError(t, err) + require.Len(t, stalled, 1, "an unsettled boundary keeps the projection working") + + outcome, err := client.InterruptSessionTask(t.Context(), session.Id, string(task.ID), time.Now().Add(time.Hour), "interrupted") + require.NoError(t, err) + require.Equal(t, &SessionTaskInterruption{State: a2a.TaskStateCompleted, Settled: true}, outcome) + settled, err := client.GetSettledSessionTask(t.Context(), session.Id, string(task.ID), nil) + require.NoError(t, err) + require.Equal(t, a2a.TaskStateCompleted, settled.Status.State) + require.Equal(t, a2a.NewTextPart("done"), settled.Status.Message.Parts[0]) + work, err := client.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) + require.NoError(t, err, "the runtime's boundary keeps its idle work") + require.Equal(t, boundary, work.Version) +} + +func TestInterruptSessionTaskRefusesASessionInALifecycleOperation(t *testing.T) { + client, session, task, _ := stalledTaskFixture(t, a2a.TaskStateSubmitted) + _, err := client.BeginSessionOperation(t.Context(), session.Id, apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_DELETE) + require.NoError(t, err) + stalled, err := client.ListStalledSessionTasks(t.Context(), time.Now().Add(time.Hour), 100) + require.NoError(t, err) + require.Empty(t, stalled) + _, err = client.InterruptSessionTask(t.Context(), session.Id, string(task.ID), time.Now().Add(time.Hour), "interrupted") + require.ErrorIs(t, err, ErrConflict) +} diff --git a/go/core/internal/database/shares.go b/go/core/internal/database/shares.go index 6ed6f0a2c7..ca4ac46bf7 100644 --- a/go/core/internal/database/shares.go +++ b/go/core/internal/database/shares.go @@ -66,16 +66,17 @@ func (c *Client) CreateSessionShare(ctx context.Context, share *apiv1alpha1.Sess } // GetSessionShareByTokenHash resolves a token digest to its share and the session -// owner's ID, or ErrNotFound, which an expired share is too. Callers apply the share's -// permission when granting access. -func (c *Client) GetSessionShareByTokenHash(ctx context.Context, tokenHash []byte) (*apiv1alpha1.SessionShare, string, error) { +// owner's ID, or ErrNotFound, which a share expired at now is too. now comes from the +// clock that set the share's expires_at, never the database's. Callers apply the +// share's permission when granting access. +func (c *Client) GetSessionShareByTokenHash(ctx context.Context, tokenHash []byte, now time.Time) (*apiv1alpha1.SessionShare, string, error) { row, err := queryOne(ctx, c.db, ` SELECT s.id, s.session_id, s.permission, s.data, s.expires_at, i.user_id AS owner_user_id FROM session_share s JOIN session_record i ON i.id = s.session_id WHERE s.token_hash = $1 AND i.state <> 'RUNTIME_STATE_DELETED' - AND (s.expires_at IS NULL OR s.expires_at > now()) - `, pgx.RowToStructByName[sessionShareRow], tokenHash) + AND (s.expires_at IS NULL OR s.expires_at > $2) + `, pgx.RowToStructByName[sessionShareRow], tokenHash, now) if err != nil { return nil, "", fmt.Errorf("get Session share by token: %w", notFoundOr(err)) } @@ -132,6 +133,21 @@ func (c *Client) DeleteSessionShare(ctx context.Context, id, userID string) erro return nil } +// DeleteExpiredSessionShares removes every share expired at now and returns how +// many went. now comes from the clock that set the shares' expires_at, as in +// GetSessionShareByTokenHash, so a share stops granting and leaves the table on +// the same clock. A share without expiry stays. +func (c *Client) DeleteExpiredSessionShares(ctx context.Context, now time.Time) (int64, error) { + count, err := c.db.Exec(ctx, ` + DELETE FROM session_share + WHERE expires_at IS NOT NULL AND expires_at <= $1 + `, now) + if err != nil { + return 0, fmt.Errorf("delete expired Session shares: %w", err) + } + return count.RowsAffected(), nil +} + type sessionShareRow struct { ID uuid.UUID SessionID uuid.UUID diff --git a/go/core/internal/database/superseded_sessions.go b/go/core/internal/database/superseded_sessions.go new file mode 100644 index 0000000000..0f1733aead --- /dev/null +++ b/go/core/internal/database/superseded_sessions.go @@ -0,0 +1,136 @@ +package database + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// ListSessionsOnSupersededRevisions returns READY sessions whose runtime is +// settled — no lifecycle operation, no dispatch reserved, no turn running and +// no idle boundary claimed — and whose prepared revision is no longer their +// agent's current one, by id after afterID, up to limit. The current revision +// is the latest successful revision of the agent the prepared revision was +// rendered for; a session of a retired agent, or of an agent without a current +// revision, has nowhere to move and is left out, as is one whose current +// revision lives in another atespace. Callers authorize access. +func (c *Client) ListSessionsOnSupersededRevisions(ctx context.Context, afterID string, limit int) ([]*apiv1alpha1.Session, error) { + rows, err := queryMany(ctx, c.db, ` + SELECT i.id, i.user_id, i.prepared_revision, i.state, i.data, i.operation, i.context_id, + i.source_checkpoint_id, i.history_id, i.operation_id, i.executor_id + FROM session_record i + JOIN agent_runtime_revision prepared ON prepared.revision = i.prepared_revision + JOIN agent_definition p + ON p.namespace = prepared.namespace + AND p.agent_uid = prepared.agent_uid + AND p.retired_at IS NULL + JOIN agent_runtime_revision current ON current.revision = p.latest_successful_revision AND current.deleted_at IS NULL + WHERE i.state = 'RUNTIME_STATE_READY' + AND i.operation = 'RUNTIME_OPERATION_NONE' + AND (i.dispatch_expires_at IS NULL OR i.dispatch_expires_at <= clock_timestamp()) + AND current.revision <> prepared.revision + AND current.actor_template_atespace = prepared.actor_template_atespace + AND NOT EXISTS (SELECT 1 FROM session_task WHERE history_id = i.history_id + AND state IN ('TASK_STATE_SUBMITTED', 'TASK_STATE_WORKING')) + AND NOT EXISTS (SELECT 1 FROM session_task_event WHERE history_id = i.history_id + AND (NOT published OR (quiescence_pending AND quiescence_executor_id IS NOT NULL))) + AND ($1 = '' OR i.id > $1::uuid) + ORDER BY i.id + LIMIT $2 + `, pgx.RowToStructByName[sessionRow], afterID, int32(limit)) + if err != nil { + return nil, fmt.Errorf("list Sessions on superseded revisions: %w", err) + } + result := make([]*apiv1alpha1.Session, 0, len(rows)) + for _, row := range rows { + session, err := toSession(row) + if err != nil { + return nil, err + } + result = append(result, session) + } + return result, nil +} + +// RepointSessionOperation records that the claiming executor of a resume moved +// the session's Actor onto revision, the agent's current one. +func (c *Client) RepointSessionOperation(ctx context.Context, sessionID string, id, executorID uuid.UUID, revision string) error { + if id == uuid.Nil || executorID == uuid.Nil { + return fmt.Errorf("lifecycle generation and executor IDs are required") + } + _, err := c.repointSession(ctx, sessionID, revision, func(operation *SessionOperation) bool { + return operation.ID == id && operation.ExecutorID == executorID && + operation.Instance.Operation == apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_RESUME + }) + return err +} + +// RepointSession records that the quiesced Actor of a READY session moved from +// revision from onto revision to, the agent's current one. A session another +// caller has recorded on to meanwhile is returned as it is: the gateway's +// repoint before a turn and the sweep of superseded revisions may move the +// same Actor. It answers ErrConflict, with the session as it is, when the +// session is no longer READY on from or to without a lifecycle operation: an +// operation that claimed it meanwhile wins. +func (c *Client) RepointSession(ctx context.Context, sessionID, from, to string) (*apiv1alpha1.Session, error) { + return c.repointSession(ctx, sessionID, to, func(operation *SessionOperation) bool { + session := operation.Instance + return (session.PreparedRevision == from || session.PreparedRevision == to) && + session.State == apiv1alpha1.RuntimeState_RUNTIME_STATE_READY && + session.Operation == apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_NONE + }) +} + +// repointSession moves the session's prepared revision to revision when owned +// accepts the locked session; a session already on revision is left as it is. +// The revision row is locked so the runtime revision GC cannot collect it +// concurrently; the superseded revision is released with the same update. A +// refused repoint returns the session as it is with ErrConflict. +func (c *Client) repointSession(ctx context.Context, sessionID, revision string, owned func(*SessionOperation) bool) (*apiv1alpha1.Session, error) { + var result *apiv1alpha1.Session + err := c.withTx(ctx, func(tx pgx.Tx) error { + row, err := lockSession(ctx, tx, sessionID) + if errors.Is(err, pgx.ErrNoRows) { + return ErrConflict + } + if err != nil { + return err + } + operation, err := toSessionOperation(row) + if err != nil { + return err + } + result = operation.Instance + if !owned(operation) { + return fmt.Errorf("session changed before its repoint was recorded: %w", ErrConflict) + } + if result.PreparedRevision == revision { + return nil + } + if _, err := getAvailableRuntimeRevisionForUpdate(ctx, tx, revision); err != nil { + return err + } + result.PreparedRevision = revision + result.UpdatedAt = timestamppb.Now() + data, err := marshalSession(result) + if err != nil { + return err + } + if err := execSQL(ctx, tx, `UPDATE runtime_instance SET prepared_revision = $2 WHERE id = $1 AND kind = 'agent'`, sessionID, revision); err != nil { + return err + } + return execSQL(ctx, tx, `UPDATE session SET data = $2 WHERE id = $1`, sessionID, data) + }) + if errors.Is(err, ErrConflict) { + return result, fmt.Errorf("repoint Session %s to revision %s: %w", sessionID, revision, err) + } + if err != nil { + return nil, fmt.Errorf("repoint Session %s to revision %s: %w", sessionID, revision, err) + } + return result, nil +} diff --git a/go/core/internal/database/superseded_sessions_test.go b/go/core/internal/database/superseded_sessions_test.go new file mode 100644 index 0000000000..7e3f1f363d --- /dev/null +++ b/go/core/internal/database/superseded_sessions_test.go @@ -0,0 +1,150 @@ +package database + +import ( + "testing" + + a2apb "github.com/a2aproject/a2a-go/v2/a2apb/v1" + "github.com/google/uuid" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "github.com/stretchr/testify/require" +) + +func TestSessionsOnSupersededRevisionsAreListedByID(t *testing.T) { + client, ctx := NewClient(setupTestDB(t)), t.Context() + sessionFixture(t, client, ctx, "team-a", "revision-1", "assistant", "kagent") + readySession := func() *apiv1alpha1.Session { + t.Helper() + session, _, err := client.CreateSession(ctx, newSessionRequest(uuid.NewString(), "assistant", "kagent", ""), uuid.NewString()) + require.NoError(t, err) + session, err = markSessionReady(ctx, client, session.Id, "agent.example") + require.NoError(t, err) + return session + } + ids := func(sessions []*apiv1alpha1.Session) []string { + result := make([]string, 0, len(sessions)) + for _, session := range sessions { + result = append(result, session.GetId()) + } + return result + } + first, second := readySession(), readySession() + if first.Id > second.Id { + first, second = second, first + } + suspending := readySession() + _, err := client.BeginSessionOperation(ctx, suspending.Id, apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_SUSPEND) + require.NoError(t, err) + + listed, err := client.ListSessionsOnSupersededRevisions(ctx, "", 10) + require.NoError(t, err) + require.Empty(t, listed, "every session is on its agent's current revision") + _, err = client.GetCurrentRuntimeRevision(ctx, "revision-1") + require.NoError(t, err) + + // The agent is re-rendered: revision-2 becomes current. + current := RuntimeRevision{ + Revision: "revision-2", Namespace: "team-a", AgentName: "assistant", AgentUID: "assistant-uid", + SourceSnapshot: []byte("{}"), AgentCard: &a2apb.AgentCard{}, EgressDestinations: []string{}, + ActorTemplateAtespace: "team-a", ActorTemplateName: "revision-2-actor-template", ActorTemplateUID: "revision-2-actor-uid", + } + require.NoError(t, client.UpsertAgentDefinition(ctx, AgentDefinition{ + Namespace: "team-a", AgentName: "assistant", AgentUID: "assistant-uid", DesiredRevision: current.Revision, + })) + require.NoError(t, client.RecordRuntimeRevision(ctx, current, true)) + moved := readySession() + require.Equal(t, "revision-2", moved.GetPreparedRevision(), "a new session takes the current revision") + + resolved, err := client.GetCurrentRuntimeRevision(ctx, "revision-1") + require.NoError(t, err) + require.Equal(t, "revision-2", resolved.Revision, "the current revision of the agent revision-1 was rendered for") + require.Equal(t, current.ActorTemplateName, resolved.ActorTemplateName) + + listed, err = client.ListSessionsOnSupersededRevisions(ctx, "", 10) + require.NoError(t, err) + require.Equal(t, []string{first.Id, second.Id}, ids(listed), "READY sessions without an operation on the superseded revision, by id; the new session is on the current one") + require.Equal(t, "revision-1", listed[0].GetPreparedRevision()) + + listed, err = client.ListSessionsOnSupersededRevisions(ctx, "", 1) + require.NoError(t, err) + require.Equal(t, []string{first.Id}, ids(listed)) + listed, err = client.ListSessionsOnSupersededRevisions(ctx, first.Id, 1) + require.NoError(t, err) + require.Equal(t, []string{second.Id}, ids(listed), "the page after an id") + + dispatch := uuid.New() + require.NoError(t, client.ReserveSessionDispatch(ctx, second.Id, dispatch, "next")) + listed, err = client.ListSessionsOnSupersededRevisions(ctx, "", 10) + require.NoError(t, err) + require.Equal(t, []string{first.Id}, ids(listed), "a session with a turn reserved is left to the turn") + _, err = client.RevokeSessionDispatch(ctx, second.Id, dispatch, "next") + require.NoError(t, err) + + _, err = client.RepointSession(ctx, first.Id, "revision-1", current.Revision) + require.NoError(t, err) + listed, err = client.ListSessionsOnSupersededRevisions(ctx, "", 10) + require.NoError(t, err) + require.Equal(t, []string{second.Id}, ids(listed), "a moved session leaves the listing") + + // The agent retired: its sessions have no current revision to move to. + require.NoError(t, client.RetireAgentIdentities(ctx, "team-a", "assistant", nil)) + listed, err = client.ListSessionsOnSupersededRevisions(ctx, "", 10) + require.NoError(t, err) + require.Empty(t, listed) + _, err = client.GetCurrentRuntimeRevision(ctx, "revision-1") + require.ErrorIs(t, err, ErrNotFound) +} + +func TestRepointSessionIsIdempotentAndYieldsToAnOperation(t *testing.T) { + client, ctx := NewClient(setupTestDB(t)), t.Context() + sessionFixture(t, client, ctx, "team-a", "revision-1", "assistant", "kagent") + sessionFixture(t, client, ctx, "team-a", "revision-2", "assistant", "kagent") + session, _, err := client.CreateSession(ctx, newSessionRequest(uuid.NewString(), "assistant", "kagent", ""), uuid.NewString()) + require.NoError(t, err) + _, err = markSessionReady(ctx, client, session.Id, "agent.example") + require.NoError(t, err) + require.Equal(t, "revision-2", session.GetPreparedRevision(), "a new session takes the current revision") + + moved, err := client.RepointSession(ctx, session.Id, "revision-2", "revision-1") + require.NoError(t, err) + require.Equal(t, "revision-1", moved.GetPreparedRevision()) + stored, err := client.GetSessionByID(ctx, session.Id) + require.NoError(t, err) + require.Equal(t, "revision-1", stored.GetPreparedRevision(), "the column records the move") + again, err := client.RepointSession(ctx, session.Id, "revision-2", "revision-1") + require.NoError(t, err, "a repoint another caller recorded is accepted") + require.Equal(t, "revision-1", again.GetPreparedRevision()) + require.Equal(t, moved.GetUpdatedAt().AsTime(), again.GetUpdatedAt().AsTime(), "and changes nothing") + + _, err = client.BeginSessionOperation(ctx, session.Id, apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_SUSPEND) + require.NoError(t, err) + refused, err := client.RepointSession(ctx, session.Id, "revision-1", "revision-2") + require.ErrorIs(t, err, ErrConflict, "a lifecycle operation that claimed the session wins") + require.Equal(t, "revision-1", refused.GetPreparedRevision(), "the refusal reports the session as it is") + require.Equal(t, apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_SUSPEND, refused.GetOperation()) +} + +func TestRepointSessionOperationRecordsOnlyTheClaimingResume(t *testing.T) { + client, ctx := NewClient(setupTestDB(t)), t.Context() + sessionFixture(t, client, ctx, "team-a", "revision-1", "assistant", "kagent") + session, _, err := client.CreateSession(ctx, newSessionRequest(uuid.NewString(), "assistant", "kagent", ""), uuid.NewString()) + require.NoError(t, err) + _, err = markSessionReady(ctx, client, session.Id, "agent.example") + require.NoError(t, err) + _, err = finishSessionOperation(ctx, client, session.Id, apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_SUSPEND, "") + require.NoError(t, err) + sessionFixture(t, client, ctx, "team-a", "revision-2", "assistant", "kagent") + + operation, err := client.BeginSessionOperation(ctx, session.Id, apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_RESUME) + require.NoError(t, err) + executor := uuid.New() + require.ErrorIs(t, client.RepointSessionOperation(ctx, session.Id, operation.ID, executor, "revision-2"), ErrConflict, "only the claiming executor records a move") + claimed, err := client.ClaimSessionOperation(ctx, session.Id, operation.ID, executor) + require.NoError(t, err) + require.True(t, claimed) + require.NoError(t, client.RepointSessionOperation(ctx, session.Id, operation.ID, executor, "revision-2")) + require.NoError(t, client.RepointSessionOperation(ctx, session.Id, operation.ID, executor, "revision-2"), "a repeated record is accepted") + resumed, err := client.FinishSessionOperation(ctx, session.Id, operation.ID, executor, "", "", "") + require.NoError(t, err) + require.Equal(t, apiv1alpha1.RuntimeState_RUNTIME_STATE_READY, resumed.GetState()) + require.Equal(t, "revision-2", resumed.GetPreparedRevision(), "the session resumed on the current revision") +} diff --git a/go/core/internal/database/task_finalization.go b/go/core/internal/database/task_finalization.go index 50ccf459c9..95eee203b9 100644 --- a/go/core/internal/database/task_finalization.go +++ b/go/core/internal/database/task_finalization.go @@ -23,51 +23,57 @@ func (c *Client) SettleSessionTask(ctx context.Context, sessionID, taskID string if session.State == "RUNTIME_STATE_DELETED" { return ErrNotFound } - type boundary struct { - Data []byte - ExpectedVersion int64 - Published bool - } - row, err := queryOne(ctx, tx, ` - SELECT data, expected_version, published FROM session_task_event - WHERE history_id = $1 AND task_id = $2 AND sequence = $3 - AND expected_version IS NOT NULL AND quiescence_pending IS NOT NULL - `, pgx.RowToStructByName[boundary], session.HistoryID, taskID, version) - if errors.Is(err, pgx.ErrNoRows) { - return ErrConflict - } - if err != nil { - return err - } - if row.Published { - return nil - } - stored, err := readSessionTask(ctx, tx, session.HistoryID, taskID) - if err != nil { - return err - } - wire, err := applyStoredBoundary(stored.Data, row.Data) - if err != nil { - return err - } - task, err := pbconv.FromProtoTask(wire) - if err != nil { - return err - } - data, err := proto.Marshal(wire) - if err != nil { - return err - } - if _, err := saveTaskProjection(ctx, tx, session.HistoryID, taskID, string(task.Status.State), task.Status.Timestamp, data); err != nil { - return err - } - return execSQL(ctx, tx, ` - UPDATE session_task_event SET published = TRUE - WHERE history_id = $1 AND task_id = $2 AND sequence > $3 AND sequence <= $4 - `, session.HistoryID, taskID, row.ExpectedVersion, version) + return settleSessionTask(ctx, tx, session, taskID, version) }) } +// settleSessionTask publishes the boundary at version in the caller's +// transaction. The caller holds the session lock. +func settleSessionTask(ctx context.Context, tx pgx.Tx, session sessionRow, taskID string, version int64) error { + type boundary struct { + Data []byte + ExpectedVersion int64 + Published bool + } + row, err := queryOne(ctx, tx, ` + SELECT data, expected_version, published FROM session_task_event + WHERE history_id = $1 AND task_id = $2 AND sequence = $3 + AND expected_version IS NOT NULL AND quiescence_pending IS NOT NULL + `, pgx.RowToStructByName[boundary], session.HistoryID, taskID, version) + if errors.Is(err, pgx.ErrNoRows) { + return ErrConflict + } + if err != nil { + return err + } + if row.Published { + return nil + } + stored, err := readSessionTask(ctx, tx, session.HistoryID, taskID) + if err != nil { + return err + } + wire, err := applyStoredBoundary(stored.Data, row.Data) + if err != nil { + return err + } + task, err := pbconv.FromProtoTask(wire) + if err != nil { + return err + } + data, err := proto.Marshal(wire) + if err != nil { + return err + } + if _, err := saveTaskProjection(ctx, tx, session.HistoryID, taskID, string(task.Status.State), task.Status.Timestamp, data); err != nil { + return err + } + return execSQL(ctx, tx, ` + UPDATE session_task_event SET published = TRUE + WHERE history_id = $1 AND task_id = $2 AND sequence > $3 AND sequence <= $4 + `, session.HistoryID, taskID, row.ExpectedVersion, version) +} + func applyStoredBoundary(data, eventData []byte) (*a2apb.Task, error) { task, event := &a2apb.Task{}, &a2apb.StreamResponse{} if err := proto.Unmarshal(data, task); err != nil { diff --git a/go/core/internal/database/task_fixtures_test.go b/go/core/internal/database/task_fixtures_test.go index 81baaefb2b..986a3b47e2 100644 --- a/go/core/internal/database/task_fixtures_test.go +++ b/go/core/internal/database/task_fixtures_test.go @@ -35,7 +35,7 @@ func saveRuntimeTask(t *testing.T, client *Client, sessionID string, task *a2a.T if err := client.SettleSessionTask(ctx, sessionID, string(task.ID), version); err != nil { return err } - work, err := client.ClaimSessionQuiescence(ctx) + work, err := client.ClaimSessionQuiescence(ctx, testClaimLease, 0, nil) if err != nil { return err } diff --git a/go/core/internal/database/task_store_test.go b/go/core/internal/database/task_store_test.go index 7db438a74d..c93bd80309 100644 --- a/go/core/internal/database/task_store_test.go +++ b/go/core/internal/database/task_store_test.go @@ -3,6 +3,7 @@ package database import ( "crypto/sha256" "testing" + "time" "github.com/a2aproject/a2a-go/v2/a2a" "github.com/google/uuid" @@ -182,17 +183,17 @@ func TestRuntimeCompletionDoesNotWaitForSnapshot(t *testing.T) { _, err = client.GetSettledSessionTask(t.Context(), session.Id, string(waiting.ID), nil) require.ErrorIs(t, err, ErrConflict) require.ErrorIs(t, deleteSession(t.Context(), client, session.Id), ErrFailedPrecondition) - _, err = client.ClaimSessionQuiescence(t.Context()) + _, err = client.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) require.ErrorIs(t, err, ErrNotFound) // The native cleanup callback has not finished. require.NoError(t, client.SettleSessionTask(t.Context(), session.Id, string(waiting.ID), version)) public, err = client.GetSettledSessionTask(t.Context(), session.Id, string(waiting.ID), nil) require.NoError(t, err) require.Equal(t, a2a.TaskStateCompleted, public.Status.State) require.Len(t, public.History, 3) - work, err := client.ClaimSessionQuiescence(t.Context()) + work, err := client.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) require.NoError(t, err) require.Equal(t, version, work.Version) - _, err = client.ClaimSessionQuiescence(t.Context()) + _, err = client.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) require.ErrorIs(t, err, ErrNotFound) // Claims cannot expire into a second suspend. require.Error(t, client.FinishSessionQuiescence(t.Context(), work, nil)) // Even an unfinished or failed snapshot never hides the completed task. @@ -213,10 +214,73 @@ func TestRuntimeCompletionDoesNotWaitForSnapshot(t *testing.T) { require.NoError(t, err) require.Equal(t, a2a.TaskStateCompleted, public.Status.State) require.Len(t, public.History, 3) - _, err = client.ClaimSessionQuiescence(t.Context()) + _, err = client.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) require.ErrorIs(t, err, ErrNotFound) } +// A terminal boundary whose suspend did not happen is released without a +// snapshot: the next turn is admitted and the boundary is not claimed again. +func TestReleasedTerminalBoundaryAdmitsTheNextTurn(t *testing.T) { + client := NewClient(setupTestDB(t)) + sessionFixture(t, client, t.Context(), "team-a", "revision", "assistant", "kagent") + session, waiting := waitingTaskFixture(t, client) + reply := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("PostgreSQL")) + reply.TaskID, reply.ContextID = waiting.ID, waiting.ContextID + current, initialVersion := resumeRuntimeTask(t, client, session.Id, reply) + current.Status = a2a.TaskStatus{State: a2a.TaskStateCompleted} + hash := sha256.Sum256([]byte("released")) + version, err := client.UpdateSessionTask(t.Context(), session.Id, initialVersion, hash[:], current, current, "") + require.NoError(t, err) + require.NoError(t, client.SettleSessionTask(t.Context(), session.Id, string(current.ID), version)) + work, err := client.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) + require.NoError(t, err) + fresh := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("next")) + fresh.ContextID = session.ContextId + _, err = client.CreateRuntimeTask(t.Context(), session.Id, hash[:], a2a.NewSubmittedTask(fresh, fresh), "") + require.ErrorIs(t, err, ErrFailedPrecondition, "a held claim refuses the next turn") + + failure := &apiv1alpha1.QuiescenceFailure{Reason: "SuspendFailed", Message: "retry after a failed request: deadline exceeded"} + stale := *work + stale.ExecutorID = uuid.New() + require.ErrorIs(t, client.ReleaseSessionQuiescence(t.Context(), &stale, failure), ErrNotFound, "another executor cannot release the claim") + require.ErrorIs(t, client.ReleaseSessionQuiescence(t.Context(), work, &apiv1alpha1.QuiescenceFailure{}), ErrFailedPrecondition, "a release says why") + require.NoError(t, client.ReleaseSessionQuiescence(t.Context(), work, failure)) + require.NoError(t, client.ReleaseSessionQuiescence(t.Context(), work, failure), "a retried release is harmless") + _, err = client.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) + require.ErrorIs(t, err, ErrNotFound) + _, _, err = client.ReserveSessionCheckpoint(t.Context(), &apiv1alpha1.Checkpoint{Id: uuid.NewString(), SessionId: session.Id, HeadTaskId: string(current.ID)}, "alice", uuid.NewString()) + require.ErrorIs(t, err, ErrFailedPrecondition, "a boundary without a snapshot cannot be checkpointed") + + // The session's status and its listing explain the missing snapshot. + got, err := client.GetSessionByID(t.Context(), session.Id) + require.NoError(t, err) + recorded := got.GetLastQuiescenceFailure() + require.Equal(t, string(current.ID), recorded.GetTaskId()) + require.Equal(t, failure.Reason, recorded.GetReason()) + require.Equal(t, failure.Message, recorded.GetMessage()) + require.WithinDuration(t, time.Now(), recorded.GetFailedAt().AsTime(), time.Minute) + listed, err := client.ListSessions(t.Context(), SessionQuery{AllUsers: true, Limit: 10}) + require.NoError(t, err) + require.Len(t, listed, 1) + require.Equal(t, failure.Reason, listed[0].GetLastQuiescenceFailure().GetReason()) + + // The next turn's snapshot clears the failure. + next := a2a.NewSubmittedTask(fresh, fresh) + nextVersion, err := client.CreateRuntimeTask(t.Context(), session.Id, hash[:], next, "") + require.NoError(t, err) + next.Status = a2a.TaskStatus{State: a2a.TaskStateCompleted} + nextHash := sha256.Sum256([]byte("snapshotted")) + nextVersion, err = client.UpdateSessionTask(t.Context(), session.Id, nextVersion, nextHash[:], next, next, "") + require.NoError(t, err) + require.NoError(t, client.SettleSessionTask(t.Context(), session.Id, string(next.ID), nextVersion)) + work, err = client.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) + require.NoError(t, err) + require.NoError(t, client.FinishSessionQuiescence(t.Context(), work, &SessionTaskSnapshot{Atespace: "team-a", URI: "s3://snapshot/next", ContentScope: "DATA"})) + got, err = client.GetSessionByID(t.Context(), session.Id) + require.NoError(t, err) + require.Nil(t, got.GetLastQuiescenceFailure()) +} + func TestRuntimeForkRetainsOnlyTheCheckpointBoundary(t *testing.T) { client := NewClient(setupTestDB(t)) sessionFixture(t, client, t.Context(), "team-a", "revision", "assistant", "kagent") @@ -231,7 +295,7 @@ func TestRuntimeForkRetainsOnlyTheCheckpointBoundary(t *testing.T) { _, _, err = client.ReserveSessionCheckpoint(t.Context(), &apiv1alpha1.Checkpoint{Id: uuid.NewString(), SessionId: source.Id, HeadTaskId: string(completed.ID)}, "alice", uuid.NewString()) require.ErrorIs(t, err, ErrFailedPrecondition) require.NoError(t, client.SettleSessionTask(t.Context(), source.Id, string(completed.ID), version)) - boundary, err := client.ClaimSessionQuiescence(t.Context()) + boundary, err := client.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) require.NoError(t, err) require.NoError(t, client.FinishSessionQuiescence(t.Context(), boundary, &SessionTaskSnapshot{Atespace: "team-a", URI: "turn-N", ContentScope: "DATA"})) checkpoint, _, err := client.ReserveSessionCheckpoint(t.Context(), &apiv1alpha1.Checkpoint{Id: uuid.NewString(), SessionId: source.Id, HeadTaskId: string(completed.ID)}, "alice", uuid.NewString()) @@ -316,7 +380,7 @@ func TestNewExecutionRacesIdleClaim(t *testing.T) { claimErrors, writeErrors := make(chan error, 1), make(chan error, 1) go func() { <-start - work, err := client.ClaimSessionQuiescence(ctx) + work, err := client.ClaimSessionQuiescence(ctx, testClaimLease, 0, nil) claimed <- work claimErrors <- err }() @@ -338,13 +402,13 @@ func TestNewExecutionRacesIdleClaim(t *testing.T) { require.ErrorIs(t, claimErr, ErrNotFound) require.NoError(t, writeErr) } - _, err = client.ClaimSessionQuiescence(ctx) + _, err = client.ClaimSessionQuiescence(ctx, testClaimLease, 0, nil) require.ErrorIs(t, err, ErrNotFound, "dispatch must protect the network gap before persistence") _, err = client.CreateRuntimeTask(ctx, session.Id, taskMutationHash("next"), next, dispatchID.String()) require.NoError(t, err) // A lost cleanup acknowledgement cannot requeue an obsolete suspension. require.NoError(t, client.SettleSessionTask(ctx, session.Id, string(task.ID), version)) - _, err = client.ClaimSessionQuiescence(ctx) + _, err = client.ClaimSessionQuiescence(ctx, testClaimLease, 0, nil) require.ErrorIs(t, err, ErrNotFound) visible, err := client.GetSettledSessionTask(ctx, session.Id, string(task.ID), nil) require.NoError(t, err) @@ -381,7 +445,7 @@ func TestDispatchFencesIdleWorkAndLateAcceptance(t *testing.T) { session, waiting := waitingTaskFixture(t, client) dispatchID := uuid.New() require.NoError(t, client.ReserveSessionDispatch(ctx, session.Id, dispatchID, "")) - _, err := client.ClaimSessionQuiescence(ctx) + _, err := client.ClaimSessionQuiescence(ctx, testClaimLease, 0, nil) require.ErrorIs(t, err, ErrNotFound) require.ErrorIs(t, client.ReserveSessionDispatch(ctx, session.Id, uuid.New(), ""), ErrDispatchBusy) revoked, err := client.RevokeSessionDispatch(ctx, session.Id, dispatchID, "new-input") @@ -399,7 +463,7 @@ func TestDispatchFencesIdleWorkAndLateAcceptance(t *testing.T) { // SDKs may first append input while retaining the waiting state. version, err = client.UpdateSessionTask(ctx, session.Id, version, taskMutationHash("input-only"), waiting, waiting, dispatchID.String()) require.NoError(t, err) - _, err = client.ClaimSessionQuiescence(ctx) + _, err = client.ClaimSessionQuiescence(ctx, testClaimLease, 0, nil) require.ErrorIs(t, err, ErrNotFound) _, err = client.UpdateSessionTask(ctx, session.Id, version, taskMutationHash("active"), &resumed, &resumed, dispatchID.String()) require.NoError(t, err) @@ -470,7 +534,7 @@ func TestCheckpointPinsExpectedTaskWhileSnapshotIsPending(t *testing.T) { request := &apiv1alpha1.Checkpoint{Id: uuid.NewString(), SessionId: session.Id, HeadTaskId: string(task.ID)} _, _, err = client.ReserveSessionCheckpoint(ctx, request, "alice", "checkpoint") require.ErrorIs(t, err, ErrSnapshotPending) - work, err := client.ClaimSessionQuiescence(ctx) + work, err := client.ClaimSessionQuiescence(ctx, testClaimLease, 0, nil) require.NoError(t, err) require.NoError(t, client.FinishSessionQuiescence(ctx, work, &SessionTaskSnapshot{Atespace: "team-a", URI: "snapshot", ContentScope: "DATA"})) saved, _, err := client.ReserveSessionCheckpoint(ctx, request, "alice", "saved") diff --git a/go/core/internal/egress/destination.go b/go/core/internal/egress/destination.go index ade6f203ed..6171155f25 100644 --- a/go/core/internal/egress/destination.go +++ b/go/core/internal/egress/destination.go @@ -1,9 +1,14 @@ package egress import ( + "fmt" "net" + "net/netip" "net/url" + "strconv" "strings" + + "k8s.io/apimachinery/pkg/util/validation" ) // Origin retains the protocol and destination port needed by Substrate's @@ -24,3 +29,40 @@ func Origin(u *url.URL) string { } return (&url.URL{Scheme: u.Scheme, Host: host}).String() } + +// ParseOrigin validates a declared HTTP(S) origin, such as +// "https://proxy.golang.org" or "https://*.githubusercontent.com", and returns +// its canonical form. Unlike Origin, it refuses anything beyond scheme, host +// and port, because a declared path or credential would silently widen to the +// whole origin. +func ParseOrigin(value string) (string, error) { + u, err := url.Parse(value) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Opaque != "" || u.User != nil || u.Hostname() == "" || + u.Path != "" || u.ForceQuery || u.RawQuery != "" || u.Fragment != "" { + return "", fmt.Errorf("invalid egress origin %q: expected http(s)://host[:port]", value) + } + if !ValidHostPattern(strings.TrimSuffix(strings.ToLower(u.Hostname()), ".")) { + return "", fmt.Errorf("invalid egress origin %q: the host must be a DNS name, optionally with \"*\" as its leftmost label", value) + } + if port := u.Port(); port != "" { + if number, err := strconv.ParseUint(port, 10, 16); err != nil || number == 0 { + return "", fmt.Errorf("invalid egress origin %q: invalid port", value) + } + } + return Origin(u), nil +} + +// ValidHostPattern reports whether a normalized host is a DNS name, or a DNS +// name with "*" as its leftmost label, which is what Substrate's hostname +// rules match. An IP address and "*" alone are not patterns, and a wildcard +// needs two labels under it: "*.com" would open a whole top-level domain. +func ValidHostPattern(host string) bool { + name, wildcard := strings.CutPrefix(host, "*.") + if _, err := netip.ParseAddr(name); err == nil { + return false + } + if wildcard && !strings.Contains(name, ".") { + return false + } + return len(validation.IsDNS1123Subdomain(name)) == 0 +} diff --git a/go/core/internal/egress/destination_test.go b/go/core/internal/egress/destination_test.go index dc9fa6d812..1bcebcd606 100644 --- a/go/core/internal/egress/destination_test.go +++ b/go/core/internal/egress/destination_test.go @@ -22,3 +22,31 @@ func TestOrigin(t *testing.T) { }) } } + +func TestParseOrigin(t *testing.T) { + for _, tt := range []struct{ raw, want string }{ + {"https://proxy.golang.org", "https://proxy.golang.org:443"}, + {"https://Proxy.Golang.org.", "https://proxy.golang.org:443"}, + {"http://mirror.internal", "http://mirror.internal:80"}, + {"https://git.internal:8443", "https://git.internal:8443"}, + {"https://*.githubusercontent.com", "https://*.githubusercontent.com:443"}, + } { + t.Run(tt.raw, func(t *testing.T) { + got, err := ParseOrigin(tt.raw) + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } + for _, raw := range []string{ + "proxy.golang.org", "ftp://proxy.golang.org", "https://", "https://*", "https://*.", + "https://a.*.example.com", "https://*.*.example.com", "https://*github.com", "https://*.com", "https://-bad.example.com", + "https://192.0.2.1", "https://[2001:db8::1]", "https://proxy.golang.org/", "https://proxy.golang.org/path", + "https://proxy.golang.org?x=1", "https://proxy.golang.org?", "https://proxy.golang.org#f", + "https://user@proxy.golang.org", "https://proxy.golang.org:0", "https://proxy.golang.org:70000", + } { + t.Run(raw, func(t *testing.T) { + _, err := ParseOrigin(raw) + require.Error(t, err) + }) + } +} diff --git a/go/core/internal/grpcserver/interceptors_test.go b/go/core/internal/grpcserver/interceptors_test.go index 38cb6495bf..fe27e922e0 100644 --- a/go/core/internal/grpcserver/interceptors_test.go +++ b/go/core/internal/grpcserver/interceptors_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/url" "testing" + "time" a2apb "github.com/a2aproject/a2a-go/v2/a2apb/v1" "github.com/google/uuid" @@ -57,7 +58,7 @@ type testShareStore struct { ownerUserID string } -func (s *testShareStore) GetSessionShareByTokenHash(context.Context, []byte) (*apiv1alpha1.SessionShare, string, error) { +func (s *testShareStore) GetSessionShareByTokenHash(context.Context, []byte, time.Time) (*apiv1alpha1.SessionShare, string, error) { if s.sessionShare == nil && s.sessionShareErr == nil { return nil, "", database.ErrNotFound } diff --git a/go/core/internal/grpcserver/sandboxtemplate_test.go b/go/core/internal/grpcserver/sandboxtemplate_test.go index f2f01be515..6c76e177e2 100644 --- a/go/core/internal/grpcserver/sandboxtemplate_test.go +++ b/go/core/internal/grpcserver/sandboxtemplate_test.go @@ -4,6 +4,7 @@ package grpcserver import ( + "os" "testing" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" @@ -24,8 +25,11 @@ import ( ) // TestSandboxTemplateCatalog exercises the transport and persistence against the -// actual CRD schema. The normal Go test job supplies KUBEBUILDER_ASSETS. +// actual CRD schema. It skips without KUBEBUILDER_ASSETS, which `make test` sets. func TestSandboxTemplateCatalog(t *testing.T) { + if os.Getenv("KUBEBUILDER_ASSETS") == "" { + t.Skip("KUBEBUILDER_ASSETS is not set: run `make test` in go/, or export KUBEBUILDER_ASSETS=\"$(make -s envtest-path)\"") + } testEnv := &envtest.Environment{ CRDDirectoryPaths: []string{"../../../api/config/crd/bases"}, ErrorIfCRDPathMissing: true, } diff --git a/go/core/internal/grpcserver/scheduledrun_controller_test.go b/go/core/internal/grpcserver/scheduledrun_controller_test.go index 8264317a5e..9be44f6acd 100644 --- a/go/core/internal/grpcserver/scheduledrun_controller_test.go +++ b/go/core/internal/grpcserver/scheduledrun_controller_test.go @@ -351,7 +351,7 @@ func TestScheduledRunControllerThroughGRPC(t *testing.T) { if tc.want == apiv1alpha1.ScheduledRunExecutionState_SCHEDULED_RUN_EXECUTION_STATE_SUCCEEDED && !tc.stream { controllerStore.loseTaskLink = true } - interactions := sessionsvc.NewInteractionService(store, nil, sessionsvc.NewService(store, scheduledControllerAuthorizer{}, nil)) + interactions := sessionsvc.NewInteractionService(store, nil, sessionsvc.NewService(store, scheduledControllerAuthorizer{}, staticSessionWorkflow{})) controller := scheduledrun.NewController(controllerStore, workflow, a2agateway.New(interactions, dialer, "http://gateway.test"), time.Second) go func() { done <- controller.Start(ctx) }() t.Cleanup(func() { cancel(); require.NoError(t, <-done) }) diff --git a/go/core/internal/grpcserver/scheduledrun_test.go b/go/core/internal/grpcserver/scheduledrun_test.go index 07a006075c..d5c774362a 100644 --- a/go/core/internal/grpcserver/scheduledrun_test.go +++ b/go/core/internal/grpcserver/scheduledrun_test.go @@ -194,7 +194,7 @@ func scheduledRunTestServer(t *testing.T) (*database.Client, apiv1alpha1.Schedul Listener: listener, Authenticator: &authimpl.InsecureAuthenticator{}, SystemService: testSystemService(), ScheduledRunService: scheduledrun.NewService(store, kube, &pkgauth.NoopAuthorizer{}), - SessionService: sessionsvc.NewService(store, &pkgauth.NoopAuthorizer{}, nil), + SessionService: sessionsvc.NewService(store, &pkgauth.NoopAuthorizer{}, staticSessionWorkflow{}), }) require.NoError(t, err) ctx, cancel := context.WithCancel(t.Context()) diff --git a/go/core/internal/grpcserver/session_workflow_test.go b/go/core/internal/grpcserver/session_workflow_test.go new file mode 100644 index 0000000000..0810985a1f --- /dev/null +++ b/go/core/internal/grpcserver/session_workflow_test.go @@ -0,0 +1,41 @@ +package grpcserver + +import ( + "context" + + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" +) + +// staticSessionWorkflow is the session workflow of the tests that drive turns +// through a gateway against a fake runtime: no Substrate stands behind the +// sessions, so a lifecycle call leaves every session as it is, no runtime is +// lost and nothing moves to another revision before a turn. +type staticSessionWorkflow struct{} + +func (staticSessionWorkflow) Create(_ context.Context, session *apiv1alpha1.Session) (*apiv1alpha1.Session, error) { + return session, nil +} + +func (staticSessionWorkflow) Suspend(_ context.Context, session *apiv1alpha1.Session) (*apiv1alpha1.Session, error) { + return session, nil +} + +func (staticSessionWorkflow) Resume(_ context.Context, session *apiv1alpha1.Session) (*apiv1alpha1.Session, error) { + return session, nil +} + +func (staticSessionWorkflow) Delete(_ context.Context, session *apiv1alpha1.Session) (*apiv1alpha1.Session, error) { + return session, nil +} + +func (staticSessionWorkflow) RuntimeLost(context.Context, *apiv1alpha1.Session) (string, bool, error) { + return "", false, nil +} + +func (staticSessionWorkflow) RepointQuiesced(_ context.Context, session *apiv1alpha1.Session) (*apiv1alpha1.Session, error) { + return session, nil +} + +func (staticSessionWorkflow) AwaitRuntime(context.Context, *apiv1alpha1.Session) error { + return nil +} diff --git a/go/core/internal/grpcserver/task_store_test.go b/go/core/internal/grpcserver/task_store_test.go index 2224849783..a47f5e1a3b 100644 --- a/go/core/internal/grpcserver/task_store_test.go +++ b/go/core/internal/grpcserver/task_store_test.go @@ -232,7 +232,7 @@ func TestRuntimeTaskStoreThroughGRPC(t *testing.T) { var httpTransport a2aclient.Transport for i := range gateways { publicListener := bufconn.Listen(DefaultMaxMessageSize) - interactions := sessionsvc.NewInteractionService(store, nil, sessionsvc.NewService(store, &auth.NoopAuthorizer{}, nil)) + interactions := sessionsvc.NewInteractionService(store, nil, sessionsvc.NewService(store, &auth.NoopAuthorizer{}, staticSessionWorkflow{})) gateway := a2agateway.New(interactions, taskStoreRuntimeDialer{runtimeListener}, "http://gateway.test") public, err := New(Config{ Listener: publicListener, SystemService: testSystemService(), diff --git a/go/core/internal/grpcserver/telemetry_test.go b/go/core/internal/grpcserver/telemetry_test.go index 3ee1551c50..79ececb8ac 100644 --- a/go/core/internal/grpcserver/telemetry_test.go +++ b/go/core/internal/grpcserver/telemetry_test.go @@ -6,13 +6,16 @@ import ( "slices" "strings" "testing" + "time" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/pkg/telemetry/telemetrytest" "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" otelprometheus "go.opentelemetry.io/otel/exporters/prometheus" sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace/tracetest" "go.opentelemetry.io/otel/trace" @@ -77,6 +80,22 @@ func TestServerTelemetryShape(t *testing.T) { t.Fatal(err) } + // The client has its responses before the server is done with the RPCs: + // otelgrpc ends the server span and records rpc.server.call.duration on + // the stats handler's End event, after the response is written. Wait for + // both instead of assuming they are there when the calls return. + require.Eventually(t, func() bool { + if len(spans.GetSpans()) == 0 { + return false + } + var data metricdata.ResourceMetrics + if err := reader.Collect(context.Background(), &data); err != nil { + return false + } + _, ok := telemetrytest.FindMetric(data, "rpc.server.call.duration") + return ok + }, 10*time.Second, 10*time.Millisecond, "the GetVersion server span and rpc.server.call.duration") + exported := spans.GetSpans() telemetrytest.AssertTraceShape(t, exported) if len(exported) != 1 { diff --git a/go/core/internal/mcp/tasks_test.go b/go/core/internal/mcp/tasks_test.go index 8ca27d0449..234cef5c57 100644 --- a/go/core/internal/mcp/tasks_test.go +++ b/go/core/internal/mcp/tasks_test.go @@ -552,6 +552,14 @@ func (*fakeSessionStore) DeleteSessionShare(context.Context, string, string) err return nil } +func (*fakeSessionStore) FailSession(context.Context, string, *apiv1alpha1.Failure) (*apiv1alpha1.Session, error) { + return nil, database.ErrNotFound +} + +func (*fakeSessionStore) InterruptSessionTask(context.Context, string, string, time.Time, string) (*database.SessionTaskInterruption, error) { + return nil, database.ErrNotFound +} + type fakeSessionWorkflow struct{} func (*fakeSessionWorkflow) Create(_ context.Context, session *apiv1alpha1.Session) (*apiv1alpha1.Session, error) { @@ -570,6 +578,18 @@ func (*fakeSessionWorkflow) Delete(_ context.Context, session *apiv1alpha1.Sessi return session, nil } +func (*fakeSessionWorkflow) RuntimeLost(context.Context, *apiv1alpha1.Session) (string, bool, error) { + return "", false, nil +} + +func (*fakeSessionWorkflow) AwaitRuntime(context.Context, *apiv1alpha1.Session) error { + return nil +} + +func (*fakeSessionWorkflow) RepointQuiesced(_ context.Context, session *apiv1alpha1.Session) (*apiv1alpha1.Session, error) { + return session, nil +} + func testSessionService() *sessionsvc.Service { return sessionsvc.NewService(&fakeSessionStore{}, &auth.NoopAuthorizer{}, &fakeSessionWorkflow{}) } diff --git a/go/core/internal/service/sandbox/guest_test.go b/go/core/internal/service/sandbox/guest_test.go index 1b3ee05290..8cd5570394 100644 --- a/go/core/internal/service/sandbox/guest_test.go +++ b/go/core/internal/service/sandbox/guest_test.go @@ -101,7 +101,7 @@ func (a *testActors) SuspendActor(context.Context, string, string) (*ateapipb.Ac a.actor.Status.State = ateapipb.ActorState_ACTOR_STATE_SUSPENDED return proto.CloneOf(a.actor), a.mutationErr } -func (a *testActors) DeleteActor(context.Context, string, string) error { +func (a *testActors) DeleteActor(context.Context, string, string, bool) error { a.mu.Lock() defer a.mu.Unlock() a.calls = append(a.calls, "delete") diff --git a/go/core/internal/service/sandbox/service_test.go b/go/core/internal/service/sandbox/service_test.go index 69d7f3727d..1336f68b7b 100644 --- a/go/core/internal/service/sandbox/service_test.go +++ b/go/core/internal/service/sandbox/service_test.go @@ -98,7 +98,7 @@ func (a *testActors) SuspendActor(context.Context, string, string) (*ateapipb.Ac a.actor.Status.State = ateapipb.ActorState_ACTOR_STATE_SUSPENDED return proto.CloneOf(a.actor), a.mutationErr } -func (a *testActors) DeleteActor(context.Context, string, string) error { +func (a *testActors) DeleteActor(context.Context, string, string, bool) error { a.mu.Lock() defer a.mu.Unlock() a.calls = append(a.calls, "delete") diff --git a/go/core/internal/service/session/agent_deleted_test.go b/go/core/internal/service/session/agent_deleted_test.go new file mode 100644 index 0000000000..5b3cbb4135 --- /dev/null +++ b/go/core/internal/service/session/agent_deleted_test.go @@ -0,0 +1,150 @@ +package session + +import ( + "errors" + "testing" + "time" + + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "github.com/google/uuid" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "github.com/kagent-dev/kagent/go/core/internal/database" + "github.com/kagent-dev/kagent/go/core/internal/substrate" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// Deleting an Agent retires its definition, which is all the reconciler does; +// the sweep then deletes the Agent's sessions, READY runtimes included, through +// the ordinary delete workflow, so the revision they pinned becomes unreferenced +// and the runtime revision GC collects its ActorTemplate. +func TestSessionsOfDeletedAgentAreDeleted(t *testing.T) { + store, session := lifecycleFixture(t) + actors := &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}} + workflow := NewActorWorkflow(store, actors) + session, err := workflow.Create(t.Context(), session) + require.NoError(t, err) + require.Equal(t, apiv1alpha1.RuntimeState_RUNTIME_STATE_READY, session.State) + // A live Actor, as after a turn: the delete suspends it first. + _, err = actors.ResumeActor(t.Context(), store.revision.ActorTemplateAtespace, substrate.ActorName(session.Id)) + require.NoError(t, err) + // Idle deletion off: the Agent's deletion alone ends the session. + worker, err := NewExpirationWorker(store, workflow, 0, time.Minute) + require.NoError(t, err) + + listed := func() []string { + t.Helper() + ids, err := store.ListSessionsOfDeletedAgents(t.Context(), "", 100) + require.NoError(t, err) + return ids + } + require.Empty(t, listed(), "a session of an active Agent stays") + worker.sweepDeletedAgents(t.Context()) + _, err = store.GetSessionByID(t.Context(), session.Id) + require.NoError(t, err) + + require.NoError(t, store.RetireAgentIdentities(t.Context(), "team-a", "assistant", nil)) + require.Equal(t, []string{session.Id}, listed()) + worker.sweepDeletedAgents(t.Context()) + _, err = store.GetSessionByID(t.Context(), session.Id) + require.ErrorIs(t, err, database.ErrNotFound) + require.Empty(t, actors.actors, "the session's Actor is deleted") + unreferenced, err := store.ListUnreferencedRuntimeRevisions(t.Context()) + require.NoError(t, err) + require.Len(t, unreferenced, 1, "nothing references the revision any more: the GC collects its ActorTemplate") + require.Equal(t, store.revision.Revision, unreferenced[0].Revision) + require.Empty(t, listed()) +} + +// An Agent deleted and created again under its name is a new identity; the +// sessions of the old one keep their pinned revision until they idle out. +func TestSessionsOfReplacedAgentStay(t *testing.T) { + store, session := lifecycleFixture(t) + actors := &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}} + workflow := NewActorWorkflow(store, actors) + session, err := workflow.Create(t.Context(), session) + require.NoError(t, err) + require.NoError(t, store.RetireAgentIdentities(t.Context(), "team-a", "assistant", nil)) + require.NoError(t, store.UpsertAgentDefinition(t.Context(), database.AgentDefinition{Namespace: "team-a", AgentName: "assistant", AgentUID: "template-uid-2", DesiredRevision: "revision-2"})) + + ids, err := store.ListSessionsOfDeletedAgents(t.Context(), "", 100) + require.NoError(t, err) + require.Empty(t, ids) + // A listing made before the Agent came back is rechecked at admission. + _, err = store.BeginDeletedAgentSessionDeletion(t.Context(), session.Id) + require.ErrorIs(t, err, database.ErrConflict) + worker, err := NewExpirationWorker(store, workflow, 0, time.Minute) + require.NoError(t, err) + worker.sweepDeletedAgents(t.Context()) + current, err := store.GetSessionByID(t.Context(), session.Id) + require.NoError(t, err) + require.Equal(t, apiv1alpha1.RuntimeState_RUNTIME_STATE_READY, current.State) + require.Len(t, actors.actors, 1) +} + +// A deletion whose runtime work fails stays admitted and is finished by a later +// sweep, without a client retry, and never deletes a second Actor. +func TestSessionDeletionAfterAgentDeletionRetries(t *testing.T) { + for _, failure := range []string{"preparation", "lost runtime response", "persistence"} { + t.Run(failure, func(t *testing.T) { + store, session := lifecycleFixture(t) + base := &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}} + session, err := NewActorWorkflow(store, base).Create(t.Context(), session) + require.NoError(t, err) + require.NoError(t, store.RetireAgentIdentities(t.Context(), "team-a", "assistant", nil)) + actors := &expirationActors{retryTestActors: &retryTestActors{lifecycleTestActors: base}} + writes := &completionTestStore{lifecycleTestStore: store} + switch failure { + case "preparation": + actors.readErr = status.Error(codes.Unavailable, "read unavailable") + case "lost runtime response": + actors.loseDeleteResponse = true + case "persistence": + writes.finishErr = errors.New("database unavailable") + } + worker, err := NewExpirationWorker(store, NewActorWorkflow(writes, actors), 0, time.Minute) + require.NoError(t, err) + require.Error(t, worker.deleteOfDeletedAgent(t.Context(), session.Id)) + require.ErrorIs(t, store.ReserveSessionDispatch(t.Context(), session.Id, uuid.New(), "next turn"), database.ErrConflict) + + actors.readErr, writes.finishErr = nil, nil + ids, err := store.ListSessionsOfDeletedAgents(t.Context(), "", 100) + require.NoError(t, err) + require.Equal(t, []string{session.Id}, ids, "the admitted deletion is listed until it is finished") + require.NoError(t, worker.deleteOfDeletedAgent(t.Context(), session.Id)) + _, err = store.GetSessionByID(t.Context(), session.Id) + require.ErrorIs(t, err, database.ErrNotFound) + require.Empty(t, base.actors) + require.Equal(t, 1, actors.deletions, "retries must not allocate or delete another Actor") + }) + } +} + +// A session whose turn is still being dispatched or settled is left for a later +// sweep: the delete is admitted only once the runtime is settled. +func TestSessionDeletionAfterAgentDeletionWaitsForTheTurn(t *testing.T) { + store, session := lifecycleFixture(t) + actors := &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}} + workflow := NewActorWorkflow(store, actors) + session, err := workflow.Create(t.Context(), session) + require.NoError(t, err) + dispatch := uuid.New() + require.NoError(t, store.ReserveSessionDispatch(t.Context(), session.Id, dispatch, "turn")) + require.NoError(t, store.RetireAgentIdentities(t.Context(), "team-a", "assistant", nil)) + worker, err := NewExpirationWorker(store, workflow, 0, time.Minute) + require.NoError(t, err) + + require.ErrorIs(t, worker.deleteOfDeletedAgent(t.Context(), session.Id), database.ErrFailedPrecondition) + _, err = store.GetSessionByID(t.Context(), session.Id) + require.NoError(t, err) + require.Len(t, actors.actors, 1) + + revoked, err := store.RevokeSessionDispatch(t.Context(), session.Id, dispatch, "turn") + require.NoError(t, err) + require.True(t, revoked) + require.NoError(t, worker.deleteOfDeletedAgent(t.Context(), session.Id)) + _, err = store.GetSessionByID(t.Context(), session.Id) + require.ErrorIs(t, err, database.ErrNotFound) + require.Empty(t, actors.actors) +} diff --git a/go/core/internal/service/session/expiration.go b/go/core/internal/service/session/expiration.go index 910e24f41b..baab93b831 100644 --- a/go/core/internal/service/session/expiration.go +++ b/go/core/internal/service/session/expiration.go @@ -14,19 +14,30 @@ import ( "sigs.k8s.io/controller-runtime/pkg/manager" ) +// expirationPage bounds the sessions one listing of a sweep returns; a sweep +// walks the pages until the last. +const expirationPage = 100 + type expirationStore interface { ListIdleSessions(context.Context, time.Time, string, int) ([]string, error) BeginIdleSessionDeletion(context.Context, string, time.Time) (*database.IdleSessionDeletion, error) + ListSessionsOfDeletedAgents(context.Context, string, int) ([]string, error) + BeginDeletedAgentSessionDeletion(context.Context, string) (*database.SessionOperation, error) + DeleteExpiredSessionShares(context.Context, time.Time) (int64, error) } -// ExpirationWorker deletes idle sessions through their ordinary delete workflow. -// PostgreSQL admission and execution claims fence concurrent API requests. +// ExpirationWorker deletes idle sessions and the sessions of deleted Agents +// through their ordinary delete workflow and drops the session shares that +// expired. PostgreSQL admission and execution claims fence concurrent API +// requests. type ExpirationWorker struct { - store expirationStore - workflow *ActorWorkflow - idleTTL time.Duration - pollInterval time.Duration - deleted metric.Int64Counter + store expirationStore + workflow *ActorWorkflow + idleTTL time.Duration + pollInterval time.Duration + deleted metric.Int64Counter + agentDeleted metric.Int64Counter + sharesExpired metric.Int64Counter } var _ manager.Runnable = (*ExpirationWorker)(nil) @@ -40,51 +51,42 @@ func NewExpirationWorker(store expirationStore, workflow *ActorWorkflow, idleTTL if pollInterval <= 0 { return nil, fmt.Errorf("session expiration poll interval must be positive") } - deleted, err := otel.Meter("github.com/kagent-dev/kagent/go/core/internal/service/session").Int64Counter( + meter := otel.Meter("github.com/kagent-dev/kagent/go/core/internal/service/session") + deleted, err := meter.Int64Counter( "kagent.session.expired", metric.WithDescription("Sessions deleted by the idle expiration sweep."), metric.WithUnit("{session}")) if err != nil { return nil, fmt.Errorf("create session expiration counter: %w", err) } - return &ExpirationWorker{store: store, workflow: workflow, idleTTL: idleTTL, pollInterval: pollInterval, deleted: deleted}, nil + agentDeleted, err := meter.Int64Counter( + "kagent.session.agent_deleted", metric.WithDescription("Sessions deleted by the sweep because their Agent was deleted."), metric.WithUnit("{session}")) + if err != nil { + return nil, fmt.Errorf("create deleted-Agent session counter: %w", err) + } + sharesExpired, err := meter.Int64Counter( + "kagent.session.share_expired", metric.WithDescription("Session shares deleted by the expiration sweep after they expired."), metric.WithUnit("{share}")) + if err != nil { + return nil, fmt.Errorf("create session share expiration counter: %w", err) + } + return &ExpirationWorker{store: store, workflow: workflow, idleTTL: idleTTL, pollInterval: pollInterval, deleted: deleted, agentDeleted: agentDeleted, sharesExpired: sharesExpired}, nil } func (*ExpirationWorker) NeedLeaderElection() bool { return true } -// Start scans bounded pages, like sandbox expiration. Ordinary pending lifecycle -// work is still client-driven. Zero disables both admission and expiration retries. +// Start sweeps on the poll interval: the idle sessions in bounded pages, like +// sandbox expiration, the sessions of deleted Agents the same way, then the +// shares that expired. Ordinary pending lifecycle work is still client-driven. +// A zero idle TTL disables idle deletion, its admission and its retries; a +// session's Agent is deleted and a share expires on their own, and both are +// swept regardless. func (e *ExpirationWorker) Start(ctx context.Context) error { - if e.idleTTL == 0 { - <-ctx.Done() - return nil - } ticker := time.NewTicker(e.pollInterval) defer ticker.Stop() - var afterID string for ctx.Err() == nil { - before := time.Now().Add(-e.idleTTL) - ids, err := e.store.ListIdleSessions(ctx, before, afterID, 100) - if err != nil { - logging.FromContext(ctx).ErrorContext(ctx, "list idle sessions", "error", err) - } else { - var group errgroup.Group - group.SetLimit(4) - for _, id := range ids { - group.Go(func() error { - if err := e.expire(ctx, id, before); err != nil && !errors.Is(err, database.ErrConflict) && !errors.Is(err, database.ErrFailedPrecondition) && !errors.Is(err, database.ErrNotFound) && ctx.Err() == nil { - logging.FromContext(ctx).ErrorContext(ctx, "expire session", "session_id", id, "error", err) - } - return nil - }) - } - if err := group.Wait(); err != nil { - return err - } - afterID = "" - if len(ids) == 100 { - afterID = ids[len(ids)-1] - continue - } + if e.idleTTL > 0 { + e.expireIdle(ctx) } + e.sweepDeletedAgents(ctx) + e.expireShares(ctx, time.Now()) select { case <-ctx.Done(): case <-ticker.C: @@ -93,6 +95,54 @@ func (e *ExpirationWorker) Start(ctx context.Context) error { return nil } +// expireIdle deletes the sessions idle for the TTL. +func (e *ExpirationWorker) expireIdle(ctx context.Context) { + var before time.Time + e.sweep(ctx, "idle sessions", func(ctx context.Context, afterID string) ([]string, error) { + before = time.Now().Add(-e.idleTTL) + return e.store.ListIdleSessions(ctx, before, afterID, expirationPage) + }, func(ctx context.Context, id string) error { + return e.expire(ctx, id, before) + }) +} + +// sweepDeletedAgents deletes the sessions whose Agent is gone. +func (e *ExpirationWorker) sweepDeletedAgents(ctx context.Context) { + e.sweep(ctx, "sessions of deleted Agents", func(ctx context.Context, afterID string) ([]string, error) { + return e.store.ListSessionsOfDeletedAgents(ctx, afterID, expirationPage) + }, e.deleteOfDeletedAgent) +} + +// sweep deletes the sessions a listing returns, page by page until a page is +// short, four at a time. A listing that fails ends the sweep; the next tick +// lists again. A session left for a later sweep, because its admission was +// refused, another caller holds it or it is gone meanwhile, is not an error. +func (e *ExpirationWorker) sweep(ctx context.Context, what string, list func(context.Context, string) ([]string, error), remove func(context.Context, string) error) { + var afterID string + for ctx.Err() == nil { + ids, err := list(ctx, afterID) + if err != nil { + logging.FromContext(ctx).ErrorContext(ctx, "list sessions to delete", "sweep", what, "error", err) + return + } + var group errgroup.Group + group.SetLimit(4) + for _, id := range ids { + group.Go(func() error { + if err := remove(ctx, id); err != nil && !errors.Is(err, database.ErrConflict) && !errors.Is(err, database.ErrFailedPrecondition) && !errors.Is(err, database.ErrNotFound) && ctx.Err() == nil { + logging.FromContext(ctx).ErrorContext(ctx, "delete session", "sweep", what, "session_id", id, "error", err) + } + return nil + }) + } + _ = group.Wait() // every deletion logs its own error and returns nil + if len(ids) < expirationPage { + return + } + afterID = ids[len(ids)-1] + } +} + func (e *ExpirationWorker) expire(ctx context.Context, id string, before time.Time) error { ctx, cancel := context.WithTimeout(ctx, database.RuntimeOperationTimeout) defer cancel() @@ -107,3 +157,39 @@ func (e *ExpirationWorker) expire(ctx context.Context, id string, before time.Ti logging.FromContext(ctx).DebugContext(ctx, "expired idle session", "session_id", id, "idle_time", time.Since(deletion.IdleSince)) return nil } + +// deleteOfDeletedAgent deletes one session whose Agent is gone, or finishes +// the deletion a sweep before it admitted and could not complete. +func (e *ExpirationWorker) deleteOfDeletedAgent(ctx context.Context, id string) error { + ctx, cancel := context.WithTimeout(ctx, database.RuntimeOperationTimeout) + defer cancel() + operation, err := e.store.BeginDeletedAgentSessionDeletion(ctx, id) + if err != nil { + return err + } + if _, err := e.workflow.execute(ctx, operation); err != nil { + return err + } + e.agentDeleted.Add(ctx, 1) + logging.FromContext(ctx).DebugContext(ctx, "deleted the session of a deleted Agent", "session_id", id) + return nil +} + +// expireShares deletes the shares expired at now, judged on the clock that set +// their expires_at, as the token lookup does. An expired share has granted +// nothing since its expiry; its row only waited for the owner to revoke it or +// for the session to be deleted. +func (e *ExpirationWorker) expireShares(ctx context.Context, now time.Time) { + count, err := e.store.DeleteExpiredSessionShares(ctx, now) + if err != nil { + if ctx.Err() == nil { + logging.FromContext(ctx).ErrorContext(ctx, "delete expired session shares", "error", err) + } + return + } + if count == 0 { + return + } + e.sharesExpired.Add(ctx, count) + logging.FromContext(ctx).DebugContext(ctx, "deleted expired session shares", "count", count) +} diff --git a/go/core/internal/service/session/expiration_test.go b/go/core/internal/service/session/expiration_test.go index 6d51c87b05..fb8a3c7498 100644 --- a/go/core/internal/service/session/expiration_test.go +++ b/go/core/internal/service/session/expiration_test.go @@ -3,12 +3,14 @@ package session import ( "context" "errors" + "sync/atomic" "testing" "time" "github.com/a2aproject/a2a-go/v2/a2a" "github.com/agent-substrate/substrate/pkg/proto/ateapipb" "github.com/google/uuid" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/core/internal/database" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" @@ -16,6 +18,7 @@ import ( "go.opentelemetry.io/otel/sdk/metric/metricdata" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" ) func TestSessionExpirationRetriesDeletion(t *testing.T) { @@ -63,9 +66,9 @@ type expirationActors struct { deletions int } -func (e *expirationActors) DeleteActor(ctx context.Context, space, name string) error { +func (e *expirationActors) DeleteActor(ctx context.Context, space, name string, anyState bool) error { e.deletions++ - if err := e.retryTestActors.DeleteActor(ctx, space, name); err != nil { + if err := e.retryTestActors.DeleteActor(ctx, space, name, anyState); err != nil { return err } if e.loseDeleteResponse { @@ -132,19 +135,74 @@ func TestSessionExpirationCountsCompletedSweepDeletionOnce(t *testing.T) { writes.finishErr = nil require.NoError(t, worker.expire(t.Context(), session.Id, time.Now())) require.ErrorIs(t, worker.expire(t.Context(), session.Id, time.Time{}), database.ErrNotFound) + require.EqualValues(t, 1, counterValue(t, reader, "kagent.session.expired")) +} + +// counterValue reads one Int64Counter's single data point, or fails the test. +func counterValue(t *testing.T, reader *metric.ManualReader, name string) int64 { + t.Helper() var metrics metricdata.ResourceMetrics require.NoError(t, reader.Collect(t.Context(), &metrics)) for _, scope := range metrics.ScopeMetrics { for _, measurement := range scope.Metrics { - if measurement.Name == "kagent.session.expired" { + if measurement.Name == name { sum := measurement.Data.(metricdata.Sum[int64]) require.Len(t, sum.DataPoints, 1) - require.EqualValues(t, 1, sum.DataPoints[0].Value) - return + return sum.DataPoints[0].Value } } } - t.Fatal("missing session expiration counter") + t.Fatalf("missing counter %s", name) + return 0 +} + +// The sweep deletes the shares expired at its clock and counts them; a share +// expiring later and one without expiry stay for the owner to see and revoke. +func TestSessionExpirationDeletesExpiredShares(t *testing.T) { + reader := metric.NewManualReader() + provider := metric.NewMeterProvider(metric.WithReader(reader)) + previous := otel.GetMeterProvider() + otel.SetMeterProvider(provider) + t.Cleanup(func() { + otel.SetMeterProvider(previous) + require.NoError(t, provider.Shutdown(context.Background())) + }) + store, session := lifecycleFixture(t) + worker, err := NewExpirationWorker(store, NewActorWorkflow(store, &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}}), time.Hour, time.Minute) + require.NoError(t, err) + now := time.Now().Truncate(time.Microsecond) + share := func(expiresAt *timestamppb.Timestamp, token string) string { + t.Helper() + created, err := store.CreateSessionShare(t.Context(), &apiv1alpha1.SessionShare{ + Id: uuid.NewString(), SessionId: session.Id, + Permission: apiv1alpha1.SessionSharePermission_SESSION_SHARE_PERMISSION_READ_ONLY, + ExpiresAt: expiresAt, + }, []byte(token), session.Creator) + require.NoError(t, err) + return created.GetId() + } + listed := func() []string { + t.Helper() + shares, err := store.ListSessionShares(t.Context(), session.Id, session.Creator, "", 10) + require.NoError(t, err) + ids := make([]string, 0, len(shares)) + for _, share := range shares { + ids = append(ids, share.GetId()) + } + return ids + } + share(timestamppb.New(now.Add(-time.Second)), "expired") + later := share(timestamppb.New(now.Add(time.Hour)), "later") + unbounded := share(nil, "unbounded") + + worker.expireShares(t.Context(), now) + require.ElementsMatch(t, []string{later, unbounded}, listed()) + worker.expireShares(t.Context(), now) + require.EqualValues(t, 1, counterValue(t, reader, "kagent.session.share_expired"), "a sweep that finds nothing counts nothing") + + worker.expireShares(t.Context(), now.Add(2*time.Hour)) + require.Equal(t, []string{unbounded}, listed(), "a share without expiry stays") + require.EqualValues(t, 2, counterValue(t, reader, "kagent.session.share_expired")) } func TestSessionExpirationConfiguration(t *testing.T) { @@ -154,22 +212,52 @@ func TestSessionExpirationConfiguration(t *testing.T) { _, err := NewExpirationWorker(nil, nil, time.Hour, interval) require.ErrorContains(t, err, "poll interval must be positive") } - worker, err := NewExpirationWorker(nil, nil, 0, time.Minute) + store := &sweepCountingStore{} + worker, err := NewExpirationWorker(store, nil, 0, time.Millisecond) require.NoError(t, err) require.True(t, worker.NeedLeaderElection()) ctx, cancel := context.WithCancel(t.Context()) t.Cleanup(cancel) done := make(chan error, 1) go func() { done <- worker.Start(ctx) }() - select { - case <-done: - t.Fatal("disabled worker should wait for shutdown without accessing the store") - case <-time.After(10 * time.Millisecond): - } + // A zero idle TTL turns idle deletion off, not the share sweep and not the + // sweep of the sessions of deleted Agents. + require.Eventually(t, func() bool { return store.shareSweeps.Load() > 1 && store.deletedAgentListings.Load() > 1 }, 5*time.Second, time.Millisecond) + require.Zero(t, store.idleListings.Load(), "a disabled idle deletion never lists idle sessions") cancel() require.NoError(t, <-done) } +// sweepCountingStore counts the sweeps a worker runs and holds nothing. +type sweepCountingStore struct { + idleListings atomic.Int64 + deletedAgentListings atomic.Int64 + shareSweeps atomic.Int64 +} + +func (s *sweepCountingStore) ListIdleSessions(context.Context, time.Time, string, int) ([]string, error) { + s.idleListings.Add(1) + return nil, nil +} + +func (*sweepCountingStore) BeginIdleSessionDeletion(context.Context, string, time.Time) (*database.IdleSessionDeletion, error) { + return nil, database.ErrNotFound +} + +func (s *sweepCountingStore) ListSessionsOfDeletedAgents(context.Context, string, int) ([]string, error) { + s.deletedAgentListings.Add(1) + return nil, nil +} + +func (*sweepCountingStore) BeginDeletedAgentSessionDeletion(context.Context, string) (*database.SessionOperation, error) { + return nil, database.ErrNotFound +} + +func (s *sweepCountingStore) DeleteExpiredSessionShares(context.Context, time.Time) (int64, error) { + s.shareSweeps.Add(1) + return 0, nil +} + func TestSessionExpirationWorkerDiscoversIdleSessions(t *testing.T) { store, session := lifecycleFixture(t) actors := &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}} diff --git a/go/core/internal/service/session/interactions.go b/go/core/internal/service/session/interactions.go index fc1cd74983..15f29732b7 100644 --- a/go/core/internal/service/session/interactions.go +++ b/go/core/internal/service/session/interactions.go @@ -154,6 +154,31 @@ func (s *InteractionService) PrepareSend(ctx context.Context, agent types.Namesp return &PreparedSend{Session: session, DispatchID: id}, nil } +// FailLostRuntime records the loss of a resolved session's runtime, when it is +// lost, and returns the failure later sends are refused with. +func (s *InteractionService) FailLostRuntime(ctx context.Context, session *apiv1alpha1.Session) (*apiv1alpha1.Failure, error) { + return s.sessions.FailLostRuntime(ctx, session) +} + +// FailLostTurn ends a resolved session's turn that its lost runtime took and +// records the loss, when the runtime is lost, and returns the failure the turn +// and later sends are refused with. +func (s *InteractionService) FailLostTurn(ctx context.Context, session *apiv1alpha1.Session, taskID a2atype.TaskID) (*apiv1alpha1.Failure, error) { + return s.sessions.FailLostTurn(ctx, session, string(taskID)) +} + +// AwaitRuntime waits, bounded, until a resolved session's runtime that refused +// a send can take it. +func (s *InteractionService) AwaitRuntime(ctx context.Context, session *apiv1alpha1.Session) error { + return s.sessions.AwaitRuntime(ctx, session) +} + +// RepointQuiesced moves a resolved session's quiesced runtime onto its agent's +// current revision before the turn the caller has reserved wakes it. +func (s *InteractionService) RepointQuiesced(ctx context.Context, session *apiv1alpha1.Session) (*apiv1alpha1.Session, error) { + return s.sessions.RepointQuiesced(ctx, session) +} + // PrepareCancelTask authorizes cancellation and returns the actor target and // committed task. A terminal task needs no actor call. func (s *InteractionService) PrepareCancelTask(ctx context.Context, agent types.NamespacedName, req *a2atype.CancelTaskRequest) (*apiv1alpha1.Session, *a2atype.Task, error) { @@ -392,6 +417,9 @@ func (s *InteractionService) resolveSend(ctx context.Context, agent types.Namesp return nil, a2atype.NewError(a2atype.ErrInvalidRequest, "message context does not match task") } req.Message.ContextID = session.ContextId + if session.State == apiv1alpha1.RuntimeState_RUNTIME_STATE_FAILED && session.GetFailure().GetReason() == apia2a.FailureReasonRuntimeLost { + return nil, a2atype.NewError(a2atype.ErrUnsupportedOperation, session.GetFailure().GetMessage()) + } if session.State != apiv1alpha1.RuntimeState_RUNTIME_STATE_READY || session.Operation != apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_NONE { return nil, a2atype.NewError(a2atype.ErrUnsupportedOperation, "Session cannot accept work during a lifecycle operation") } diff --git a/go/core/internal/service/session/pause_ttl_test.go b/go/core/internal/service/session/pause_ttl_test.go new file mode 100644 index 0000000000..ab87eafe46 --- /dev/null +++ b/go/core/internal/service/session/pause_ttl_test.go @@ -0,0 +1,199 @@ +package session + +import ( + "context" + "crypto/sha256" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "github.com/google/uuid" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "github.com/kagent-dev/kagent/go/core/internal/database" + "github.com/kagent-dev/kagent/go/core/internal/substrate" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// countingActors counts the pause and suspend calls of the fake actor client. +type countingActors struct { + *lifecycleTestActors + pauses, suspends atomic.Int32 +} + +func (a *countingActors) PauseActor(ctx context.Context, atespace, name string) (*ateapipb.Actor, error) { + a.pauses.Add(1) + return a.lifecycleTestActors.PauseActor(ctx, atespace, name) +} + +func (a *countingActors) SuspendActor(ctx context.Context, atespace, name string) (*ateapipb.Actor, error) { + a.suspends.Add(1) + return a.lifecycleTestActors.SuspendActor(ctx, atespace, name) +} + +// pausedSessionFixture leaves a session paused on a waiting task through the +// idle worker's own pause, with the boundary's age set for the TTL check. +func pausedSessionFixture(t *testing.T, store *lifecycleTestStore, actors actorClient, session *apiv1alpha1.Session, age time.Duration) *a2a.Task { + t.Helper() + message := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("hello")) + message.ContextID = session.ContextId + task := a2a.NewSubmittedTask(message, message) + task.Status = a2a.TaskStatus{State: a2a.TaskStateInputRequired, Message: a2a.NewMessageForTask(a2a.MessageRoleAgent, task, a2a.NewTextPart("Which database?"))} + hash := sha256.Sum256([]byte("waiting")) + version, err := store.CreateRuntimeTask(t.Context(), session.Id, hash[:], task, "") + require.NoError(t, err) + require.NoError(t, store.SettleSessionTask(t.Context(), session.Id, string(task.ID), version)) + workflow := NewActorWorkflow(store, actors) + work, err := store.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) + require.NoError(t, err) + require.False(t, work.Suspend) + workflow.quiesceIdleSession(t.Context(), work) + _, err = store.pool.Exec(t.Context(), ` + UPDATE session_task_event SET created_at = created_at - $2::interval + WHERE history_id = (SELECT history_id FROM session WHERE id = $1) + `, session.Id, age) + require.NoError(t, err) + return task +} + +// A pause older than the TTL is suspended durably: the Actor moves from PAUSED +// to SUSPENDED and the waiting task records the snapshot, so a reply after the +// pause's node is gone restores the runtime elsewhere. +func TestIdleWorkerSuspendsAnExpiredPause(t *testing.T) { + store, session := lifecycleFixture(t) + actors := &countingActors{lifecycleTestActors: &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}}} + session, err := NewActorWorkflow(store, actors).Create(t.Context(), session) + require.NoError(t, err) + task := pausedSessionFixture(t, store, actors, session, time.Hour) + require.EqualValues(t, 1, actors.pauses.Load()) + for _, actor := range actors.actors { + require.Equal(t, ateapipb.ActorState_ACTOR_STATE_PAUSED, actor.GetStatus().GetState()) + } + + workflow := NewActorWorkflow(store, actors, WithPausedRuntimeTTL(2*time.Minute)) + work, err := store.ClaimSessionQuiescence(t.Context(), testClaimLease, workflow.pausedRuntimeTTL, nil) + require.NoError(t, err) + require.True(t, work.Suspend) + workflow.quiesceIdleSession(t.Context(), work) + require.EqualValues(t, 1, actors.suspends.Load()) + require.EqualValues(t, 1, actors.pauses.Load(), "an expired pause is suspended, not paused again") + for _, actor := range actors.actors { + require.Equal(t, ateapipb.ActorState_ACTOR_STATE_SUSPENDED, actor.GetStatus().GetState()) + } + _, err = store.ClaimSessionQuiescence(t.Context(), testClaimLease, workflow.pausedRuntimeTTL, nil) + require.ErrorIs(t, err, database.ErrNotFound, "the recorded snapshot ends the pause TTL's interest") + waiting, err := store.GetSettledSessionTask(t.Context(), session.Id, string(task.ID), nil) + require.NoError(t, err) + require.Equal(t, a2a.TaskStateInputRequired, waiting.Status.State) + require.NoError(t, store.ReserveSessionDispatch(t.Context(), session.Id, uuid.New(), "reply")) +} + +// Without a TTL the idle worker leaves an old pause in place. +func TestIdleWorkerLeavesAPauseWithoutATTL(t *testing.T) { + store, session := lifecycleFixture(t) + actors := &countingActors{lifecycleTestActors: &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}}} + session, err := NewActorWorkflow(store, actors).Create(t.Context(), session) + require.NoError(t, err) + pausedSessionFixture(t, store, actors, session, time.Hour) + _, err = store.ClaimSessionQuiescence(t.Context(), testClaimLease, NewActorWorkflow(store, actors).pausedRuntimeTTL, nil) + require.ErrorIs(t, err, database.ErrNotFound) + require.Zero(t, actors.suspends.Load()) +} + +func TestPauseNodeLost(t *testing.T) { + session := &apiv1alpha1.Session{Id: "session-1", PreparedRevision: "revision-1"} + store := &lifecycleTestStore{revision: &database.RuntimeRevision{ActorTemplateAtespace: "team-a", ActorTemplateName: "assistant-kagent-revision"}} + active := &ateapipb.Worker{NodeName: "node-a", Status: &ateapipb.WorkerStatus{State: ateapipb.WorkerState_WORKER_STATE_ACTIVE}} + draining := &ateapipb.Worker{NodeName: "node-a", Status: &ateapipb.WorkerStatus{State: ateapipb.WorkerState_WORKER_STATE_DRAINING}} + elsewhere := &ateapipb.Worker{NodeName: "node-b", Status: &ateapipb.WorkerStatus{State: ateapipb.WorkerState_WORKER_STATE_ACTIVE}} + for name, test := range map[string]struct { + state ateapipb.ActorState + local *ateapipb.LocalSnapshot + workers []*ateapipb.Worker + lost bool + }{ + "worker on the node": {state: ateapipb.ActorState_ACTOR_STATE_PAUSED, local: &ateapipb.LocalSnapshot{NodeVmsWithLocalSnapshots: []string{"node-a"}}, workers: []*ateapipb.Worker{elsewhere, active}}, + "node gone": {state: ateapipb.ActorState_ACTOR_STATE_PAUSED, local: &ateapipb.LocalSnapshot{NodeVmsWithLocalSnapshots: []string{"node-a"}}, workers: []*ateapipb.Worker{elsewhere}, lost: true}, + "node draining": {state: ateapipb.ActorState_ACTOR_STATE_PAUSED, local: &ateapipb.LocalSnapshot{NodeVmsWithLocalSnapshots: []string{"node-a"}}, workers: []*ateapipb.Worker{draining}, lost: true}, + "no worker at all": {state: ateapipb.ActorState_ACTOR_STATE_PAUSED, local: &ateapipb.LocalSnapshot{NodeVmsWithLocalSnapshots: []string{"node-a"}}, lost: true}, + "durable copy exists": {state: ateapipb.ActorState_ACTOR_STATE_PAUSED, local: &ateapipb.LocalSnapshot{NodeVmsWithLocalSnapshots: []string{"node-a"}, DurableCopy: &ateapipb.ExternalSnapshot{SnapshotUri: "s3://snapshots/pause"}}}, + "no node recorded": {state: ateapipb.ActorState_ACTOR_STATE_PAUSED}, + "not paused": {state: ateapipb.ActorState_ACTOR_STATE_RUNNING, local: &ateapipb.LocalSnapshot{NodeVmsWithLocalSnapshots: []string{"node-a"}}}, + } { + t.Run(name, func(t *testing.T) { + actors := &lifecycleTestActors{workers: test.workers, actors: map[string]*ateapipb.Actor{ + actorKey("team-a", substrate.ActorName(session.Id)): { + Metadata: &ateapipb.ResourceMetadata{Atespace: "team-a", Name: substrate.ActorName(session.Id), Uid: "actor-uid"}, + ActorTemplate: &ateapipb.ObjectRef{Atespace: "team-a", Name: "assistant-kagent-revision"}, + Status: &ateapipb.ActorStatus{State: test.state, LocalSnapshot: test.local}, + }, + }} + lost, err := NewActorWorkflow(store, actors).PauseNodeLost(t.Context(), session) + require.NoError(t, err) + require.Equal(t, test.lost, lost) + }) + } + t.Run("workers unknown", func(t *testing.T) { + actors := &lifecycleTestActors{workersErr: status.Error(codes.Unavailable, "ate-api is rolling"), actors: map[string]*ateapipb.Actor{ + actorKey("team-a", substrate.ActorName(session.Id)): { + Metadata: &ateapipb.ResourceMetadata{Atespace: "team-a", Name: substrate.ActorName(session.Id), Uid: "actor-uid"}, + ActorTemplate: &ateapipb.ObjectRef{Atespace: "team-a", Name: "assistant-kagent-revision"}, + Status: &ateapipb.ActorStatus{State: ateapipb.ActorState_ACTOR_STATE_PAUSED, LocalSnapshot: &ateapipb.LocalSnapshot{NodeVmsWithLocalSnapshots: []string{"node-a"}}}, + }, + }} + _, err := NewActorWorkflow(store, actors).PauseNodeLost(t.Context(), session) + require.ErrorIs(t, err, status.Error(codes.Unavailable, "ate-api is rolling")) + }) +} + +// An expired pause whose checkpoint node has no worker is left alone: the +// claim is released so a reply is admitted, no suspend is issued, and the +// session sits out the next claims for one TTL. +func TestIdleWorkerLeavesAnExpiredPauseOnALostNode(t *testing.T) { + for name, workersErr := range map[string]error{"node gone": nil, "workers unknown": status.Error(codes.Unavailable, "ate-api is rolling")} { + t.Run(name, func(t *testing.T) { + store, session := lifecycleFixture(t) + actors := &countingActors{lifecycleTestActors: &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}}} + session, err := NewActorWorkflow(store, actors).Create(t.Context(), session) + require.NoError(t, err) + pausedSessionFixture(t, store, actors, session, time.Hour) + actors.workersErr = workersErr + for _, actor := range actors.actors { + actor.Status.LocalSnapshot = &ateapipb.LocalSnapshot{NodeVmsWithLocalSnapshots: []string{"node-a"}} + } + + workflow := NewActorWorkflow(store, actors, WithPausedRuntimeTTL(2*time.Minute)) + work, err := store.ClaimSessionQuiescence(t.Context(), testClaimLease, workflow.pausedRuntimeTTL, workflow.deferred.active(time.Now())) + require.NoError(t, err) + require.True(t, work.Suspend) + workflow.quiesceIdleSession(t.Context(), work) + require.Zero(t, actors.suspends.Load()) + for _, actor := range actors.actors { + require.Equal(t, ateapipb.ActorState_ACTOR_STATE_PAUSED, actor.GetStatus().GetState()) + } + require.NoError(t, store.ReserveSessionDispatch(t.Context(), session.Id, uuid.New(), "reply"), "the released claim admits the reply") + skip := workflow.deferred.active(time.Now()) + require.Equal(t, []string{session.Id}, skip) + _, err = store.ClaimSessionQuiescence(t.Context(), testClaimLease, workflow.pausedRuntimeTTL, skip) + require.ErrorIs(t, err, database.ErrNotFound, "a deferred session is not claimed again") + }) + } +} + +func TestDeferralsExpire(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + var deferred deferrals + deferred.add("b", time.Now().Add(2*time.Minute)) + deferred.add("a", time.Now().Add(time.Minute)) + require.Equal(t, []string{"a", "b"}, deferred.active(time.Now())) + time.Sleep(time.Minute) + require.Equal(t, []string{"b"}, deferred.active(time.Now())) + time.Sleep(time.Minute) + require.Empty(t, deferred.active(time.Now())) + require.Empty(t, deferred.until) + }) +} diff --git a/go/core/internal/service/session/quiescence.go b/go/core/internal/service/session/quiescence.go index 208c4a30a2..40f269898f 100644 --- a/go/core/internal/service/session/quiescence.go +++ b/go/core/internal/service/session/quiescence.go @@ -3,12 +3,20 @@ package session import ( "context" "errors" + "fmt" + "log/slog" + "slices" "sync" "time" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/pkg/logging" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "github.com/kagent-dev/kagent/go/core/internal/database" + "github.com/kagent-dev/kagent/go/core/internal/substrate" "sigs.k8s.io/controller-runtime/pkg/manager" ) @@ -20,7 +28,11 @@ func (*ActorWorkflow) NeedLeaderElection() bool { return false } // Start pauses or suspends idle sessions independently of task publication. // A periodic scan discovers settled work across API replicas and restarts. -// Workers are bounded; task reads never wait for them. +// Workers are bounded; task reads never wait for them. A claim's holder renews +// its lease until the claim is settled, so the claim of a replica that stopped +// is taken over by another and settled there. A pause older than the +// paused runtime TTL is claimed again for a suspend, so a reply that outlives +// the pause's node restores the runtime from its external snapshot instead. func (w *ActorWorkflow) Start(ctx context.Context) error { var workers sync.WaitGroup for range 4 { @@ -28,7 +40,7 @@ func (w *ActorWorkflow) Start(ctx context.Context) error { timer := time.NewTicker(time.Second) defer timer.Stop() for ctx.Err() == nil { - work, err := w.store.ClaimSessionQuiescence(ctx) + work, err := w.store.ClaimSessionQuiescence(ctx, w.claimLease, w.pausedRuntimeTTL, w.deferred.active(time.Now())) if err == nil { w.quiesceIdleSession(ctx, work) continue @@ -45,30 +57,293 @@ func (w *ActorWorkflow) Start(ctx context.Context) error { }) } workers.Wait() + w.settling.Wait() return nil } +// quiesceIdleSession holds the claim's lease until its boundary is settled. A +// claim taken over from a stopped holder is settled from the Actor's state. func (w *ActorWorkflow) quiesceIdleSession(ctx context.Context, work *database.SessionQuiescence) { + release := w.holdClaim(ctx, work) + if work.TakenOver { + logging.FromContext(ctx).WarnContext(ctx, "runtime boundary taken over from a stopped holder", "session_id", work.Session.Id, "task_id", work.TaskID, "version", work.Version) + } else if w.issueIdleWork(ctx, work) { + release() + return + } + w.settling.Go(func() { + defer release() + w.settleIdleWork(ctx, work) + }) +} + +// holdClaim renews the claim's lease every third of it until the returned +// function is called. Stopping the process stops the renewal, and another +// worker takes the claim over once the lease has run out. +func (w *ActorWorkflow) holdClaim(ctx context.Context, work *database.SessionQuiescence) func() { + holdCtx, cancel := context.WithCancel(ctx) + var renewing sync.WaitGroup + renewing.Go(func() { + ticker := time.NewTicker(w.claimLease / 3) + defer ticker.Stop() + for { + select { + case <-holdCtx.Done(): + return + case <-ticker.C: + } + renewCtx, renewCancel := context.WithTimeout(holdCtx, w.claimLease/3) + err := w.store.RenewSessionQuiescence(renewCtx, work, w.claimLease) + renewCancel() + switch { + case err == nil, holdCtx.Err() != nil: + case errors.Is(err, database.ErrNotFound): + return + default: + logging.FromContext(ctx).WarnContext(ctx, "renew runtime boundary claim", "session_id", work.Session.Id, "version", work.Version, "error", err) + } + } + }) + return func() { + cancel() + renewing.Wait() + } +} + +// issueIdleWork pauses or suspends the claimed session and records the outcome. +// It reports false when the runtime request's outcome is unknown and the claim +// is left for settleIdleWork. The request carries the holder's fencing token: +// a holder frozen past its lease, whose claim another worker took over and +// settled, finds its late request refused by Substrate and leaves the claim to +// its successor. +func (w *ActorWorkflow) issueIdleWork(ctx context.Context, work *database.SessionQuiescence) bool { runtimeCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) var snapshot *database.SessionTaskSnapshot - var err error - if work.State.Terminal() { + runtimeCtx, err := w.fence(runtimeCtx, work) + if err != nil { + cancel() + logging.FromContext(ctx).ErrorContext(ctx, "runtime boundary not issued", "session_id", work.Session.Id, "version", work.Version, "error", err) + return false + } + if work.Suspend { + // Nothing has been issued yet, so a suspend that cannot happen now + // releases the claim, reopening admission for the reply, and the + // session is left out of the next claims for one TTL. + lost, err := w.PauseNodeLost(runtimeCtx, work.Session) + if err != nil || lost { + cancel() + if err != nil { + logging.FromContext(ctx).WarnContext(ctx, "expired pause left in place", "session_id", work.Session.Id, "version", work.Version, "error", err) + } else { + logging.FromContext(ctx).InfoContext(ctx, "expired pause left to node-loss handling", "session_id", work.Session.Id, "version", work.Version) + } + w.deferred.add(work.Session.Id, time.Now().Add(w.pausedRuntimeTTL)) + w.finishIdleWork(ctx, work, nil) + return true + } + } + if wantsSnapshot(work) { snapshot, err = w.Quiesce(runtimeCtx, work.Session) } else { err = w.Pause(runtimeCtx, work.Session) } cancel() + if status.Code(err) == codes.FailedPrecondition { + // Substrate holds a newer fencing token: this claim was taken over and + // its successor settles it. Renewal stops; should the refusal have had + // another cause, the lease runs out and the claim is taken over as well. + logging.FromContext(ctx).WarnContext(ctx, "runtime boundary superseded by a newer holder", "session_id", work.Session.Id, "task_id", work.TaskID, "version", work.Version, "error", err) + return true + } if err != nil { // No timeout-based takeover: the Substrate request may still complete. - // Keep admission closed until its outcome can be safely reconciled. - logging.FromContext(ctx).ErrorContext(ctx, "runtime boundary outcome unknown", "session_id", work.Session.Id, "version", work.Version, "error", err) - return + // Keep admission closed until the Actor's own state settles the outcome. + logging.FromContext(ctx).ErrorContext(ctx, "runtime boundary outcome unknown", "session_id", work.Session.Id, "task_id", work.TaskID, "version", work.Version, "error", err) + return false } - // Keep a known snapshot until its reference is stored. Retry database failures - // without repeating runtime work; give shutdown one bounded completion attempt. + w.finishIdleWork(ctx, work, snapshot) + return true +} + +// settleIdleWork resolves a claim whose Pause or Quiesce failed from the state +// Substrate reports for the Actor, so the session does not refuse sends forever. +// A suspended Actor's external snapshot finishes the claim as Quiesce would +// have, and a paused Actor finishes a pause. While the Actor is still in +// transition, or Substrate cannot answer, the claim is kept and read again with +// a backoff. A running Actor did not take the request, or Substrate abandoned +// it: the request is sent once more, fenced, and its outcome settled the same +// way. Any other state, a crashed or missing Actor included, or an Actor still +// running after the retry, releases the claim without a snapshot and records +// why on the session: the next send takes the runtime as it is, and a lost one +// fails the session there; a running Actor is fenced first. A worker that stops +// while it settles leaves the claim to another once the lease has run out. +func (w *ActorWorkflow) settleIdleWork(ctx context.Context, work *database.SessionQuiescence) { + log := logging.FromContext(ctx).With("session_id", work.Session.Id, "task_id", work.TaskID, "version", work.Version) + var retryErr error + retried := false + for delay := w.settleDelay; ; delay = min(2*delay, time.Minute) { + readCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + actor, snapshot, err := w.boundaryActor(readCtx, work.Session) + cancel() + state := actor.GetStatus().GetState() + switch { + case err != nil: + log.WarnContext(ctx, "runtime boundary still unknown", "error", err, "retry_in", delay) + case actor != nil && inTransition(state): + log.InfoContext(ctx, "runtime boundary still in transition", "actor_state", state, "retry_in", delay) + case snapshot != nil: + log.InfoContext(ctx, "runtime boundary settled from suspended Actor", "snapshot_uri", snapshot.URI) + w.finishIdleWork(ctx, work, snapshot) + return + case state == ateapipb.ActorState_ACTOR_STATE_PAUSED && !work.State.Terminal() && !work.Suspend: + log.InfoContext(ctx, "runtime boundary settled from paused Actor") + w.finishIdleWork(ctx, work, nil) + return + case state == ateapipb.ActorState_ACTOR_STATE_RUNNING && !retried: + retried = true + log.InfoContext(ctx, "runtime boundary retried on running Actor") + snapshot, retryErr = w.retryIdleWork(ctx, work) + switch { + case status.Code(retryErr) == codes.FailedPrecondition: + log.WarnContext(ctx, "runtime boundary superseded by a newer holder", "error", retryErr) + return + case retryErr == nil: + log.InfoContext(ctx, "runtime boundary settled by its retry") + w.finishIdleWork(ctx, work, snapshot) + return + } + log.WarnContext(ctx, "runtime boundary retry failed", "error", retryErr, "retry_in", delay) + default: + if w.releaseIdleWork(ctx, log, work, actor, quiescenceFailure(work, actor, retryErr), delay) { + return + } + } + select { + case <-ctx.Done(): + return + case <-time.After(delay): + } + } +} + +// releaseIdleWork releases a claim whose Actor was neither suspended nor +// paused. A running Actor first gets a fencing token newer than any request +// this claim's holders sent, by a fenced Resume that leaves the runtime as it +// is, so a Pause or Suspend still in flight cannot land after the release. It +// reports false when the fence could not be set yet and is tried again. +func (w *ActorWorkflow) releaseIdleWork(ctx context.Context, log *slog.Logger, work *database.SessionQuiescence, actor *ateapipb.Actor, failure *apiv1alpha1.QuiescenceFailure, delay time.Duration) bool { + state := actor.GetStatus().GetState() + if state == ateapipb.ActorState_ACTOR_STATE_RUNNING { + err := w.fenceRunning(ctx, work, actor) + if status.Code(err) == codes.FailedPrecondition { + log.WarnContext(ctx, "runtime boundary superseded by a newer holder", "error", err) + return true + } + if err != nil { + log.WarnContext(ctx, "fence running Actor", "error", err, "retry_in", delay) + return false + } + } + log.WarnContext(ctx, "runtime boundary released without snapshot", "actor_state", state, "actor_found", actor != nil, "reason", failure.GetReason(), "message", failure.GetMessage()) + if work.Suspend { + w.deferred.add(work.Session.Id, time.Now().Add(w.pausedRuntimeTTL)) + } + w.recordIdleWork(ctx, work, func(ctx context.Context) error { return w.store.ReleaseSessionQuiescence(ctx, work, failure) }) + return true +} + +// retryIdleWork sends work's Pause or Quiesce once more, fenced with a newer +// token, after the Actor was found running again. A FailedPrecondition answer +// means another holder took the claim over. +func (w *ActorWorkflow) retryIdleWork(ctx context.Context, work *database.SessionQuiescence) (*database.SessionTaskSnapshot, error) { + runtimeCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + runtimeCtx, err := w.fence(runtimeCtx, work) + if err != nil { + return nil, err + } + if wantsSnapshot(work) { + return w.Quiesce(runtimeCtx, work.Session) + } + return nil, w.Pause(runtimeCtx, work.Session) +} + +// wantsSnapshot reports whether work's boundary is a suspend with an external +// snapshot rather than a pause. +func wantsSnapshot(work *database.SessionQuiescence) bool { + return work.State.Terminal() || work.Suspend +} + +// quiescenceFailure explains a boundary released without a pause or a +// snapshot: a crashed or missing Actor by its state, a running one by the +// error of the request sent again. +func quiescenceFailure(work *database.SessionQuiescence, actor *ateapipb.Actor, retryErr error) *apiv1alpha1.QuiescenceFailure { + state := actor.GetStatus().GetState() + switch { + case actor == nil: + return &apiv1alpha1.QuiescenceFailure{Reason: "RuntimeUnavailable", Message: "the runtime's Actor was not found"} + case state != ateapipb.ActorState_ACTOR_STATE_RUNNING || retryErr == nil: + return &apiv1alpha1.QuiescenceFailure{Reason: "RuntimeUnavailable", Message: fmt.Sprintf("the runtime's Actor is %s", state)} + case wantsSnapshot(work): + return &apiv1alpha1.QuiescenceFailure{Reason: "SuspendFailed", Message: fmt.Sprintf("retry after a failed request: %v", retryErr)} + default: + return &apiv1alpha1.QuiescenceFailure{Reason: "PauseFailed", Message: fmt.Sprintf("retry after a failed request: %v", retryErr)} + } +} + +// fenceRunning records a new fencing token of work's holder on the running +// Actor without touching the runtime: Substrate's Resume of a running Actor +// only records a newer token. +func (w *ActorWorkflow) fenceRunning(ctx context.Context, work *database.SessionQuiescence, actor *ateapipb.Actor) error { + fenceCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + fenceCtx, err := w.fence(fenceCtx, work) + if err != nil { + return err + } + atespace, name := actor.GetMetadata().GetAtespace(), actor.GetMetadata().GetName() + resumed, err := w.actors.ResumeActor(fenceCtx, atespace, name) + if err != nil { + return fmt.Errorf("fence Actor %s/%s: %w", atespace, name, err) + } + if state := resumed.GetStatus().GetState(); state != ateapipb.ActorState_ACTOR_STATE_RUNNING { + return fmt.Errorf("fence Actor %s/%s returned status %s", atespace, name, state) + } + return nil +} + +// fence returns ctx carrying a fencing token of work's holder whose generation +// is newer than that of every request sent before, by any holder. +func (w *ActorWorkflow) fence(ctx context.Context, work *database.SessionQuiescence) (context.Context, error) { + generation, err := w.store.NextQuiescenceFencingGeneration(ctx) + if err != nil { + return ctx, fmt.Errorf("next fencing generation: %w", err) + } + return substrate.WithFencingToken(ctx, &ateapipb.FencingToken{Holder: work.ExecutorID.String(), Generation: generation}), nil +} + +func inTransition(state ateapipb.ActorState) bool { + switch state { + case ateapipb.ActorState_ACTOR_STATE_UNSPECIFIED, ateapipb.ActorState_ACTOR_STATE_RESUMING, ateapipb.ActorState_ACTOR_STATE_SUSPENDING, + ateapipb.ActorState_ACTOR_STATE_PAUSING, ateapipb.ActorState_ACTOR_STATE_DELETING, ateapipb.ActorState_ACTOR_STATE_REVERTING: + return true + } + return false +} + +// finishIdleWork records the claim's outcome. Keep a known snapshot until its +// reference is stored. +func (w *ActorWorkflow) finishIdleWork(ctx context.Context, work *database.SessionQuiescence, snapshot *database.SessionTaskSnapshot) { + w.recordIdleWork(ctx, work, func(ctx context.Context) error { return w.store.FinishSessionQuiescence(ctx, work, snapshot) }) +} + +// recordIdleWork retries database failures without repeating runtime work; +// give shutdown one bounded completion attempt. +func (w *ActorWorkflow) recordIdleWork(ctx context.Context, work *database.SessionQuiescence, record func(context.Context) error) { + var err error for delay := 100 * time.Millisecond; ; delay = min(2*delay, 5*time.Second) { finishCtx, finishCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) - err = w.store.FinishSessionQuiescence(finishCtx, work, snapshot) + err = record(finishCtx) finishCancel() if err == nil { return @@ -84,3 +359,36 @@ func (w *ActorWorkflow) quiesceIdleSession(ctx context.Context, work *database.S } } } + +// deferrals holds sessions the idle worker leaves out of the pause TTL's claims +// until a time, so a pause that cannot be suspended is not claimed and released +// every second by every worker. +type deferrals struct { + mu sync.Mutex + until map[string]time.Time +} + +func (d *deferrals) add(sessionID string, until time.Time) { + d.mu.Lock() + defer d.mu.Unlock() + if d.until == nil { + d.until = map[string]time.Time{} + } + d.until[sessionID] = until +} + +// active returns the sessions still deferred at now and forgets the others. +func (d *deferrals) active(now time.Time) []string { + d.mu.Lock() + defer d.mu.Unlock() + var ids []string + for id, until := range d.until { + if now.Before(until) { + ids = append(ids, id) + } else { + delete(d.until, id) + } + } + slices.Sort(ids) + return ids +} diff --git a/go/core/internal/service/session/quiescence_test.go b/go/core/internal/service/session/quiescence_test.go index 73c41f0ae1..aa02910dd6 100644 --- a/go/core/internal/service/session/quiescence_test.go +++ b/go/core/internal/service/session/quiescence_test.go @@ -3,6 +3,7 @@ package session import ( "context" "crypto/sha256" + "sync" "sync/atomic" "testing" "time" @@ -12,11 +13,15 @@ import ( "github.com/google/uuid" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/core/internal/database" + "github.com/kagent-dev/kagent/go/core/internal/substrate" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) +// testClaimLease outlives every test that does not wait for a claim to expire. +const testClaimLease = time.Hour + func TestIdleLifecycleDoesNotOwnTaskPublication(t *testing.T) { for _, test := range []struct { name string @@ -24,7 +29,7 @@ func TestIdleLifecycleDoesNotOwnTaskPublication(t *testing.T) { finishFailures int32 }{ {name: "snapshot succeeds"}, - {name: "snapshot outcome unknown", mutationFails: true}, + {name: "lost suspend response settles from the Actor", mutationFails: true}, {name: "snapshot reference survives database retries", finishFailures: 4}, } { t.Run(test.name, func(t *testing.T) { @@ -42,8 +47,9 @@ func TestIdleLifecycleDoesNotOwnTaskPublication(t *testing.T) { require.NoError(t, store.SettleSessionTask(t.Context(), session.Id, string(task.ID), version)) entered, release := make(chan struct{}), make(chan struct{}) + var enter sync.Once actors := &retryTestActors{lifecycleTestActors: base, beforeRead: func(ctx context.Context) { - close(entered) + enter.Do(func() { close(entered) }) select { case <-release: case <-ctx.Done(): @@ -75,25 +81,387 @@ func TestIdleLifecycleDoesNotOwnTaskPublication(t *testing.T) { require.ErrorIs(t, err, database.ErrFailedPrecondition) close(release) - if test.mutationFails { - require.Eventually(t, func() bool { return actors.mutations.Load() == 1 }, 5*time.Second, 10*time.Millisecond) - visible, err = store.GetSettledSessionTask(t.Context(), session.Id, string(task.ID), nil) - require.NoError(t, err) - require.Equal(t, a2a.TaskStateCompleted, visible.Status.State) - _, err = store.ClaimSessionQuiescence(t.Context()) - require.ErrorIs(t, err, database.ErrNotFound, "an uncertain suspension cannot be reassigned") + // A suspend whose response was lost is recorded from the Actor's + // snapshot, never by suspending it again. + require.Eventually(t, func() bool { + _, snapshot, err := store.ReserveSessionCheckpoint(t.Context(), checkpoint, "alice", "checkpoint") + return err == nil && snapshot.URI == "s3://snapshots/snapshot-1" + }, 5*time.Second, 10*time.Millisecond) + require.EqualValues(t, 1, actors.mutations.Load(), "database retries must not suspend the actor again") + require.Equal(t, test.finishFailures+1, writes.attempts.Load()) + _, err = store.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) + require.ErrorIs(t, err, database.ErrNotFound) + }) + } +} + +// A Pause or Quiesce that fails without an answer leaves the claim held while +// the Actor is still in transition, and the Actor's state then settles it: a +// boundary that landed is recorded, a running Actor gets the request once more, +// and any other state, or a retry that fails too, releases the claim without a +// snapshot and records why on the session. Either way the next turn is admitted. +func TestFailedBoundarySettlesFromTheActor(t *testing.T) { + suspend := func(a *lifecycleTestActors, space, name string) { + _, _ = a.SuspendActor(context.Background(), space, name) + } + pause := func(a *lifecycleTestActors, space, name string) { + _, _ = a.PauseActor(context.Background(), space, name) + } + become := func(state ateapipb.ActorState) func(*lifecycleTestActors, string, string) { + return func(a *lifecycleTestActors, space, name string) { a.setState(space, name, state) } + } + gone := func(a *lifecycleTestActors, space, name string) { + _ = a.DeleteActor(context.Background(), space, name, true) + } + running := become(ateapipb.ActorState_ACTOR_STATE_RUNNING) + retryFails := status.Error(codes.DeadlineExceeded, "sandbox teardown outlived the retry") + for _, test := range []struct { + name string + waiting bool + settle func(*lifecycleTestActors, string, string) + retryErr error + retryCrashes bool + snapshot bool + requests int32 + failure string + failedMsg string + }{ + {name: "suspend lands", settle: suspend, snapshot: true, requests: 1}, + {name: "suspend did not happen and its retry lands", settle: running, snapshot: true, requests: 2}, + {name: "suspend did not happen and its retry fails", settle: running, retryErr: retryFails, requests: 2, failure: "SuspendFailed", failedMsg: "sandbox teardown outlived the retry"}, + {name: "suspend did not happen and the runtime crashed during its retry", settle: running, retryErr: retryFails, retryCrashes: true, requests: 2, failure: "RuntimeUnavailable", failedMsg: "ACTOR_STATE_CRASHED"}, + {name: "runtime crashed", settle: become(ateapipb.ActorState_ACTOR_STATE_CRASHED), requests: 1, failure: "RuntimeUnavailable", failedMsg: "ACTOR_STATE_CRASHED"}, + {name: "actor gone", settle: gone, requests: 1, failure: "RuntimeUnavailable", failedMsg: "not found"}, + {name: "pause lands", waiting: true, settle: pause, requests: 1}, + {name: "pause did not happen and its retry lands", waiting: true, settle: running, requests: 2}, + {name: "pause did not happen and its retry fails", waiting: true, settle: running, retryErr: retryFails, requests: 2, failure: "PauseFailed", failedMsg: "sandbox teardown outlived the retry"}, + } { + t.Run(test.name, func(t *testing.T) { + store, session := lifecycleFixture(t) + base := &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}} + session, err := NewActorWorkflow(store, base).Create(t.Context(), session) + require.NoError(t, err) + message := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("hello")) + message.ContextID = session.ContextId + task := a2a.NewSubmittedTask(message, message) + task.Status.State = a2a.TaskStateCompleted + pending := ateapipb.ActorState_ACTOR_STATE_SUSPENDING + if test.waiting { + task.Status.State = a2a.TaskStateInputRequired + pending = ateapipb.ActorState_ACTOR_STATE_PAUSING + } + hash := sha256.Sum256([]byte("turn")) + version, err := store.CreateRuntimeTask(t.Context(), session.Id, hash[:], task, "") + require.NoError(t, err) + require.NoError(t, store.SettleSessionTask(t.Context(), session.Id, string(task.ID), version)) + + actors := &unsettledActors{lifecycleTestActors: base, pending: pending, settle: test.settle, retryErr: test.retryErr, retryCrashes: test.retryCrashes, release: make(chan struct{}), reads: make(chan struct{}, 1)} + workflow := NewActorWorkflow(store, actors) + workflow.settleDelay = time.Millisecond + work, err := store.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) + require.NoError(t, err) + workflow.quiesceIdleSession(t.Context(), work) + for range 2 { + select { + case <-actors.reads: + case <-time.After(5 * time.Second): + t.Fatal("the unsettled Actor was not read again") + } + } + require.ErrorIs(t, store.ReserveSessionDispatch(t.Context(), session.Id, uuid.New(), "held"), database.ErrDispatchBusy, "an Actor in transition keeps the claim") + + close(actors.release) + workflow.settling.Wait() + require.Equal(t, test.requests, actors.mutations.Load(), "only a running Actor gets the request once more") + _, err = store.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) + require.ErrorIs(t, err, database.ErrNotFound, "the settled boundary is not claimed again") + settled, err := store.GetSessionByID(t.Context(), session.Id) + require.NoError(t, err) + if test.failure == "" { + require.Nil(t, settled.GetLastQuiescenceFailure(), "a settled boundary records no failure") } else { - require.Eventually(t, func() bool { - _, snapshot, err := store.ReserveSessionCheckpoint(t.Context(), checkpoint, "alice", "checkpoint") - return err == nil && snapshot.URI == "s3://snapshots/snapshot-1" - }, 5*time.Second, 10*time.Millisecond) - require.EqualValues(t, 1, actors.mutations.Load(), "database retries must not suspend the actor again") - require.Equal(t, test.finishFailures+1, writes.attempts.Load()) + failure := settled.GetLastQuiescenceFailure() + require.Equal(t, string(task.ID), failure.GetTaskId()) + require.Equal(t, test.failure, failure.GetReason()) + require.Contains(t, failure.GetMessage(), test.failedMsg) + require.NotNil(t, failure.GetFailedAt()) + } + if test.snapshot { + checkpoint := &apiv1alpha1.Checkpoint{Id: uuid.NewString(), SessionId: session.Id, HeadTaskId: string(task.ID)} + _, snapshot, err := store.ReserveSessionCheckpoint(t.Context(), checkpoint, "alice", "checkpoint") + require.NoError(t, err) + require.Equal(t, "s3://snapshots/snapshot-1", snapshot.URI) + return } + require.NoError(t, store.ReserveSessionDispatch(t.Context(), session.Id, uuid.New(), "next"), "the settled claim admits the next turn") }) } } +// A worker that stops while its claim is unsettled, during the runtime request +// or while it reads the Actor again, leaves the claim to another worker once +// the lease has run out. The other worker settles it from the Actor's state, +// repeating the request only on a running Actor; a live holder keeps its claim +// past the lease. +func TestStoppedHolderBoundaryIsSettledByAnotherWorker(t *testing.T) { + suspend := func(a *lifecycleTestActors, space, name string) { + _, _ = a.SuspendActor(context.Background(), space, name) + } + pause := func(a *lifecycleTestActors, space, name string) { + _, _ = a.PauseActor(context.Background(), space, name) + } + for _, test := range []struct { + name string + waiting bool + hang bool + settle func(*lifecycleTestActors, string, string) + requests int32 + }{ + {name: "stopped while settling a suspend", settle: suspend, requests: 1}, + {name: "stopped while settling a pause", waiting: true, settle: pause, requests: 1}, + {name: "stopped during the suspend request", hang: true, settle: suspend, requests: 1}, + {name: "stopped while settling an unfinished suspend", settle: func(a *lifecycleTestActors, space, name string) { + a.setState(space, name, ateapipb.ActorState_ACTOR_STATE_RUNNING) + }, requests: 2}, + } { + t.Run(test.name, func(t *testing.T) { + const lease = 300 * time.Millisecond + store, session := lifecycleFixture(t) + base := &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}} + session, err := NewActorWorkflow(store, base).Create(t.Context(), session) + require.NoError(t, err) + message := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("hello")) + message.ContextID = session.ContextId + task := a2a.NewSubmittedTask(message, message) + task.Status.State = a2a.TaskStateCompleted + pending := ateapipb.ActorState_ACTOR_STATE_SUSPENDING + if test.waiting { + task.Status.State = a2a.TaskStateInputRequired + pending = ateapipb.ActorState_ACTOR_STATE_PAUSING + } + hash := sha256.Sum256([]byte("turn")) + version, err := store.CreateRuntimeTask(t.Context(), session.Id, hash[:], task, "") + require.NoError(t, err) + require.NoError(t, store.SettleSessionTask(t.Context(), session.Id, string(task.ID), version)) + + actors := &unsettledActors{lifecycleTestActors: base, pending: pending, settle: test.settle, release: make(chan struct{}), reads: make(chan struct{}, 1), hang: test.hang} + stopped, stop := context.WithCancel(t.Context()) + first := NewActorWorkflow(store, actors) + first.settleDelay, first.claimLease = time.Millisecond, lease + work, err := store.ClaimSessionQuiescence(t.Context(), lease, 0, nil) + require.NoError(t, err) + issued := make(chan struct{}) + go func() { defer close(issued); first.quiesceIdleSession(stopped, work) }() + require.Eventually(t, func() bool { return actors.mutations.Load() == 1 }, 5*time.Second, time.Millisecond) + + // The live holder renews its claim for longer than one lease. + time.Sleep(3 * lease) + _, err = store.ClaimSessionQuiescence(t.Context(), lease, 0, nil) + require.ErrorIs(t, err, database.ErrNotFound, "a renewed claim is not taken over") + require.ErrorIs(t, store.ReserveSessionDispatch(t.Context(), session.Id, uuid.New(), "held"), database.ErrDispatchBusy) + + stop() + <-issued + first.settling.Wait() + close(actors.release) + + ctx, cancel := context.WithCancel(t.Context()) + second := NewActorWorkflow(store, actors) + second.settleDelay, second.claimLease = time.Millisecond, lease + done := make(chan error, 1) + go func() { done <- second.Start(ctx) }() + t.Cleanup(func() { cancel(); require.NoError(t, <-done) }) + require.Eventually(t, func() bool { + return store.ReserveSessionDispatch(t.Context(), session.Id, uuid.New(), "next") == nil + }, 10*time.Second, 20*time.Millisecond, "the boundary is settled by the second worker and admits the next turn") + require.Equal(t, test.requests, actors.mutations.Load(), "the second worker repeats the runtime request only on a running Actor") + }) + } +} + +// A holder whose Pause is in flight when it freezes past its lease is taken +// over: the successor finds the Actor running and pauses it with a newer +// fencing token, and the late Pause is refused, so the boundary is settled once +// and the next turn is admitted. A live holder renewing its lease keeps its +// claim and its slow Pause lands. +func TestLatePauseOfATakenOverHolderIsRefused(t *testing.T) { + for _, test := range []struct { + name string + frozen bool + }{ + {name: "frozen holder", frozen: true}, + {name: "live holder"}, + } { + t.Run(test.name, func(t *testing.T) { + const lease = 300 * time.Millisecond + store, session := lifecycleFixture(t) + base := &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}} + session, err := NewActorWorkflow(store, base).Create(t.Context(), session) + require.NoError(t, err) + atespace, name := store.revision.ActorTemplateAtespace, substrate.ActorName(session.Id) + base.setState(atespace, name, ateapipb.ActorState_ACTOR_STATE_RUNNING) + message := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("hello")) + message.ContextID = session.ContextId + task := a2a.NewSubmittedTask(message, message) + task.Status.State = a2a.TaskStateInputRequired + hash := sha256.Sum256([]byte("turn")) + version, err := store.CreateRuntimeTask(t.Context(), session.Id, hash[:], task, "") + require.NoError(t, err) + require.NoError(t, store.SettleSessionTask(t.Context(), session.Id, string(task.ID), version)) + + actors := &delayedPauseActors{lifecycleTestActors: base, issued: make(chan struct{}), land: make(chan struct{}), landed: make(chan error, 1)} + var holderStore workflowStore = store + if test.frozen { + holderStore = &frozenRenewalStore{lifecycleTestStore: store} + } + first := NewActorWorkflow(holderStore, actors) + first.claimLease = lease + work, err := store.ClaimSessionQuiescence(t.Context(), lease, 0, nil) + require.NoError(t, err) + holding := make(chan struct{}) + go func() { defer close(holding); first.quiesceIdleSession(t.Context(), work) }() + select { + case <-actors.issued: + case <-time.After(5 * time.Second): + t.Fatal("the holder did not issue its Pause") + } + + ctx, cancel := context.WithCancel(t.Context()) + second := NewActorWorkflow(store, actors) + second.settleDelay, second.claimLease = time.Millisecond, lease + done := make(chan error, 1) + go func() { done <- second.Start(ctx) }() + t.Cleanup(func() { cancel(); require.NoError(t, <-done) }) + + if !test.frozen { + time.Sleep(3 * lease) + require.ErrorIs(t, store.ReserveSessionDispatch(t.Context(), session.Id, uuid.New(), "held"), database.ErrDispatchBusy, "a renewed claim is not taken over") + close(actors.land) + require.NoError(t, <-actors.landed) + <-holding + actor, err := base.GetActor(t.Context(), atespace, name) + require.NoError(t, err) + require.Equal(t, ateapipb.ActorState_ACTOR_STATE_PAUSED, actor.GetStatus().GetState()) + require.NoError(t, store.ReserveSessionDispatch(t.Context(), session.Id, uuid.New(), "next"), "the finished pause admits the next turn") + return + } + + require.Eventually(t, func() bool { + return store.ReserveSessionDispatch(t.Context(), session.Id, uuid.New(), "next") == nil + }, 10*time.Second, 20*time.Millisecond, "the successor settles the boundary and admits the next turn") + close(actors.land) + require.Equal(t, codes.FailedPrecondition, status.Code(<-actors.landed), "the late Pause of the taken-over holder is refused") + <-holding + actor, err := base.GetActor(t.Context(), atespace, name) + require.NoError(t, err) + require.Equal(t, ateapipb.ActorState_ACTOR_STATE_PAUSED, actor.GetStatus().GetState(), "the successor's own Pause settled the boundary") + }) + } +} + +// delayedPauseActors holds the first Pause until land is closed, as a request +// in flight while its sender freezes, and reports what Substrate made of it. +// Later Pauses pass straight through. +type delayedPauseActors struct { + *lifecycleTestActors + issued, land chan struct{} + landed chan error + first atomic.Bool +} + +func (a *delayedPauseActors) PauseActor(ctx context.Context, space, name string) (*ateapipb.Actor, error) { + if !a.first.CompareAndSwap(false, true) { + return a.lifecycleTestActors.PauseActor(ctx, space, name) + } + close(a.issued) + <-a.land + actor, err := a.lifecycleTestActors.PauseActor(ctx, space, name) + a.landed <- err + return actor, err +} + +// frozenRenewalStore stands for a holder frozen past its lease: its renewals +// never reach the database. +type frozenRenewalStore struct { + *lifecycleTestStore +} + +func (*frozenRenewalStore) RenewSessionQuiescence(context.Context, *database.SessionQuiescence, time.Duration) error { + return nil +} + +// unsettledActors fails the first boundary request after Substrate took it: the +// Actor reports pending until release is closed, and then what settle made of +// it. With hang, the request does not return until its caller stops. A later +// request lands, or fails with retryErr leaving the Actor as it is, or crashed +// with retryCrashes. +type unsettledActors struct { + *lifecycleTestActors + hang bool + retryErr error + retryCrashes bool + pending ateapipb.ActorState + settle func(*lifecycleTestActors, string, string) + release chan struct{} + reads chan struct{} + failed atomic.Bool + settled sync.Once + mutations atomic.Int32 +} + +func (a *unsettledActors) GetActor(ctx context.Context, space, name string) (*ateapipb.Actor, error) { + if a.failed.Load() { + select { + case <-a.release: + a.settled.Do(func() { a.settle(a.lifecycleTestActors, space, name) }) + default: + select { + case a.reads <- struct{}{}: + default: + } + } + } + return a.lifecycleTestActors.GetActor(ctx, space, name) +} + +func (a *unsettledActors) SuspendActor(ctx context.Context, space, name string) (*ateapipb.Actor, error) { + if a.mutations.Add(1) > 1 { + if a.retryCrashes { + a.setState(space, name, ateapipb.ActorState_ACTOR_STATE_CRASHED) + } + if a.retryErr != nil { + return nil, a.retryErr + } + return a.lifecycleTestActors.SuspendActor(ctx, space, name) + } + return nil, a.fail(ctx, space, name) +} + +func (a *unsettledActors) PauseActor(ctx context.Context, space, name string) (*ateapipb.Actor, error) { + if a.mutations.Add(1) > 1 { + if a.retryErr != nil { + return nil, a.retryErr + } + return a.lifecycleTestActors.PauseActor(ctx, space, name) + } + return nil, a.fail(ctx, space, name) +} + +func (a *unsettledActors) fail(ctx context.Context, space, name string) error { + a.setState(space, name, a.pending) + a.failed.Store(true) + if a.hang { + <-ctx.Done() + } + return status.Error(codes.DeadlineExceeded, "sandbox teardown outlived the request") +} + +func (a *lifecycleTestActors) setState(space, name string, state ateapipb.ActorState) { + a.mu.Lock() + defer a.mu.Unlock() + a.actors[actorKey(space, name)].Status.State = state +} + type quiescenceRetryStore struct { *lifecycleTestStore failures int32 diff --git a/go/core/internal/service/session/repoint.go b/go/core/internal/service/session/repoint.go new file mode 100644 index 0000000000..188d5dd5a0 --- /dev/null +++ b/go/core/internal/service/session/repoint.go @@ -0,0 +1,128 @@ +package session + +import ( + "context" + "errors" + "fmt" + "time" + + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "github.com/kagent-dev/kagent/go/core/internal/database" + "github.com/kagent-dev/kagent/go/pkg/logging" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/metric" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +// revisionRepointPage bounds the sessions one listing of the sweep returns; a +// sweep walks the pages until the last. +const revisionRepointPage = 100 + +type repointStore interface { + ListSessionsOnSupersededRevisions(context.Context, string, int) ([]*apiv1alpha1.Session, error) +} + +type repointWorkflow interface { + RepointQuiesced(context.Context, *apiv1alpha1.Session) (*apiv1alpha1.Session, error) +} + +// RevisionRepointWorker moves the quiesced runtimes of sessions whose revision +// their agent has superseded onto the current one. +// +// A turn moves its own runtime before it wakes it, so a conversation that is +// written to takes its agent's current revision by itself. A conversation +// nobody writes to again keeps its revision pinned, and with it the +// ActorTemplate rendered for it, with everything an older release put there: +// the runtime revision GC collects a revision only once no session references +// it. The sweep moves those runtimes while they are suspended, at the interval +// the controller is given, through the same repoint a turn uses. +type RevisionRepointWorker struct { + store repointStore + workflow repointWorkflow + interval time.Duration + repointed metric.Int64Counter +} + +var _ manager.Runnable = (*RevisionRepointWorker)(nil) +var _ manager.LeaderElectionRunnable = (*RevisionRepointWorker)(nil) +var _ repointStore = (*database.Client)(nil) +var _ repointWorkflow = (*ActorWorkflow)(nil) + +func NewRevisionRepointWorker(store repointStore, workflow repointWorkflow, interval time.Duration) (*RevisionRepointWorker, error) { + if interval < 0 { + return nil, fmt.Errorf("revision repoint interval must be nonnegative") + } + repointed, err := otel.Meter("github.com/kagent-dev/kagent/go/core/internal/service/session").Int64Counter( + "kagent.session.runtime_repointed", metric.WithDescription("Session runtimes the revision sweep moved onto their agent's current revision."), metric.WithUnit("{session}")) + if err != nil { + return nil, fmt.Errorf("create revision repoint counter: %w", err) + } + return &RevisionRepointWorker{store: store, workflow: workflow, interval: interval, repointed: repointed}, nil +} + +func (*RevisionRepointWorker) NeedLeaderElection() bool { return true } + +// Start sweeps on the interval. Zero disables the sweep. +func (w *RevisionRepointWorker) Start(ctx context.Context) error { + if w.interval == 0 { + <-ctx.Done() + return nil + } + ticker := time.NewTicker(w.interval) + defer ticker.Stop() + for ctx.Err() == nil { + w.sweep(ctx) + select { + case <-ctx.Done(): + case <-ticker.C: + } + } + return nil +} + +// sweep visits every settled session on a superseded revision once, by id, a +// page at a time. A session the sweep could not move stays in the listing and +// is visited again by the next sweep, never by this one. +func (w *RevisionRepointWorker) sweep(ctx context.Context) { + afterID := "" + for ctx.Err() == nil { + listCtx, cancel := context.WithTimeout(ctx, time.Minute) + sessions, err := w.store.ListSessionsOnSupersededRevisions(listCtx, afterID, revisionRepointPage) + cancel() + if err != nil { + logging.FromContext(ctx).ErrorContext(ctx, "list sessions on superseded revisions", "error", err) + return + } + for _, session := range sessions { + if ctx.Err() != nil { + return + } + w.repoint(ctx, session) + } + if len(sessions) < revisionRepointPage { + return + } + afterID = sessions[len(sessions)-1].GetId() + } +} + +// repoint moves one runtime. A turn that reserved the session, or a lifecycle +// operation that claimed it since the listing, wins: the record refuses the +// move, and the next sweep looks again. +func (w *RevisionRepointWorker) repoint(ctx context.Context, session *apiv1alpha1.Session) { + ctx, cancel := context.WithTimeout(ctx, database.RuntimeOperationTimeout) + defer cancel() + moved, err := w.workflow.RepointQuiesced(ctx, session) + switch { + case errors.Is(err, database.ErrConflict): + logging.FromContext(ctx).DebugContext(ctx, "session left for its lifecycle operation; its runtime stays on the superseded revision for now", + "session_id", session.GetId(), "revision", session.GetPreparedRevision()) + case err != nil: + logging.FromContext(ctx).ErrorContext(ctx, "move the session runtime to its agent's current revision", + "session_id", session.GetId(), "revision", session.GetPreparedRevision(), "error", err) + case moved.GetPreparedRevision() != session.GetPreparedRevision(): + w.repointed.Add(ctx, 1) + logging.FromContext(ctx).InfoContext(ctx, "moved the session runtime to its agent's current revision", + "session_id", session.GetId(), "revision", moved.GetPreparedRevision(), "superseded_revision", session.GetPreparedRevision()) + } +} diff --git a/go/core/internal/service/session/repoint_test.go b/go/core/internal/service/session/repoint_test.go new file mode 100644 index 0000000000..6d7fbbf216 --- /dev/null +++ b/go/core/internal/service/session/repoint_test.go @@ -0,0 +1,92 @@ +package session + +import ( + "cmp" + "context" + "errors" + "fmt" + "slices" + "testing" + + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "github.com/kagent-dev/kagent/go/core/internal/database" + "github.com/stretchr/testify/require" +) + +// repointTestStore pages a sorted listing the way the store does: by id after +// afterID, up to limit. +type repointTestStore struct { + sessions []*apiv1alpha1.Session + err error + listings int +} + +func (s *repointTestStore) ListSessionsOnSupersededRevisions(_ context.Context, afterID string, limit int) ([]*apiv1alpha1.Session, error) { + s.listings++ + if s.err != nil { + return nil, s.err + } + var page []*apiv1alpha1.Session + for _, session := range s.sessions { + if session.GetId() > afterID { + page = append(page, session) + } + if len(page) == limit { + break + } + } + return page, nil +} + +type repointTestWorkflow struct { + visited []string + failing map[string]error +} + +func (w *repointTestWorkflow) RepointQuiesced(_ context.Context, session *apiv1alpha1.Session) (*apiv1alpha1.Session, error) { + w.visited = append(w.visited, session.GetId()) + if err := w.failing[session.GetId()]; err != nil { + return nil, err + } + moved := &apiv1alpha1.Session{Id: session.GetId(), PreparedRevision: "revision-2", State: session.GetState()} + return moved, nil +} + +func TestRevisionRepointSweepVisitsEverySessionOnce(t *testing.T) { + store := &repointTestStore{} + for i := range revisionRepointPage + 1 { + store.sessions = append(store.sessions, &apiv1alpha1.Session{Id: fmt.Sprintf("%04d", i), PreparedRevision: "revision-1", State: apiv1alpha1.RuntimeState_RUNTIME_STATE_READY}) + } + slices.SortFunc(store.sessions, func(a, b *apiv1alpha1.Session) int { return cmp.Compare(a.Id, b.Id) }) + workflow := &repointTestWorkflow{failing: map[string]error{ + "0003": fmt.Errorf("claimed: %w", database.ErrConflict), + "0007": errors.New("substrate unavailable"), + }} + worker, err := NewRevisionRepointWorker(store, workflow, 0) + require.NoError(t, err) + + worker.sweep(t.Context()) + + require.Len(t, workflow.visited, revisionRepointPage+1, "every session of every page is visited, a failure stops nothing") + require.Equal(t, 2, store.listings, "a full page is followed by the next one") + seen := map[string]bool{} + for _, id := range workflow.visited { + require.False(t, seen[id], "session %s visited twice", id) + seen[id] = true + } +} + +func TestRevisionRepointSweepStopsOnAListingError(t *testing.T) { + store := &repointTestStore{err: errors.New("database down")} + workflow := &repointTestWorkflow{} + worker, err := NewRevisionRepointWorker(store, workflow, 0) + require.NoError(t, err) + worker.sweep(t.Context()) + require.Empty(t, workflow.visited) + require.Equal(t, 1, store.listings) +} + +func TestRevisionRepointWorkerRefusesANegativeInterval(t *testing.T) { + _, err := NewRevisionRepointWorker(&repointTestStore{}, &repointTestWorkflow{}, -1) + require.Error(t, err) +} diff --git a/go/core/internal/service/session/service.go b/go/core/internal/service/session/service.go index cac0b08921..0a630eeed8 100644 --- a/go/core/internal/service/session/service.go +++ b/go/core/internal/service/session/service.go @@ -11,6 +11,7 @@ import ( "time" "github.com/google/uuid" + apia2a "github.com/kagent-dev/kagent/go/api/a2a" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/core/internal/database" "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" @@ -33,6 +34,8 @@ type store interface { CreateSessionShare(context.Context, *apiv1alpha1.SessionShare, []byte, string) (*apiv1alpha1.SessionShare, error) ListSessionShares(context.Context, string, string, string, int) ([]*apiv1alpha1.SessionShare, error) DeleteSessionShare(context.Context, string, string) error + FailSession(context.Context, string, *apiv1alpha1.Failure) (*apiv1alpha1.Session, error) + InterruptSessionTask(context.Context, string, string, time.Time, string) (*database.SessionTaskInterruption, error) } type sessionWorkflow interface { @@ -40,6 +43,9 @@ type sessionWorkflow interface { Suspend(context.Context, *apiv1alpha1.Session) (*apiv1alpha1.Session, error) Resume(context.Context, *apiv1alpha1.Session) (*apiv1alpha1.Session, error) Delete(context.Context, *apiv1alpha1.Session) (*apiv1alpha1.Session, error) + RuntimeLost(context.Context, *apiv1alpha1.Session) (string, bool, error) + RepointQuiesced(context.Context, *apiv1alpha1.Session) (*apiv1alpha1.Session, error) + AwaitRuntime(context.Context, *apiv1alpha1.Session) error } type ListRequest struct { @@ -86,6 +92,79 @@ func NewService(store store, authorizer auth.Authorizer, workflow sessionWorkflo return service } +// FailLostRuntime records that the session's runtime is gone, when it is: the +// session leaves READY for FAILED with reason RuntimeLost and a message that +// tells the person to start a new conversation, so later sends are refused +// without dialing a runtime that cannot answer. The transcript is untouched; +// the session stays readable and deletable. It returns nil when the runtime is +// not known to be lost, and the recorded failure otherwise, including one an +// earlier call recorded. +func (s *Service) FailLostRuntime(ctx context.Context, session *apiv1alpha1.Session) (*apiv1alpha1.Failure, error) { + failure, err := s.runtimeLoss(ctx, session) + if err != nil || failure == nil { + return nil, err + } + return s.failLost(ctx, session, failure) +} + +// FailLostTurn ends the session's turn whose runtime stream broke after the +// runtime took its input, when the runtime is gone: a runtime that crashed +// with a turn in flight saves nothing more for it, so the caller waiting for +// the turn's boundary would wait until its deadline and the session would keep +// the turn as active work until the stalled sweep. The turn ends FAILED with +// the loss's message, the session records the loss, and the failure is +// returned for the caller's answer. A turn that moved on meanwhile keeps its +// outcome; the loss is recorded all the same. A turn whose runtime work is not +// settled, another attempt's reservation or claimed idle work, is left alone +// with that error: the next send settles the loss. A runtime that is not lost, +// or whose state cannot be read, returns nil and leaves the turn to its runtime. +func (s *Service) FailLostTurn(ctx context.Context, session *apiv1alpha1.Session, taskID string) (*apiv1alpha1.Failure, error) { + failure, err := s.runtimeLoss(ctx, session) + if err != nil || failure == nil { + return nil, err + } + _, err = s.store.InterruptSessionTask(ctx, session.GetId(), taskID, time.Time{}, failure.GetMessage()) + if err != nil && !errors.Is(err, database.ErrConflict) && !errors.Is(err, database.ErrNotFound) { + return nil, err + } + return s.failLost(ctx, session, failure) +} + +// runtimeLoss is the failure the session's runtime is due to, nil while the +// runtime is not lost; an answer the workflow cannot give is its error. +func (s *Service) runtimeLoss(ctx context.Context, session *apiv1alpha1.Session) (*apiv1alpha1.Failure, error) { + cause, lost, err := s.workflow.RuntimeLost(ctx, session) + if err != nil || !lost { + return nil, err + } + return &apiv1alpha1.Failure{ + Reason: apia2a.FailureReasonRuntimeLost, + Message: apia2a.RuntimeLostMessagePrefix + cause + "; start a new conversation", + }, nil +} + +func (s *Service) failLost(ctx context.Context, session *apiv1alpha1.Session, failure *apiv1alpha1.Failure) (*apiv1alpha1.Failure, error) { + failed, err := s.store.FailSession(ctx, session.GetId(), failure) + if err != nil { + return nil, err + } + return failed.GetFailure(), nil +} + +// AwaitRuntime waits, bounded, until the session's runtime can take the turn +// it refused: an operation holding its Actor, such as a repoint, finishes first. +func (s *Service) AwaitRuntime(ctx context.Context, session *apiv1alpha1.Session) error { + return s.workflow.AwaitRuntime(ctx, session) +} + +// RepointQuiesced moves the session's quiesced runtime onto its agent's current +// revision when the agent has superseded the session's, and returns the session +// as it then is. The caller holds the session's dispatch, so no turn wakes the +// runtime meanwhile. +func (s *Service) RepointQuiesced(ctx context.Context, session *apiv1alpha1.Session) (*apiv1alpha1.Session, error) { + return s.workflow.RepointQuiesced(ctx, session) +} + // Create reserves and converges a new conversation. name is optional; an empty // name leaves the conversation identified by its id, which is how every session // created before names existed behaves. @@ -297,6 +376,9 @@ func (s *Service) Resume(ctx context.Context, id string) (*apiv1alpha1.Session, return nil, err } session, err = s.workflow.Resume(ctx, session) + if errors.Is(err, ErrRuntimeLost) { + return nil, serviceerrors.NewFailedPrecondition(err.Error(), err) + } if errors.Is(err, database.ErrFailedPrecondition) { return nil, serviceerrors.NewFailedPrecondition(err.Error(), err) } @@ -320,7 +402,7 @@ func (s *Service) CreateShare(ctx context.Context, sessionID string, permission return nil, "", serviceerrors.NewInvalidArgument("share permission must be READ_ONLY or READ_WRITE", nil) } if ttl < 0 { - return nil, "", serviceerrors.NewInvalidArgument("share ttl must be positive", nil) + return nil, "", serviceerrors.NewInvalidArgument("share ttl must not be negative", nil) } if s.shareMaxTTL > 0 { if ttl > s.shareMaxTTL { diff --git a/go/core/internal/service/session/service_test.go b/go/core/internal/service/session/service_test.go index 2ed86ba3a7..f23d362a12 100644 --- a/go/core/internal/service/session/service_test.go +++ b/go/core/internal/service/session/service_test.go @@ -10,11 +10,14 @@ import ( "testing" "time" + a2atype "github.com/a2aproject/a2a-go/v2/a2a" "github.com/google/uuid" + apia2a "github.com/kagent-dev/kagent/go/api/a2a" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/core/internal/database" "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" "github.com/kagent-dev/kagent/go/core/pkg/auth" + "github.com/stretchr/testify/require" ) type serviceTestSession struct{ userID string } @@ -52,6 +55,31 @@ type serviceTestStore struct { getResult *apiv1alpha1.Session shareUserID string shareErr error + failedID string + failure *apiv1alpha1.Failure + failErr error + // interruptedTask is the task InterruptSessionTask ended, with its cutoff + // and message; interruptErr is its answer instead. + interruptedTask string + interruptCutoff time.Time + interruptionMessage string + interruptErr error +} + +func (s *serviceTestStore) FailSession(_ context.Context, id string, failure *apiv1alpha1.Failure) (*apiv1alpha1.Session, error) { + s.failedID, s.failure = id, failure + if s.failErr != nil { + return nil, s.failErr + } + return &apiv1alpha1.Session{Id: id, State: apiv1alpha1.RuntimeState_RUNTIME_STATE_FAILED, Failure: failure}, nil +} + +func (s *serviceTestStore) InterruptSessionTask(_ context.Context, _, taskID string, cutoff time.Time, message string) (*database.SessionTaskInterruption, error) { + s.interruptedTask, s.interruptCutoff, s.interruptionMessage = taskID, cutoff, message + if s.interruptErr != nil { + return nil, s.interruptErr + } + return &database.SessionTaskInterruption{State: a2atype.TaskStateFailed}, nil } func (s *serviceTestStore) CreateSession(_ context.Context, session *apiv1alpha1.Session, requestID string) (*apiv1alpha1.Session, bool, error) { @@ -116,7 +144,24 @@ func (*serviceTestStore) DeleteSessionShare(context.Context, string, string) err return nil } -type serviceTestWorkflow struct{ err error } +type serviceTestWorkflow struct { + err error + lost bool + lostCause string + lostErr error +} + +func (w serviceTestWorkflow) RuntimeLost(context.Context, *apiv1alpha1.Session) (string, bool, error) { + return w.lostCause, w.lost, w.lostErr +} + +func (serviceTestWorkflow) AwaitRuntime(context.Context, *apiv1alpha1.Session) error { + return nil +} + +func (w serviceTestWorkflow) RepointQuiesced(_ context.Context, session *apiv1alpha1.Session) (*apiv1alpha1.Session, error) { + return session, w.err +} func (w serviceTestWorkflow) Create(_ context.Context, session *apiv1alpha1.Session) (*apiv1alpha1.Session, error) { return session, w.err @@ -717,3 +762,85 @@ func TestServiceListAuthorizesBeforePagination(t *testing.T) { }) } } + +func TestFailLostRuntime(t *testing.T) { + session := &apiv1alpha1.Session{Id: "session-1", State: apiv1alpha1.RuntimeState_RUNTIME_STATE_READY} + t.Run("not lost", func(t *testing.T) { + store := &serviceTestStore{} + failure, err := NewService(store, serviceTestAuthorizer{}, serviceTestWorkflow{}).FailLostRuntime(t.Context(), session) + require.NoError(t, err) + require.Nil(t, failure) + require.Empty(t, store.failedID) + }) + t.Run("lost", func(t *testing.T) { + store := &serviceTestStore{} + workflow := serviceTestWorkflow{lost: true, lostCause: "Actor team-a/session-session-1 crashed"} + failure, err := NewService(store, serviceTestAuthorizer{}, workflow).FailLostRuntime(t.Context(), session) + require.NoError(t, err) + require.Equal(t, session.Id, store.failedID) + require.Equal(t, apia2a.FailureReasonRuntimeLost, failure.GetReason()) + require.Equal(t, "runtime lost: Actor team-a/session-session-1 crashed; start a new conversation", failure.GetMessage()) + }) + t.Run("unknown", func(t *testing.T) { + store := &serviceTestStore{} + workflow := serviceTestWorkflow{lostErr: errors.New("ate-api is rolling")} + _, err := NewService(store, serviceTestAuthorizer{}, workflow).FailLostRuntime(t.Context(), session) + require.ErrorContains(t, err, "ate-api is rolling") + require.Empty(t, store.failedID, "an unknown runtime state fails nothing") + }) + t.Run("moved on", func(t *testing.T) { + store := &serviceTestStore{failErr: database.ErrConflict} + workflow := serviceTestWorkflow{lost: true, lostCause: "Actor team-a/session-session-1 not found"} + _, err := NewService(store, serviceTestAuthorizer{}, workflow).FailLostRuntime(t.Context(), session) + require.ErrorIs(t, err, database.ErrConflict) + }) +} + +func TestFailLostTurn(t *testing.T) { + session := &apiv1alpha1.Session{Id: "session-1", State: apiv1alpha1.RuntimeState_RUNTIME_STATE_READY} + lost := serviceTestWorkflow{lost: true, lostCause: "Actor team-a/session-session-1 crashed"} + want := "runtime lost: Actor team-a/session-session-1 crashed; start a new conversation" + t.Run("not lost", func(t *testing.T) { + store := &serviceTestStore{} + failure, err := NewService(store, serviceTestAuthorizer{}, serviceTestWorkflow{}).FailLostTurn(t.Context(), session, "task-1") + require.NoError(t, err) + require.Nil(t, failure) + require.Empty(t, store.interruptedTask, "an intact runtime's turn is left to it") + require.Empty(t, store.failedID) + }) + t.Run("lost", func(t *testing.T) { + store := &serviceTestStore{} + failure, err := NewService(store, serviceTestAuthorizer{}, lost).FailLostTurn(t.Context(), session, "task-1") + require.NoError(t, err) + require.Equal(t, "task-1", store.interruptedTask) + require.True(t, store.interruptCutoff.IsZero(), "the turn ends whatever it recorded last") + require.Equal(t, want, store.interruptionMessage) + require.Equal(t, session.Id, store.failedID) + require.Equal(t, apia2a.FailureReasonRuntimeLost, failure.GetReason()) + require.Equal(t, want, failure.GetMessage()) + }) + t.Run("turn moved on", func(t *testing.T) { + for name, interruptErr := range map[string]error{"ended": database.ErrConflict, "gone": database.ErrNotFound} { + t.Run(name, func(t *testing.T) { + store := &serviceTestStore{interruptErr: interruptErr} + failure, err := NewService(store, serviceTestAuthorizer{}, lost).FailLostTurn(t.Context(), session, "task-1") + require.NoError(t, err) + require.Equal(t, session.Id, store.failedID, "the loss is recorded whatever became of the turn") + require.Equal(t, want, failure.GetMessage()) + }) + } + }) + t.Run("turn not settled", func(t *testing.T) { + store := &serviceTestStore{interruptErr: database.ErrFailedPrecondition} + _, err := NewService(store, serviceTestAuthorizer{}, lost).FailLostTurn(t.Context(), session, "task-1") + require.ErrorIs(t, err, database.ErrFailedPrecondition) + require.Empty(t, store.failedID, "a turn with unsettled runtime work is left to the next send") + }) + t.Run("unknown", func(t *testing.T) { + store := &serviceTestStore{} + _, err := NewService(store, serviceTestAuthorizer{}, serviceTestWorkflow{lostErr: errors.New("ate-api is rolling")}).FailLostTurn(t.Context(), session, "task-1") + require.ErrorContains(t, err, "ate-api is rolling") + require.Empty(t, store.interruptedTask) + require.Empty(t, store.failedID, "an unknown runtime state ends nothing") + }) +} diff --git a/go/core/internal/service/session/share.go b/go/core/internal/service/session/share.go index 3b4183e28f..bcb3ceed3f 100644 --- a/go/core/internal/service/session/share.go +++ b/go/core/internal/service/session/share.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "errors" + "time" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/core/internal/database" @@ -11,9 +12,9 @@ import ( "github.com/kagent-dev/kagent/go/core/pkg/auth" ) -// ShareStore resolves the digest of a Session share token. +// ShareStore resolves the digest of a Session share token as of now. type ShareStore interface { - GetSessionShareByTokenHash(context.Context, []byte) (*apiv1alpha1.SessionShare, string, error) + GetSessionShareByTokenHash(context.Context, []byte, time.Time) (*apiv1alpha1.SessionShare, string, error) } // ResolveShare validates an optional share token after the caller authenticates. @@ -26,7 +27,7 @@ func ResolveShare(ctx context.Context, store ShareStore, token string) (*auth.Sh return nil, serviceerrors.NewInternal("share-token validation is unavailable", nil) } digest := sha256.Sum256([]byte(token)) - share, owner, err := store.GetSessionShareByTokenHash(ctx, digest[:]) + share, owner, err := store.GetSessionShareByTokenHash(ctx, digest[:], time.Now()) if errors.Is(err, database.ErrNotFound) { return nil, serviceerrors.NewPermissionDenied("invalid or expired share token", nil) } diff --git a/go/core/internal/service/session/stalled.go b/go/core/internal/service/session/stalled.go new file mode 100644 index 0000000000..b13c1cae7d --- /dev/null +++ b/go/core/internal/service/session/stalled.go @@ -0,0 +1,138 @@ +package session + +import ( + "context" + "errors" + "fmt" + "time" + + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "github.com/kagent-dev/kagent/go/core/internal/database" + "github.com/kagent-dev/kagent/go/pkg/logging" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/metric" + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +type stalledStore interface { + ListStalledSessionTasks(context.Context, time.Time, int) ([]database.StalledSessionTask, error) + InterruptSessionTask(context.Context, string, string, time.Time, string) (*database.SessionTaskInterruption, error) + GetSessionByID(context.Context, string) (*apiv1alpha1.Session, error) +} + +type lostRuntimeRecorder interface { + FailLostRuntime(context.Context, *apiv1alpha1.Session) (*apiv1alpha1.Failure, error) +} + +// StalledTurnWorker ends turns whose runtime stopped saving events. The runtime +// persists a turn's events itself, so a submitted or working task that records +// nothing for longer than the timeout has a runtime that died or hung; the +// session keeps it as active work and refuses every later message until it ends. +type StalledTurnWorker struct { + store stalledStore + sessions lostRuntimeRecorder + timeout time.Duration + pollInterval time.Duration + interrupted metric.Int64Counter +} + +var _ manager.Runnable = (*StalledTurnWorker)(nil) +var _ manager.LeaderElectionRunnable = (*StalledTurnWorker)(nil) +var _ stalledStore = (*database.Client)(nil) +var _ lostRuntimeRecorder = (*Service)(nil) + +func NewStalledTurnWorker(store stalledStore, sessions lostRuntimeRecorder, timeout, pollInterval time.Duration) (*StalledTurnWorker, error) { + if timeout < 0 { + return nil, fmt.Errorf("stalled turn timeout must be nonnegative") + } + if pollInterval <= 0 { + return nil, fmt.Errorf("stalled turn poll interval must be positive") + } + interrupted, err := otel.Meter("github.com/kagent-dev/kagent/go/core/internal/service/session").Int64Counter( + "kagent.session.turn_interrupted", metric.WithDescription("Turns ended by the stalled turn sweep."), metric.WithUnit("{task}")) + if err != nil { + return nil, fmt.Errorf("create stalled turn counter: %w", err) + } + return &StalledTurnWorker{store: store, sessions: sessions, timeout: timeout, pollInterval: pollInterval, interrupted: interrupted}, nil +} + +func (*StalledTurnWorker) NeedLeaderElection() bool { return true } + +// Start sweeps on the poll interval. Zero disables the sweep. +func (w *StalledTurnWorker) Start(ctx context.Context) error { + if w.timeout == 0 { + <-ctx.Done() + return nil + } + ticker := time.NewTicker(w.pollInterval) + defer ticker.Stop() + for ctx.Err() == nil { + w.sweep(ctx, time.Now()) + select { + case <-ctx.Done(): + case <-ticker.C: + } + } + return nil +} + +// sweep ends every turn stalled at now. Interrupted turns leave the list, so +// a full page is followed by another read until the list is short or empty. +func (w *StalledTurnWorker) sweep(ctx context.Context, now time.Time) { + cutoff := now.Add(-w.timeout) + for ctx.Err() == nil { + stalled, err := w.store.ListStalledSessionTasks(ctx, cutoff, 100) + if err != nil { + logging.FromContext(ctx).ErrorContext(ctx, "list stalled turns", "error", err) + return + } + ended := 0 + for _, task := range stalled { + err := w.interrupt(ctx, task, cutoff) + if err == nil { + ended++ + } else if !errors.Is(err, database.ErrConflict) && !errors.Is(err, database.ErrFailedPrecondition) && !errors.Is(err, database.ErrNotFound) && ctx.Err() == nil { + logging.FromContext(ctx).ErrorContext(ctx, "interrupt stalled turn", "session_id", task.SessionID, "task_id", task.TaskID, "error", err) + } + } + if len(stalled) < 100 || ended == 0 { + return + } + } +} + +// interrupt ends one stalled turn. When the runtime is also lost, the Actor +// crashed or gone, the session records the loss so the person is told to start +// a new conversation instead of sending into a runtime that cannot answer. +func (w *StalledTurnWorker) interrupt(ctx context.Context, task database.StalledSessionTask, cutoff time.Time) error { + ctx, cancel := context.WithTimeout(ctx, database.RuntimeOperationTimeout) + defer cancel() + outcome, err := w.store.InterruptSessionTask(ctx, task.SessionID, task.TaskID, cutoff, w.interruptionMessage()) + if err != nil { + return err + } + w.interrupted.Add(ctx, 1) + log := logging.FromContext(ctx) + if outcome.Settled { + log.InfoContext(ctx, "settled a stalled turn's saved outcome", "session_id", task.SessionID, "task_id", task.TaskID, "state", outcome.State, "silent_for", time.Since(task.LastEventAt)) + return nil + } + log.InfoContext(ctx, "interrupted stalled turn", "session_id", task.SessionID, "task_id", task.TaskID, "was", task.State, "silent_for", time.Since(task.LastEventAt)) + session, err := w.store.GetSessionByID(ctx, task.SessionID) + if err != nil { + return err + } + failure, err := w.sessions.FailLostRuntime(ctx, session) + if err != nil { + log.WarnContext(ctx, "check session runtime after interrupting a stalled turn", "session_id", task.SessionID, "error", err) + return nil + } + if failure != nil { + log.InfoContext(ctx, "stalled turn's runtime is lost", "session_id", task.SessionID, "reason", failure.GetReason(), "message", failure.GetMessage()) + } + return nil +} + +func (w *StalledTurnWorker) interruptionMessage() string { + return fmt.Sprintf("turn interrupted: the runtime recorded no progress for %s", w.timeout) +} diff --git a/go/core/internal/service/session/stalled_test.go b/go/core/internal/service/session/stalled_test.go new file mode 100644 index 0000000000..11e3a8958e --- /dev/null +++ b/go/core/internal/service/session/stalled_test.go @@ -0,0 +1,112 @@ +package session + +import ( + "crypto/sha256" + "testing" + "time" + + "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "github.com/google/uuid" + apia2a "github.com/kagent-dev/kagent/go/api/a2a" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "github.com/kagent-dev/kagent/go/core/internal/database" + "github.com/kagent-dev/kagent/go/core/internal/substrate" + "github.com/stretchr/testify/require" +) + +// A turn silent for longer than the timeout ends as FAILED with the +// interruption, and the session takes its next message. When the Actor is +// crashed or gone as well, the session records the lost runtime instead. +func TestStalledTurnSweepEndsTheTurn(t *testing.T) { + for _, test := range []struct { + name string + actor ateapipb.ActorState + gone bool + lost bool + }{ + {name: "runtime alive", actor: ateapipb.ActorState_ACTOR_STATE_RUNNING}, + {name: "runtime crashed", actor: ateapipb.ActorState_ACTOR_STATE_CRASHED, lost: true}, + {name: "runtime gone", gone: true, lost: true}, + } { + t.Run(test.name, func(t *testing.T) { + store, session := lifecycleFixture(t) + actors := &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}} + workflow := NewActorWorkflow(store, actors) + session, err := workflow.Create(t.Context(), session) + require.NoError(t, err) + key := actorKey("team-a", substrate.ActorName(session.Id)) + if test.gone { + delete(actors.actors, key) + } else { + actors.actors[key].Status.State = test.actor + } + task := workingTaskFixture(t, store, session) + worker, err := NewStalledTurnWorker(store, NewService(store, &serviceTestAuthorizer{}, workflow), time.Hour, time.Minute) + require.NoError(t, err) + + worker.sweep(t.Context(), time.Now()) + current, err := store.GetSessionTask(t.Context(), session.Id, string(task.ID), nil) + require.NoError(t, err) + require.Equal(t, a2a.TaskStateWorking, current.Status.State, "a turn younger than the timeout is left alone") + + worker.sweep(t.Context(), time.Now().Add(2*time.Hour)) + ended, err := store.GetSettledSessionTask(t.Context(), session.Id, string(task.ID), nil) + require.NoError(t, err) + require.Equal(t, a2a.TaskStateFailed, ended.Status.State) + require.Equal(t, a2a.NewTextPart("turn interrupted: the runtime recorded no progress for 1h0m0s"), ended.Status.Message.Parts[0]) + after, err := store.GetSessionByID(t.Context(), session.Id) + require.NoError(t, err) + if test.lost { + require.Equal(t, apiv1alpha1.RuntimeState_RUNTIME_STATE_FAILED, after.State) + require.Equal(t, apia2a.FailureReasonRuntimeLost, after.GetFailure().GetReason()) + require.ErrorIs(t, store.ReserveSessionDispatch(t.Context(), session.Id, uuid.New(), "next"), database.ErrConflict) + } else { + require.Equal(t, apiv1alpha1.RuntimeState_RUNTIME_STATE_READY, after.State) + require.Nil(t, after.GetFailure()) + require.NoError(t, store.ReserveSessionDispatch(t.Context(), session.Id, uuid.New(), "next")) + } + }) + } +} + +// A boundary the runtime saved but never settled is the runtime's own outcome: +// the sweep publishes it and leaves the session's runtime state alone. +func TestStalledTurnSweepSettlesASavedOutcome(t *testing.T) { + store, session := lifecycleFixture(t) + actors := &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}} + workflow := NewActorWorkflow(store, actors) + session, err := workflow.Create(t.Context(), session) + require.NoError(t, err) + actors.actors[actorKey("team-a", substrate.ActorName(session.Id))].Status.State = ateapipb.ActorState_ACTOR_STATE_CRASHED + task := workingTaskFixture(t, store, session) + _, version, err := store.GetVersionedSessionTask(t.Context(), session.Id, string(task.ID)) + require.NoError(t, err) + done := *task + done.Status = a2a.TaskStatus{State: a2a.TaskStateCompleted} + hash := sha256.Sum256([]byte("done")) + _, err = store.UpdateSessionTask(t.Context(), session.Id, version, hash[:], &done, &done, "") + require.NoError(t, err) + + worker, err := NewStalledTurnWorker(store, NewService(store, &serviceTestAuthorizer{}, workflow), time.Hour, time.Minute) + require.NoError(t, err) + worker.sweep(t.Context(), time.Now().Add(2*time.Hour)) + settled, err := store.GetSettledSessionTask(t.Context(), session.Id, string(task.ID), nil) + require.NoError(t, err) + require.Equal(t, a2a.TaskStateCompleted, settled.Status.State) + after, err := store.GetSessionByID(t.Context(), session.Id) + require.NoError(t, err) + require.Equal(t, apiv1alpha1.RuntimeState_RUNTIME_STATE_READY, after.State, "a settled outcome is not a lost runtime") +} + +func workingTaskFixture(t *testing.T, store *lifecycleTestStore, session *apiv1alpha1.Session) *a2a.Task { + t.Helper() + message := a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("hello")) + message.ContextID = session.ContextId + task := a2a.NewSubmittedTask(message, message) + task.Status.State = a2a.TaskStateWorking + hash := sha256.Sum256([]byte("working")) + _, err := store.CreateRuntimeTask(t.Context(), session.Id, hash[:], task, "") + require.NoError(t, err) + return task +} diff --git a/go/core/internal/service/session/workflow.go b/go/core/internal/service/session/workflow.go index 1a3c399da8..eaa9999832 100644 --- a/go/core/internal/service/session/workflow.go +++ b/go/core/internal/service/session/workflow.go @@ -4,44 +4,90 @@ import ( "context" "errors" "fmt" + "slices" "strings" + "sync" "time" "github.com/agent-substrate/substrate/pkg/proto/ateapipb" "github.com/google/uuid" + apia2a "github.com/kagent-dev/kagent/go/api/a2a" apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" "github.com/kagent-dev/kagent/go/core/internal/database" "github.com/kagent-dev/kagent/go/core/internal/substrate" + "github.com/kagent-dev/kagent/go/pkg/logging" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) type workflowStore interface { - ClaimSessionQuiescence(context.Context) (*database.SessionQuiescence, error) + ClaimSessionQuiescence(context.Context, time.Duration, time.Duration, []string) (*database.SessionQuiescence, error) + RenewSessionQuiescence(context.Context, *database.SessionQuiescence, time.Duration) error FinishSessionQuiescence(context.Context, *database.SessionQuiescence, *database.SessionTaskSnapshot) error + ReleaseSessionQuiescence(context.Context, *database.SessionQuiescence, *apiv1alpha1.QuiescenceFailure) error + NextQuiescenceFencingGeneration(context.Context) (int64, error) GetSessionForRuntime(context.Context, string, string) (*apiv1alpha1.Session, error) GetSessionCheckpointSnapshot(context.Context, string, string) (*database.SessionTaskSnapshot, string, error) GetRuntimeRevision(context.Context, string) (*database.RuntimeRevision, error) + GetCurrentRuntimeRevision(context.Context, string) (*database.RuntimeRevision, error) + RepointSessionOperation(context.Context, string, uuid.UUID, uuid.UUID, string) error + RepointSession(context.Context, string, string, string) (*apiv1alpha1.Session, error) BeginSessionOperation(context.Context, string, apiv1alpha1.RuntimeOperation) (*database.SessionOperation, error) ClaimSessionOperation(context.Context, string, uuid.UUID, uuid.UUID) (bool, error) ReleaseRuntimeOperation(context.Context, string, uuid.UUID, uuid.UUID) error FinishSessionOperation(context.Context, string, uuid.UUID, uuid.UUID, string, string, string) (*apiv1alpha1.Session, error) + FailSessionOperation(context.Context, string, uuid.UUID, uuid.UUID, *apiv1alpha1.Failure) (*apiv1alpha1.Session, error) GetSessionOperation(context.Context, string, uuid.UUID) (*database.SessionOperation, error) } type actorClient interface { substrate.LifecycleClient PauseActor(context.Context, string, string) (*ateapipb.Actor, error) + RepointActor(context.Context, string, string, string, string) (*ateapipb.Actor, error) + ReplaceActorEgressPolicy(context.Context, string, string, *ateapipb.EgressPolicy) error + ListAllWorkers(context.Context) ([]*ateapipb.Worker, error) } // ActorWorkflow runs the imperative Substrate operations behind Session // lifecycle RPCs. Only the claiming caller issues lifecycle mutations; others // observe current completion or receive a pending/superseded-operation error. type ActorWorkflow struct { - store workflowStore - actors actorClient + store workflowStore + actors actorClient + pausedRuntimeTTL time.Duration + deferred deferrals + // settling tracks the claims whose failed Pause or Quiesce is resolved from + // the Actor's state; settleDelay is the first wait between its reads. + settling sync.WaitGroup + settleDelay time.Duration + // claimLease is how long a claim outlives its holder's last renewal before + // another worker takes it over; the holder renews it every third of it. + claimLease time.Duration + // busyWait bounds how long a refused turn waits for an operation that + // holds its Actor; busyRetry is the first wait between resumes. + busyWait time.Duration + busyRetry time.Duration } -func NewActorWorkflow(store workflowStore, actors actorClient) *ActorWorkflow { - return &ActorWorkflow{store: store, actors: actors} +// maxBusyRetry caps the wait between resumes of an Actor another operation holds. +const maxBusyRetry = 5 * time.Second + +type WorkflowOption func(*ActorWorkflow) + +// WithPausedRuntimeTTL bounds how long a runtime paused for input stays on its +// node before the idle worker suspends it durably. Zero keeps every pause in +// place until the reply. +func WithPausedRuntimeTTL(ttl time.Duration) WorkflowOption { + return func(w *ActorWorkflow) { w.pausedRuntimeTTL = ttl } +} + +func NewActorWorkflow(store workflowStore, actors actorClient, options ...WorkflowOption) *ActorWorkflow { + workflow := &ActorWorkflow{store: store, actors: actors, settleDelay: time.Second, claimLease: 30 * time.Second, + busyWait: 2 * time.Minute, busyRetry: 500 * time.Millisecond} + for _, option := range options { + option(workflow) + } + return workflow } // Pause checkpoints the runtime on its current worker without changing the @@ -92,10 +138,15 @@ func (w *ActorWorkflow) Quiesce(ctx context.Context, session *apiv1alpha1.Sessio if actor.GetStatus().GetState() != ateapipb.ActorState_ACTOR_STATE_SUSPENDED { return nil, fmt.Errorf("suspend Actor %s/%s returned status %s", atespace, name, actor.GetStatus().GetState()) } - metadata := actor.GetMetadata() - if !validActorIdentity(actor, revision, name) || metadata.GetUid() != current.GetMetadata().GetUid() { + if !validActorIdentity(actor, revision, name) || actor.GetMetadata().GetUid() != current.GetMetadata().GetUid() { return nil, fmt.Errorf("suspend actor %s/%s returned invalid identity", atespace, name) } + return externalSnapshot(actor) +} + +// externalSnapshot is the session task snapshot a suspended Actor reports. +func externalSnapshot(actor *ateapipb.Actor) (*database.SessionTaskSnapshot, error) { + atespace, name := actor.GetMetadata().GetAtespace(), actor.GetMetadata().GetName() snapshot := actor.GetStatus().GetExternalSnapshot() if snapshot.GetSnapshotUri() == "" { return nil, fmt.Errorf("suspend Actor %s/%s returned no snapshot", atespace, name) @@ -110,6 +161,134 @@ func (w *ActorWorkflow) Quiesce(ctx context.Context, session *apiv1alpha1.Sessio }, nil } +// boundaryActor reads the session's Actor after a Pause or Quiesce whose +// outcome is unknown, with the external snapshot it holds when it is this +// session's Actor and suspended with a valid one. A missing Actor is returned +// as nil without an error: nothing will settle the boundary later. +func (w *ActorWorkflow) boundaryActor(ctx context.Context, session *apiv1alpha1.Session) (*ateapipb.Actor, *database.SessionTaskSnapshot, error) { + revision, err := w.store.GetRuntimeRevision(ctx, session.GetPreparedRevision()) + if err != nil { + return nil, nil, fmt.Errorf("load prepared revision: %w", err) + } + atespace, name := revision.ActorTemplateAtespace, substrate.ActorName(session.GetId()) + actor, err := w.actors.GetActor(ctx, atespace, name) + if status.Code(err) == codes.NotFound { + return nil, nil, nil + } + if err != nil { + return nil, nil, fmt.Errorf("get Actor %s/%s: %w", atespace, name, err) + } + if actor.GetStatus().GetState() != ateapipb.ActorState_ACTOR_STATE_SUSPENDED || !validActorIdentity(actor, revision, name) { + return actor, nil, nil + } + _, err = w.store.GetSessionForRuntime(ctx, session.GetId(), actor.GetMetadata().GetUid()) + if errors.Is(err, database.ErrNotFound) { + return actor, nil, nil + } + if err != nil { + return nil, nil, fmt.Errorf("verify runtime actor UID: %w", err) + } + snapshot, err := externalSnapshot(actor) + if err != nil { + return actor, nil, nil + } + return actor, snapshot, nil +} + +// PauseNodeLost reports whether the session's Actor is paused on a checkpoint +// that only nodes without a worker still hold. Suspending such an Actor uploads +// the checkpoint through its node, which cannot complete, and Substrate leaves +// the Actor SUSPENDING; the pause is the node-loss handling's to crash, not a +// caller's to touch. A pause whose checkpoint has a durable copy, or that +// records no node, can be suspended from anywhere. +func (w *ActorWorkflow) PauseNodeLost(ctx context.Context, session *apiv1alpha1.Session) (bool, error) { + revision, err := w.store.GetRuntimeRevision(ctx, session.GetPreparedRevision()) + if err != nil { + return false, fmt.Errorf("load prepared revision: %w", err) + } + atespace, name := revision.ActorTemplateAtespace, substrate.ActorName(session.GetId()) + actor, err := w.actors.GetActor(ctx, atespace, name) + if err != nil { + return false, fmt.Errorf("get Actor %s/%s: %w", atespace, name, err) + } + if actor.GetStatus().GetState() != ateapipb.ActorState_ACTOR_STATE_PAUSED { + return false, nil + } + local := actor.GetStatus().GetLocalSnapshot() + nodes := local.GetNodeVmsWithLocalSnapshots() + if local.GetDurableCopy().GetSnapshotUri() != "" || len(nodes) == 0 { + return false, nil + } + workers, err := w.actors.ListAllWorkers(ctx) + if err != nil { + return false, fmt.Errorf("list workers: %w", err) + } + for _, worker := range workers { + if slices.Contains(nodes, worker.GetNodeName()) && worker.GetStatus().GetState() != ateapipb.WorkerState_WORKER_STATE_DRAINING { + return false, nil + } + } + return true, nil +} + +// ErrRuntimeLost reports a lifecycle operation that Substrate answered can +// never succeed: the session records the loss as FAILED and stays deletable. +var ErrRuntimeLost = errors.New("session runtime is lost") + +// RuntimeLost reports whether the session's runtime can no longer take a turn: +// Substrate reports its Actor CRASHED, or the Actor is gone. Substrate crashes +// an Actor it cannot bring back and a crashed Actor never resumes, so every +// later message would fail the way the last one did. The cause names the Actor +// and what became of it. An answer Substrate cannot give is returned as the +// error it is: not knowing is not the same as lost. +func (w *ActorWorkflow) RuntimeLost(ctx context.Context, session *apiv1alpha1.Session) (string, bool, error) { + revision, err := w.store.GetRuntimeRevision(ctx, session.GetPreparedRevision()) + if err != nil { + return "", false, fmt.Errorf("load prepared revision: %w", err) + } + atespace, name := revision.ActorTemplateAtespace, substrate.ActorName(session.GetId()) + actor, err := w.actors.GetActor(ctx, atespace, name) + if status.Code(err) == codes.NotFound { + return fmt.Sprintf("Actor %s/%s not found", atespace, name), true, nil + } + if err != nil { + return "", false, fmt.Errorf("get Actor %s/%s: %w", atespace, name, err) + } + if actor.GetStatus().GetState() == ateapipb.ActorState_ACTOR_STATE_CRASHED { + return fmt.Sprintf("Actor %s/%s crashed", atespace, name), true, nil + } + return "", false, nil +} + +// AwaitRuntime resumes the session's Actor, waiting while Substrate answers +// that another operation holds it (Aborted), such as a repoint onto a newer +// template: that operation finishes on its own, after which the resume goes +// through. The wait is bounded by busyWait; any other answer, or an Actor +// still held at the bound, is returned as the error it is. +func (w *ActorWorkflow) AwaitRuntime(ctx context.Context, session *apiv1alpha1.Session) error { + revision, err := w.store.GetRuntimeRevision(ctx, session.GetPreparedRevision()) + if err != nil { + return fmt.Errorf("load prepared revision: %w", err) + } + atespace, name := revision.ActorTemplateAtespace, substrate.ActorName(session.GetId()) + ctx, cancel := context.WithTimeout(ctx, w.busyWait) + defer cancel() + for delay := w.busyRetry; ; delay = min(2*delay, maxBusyRetry) { + _, err := w.actors.ResumeActor(ctx, atespace, name) + if status.Code(err) != codes.Aborted { + if err != nil { + return fmt.Errorf("resume Actor %s/%s: %w", atespace, name, err) + } + return nil + } + select { + case <-ctx.Done(): + return fmt.Errorf("resume Actor %s/%s: still held by another operation after %s: %w", atespace, name, w.busyWait, err) + case <-time.After(delay): + } + } +} + // Create provisions the persisted session once, using its pinned checkpoint for // forks. Retries return current state; an uncertain prior creation blocks execution. func (w *ActorWorkflow) Create(ctx context.Context, session *apiv1alpha1.Session) (*apiv1alpha1.Session, error) { @@ -173,8 +352,7 @@ func (w *ActorWorkflow) execute(ctx context.Context, operation *database.Session tagName = "checkpoint-" + operation.SourceCheckpointID.String() } - binding := substrate.ActorBinding{Atespace: revision.ActorTemplateAtespace, Name: substrate.ActorName(session.Id), - TemplateAtespace: revision.ActorTemplateAtespace, TemplateName: revision.ActorTemplateName} + binding := actorBinding(session.Id, revision) var creation *substrate.ActorCreation if kind == apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_CREATE { policy, err := substrate.ActorEgressPolicy(binding.Atespace, revision.EgressDestinations, revision.Credentials) @@ -186,6 +364,30 @@ func (w *ActorWorkflow) execute(ctx context.Context, operation *database.Session creation.Snapshot = &substrate.ActorSnapshot{Tag: &ateapipb.ObjectRef{Atespace: snapshot.Atespace, Name: tagName}, URI: snapshot.URI, ContentScope: ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_DATA} } } + // A suspended Actor of a superseded revision resumes on the agent's current + // one (see repointTarget). An earlier attempt of this operation may have + // moved the Actor before its record was written: it is then found on the + // current template and the record follows below. + var current *database.RuntimeRevision + var currentPolicy *ateapipb.EgressPolicy + if kind == apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_RESUME { + current, currentPolicy, err = w.repointTarget(ctx, revision) + if err != nil { + return w.failPreparation(ctx, operation, err) + } + } + if current != nil { + actor, err := w.actors.GetActor(ctx, binding.Atespace, binding.Name) + if err != nil { + return w.failPreparation(ctx, operation, fmt.Errorf("get Actor %s/%s: %w", binding.Atespace, binding.Name, err)) + } + switch { + case validActorIdentity(actor, current, binding.Name): + binding, revision = actorBinding(session.Id, current), current + case actor.GetStatus().GetState() != ateapipb.ActorState_ACTOR_STATE_SUSPENDED: + current = nil + } + } prepare := substrate.PrepareActorTransition if operation.ExecutorID != uuid.Nil { prepare = substrate.PrepareActorRetry @@ -222,7 +424,40 @@ func (w *ActorWorkflow) execute(ctx context.Context, operation *database.Session defer cancel() err = errors.Join(err, w.store.ReleaseRuntimeOperation(finishCtx, sessionID, operation.ID, executorID)) }() + if current != nil { + repointed, err := w.repointActor(ctx, session.Id, current, currentPolicy) + if err != nil { + return nil, fmt.Errorf("lifecycle operation %s remains pending: %w", operation.ID, err) + } + if repointed != nil { + if err := w.store.RepointSessionOperation(ctx, sessionID, operation.ID, executorID, current.Revision); err != nil { + return nil, fmt.Errorf("lifecycle operation %s remains pending: %w", operation.ID, err) + } + if moved := actorBinding(session.Id, current); moved != binding { + binding, revision = moved, current + transition, err = substrate.PrepareActorRetry(ctx, w.actors, binding, kind, creation) + if err != nil { + return nil, fmt.Errorf("lifecycle operation %s remains pending: %w", operation.ID, err) + } + } + } + } if err := substrate.ApplyActorTransition(ctx, w.actors, transition); err != nil { + if kind == apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_RESUME && status.Code(err) == codes.DataLoss { + // Substrate answers DataLoss when the Actor's snapshot cannot be + // restored, its own or the golden one it builds on: no retry can + // resume it, so the outcome is known and the operation must not stay + // pending, which would refuse every later resume and delete. + message := fmt.Sprintf("%sresume Actor %s/%s: %v", apia2a.RuntimeLostMessagePrefix, binding.Atespace, binding.Name, status.Convert(err).Message()) + finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + failed, failErr := w.store.FailSessionOperation(finishCtx, sessionID, operation.ID, executorID, + &apiv1alpha1.Failure{Reason: apia2a.FailureReasonRuntimeLost, Message: message}) + if failErr != nil { + return nil, errors.Join(fmt.Errorf("resume Actor %s/%s: %w", binding.Atespace, binding.Name, err), failErr) + } + return failed, fmt.Errorf("%s: %w", message, ErrRuntimeLost) + } return nil, fmt.Errorf("lifecycle operation %s remains pending: %w", operation.ID, err) } var authority string @@ -236,6 +471,116 @@ func (w *ActorWorkflow) execute(ctx context.Context, operation *database.Session return w.store.FinishSessionOperation(finishCtx, sessionID, operation.ID, executorID, authority, transition.ActorUID(), "") } +// actorBinding names the session's Actor and the ActorTemplate of revision. +func actorBinding(sessionID string, revision *database.RuntimeRevision) substrate.ActorBinding { + return substrate.ActorBinding{Atespace: revision.ActorTemplateAtespace, Name: substrate.ActorName(sessionID), + TemplateAtespace: revision.ActorTemplateAtespace, TemplateName: revision.ActorTemplateName} +} + +// repointTarget returns the revision an Actor of revision moves to when it +// resumes, with that revision's egress allowlist, or nil when it stays: a +// revision rendered by an older release lacks configuration the platform needs +// now, and once no session references it the runtime revision GC collects it. +// The target is the agent's current revision in the same atespace; an Actor +// never moves between atespaces. +func (w *ActorWorkflow) repointTarget(ctx context.Context, revision *database.RuntimeRevision) (*database.RuntimeRevision, *ateapipb.EgressPolicy, error) { + current, err := w.store.GetCurrentRuntimeRevision(ctx, revision.Revision) + switch { + case errors.Is(err, database.ErrNotFound): + return nil, nil, nil + case err != nil: + return nil, nil, fmt.Errorf("load current revision: %w", err) + case current.Revision == revision.Revision || current.ActorTemplateAtespace != revision.ActorTemplateAtespace: + return nil, nil, nil + } + policy, err := substrate.ActorEgressPolicy(current.ActorTemplateAtespace, current.EgressDestinations, current.Credentials) + if err != nil { + return nil, nil, fmt.Errorf("build egress policy of revision %s: %w", current.Revision, err) + } + return current, policy, nil +} + +// repointActor moves a suspended Actor onto the ActorTemplate of current and +// gives it that revision's egress allowlist; an Actor already on the template +// keeps it. Substrate refuses, without effect, an Actor that is no longer +// suspended and a template whose sandbox config or durable volumes differ, +// since the Actor's data could not be restored onto it: that returns a nil +// Actor and the Actor stays on its prepared revision. +func (w *ActorWorkflow) repointActor(ctx context.Context, sessionID string, current *database.RuntimeRevision, policy *ateapipb.EgressPolicy) (*ateapipb.Actor, error) { + atespace, name := current.ActorTemplateAtespace, substrate.ActorName(sessionID) + actor, err := w.actors.RepointActor(ctx, atespace, name, current.ActorTemplateAtespace, current.ActorTemplateName) + if status.Code(err) == codes.FailedPrecondition { + logging.FromContext(ctx).InfoContext(ctx, "the session stays on its prepared revision: Substrate refused its agent's current ActorTemplate", + "session_id", sessionID, "revision", current.Revision, "reason", status.Convert(err).Message()) + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("repoint Actor %s/%s to ActorTemplate %s: %w", atespace, name, current.ActorTemplateName, err) + } + if !validActorIdentity(actor, current, name) || actor.GetStatus().GetState() != ateapipb.ActorState_ACTOR_STATE_SUSPENDED { + return nil, fmt.Errorf("repoint Actor %s/%s returned unexpected identity or state", atespace, name) + } + if err := w.actors.ReplaceActorEgressPolicy(ctx, atespace, name, policy); err != nil { + return nil, fmt.Errorf("replace Actor %s/%s egress policy: %w", atespace, name, err) + } + return actor, nil +} + +// RepointQuiesced moves the quiesced Actor of a READY session onto its agent's +// current revision, before a turn wakes it or because nothing has for a while, +// and returns the session as it then is. The caller keeps every turn of its +// own off the session meanwhile: the gateway holds the turn's dispatch, the +// sweep visits only settled sessions. A session whose Actor is current, live, +// paused, not its own, or refused by Substrate is returned unchanged. Two +// callers may move the same Actor, the gateway before a turn and the sweep of +// superseded revisions: the second finds the Actor moved and records what the +// first has yet to. A lifecycle operation that claimed the session meanwhile +// wins: the Actor goes back to the template of the revision the session +// records. +func (w *ActorWorkflow) RepointQuiesced(ctx context.Context, session *apiv1alpha1.Session) (*apiv1alpha1.Session, error) { + if session.GetState() != apiv1alpha1.RuntimeState_RUNTIME_STATE_READY { + return session, nil + } + revision, err := w.store.GetRuntimeRevision(ctx, session.GetPreparedRevision()) + if err != nil { + return nil, fmt.Errorf("load prepared revision: %w", err) + } + current, policy, err := w.repointTarget(ctx, revision) + if err != nil || current == nil { + return session, err + } + atespace, name := revision.ActorTemplateAtespace, substrate.ActorName(session.GetId()) + actor, err := w.actors.GetActor(ctx, atespace, name) + if err != nil { + return nil, fmt.Errorf("get Actor %s/%s: %w", atespace, name, err) + } + if actor.GetStatus().GetState() != ateapipb.ActorState_ACTOR_STATE_SUSPENDED || (!validActorIdentity(actor, revision, name) && !validActorIdentity(actor, current, name)) { + return session, nil + } + if _, err := w.store.GetSessionForRuntime(ctx, session.GetId(), actor.GetMetadata().GetUid()); err != nil { + if errors.Is(err, database.ErrNotFound) { + return session, nil + } + return nil, fmt.Errorf("verify runtime actor UID: %w", err) + } + repointed, err := w.repointActor(ctx, session.GetId(), current, policy) + if err != nil || repointed == nil { + return session, err + } + updated, err := w.store.RepointSession(ctx, session.GetId(), revision.Revision, current.Revision) + if errors.Is(err, database.ErrConflict) && updated.GetPreparedRevision() == revision.Revision { + previous, policyErr := substrate.ActorEgressPolicy(atespace, revision.EgressDestinations, revision.Credentials) + if policyErr == nil { + _, policyErr = w.repointActor(ctx, session.GetId(), revision, previous) + } + return nil, errors.Join(err, policyErr) + } + if err != nil { + return nil, err + } + return updated, nil +} + // failPreparation releases only unissued work. If another caller won, observe // the same generation instead. Completion returns current state; supersession // returns a conflict. A local preparation error cannot clear a newer operation. diff --git a/go/core/internal/service/session/workflow_retry_test.go b/go/core/internal/service/session/workflow_retry_test.go index 81d8f4ec99..1e2a1f1c21 100644 --- a/go/core/internal/service/session/workflow_retry_test.go +++ b/go/core/internal/service/session/workflow_retry_test.go @@ -75,9 +75,9 @@ func (a *retryTestActors) SuspendActor(ctx context.Context, space, name string) return actor, err } -func (a *retryTestActors) DeleteActor(ctx context.Context, space, name string) error { +func (a *retryTestActors) DeleteActor(ctx context.Context, space, name string, anyState bool) error { a.mutations.Add(1) - if err := a.lifecycleTestActors.DeleteActor(ctx, space, name); err != nil { + if err := a.lifecycleTestActors.DeleteActor(ctx, space, name, anyState); err != nil { return err } return a.mutationErr diff --git a/go/core/internal/service/session/workflow_test.go b/go/core/internal/service/session/workflow_test.go index 69e7cca446..38a73a1a93 100644 --- a/go/core/internal/service/session/workflow_test.go +++ b/go/core/internal/service/session/workflow_test.go @@ -3,8 +3,10 @@ package session import ( "context" "crypto/sha256" + "fmt" "sync" "testing" + "time" "github.com/a2aproject/a2a-go/v2/a2a" "github.com/a2aproject/a2a-go/v2/a2apb/v1" @@ -158,25 +160,69 @@ func lifecycleFixture(t *testing.T) (*lifecycleTestStore, *apiv1alpha1.Session) require.NoError(t, client.RecordRuntimeRevision(t.Context(), *revision, true)) session, _, err := client.CreateSession(t.Context(), &apiv1alpha1.Session{Id: uuid.NewString(), Creator: "alice", Agent: &apiv1alpha1.ResourceReference{Namespace: "team-a", Name: "assistant"}}, uuid.NewString()) require.NoError(t, err) - return &lifecycleTestStore{Client: client, revision: revision}, session + return &lifecycleTestStore{Client: client, pool: pool, revision: revision}, session } type lifecycleTestStore struct { *database.Client + pool *pgxpool.Pool revision *database.RuntimeRevision } -func (s *lifecycleTestStore) GetRuntimeRevision(context.Context, string) (*database.RuntimeRevision, error) { +// GetRuntimeRevision answers the fixture's revision; another revision the +// fixture's database knows, one a session moved to, is read from there. +func (s *lifecycleTestStore) GetRuntimeRevision(ctx context.Context, revision string) (*database.RuntimeRevision, error) { + if s.Client != nil && s.revision.Revision != "" && revision != s.revision.Revision { + return s.Client.GetRuntimeRevision(ctx, revision) + } return s.revision, nil } type lifecycleTestActors struct { - mu sync.Mutex - actors map[string]*ateapipb.Actor - policyErr error - policy *ateapipb.EgressPolicy - policyActor string - policyCalls int + mu sync.Mutex + actors map[string]*ateapipb.Actor + workers []*ateapipb.Worker + workersErr error + getErr error + policyErr error + policy *ateapipb.EgressPolicy + policyActor string + policyCalls int + repointErr error + repointCalls int + replacedPolicies int +} + +// RepointActor moves a suspended Actor onto another template as Substrate +// does: an Actor already there is returned as it is, a live one is refused. +func (a *lifecycleTestActors) RepointActor(_ context.Context, atespace, name, templateAtespace, templateName string) (*ateapipb.Actor, error) { + a.mu.Lock() + defer a.mu.Unlock() + if a.repointErr != nil { + return nil, a.repointErr + } + actor := a.actors[actorKey(atespace, name)] + if actor == nil { + return nil, status.Error(codes.NotFound, "missing") + } + if template := actor.GetActorTemplate(); template.GetAtespace() == templateAtespace && template.GetName() == templateName { + return proto.CloneOf(actor), nil + } + if actor.GetStatus().GetState() != ateapipb.ActorState_ACTOR_STATE_SUSPENDED { + return nil, status.Error(codes.FailedPrecondition, "actor is not suspended") + } + actor.ActorTemplate = &ateapipb.ObjectRef{Atespace: templateAtespace, Name: templateName} + a.repointCalls++ + return proto.CloneOf(actor), nil +} + +func (a *lifecycleTestActors) ReplaceActorEgressPolicy(_ context.Context, atespace, name string, policy *ateapipb.EgressPolicy) error { + a.mu.Lock() + defer a.mu.Unlock() + a.policyActor = actorKey(atespace, name) + a.policy = proto.CloneOf(policy) + a.replacedPolicies++ + return a.policyErr } func actorKey(atespace, name string) string { return atespace + "/" + name } @@ -184,6 +230,9 @@ func actorKey(atespace, name string) string { return atespace + "/" + name } func (a *lifecycleTestActors) GetActor(_ context.Context, atespace, name string) (*ateapipb.Actor, error) { a.mu.Lock() defer a.mu.Unlock() + if a.getErr != nil { + return nil, a.getErr + } actor := a.actors[actorKey(atespace, name)] if actor == nil { return nil, status.Error(codes.NotFound, "missing") @@ -219,38 +268,213 @@ func (a *lifecycleTestActors) CreateActorFromTag(_ context.Context, atespace, na return proto.CloneOf(actor), nil } -func (a *lifecycleTestActors) ResumeActor(_ context.Context, atespace, name string) (*ateapipb.Actor, error) { +func (a *lifecycleTestActors) ResumeActor(ctx context.Context, atespace, name string) (*ateapipb.Actor, error) { a.mu.Lock() defer a.mu.Unlock() actor := a.actors[actorKey(atespace, name)] + if err := fenceActor(ctx, actor); err != nil { + return nil, err + } actor.Status.State = ateapipb.ActorState_ACTOR_STATE_RUNNING return proto.CloneOf(actor), nil } -func (a *lifecycleTestActors) PauseActor(_ context.Context, atespace, name string) (*ateapipb.Actor, error) { +func (a *lifecycleTestActors) PauseActor(ctx context.Context, atespace, name string) (*ateapipb.Actor, error) { a.mu.Lock() defer a.mu.Unlock() actor := a.actors[actorKey(atespace, name)] + if err := fenceActor(ctx, actor); err != nil { + return nil, err + } actor.Status.State = ateapipb.ActorState_ACTOR_STATE_PAUSED return proto.CloneOf(actor), nil } -func (a *lifecycleTestActors) SuspendActor(_ context.Context, atespace, name string) (*ateapipb.Actor, error) { +func (a *lifecycleTestActors) SuspendActor(ctx context.Context, atespace, name string) (*ateapipb.Actor, error) { a.mu.Lock() defer a.mu.Unlock() actor := a.actors[actorKey(atespace, name)] + if err := fenceActor(ctx, actor); err != nil { + return nil, err + } actor.Status.State = ateapipb.ActorState_ACTOR_STATE_SUSPENDED actor.Status.ExternalSnapshot = &ateapipb.ExternalSnapshot{SnapshotUri: "s3://snapshots/snapshot-1", ContentScope: ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_DATA} return proto.CloneOf(actor), nil } -func (a *lifecycleTestActors) DeleteActor(_ context.Context, atespace, name string) error { +// fenceActor checks a request's fencing token as Substrate does: an older +// token, or the same generation from another holder, is refused; a newer one +// is recorded; a request without one is admitted. +func fenceActor(ctx context.Context, actor *ateapipb.Actor) error { + token := substrate.FencingTokenFrom(ctx) + if token == nil { + return nil + } + if recorded := actor.GetStatus().GetFencingToken(); recorded != nil { + if token.GetGeneration() < recorded.GetGeneration() || (token.GetGeneration() == recorded.GetGeneration() && token.GetHolder() != recorded.GetHolder()) { + return status.Errorf(codes.FailedPrecondition, "fencing token %s/%d is older than %s/%d", token.GetHolder(), token.GetGeneration(), recorded.GetHolder(), recorded.GetGeneration()) + } + } + actor.Status.FencingToken = proto.CloneOf(token) + return nil +} + +func (a *lifecycleTestActors) ListAllWorkers(context.Context) ([]*ateapipb.Worker, error) { + a.mu.Lock() + defer a.mu.Unlock() + return a.workers, a.workersErr +} + +func (a *lifecycleTestActors) DeleteActor(_ context.Context, atespace, name string, _ bool) error { a.mu.Lock() defer a.mu.Unlock() delete(a.actors, actorKey(atespace, name)) return nil } +// supersedingRevision re-renders the fixture's agent: revision-2, on another +// ActorTemplate with another allowlist, becomes its current revision. +func supersedingRevision(t *testing.T, store *lifecycleTestStore) *database.RuntimeRevision { + t.Helper() + current := database.RuntimeRevision{ + Revision: "revision-2", Namespace: "team-a", AgentName: "assistant", AgentUID: "template-uid", + SourceSnapshot: []byte("{}"), AgentCard: &a2apb.AgentCard{Name: "assistant"}, EgressDestinations: []string{"https://api.example"}, + ActorTemplateAtespace: "team-a", ActorTemplateName: "assistant-kagent-revision-2", ActorTemplateUID: "actor-template-uid-2", + } + require.NoError(t, store.UpsertAgentDefinition(t.Context(), database.AgentDefinition{Namespace: "team-a", AgentName: "assistant", AgentUID: "template-uid", DesiredRevision: current.Revision})) + require.NoError(t, store.RecordRuntimeRevision(t.Context(), current, true)) + return ¤t +} + +func TestRepointQuiescedMovesASuspendedActorOntoTheCurrentRevision(t *testing.T) { + store, session := lifecycleFixture(t) + actors := &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}} + workflow := NewActorWorkflow(store, actors) + session, err := workflow.Create(t.Context(), session) + require.NoError(t, err) + name := substrate.ActorName(session.Id) + + unchanged, err := workflow.RepointQuiesced(t.Context(), session) + require.NoError(t, err) + require.Equal(t, "revision-1", unchanged.GetPreparedRevision(), "a session on its agent's current revision stays") + require.Equal(t, 0, actors.repointCalls) + + current := supersedingRevision(t, store) + moved, err := workflow.RepointQuiesced(t.Context(), session) + require.NoError(t, err) + require.Equal(t, current.Revision, moved.GetPreparedRevision()) + actor, err := actors.GetActor(t.Context(), "team-a", name) + require.NoError(t, err) + require.Equal(t, current.ActorTemplateName, actor.GetActorTemplate().GetName(), "the Actor runs on the current revision's template") + require.Equal(t, ateapipb.ActorState_ACTOR_STATE_SUSPENDED, actor.GetStatus().GetState(), "and stays suspended until a turn wakes it") + require.Equal(t, actorKey("team-a", name), actors.policyActor) + require.Equal(t, 1, actors.replacedPolicies, "the Actor takes the current revision's allowlist") + require.NotEmpty(t, actors.policy.GetRules()) + stored, err := store.GetSessionByID(t.Context(), session.Id) + require.NoError(t, err) + require.Equal(t, current.Revision, stored.GetPreparedRevision(), "the session is recorded on the current revision") + + again, err := workflow.RepointQuiesced(t.Context(), moved) + require.NoError(t, err) + require.Equal(t, current.Revision, again.GetPreparedRevision()) + require.Equal(t, 1, actors.repointCalls, "a moved Actor is left as it is") + + // The gateway, holding a session read before the sweep moved it, finds the + // Actor moved and records nothing new. + stale, err := workflow.RepointQuiesced(t.Context(), session) + require.NoError(t, err) + require.Equal(t, current.Revision, stale.GetPreparedRevision()) + require.Equal(t, 1, actors.repointCalls) +} + +func TestRepointQuiescedLeavesARefusedOrLiveActor(t *testing.T) { + store, session := lifecycleFixture(t) + actors := &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}} + workflow := NewActorWorkflow(store, actors) + session, err := workflow.Create(t.Context(), session) + require.NoError(t, err) + supersedingRevision(t, store) + + actors.repointErr = status.Error(codes.FailedPrecondition, "volume layout differs") + refused, err := workflow.RepointQuiesced(t.Context(), session) + require.NoError(t, err, "a template Substrate refuses is not an error") + require.Equal(t, "revision-1", refused.GetPreparedRevision(), "the session stays on its prepared revision") + require.Equal(t, 0, actors.replacedPolicies) + + actors.repointErr = nil + running, err := actors.ResumeActor(t.Context(), "team-a", substrate.ActorName(session.Id)) + require.NoError(t, err) + require.Equal(t, ateapipb.ActorState_ACTOR_STATE_RUNNING, running.GetStatus().GetState()) + live, err := workflow.RepointQuiesced(t.Context(), session) + require.NoError(t, err) + require.Equal(t, "revision-1", live.GetPreparedRevision(), "a live Actor is never moved") + require.Equal(t, 0, actors.repointCalls) + stored, err := store.GetSessionByID(t.Context(), session.Id) + require.NoError(t, err) + require.Equal(t, "revision-1", stored.GetPreparedRevision()) +} + +// claimedStore answers every repoint record as a lifecycle operation that +// claimed the session first, with the session still on its revision. +type claimedStore struct { + *lifecycleTestStore +} + +func (s *claimedStore) RepointSession(ctx context.Context, sessionID, _, _ string) (*apiv1alpha1.Session, error) { + session, err := s.GetSessionByID(ctx, sessionID) + if err != nil { + return nil, err + } + return session, fmt.Errorf("claimed: %w", database.ErrConflict) +} + +func TestRepointQuiescedGoesBackWhenALifecycleOperationClaimedTheSession(t *testing.T) { + store, session := lifecycleFixture(t) + actors := &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}} + session, err := NewActorWorkflow(store, actors).Create(t.Context(), session) + require.NoError(t, err) + supersedingRevision(t, store) + + _, err = NewActorWorkflow(&claimedStore{store}, actors).RepointQuiesced(t.Context(), session) + require.ErrorIs(t, err, database.ErrConflict) + actor, err := actors.GetActor(t.Context(), "team-a", substrate.ActorName(session.Id)) + require.NoError(t, err) + require.Equal(t, store.revision.ActorTemplateName, actor.GetActorTemplate().GetName(), "the Actor goes back to the template of the revision the session records") + require.Equal(t, 2, actors.repointCalls) + require.Equal(t, 2, actors.replacedPolicies, "with that revision's allowlist") + require.Empty(t, actors.policy.GetRules()) +} + +func TestResumeMovesASuspendedSessionOntoTheCurrentRevision(t *testing.T) { + store, session := lifecycleFixture(t) + actors := &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}} + workflow := NewActorWorkflow(store, actors) + session, err := workflow.Create(t.Context(), session) + require.NoError(t, err) + suspended, err := workflow.Suspend(t.Context(), session) + require.NoError(t, err) + require.Equal(t, apiv1alpha1.RuntimeState_RUNTIME_STATE_SUSPENDED, suspended.GetState()) + current := supersedingRevision(t, store) + + resumed, err := workflow.Resume(t.Context(), suspended) + require.NoError(t, err) + require.Equal(t, apiv1alpha1.RuntimeState_RUNTIME_STATE_READY, resumed.GetState()) + require.Equal(t, current.Revision, resumed.GetPreparedRevision(), "the session resumed on its agent's current revision") + actor, err := actors.GetActor(t.Context(), "team-a", substrate.ActorName(session.Id)) + require.NoError(t, err) + require.Equal(t, current.ActorTemplateName, actor.GetActorTemplate().GetName()) + require.Equal(t, ateapipb.ActorState_ACTOR_STATE_RUNNING, actor.GetStatus().GetState()) + require.Equal(t, 1, actors.replacedPolicies) + + // A later resume of the same session finds nothing to move. + suspended, err = workflow.Suspend(t.Context(), resumed) + require.NoError(t, err) + resumed, err = workflow.Resume(t.Context(), suspended) + require.NoError(t, err) + require.Equal(t, current.Revision, resumed.GetPreparedRevision()) + require.Equal(t, 1, actors.repointCalls) +} + func TestQuiesceRejectsWrongActorIdentity(t *testing.T) { session := &apiv1alpha1.Session{Id: "session-1", PreparedRevision: "revision-1"} store := &lifecycleTestStore{revision: &database.RuntimeRevision{ActorTemplateAtespace: "team-a"}} @@ -281,7 +505,7 @@ func lifecycleForkFixture(t *testing.T, store *lifecycleTestStore, actors *lifec version, err := store.UpdateSessionTask(t.Context(), source.Id, initialVersion, hash[:], task, task, "") require.NoError(t, err) require.NoError(t, store.SettleSessionTask(t.Context(), source.Id, string(task.ID), version)) - boundary, err := store.ClaimSessionQuiescence(t.Context()) + boundary, err := store.ClaimSessionQuiescence(t.Context(), testClaimLease, 0, nil) require.NoError(t, err) require.NoError(t, store.FinishSessionQuiescence(t.Context(), boundary, &database.SessionTaskSnapshot{Atespace: "team-a", URI: "s3://snapshots/source", ContentScope: "DATA"})) @@ -363,7 +587,11 @@ func TestActorEgressPolicy(t *testing.T) { policy, err = substrate.ActorEgressPolicy("team-a", nil, nil) require.NoError(t, err) require.Empty(t, policy.Rules, "no destinations must deny all egress") - for _, destination := range []string{"", "*", "api.example.com", "https://api.example.com/path", "http://api.example.com:0", "http://api.example.com:65536", "http://user@api.example.com", "http://api.example.com?key=value", "http://*.example.com"} { + policy, err = substrate.ActorEgressPolicy("team-a", []string{"https://*.example.com:443"}, nil) + require.NoError(t, err, "a leftmost-label wildcard is a Substrate hostname pattern") + require.Equal(t, []string{"*.example.com"}, policy.Rules[0].GetHttps().GetHostnames()) + for _, destination := range []string{"", "*", "api.example.com", "https://api.example.com/path", "http://api.example.com:0", "http://api.example.com:65536", "http://user@api.example.com", "http://api.example.com?key=value", + "https://*", "https://*.com", "https://a.*.example.com", "https://*.*.example.com", "https://*example.com"} { t.Run(destination, func(t *testing.T) { _, err := substrate.ActorEgressPolicy("team-a", []string{destination}, nil) require.Error(t, err) @@ -424,3 +652,150 @@ func TestServiceLifecycleRetriesUseCurrentStateAndRespectDeletion(t *testing.T) require.True(t, serviceerrors.IsCode(err, serviceerrors.CodeNotFound)) require.Equal(t, mutations, actors.mutations.Load(), "a tombstoned request must not create or touch compute") } + +func TestRuntimeLost(t *testing.T) { + session := &apiv1alpha1.Session{Id: "session-1", PreparedRevision: "revision-1"} + store := &lifecycleTestStore{revision: &database.RuntimeRevision{ActorTemplateAtespace: "team-a"}} + name := substrate.ActorName(session.Id) + for _, test := range []struct { + name string + state ateapipb.ActorState + missing bool + getErr error + wantCause string + wantLost bool + wantErr string + }{ + {name: "missing", missing: true, wantCause: "Actor team-a/" + name + " not found", wantLost: true}, + {name: "crashed", state: ateapipb.ActorState_ACTOR_STATE_CRASHED, wantCause: "Actor team-a/" + name + " crashed", wantLost: true}, + {name: "paused", state: ateapipb.ActorState_ACTOR_STATE_PAUSED}, + {name: "running", state: ateapipb.ActorState_ACTOR_STATE_RUNNING}, + {name: "suspended", state: ateapipb.ActorState_ACTOR_STATE_SUSPENDED}, + {name: "unknown", getErr: status.Error(codes.Unavailable, "ate-api is rolling"), wantErr: "ate-api is rolling"}, + } { + t.Run(test.name, func(t *testing.T) { + actors := &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}, getErr: test.getErr} + if !test.missing { + actors.actors[actorKey("team-a", name)] = &ateapipb.Actor{ + Metadata: &ateapipb.ResourceMetadata{Atespace: "team-a", Name: name, Uid: "actor-uid"}, + Status: &ateapipb.ActorStatus{State: test.state}, + } + } + cause, lost, err := NewActorWorkflow(store, actors).RuntimeLost(t.Context(), session) + if test.wantErr != "" { + require.ErrorContains(t, err, test.wantErr) + require.False(t, lost, "not knowing is not lost") + return + } + require.NoError(t, err) + require.Equal(t, test.wantLost, lost) + require.Equal(t, test.wantCause, cause) + }) + } +} + +// heldActors answers the resumes in answers in order, the way ate-api answers +// while another operation holds the Actor, and resumes it after them. +type heldActors struct { + *lifecycleTestActors + answers []error + resumes int +} + +func (a *heldActors) ResumeActor(ctx context.Context, atespace, name string) (*ateapipb.Actor, error) { + a.resumes++ + if len(a.answers) > 0 { + err := a.answers[0] + if len(a.answers) > 1 || status.Code(err) != codes.Aborted { + a.answers = a.answers[1:] + } + return nil, err + } + return &ateapipb.Actor{Status: &ateapipb.ActorStatus{State: ateapipb.ActorState_ACTOR_STATE_RUNNING}}, nil +} + +func TestAwaitRuntime(t *testing.T) { + session := &apiv1alpha1.Session{Id: "session-1", PreparedRevision: "revision-1"} + store := &lifecycleTestStore{revision: &database.RuntimeRevision{ActorTemplateAtespace: "team-a"}} + held := status.Error(codes.Aborted, "another operation is in progress for this actor") + for _, test := range []struct { + name string + answers []error + wantResumes int + wantErr string + }{ + {name: "free", wantResumes: 1}, + {name: "held, then free", answers: []error{held, held, held, nil}, wantResumes: 4}, + // The last answer repeats: the operation outlasts the bound. + {name: "held past the bound", answers: []error{held}, wantErr: "still held by another operation"}, + {name: "not transient", answers: []error{held, status.Error(codes.PermissionDenied, "denied")}, wantResumes: 2, wantErr: "denied"}, + } { + t.Run(test.name, func(t *testing.T) { + actors := &heldActors{lifecycleTestActors: &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}}, answers: test.answers} + workflow := NewActorWorkflow(store, actors) + workflow.busyWait, workflow.busyRetry = 200*time.Millisecond, time.Millisecond + err := workflow.AwaitRuntime(t.Context(), session) + if test.wantErr != "" { + require.ErrorContains(t, err, test.wantErr) + } else { + require.NoError(t, err) + } + if test.wantResumes != 0 { + require.Equal(t, test.wantResumes, actors.resumes) + } else { + require.Greater(t, actors.resumes, 1, "an Actor another operation holds is resumed again within the bound") + } + }) + } +} + +// snapshotLossActors answers every resume the way Substrate does when the +// Actor's snapshot is gone from the store: DataLoss, the Actor left as state. +type snapshotLossActors struct { + *lifecycleTestActors + state ateapipb.ActorState +} + +func (a *snapshotLossActors) ResumeActor(_ context.Context, atespace, name string) (*ateapipb.Actor, error) { + a.mu.Lock() + defer a.mu.Unlock() + a.actors[actorKey(atespace, name)].Status.State = a.state + return nil, status.Error(codes.DataLoss, "external snapshot not found") +} + +func TestResumeAfterSnapshotLossFailsTheSessionAndDeleteProceeds(t *testing.T) { + for _, state := range []ateapipb.ActorState{ateapipb.ActorState_ACTOR_STATE_CRASHED, ateapipb.ActorState_ACTOR_STATE_SUSPENDED} { + t.Run(state.String(), func(t *testing.T) { + store, session := lifecycleFixture(t) + base := &lifecycleTestActors{actors: map[string]*ateapipb.Actor{}} + setup := NewActorWorkflow(store, base) + session, err := setup.Create(t.Context(), session) + require.NoError(t, err) + session, err = setup.Suspend(t.Context(), session) + require.NoError(t, err) + + workflow := NewActorWorkflow(store, &snapshotLossActors{lifecycleTestActors: base, state: state}) + _, err = workflow.Resume(t.Context(), session) + require.ErrorIs(t, err, ErrRuntimeLost) + require.ErrorContains(t, err, "external snapshot not found") + + failed, err := store.GetSessionByID(t.Context(), session.Id) + require.NoError(t, err) + require.Equal(t, apiv1alpha1.RuntimeState_RUNTIME_STATE_FAILED, failed.State) + require.Equal(t, apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_NONE, failed.Operation) + require.Equal(t, "RuntimeLost", failed.GetFailure().GetReason()) + require.Contains(t, failed.GetFailure().GetMessage(), "external snapshot not found") + + _, err = workflow.Resume(t.Context(), failed) + require.ErrorIs(t, err, database.ErrConflict, "a failed session cannot be resumed again") + + deleted, err := workflow.Delete(t.Context(), failed) + require.NoError(t, err) + require.Equal(t, apiv1alpha1.RuntimeState_RUNTIME_STATE_DELETED, deleted.State) + _, err = store.GetSession(t.Context(), session.Id, session.Creator) + require.ErrorIs(t, err, database.ErrNotFound) + _, err = base.GetActor(t.Context(), "team-a", substrate.ActorName(session.Id)) + require.Equal(t, codes.NotFound, status.Code(err), "Delete must remove the Actor") + }) + } +} diff --git a/go/core/internal/skillsinit/git.go b/go/core/internal/skillsinit/git.go index 35ddca9278..9cca90dee1 100644 --- a/go/core/internal/skillsinit/git.go +++ b/go/core/internal/skillsinit/git.go @@ -48,25 +48,51 @@ func CloneGit(ref GitRef) error { return nil } +// credentialPlaceholder is the Authorization value git sends for an +// authenticated source. The egress gateway replaces the whole header with the +// source's credential and never inspects the placeholder. +const credentialPlaceholder = "Basic kagent-credential-injected" + // CloneGitCommit fetches only one immutable commit instead of cloning the -// repository's complete history. -func CloneGitCommit(url, commit, destination string) error { +// repository's complete history. An authenticated source sends a placeholder +// Authorization header for its URL only: the egress gateway replaces a +// credential header a request carries and leaves a request without one +// untouched, and git sends none of its own before a challenge. +func CloneGitCommit(url, commit, destination string, authenticated bool) error { if !immutableGitCommit.MatchString(commit) { return fmt.Errorf("git commit must be a full SHA") } if err := os.MkdirAll(destination, 0o755); err != nil { return err } - if err := runGitIn(destination, "init"); err != nil { + env := gitEnvironment(url, authenticated) + if err := runGitWith(destination, env, "init"); err != nil { return err } - if err := runGitIn(destination, "remote", "add", "origin", url); err != nil { + if err := runGitWith(destination, env, "remote", "add", "origin", url); err != nil { return err } - if err := runGitIn(destination, "fetch", "--depth", "1", "origin", commit); err != nil { + if err := runGitWith(destination, env, "fetch", "--depth", "1", "origin", commit); err != nil { return err } - return runGitIn(destination, "checkout", "--detach", "FETCH_HEAD") + return runGitWith(destination, env, "checkout", "--detach", "FETCH_HEAD") +} + +// gitEnvironment returns the environment additions for fetching url. Git never +// prompts: a refused credential fails with its error instead of waiting on a +// terminal the actor does not have. An authenticated source gets the +// placeholder header through git's per-invocation configuration, scoped to its +// URL and never written to a gitconfig. +func gitEnvironment(url string, authenticated bool) []string { + env := []string{"GIT_TERMINAL_PROMPT=0"} + if authenticated { + env = append(env, + "GIT_CONFIG_COUNT=1", + "GIT_CONFIG_KEY_0=http."+url+".extraHeader", + "GIT_CONFIG_VALUE_0=Authorization: "+credentialPlaceholder, + ) + } + return env } func runGit(args ...string) error { @@ -74,13 +100,17 @@ func runGit(args ...string) error { } func runGitIn(dir string, args ...string) error { + return runGitWith(dir, nil, args...) +} + +func runGitWith(dir string, env []string, args ...string) error { cmd := exec.Command("git", args...) if dir != "" { cmd.Dir = dir } cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - cmd.Env = os.Environ() + cmd.Env = append(os.Environ(), env...) return cmd.Run() } diff --git a/go/core/internal/skillsinit/git_test.go b/go/core/internal/skillsinit/git_test.go index f1b486645d..be99676b5c 100644 --- a/go/core/internal/skillsinit/git_test.go +++ b/go/core/internal/skillsinit/git_test.go @@ -1,10 +1,14 @@ package skillsinit import ( + "fmt" + "net/http" + "net/http/httptest" "os" "os/exec" "path/filepath" "strings" + "sync" "testing" "github.com/stretchr/testify/assert" @@ -46,10 +50,54 @@ func Test_applySubPath_rejectsNonDir(t *testing.T) { } func TestCloneGitCommitRejectsMutableRef(t *testing.T) { - err := CloneGitCommit("https://example.com/repository.git", "main", t.TempDir()) + err := CloneGitCommit("https://example.com/repository.git", "main", t.TempDir(), false) require.ErrorContains(t, err, "full SHA") } +// TestCloneGitCommitAuthorizationPlaceholder guards the egress contract: the +// gateway replaces an Authorization header the request carries and adds none, +// so an authenticated source must send the placeholder on its first request, +// and a public source must send nothing. The server answers 401 like a private +// repository without a credential; git must fail without prompting. +func TestCloneGitCommitAuthorizationPlaceholder(t *testing.T) { + for _, authenticated := range []bool{true, false} { + t.Run(fmt.Sprintf("authenticated=%t", authenticated), func(t *testing.T) { + var mu sync.Mutex + var seen []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + seen = append(seen, r.Header.Get("Authorization")) + mu.Unlock() + w.Header().Set("WWW-Authenticate", `Basic realm="test"`) + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + err := CloneGitCommit(server.URL+"/org/private", strings.Repeat("a", 40), filepath.Join(t.TempDir(), "dest"), authenticated) + require.Error(t, err) + + mu.Lock() + defer mu.Unlock() + require.NotEmpty(t, seen) + want := "" + if authenticated { + want = credentialPlaceholder + } + assert.Equal(t, want, seen[0]) + }) + } +} + +func TestGitEnvironmentScopesThePlaceholderToTheSource(t *testing.T) { + assert.Equal(t, []string{"GIT_TERMINAL_PROMPT=0"}, gitEnvironment("https://github.com/org/public", false)) + assert.Equal(t, []string{ + "GIT_TERMINAL_PROMPT=0", + "GIT_CONFIG_COUNT=1", + "GIT_CONFIG_KEY_0=http.https://github.com/org/private.extraHeader", + "GIT_CONFIG_VALUE_0=Authorization: Basic kagent-credential-injected", + }, gitEnvironment("https://github.com/org/private", true)) +} + // TestCloneGit_fullCheckoutBySHA guards #2608: a Full clone with a commit // SHA ref must check the SHA out as a revision. Passing it after `--` // made git treat it as a pathspec and fail with "did not match any diff --git a/go/core/internal/substrate/actor_template.go b/go/core/internal/substrate/actor_template.go index 93825620b9..fb04454d14 100644 --- a/go/core/internal/substrate/actor_template.go +++ b/go/core/internal/substrate/actor_template.go @@ -86,7 +86,18 @@ func ActorTemplateForRevision(spec *translator.Revision, revisionID translator.R if err != nil { return nil, err } + // Substrate copies the template's default policy onto every Actor it + // creates from it, including the golden Actor, which boots before any + // client call could give it a policy of its own. + egressPolicy, err := ActorEgressPolicy(spec.Namespace, spec.EgressDestinations, spec.Credentials) + if err != nil { + return nil, fmt.Errorf("compile egress policy: %w", err) + } + var securityContext *ateapipb.SecurityContext + if len(spec.Capabilities) != 0 { + securityContext = &ateapipb.SecurityContext{Capabilities: &ateapipb.Capabilities{Add: append([]string(nil), spec.Capabilities...)}} + } template := &ateapipb.ActorTemplate{ Metadata: &ateapipb.ResourceMetadata{Atespace: spec.Namespace, Name: name}, SandboxConfig: sandboxConfig, @@ -105,8 +116,10 @@ func ActorTemplateForRevision(spec *translator.Revision, revisionID translator.R {Name: egressTrustVolume, MountPath: egressTrustMount}, {Name: actorIdentityVolume, MountPath: actorIdentityMount}, }, + SecurityContext: securityContext, }}, - WorkerSelector: workerSelectorForPool(workerKey), + WorkerSelector: workerSelectorForPool(workerKey), + DefaultEgressPolicy: &ateapipb.EgressPolicyTemplate{Rules: egressPolicy.GetRules()}, SnapshotConfig: &ateapipb.SnapshotConfig{ StorageLocation: spec.SnapshotLocation, OnPause: ateapipb.SnapshotContentScope_SNAPSHOT_CONTENT_SCOPE_FULL, @@ -147,12 +160,13 @@ func actorTemplateSpec(template *ateapipb.ActorTemplate) *ateapipb.ActorTemplate Atespace: template.GetMetadata().GetAtespace(), Name: template.GetMetadata().GetName(), }, - WorkerSelector: template.GetWorkerSelector(), - Containers: template.GetContainers(), - Volumes: template.GetVolumes(), - SnapshotConfig: template.GetSnapshotConfig(), - SandboxConfig: template.GetSandboxConfig(), - Resources: template.GetResources(), + WorkerSelector: template.GetWorkerSelector(), + Containers: template.GetContainers(), + Volumes: template.GetVolumes(), + SnapshotConfig: template.GetSnapshotConfig(), + SandboxConfig: template.GetSandboxConfig(), + Resources: template.GetResources(), + DefaultEgressPolicy: template.GetDefaultEgressPolicy(), } } diff --git a/go/core/internal/substrate/actor_template_test.go b/go/core/internal/substrate/actor_template_test.go index 35875dfba6..260eb16b4b 100644 --- a/go/core/internal/substrate/actor_template_test.go +++ b/go/core/internal/substrate/actor_template_test.go @@ -11,6 +11,7 @@ import ( atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1" "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "github.com/kagent-dev/kagent/go/core/internal/egress" "github.com/kagent-dev/kagent/go/core/internal/translator" "github.com/stretchr/testify/require" "google.golang.org/protobuf/proto" @@ -66,7 +67,9 @@ func TestActorTemplateForRevision(t *testing.T) { WorkerPoolName: "default", SnapshotLocation: "snapshots", ConfigJSON: []byte(`{"instruction":"help"}`), AgentCard: &a2apb.AgentCard{Name: "helper", Version: "v1", Capabilities: &a2apb.AgentCapabilities{Streaming: new(true)}, SupportedInterfaces: []*a2apb.AgentInterface{{Url: "http://127.0.0.1:80", ProtocolBinding: "GRPC", ProtocolVersion: "1.0"}}, DefaultInputModes: []string{"text"}, DefaultOutputModes: []string{"text"}}, - Environment: []corev1.EnvVar{{Name: "API_KEY", Value: translator.CredentialPlaceholder}}, + Environment: []corev1.EnvVar{{Name: "API_KEY", Value: translator.CredentialPlaceholder}}, + EgressDestinations: []string{"https://github.com:443", "https://api.anthropic.com:443"}, + Credentials: []egress.Credential{{Hostname: "api.anthropic.com", Header: "x-api-key", URI: "ate-secret://k8s.io/default/agents/anthropic/key"}}, } revisionID, err := spec.Digest() if err != nil { @@ -119,6 +122,36 @@ func TestActorTemplateForRevision(t *testing.T) { if environment["KAGENT_CONFIG_JSON"].Value != string(spec.ConfigJSON) { t.Fatal("config was not embedded as a non-secret literal") } + wantPolicy, err := ActorEgressPolicy("agents", spec.EgressDestinations, spec.Credentials) + require.NoError(t, err) + require.True(t, proto.Equal(&ateapipb.EgressPolicyTemplate{Rules: wantPolicy.GetRules()}, template.GetDefaultEgressPolicy()), + "template default egress policy = %v, want %v", template.GetDefaultEgressPolicy(), wantPolicy.GetRules()) + var injected []*ateapipb.CredentialHeader + for _, rule := range template.GetDefaultEgressPolicy().GetRules() { + injected = append(injected, rule.GetHttps().GetEffects().GetReplaceHeaders()...) + } + require.Len(t, injected, 1) + require.Equal(t, "x-api-key", injected[0].GetHeader()) +} + +func TestActorTemplateForRevisionDeniesAllEgressWithoutDestinations(t *testing.T) { + spec := &translator.Revision{ + Namespace: "agents", AgentName: "helper", + Image: "agent.example/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + WorkerPoolName: "default", SnapshotLocation: "snapshots", + ConfigJSON: []byte(`{}`), AgentCard: &a2apb.AgentCard{Name: "helper", Version: "v1", Capabilities: &a2apb.AgentCapabilities{}, + SupportedInterfaces: []*a2apb.AgentInterface{{Url: "http://127.0.0.1:80", ProtocolBinding: "GRPC", ProtocolVersion: "1.0"}}, DefaultInputModes: []string{"text"}, DefaultOutputModes: []string{"text"}}, + } + revisionID, err := spec.Digest() + require.NoError(t, err) + template, err := ActorTemplateForRevision(spec, revisionID) + require.NoError(t, err) + require.NotNil(t, template.GetDefaultEgressPolicy(), "a revision without destinations must carry an empty (deny-all) policy") + require.Empty(t, template.GetDefaultEgressPolicy().GetRules()) + invalid := *spec + invalid.EgressDestinations = []string{"github.com"} + _, err = ActorTemplateForRevision(&invalid, revisionID) + require.ErrorContains(t, err, "compile egress policy") } func TestActorTemplateStampsTheRevisionOnTheResource(t *testing.T) { @@ -167,6 +200,11 @@ func TestActorTemplateSpecEqualIgnoresServerFields(t *testing.T) { if ActorTemplateSpecEqual(left, right) { t.Fatal("different container image was accepted") } + right = proto.CloneOf(left) + right.DefaultEgressPolicy = &ateapipb.EgressPolicyTemplate{Rules: []*ateapipb.EgressRule{{Https: &ateapipb.HTTPSRule{Hostnames: []string{"github.com"}}}}} + if ActorTemplateSpecEqual(left, right) { + t.Fatal("different default egress policy was accepted") + } } func TestActorTemplateEnvironmentValueSize(t *testing.T) { @@ -211,3 +249,39 @@ func TestActorTemplateEnvironmentValueSize(t *testing.T) { }) } } + +func TestActorTemplateAddsTheRevisionsCapabilities(t *testing.T) { + spec := &translator.Revision{ + Namespace: "agents", AgentName: "helper", + Image: "agent.example/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + WorkerPoolName: "default", SnapshotLocation: "snapshots", + ConfigJSON: []byte(`{}`), AgentCard: &a2apb.AgentCard{Name: "helper", Version: "v1", Capabilities: &a2apb.AgentCapabilities{}, + SupportedInterfaces: []*a2apb.AgentInterface{{Url: "http://127.0.0.1:80", ProtocolBinding: "GRPC", ProtocolVersion: "1.0"}}, DefaultInputModes: []string{"text"}, DefaultOutputModes: []string{"text"}}, + } + plainID, err := spec.Digest() + if err != nil { + t.Fatal(err) + } + plain, err := ActorTemplateForRevision(spec, plainID) + if err != nil { + t.Fatal(err) + } + if plain.GetContainers()[0].GetSecurityContext() != nil { + t.Fatalf("a revision without capabilities keeps Substrate's default set, got %v", plain.GetContainers()[0].GetSecurityContext()) + } + spec.Capabilities = []string{"SETUID", "SETGID"} + id, err := spec.Digest() + if err != nil { + t.Fatal(err) + } + if id == plainID { + t.Fatal("the capabilities are part of the revision identity") + } + template, err := ActorTemplateForRevision(spec, id) + if err != nil { + t.Fatal(err) + } + if got := template.GetContainers()[0].GetSecurityContext().GetCapabilities().GetAdd(); !slices.Equal(got, []string{"SETUID", "SETGID"}) { + t.Fatalf("added capabilities = %v", got) + } +} diff --git a/go/core/internal/substrate/client.go b/go/core/internal/substrate/client.go index acb9c23311..c60c026093 100644 --- a/go/core/internal/substrate/client.go +++ b/go/core/internal/substrate/client.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "strings" + "sync/atomic" "time" "github.com/agent-substrate/substrate/pkg/proto/ateapipb" @@ -24,6 +25,8 @@ type Client struct { ateapipb.ControlClient conn *grpc.ClientConn cfg Config + // fencingUnsupported is set once ate-api refused the fencing token. + fencingUnsupported atomic.Bool } // Dial connects to the ate-api server. @@ -189,17 +192,43 @@ func (c *Client) createActor(ctx context.Context, atespace, actorID, tmplNS, tmp func (c *Client) ResumeActor(ctx context.Context, atespace, actorID string) (*ateapipb.Actor, error) { ctx, cancel := c.callCtx(ctx) defer cancel() - resp, err := c.ControlClient.ResumeActor(ctx, &ateapipb.ResumeActorRequest{Actor: actorRef(atespace, actorID)}) + resp, err := fenced(ctx, c, func(token *ateapipb.FencingToken) (*ateapipb.ResumeActorResponse, error) { + return c.ControlClient.ResumeActor(ctx, &ateapipb.ResumeActorRequest{Actor: actorRef(atespace, actorID), FencingToken: token}) + }) if err != nil { return nil, err } return resp.GetActor(), nil } +// RepointActor moves a suspended Actor onto another ActorTemplate. Substrate +// accepts the change only while the Actor is suspended and the new template +// keeps the sandbox config and the durable volumes; the next resume restores +// the Actor's data onto the new template's golden. An Actor already on the +// template is returned as it is. +func (c *Client) RepointActor(ctx context.Context, atespace, actorID, tmplNS, tmplName string) (*ateapipb.Actor, error) { + ctx, cancel := c.callCtx(ctx) + defer cancel() + actor, err := c.ControlClient.GetActor(ctx, &ateapipb.GetActorRequest{Actor: actorRef(atespace, actorID)}) + if err != nil { + return nil, err + } + if template := actor.GetActorTemplate(); template.GetAtespace() == tmplNS && template.GetName() == tmplName { + return actor, nil + } + // The read's metadata carries the uid and version Substrate requires as + // preconditions, so a concurrent change fails the update. + actor.ActorTemplate = actorRef(tmplNS, tmplName) + actor.Status = nil + return c.UpdateActor(ctx, &ateapipb.UpdateActorRequest{Actor: actor}) +} + func (c *Client) PauseActor(ctx context.Context, atespace, actorID string) (*ateapipb.Actor, error) { ctx, cancel := c.callCtx(ctx) defer cancel() - resp, err := c.ControlClient.PauseActor(ctx, &ateapipb.PauseActorRequest{Actor: actorRef(atespace, actorID)}) + resp, err := fenced(ctx, c, func(token *ateapipb.FencingToken) (*ateapipb.PauseActorResponse, error) { + return c.ControlClient.PauseActor(ctx, &ateapipb.PauseActorRequest{Actor: actorRef(atespace, actorID), FencingToken: token}) + }) if err != nil { return nil, err } @@ -209,7 +238,9 @@ func (c *Client) PauseActor(ctx context.Context, atespace, actorID string) (*ate func (c *Client) SuspendActor(ctx context.Context, atespace, actorID string) (*ateapipb.Actor, error) { ctx, cancel := c.callCtx(ctx) defer cancel() - resp, err := c.ControlClient.SuspendActor(ctx, &ateapipb.SuspendActorRequest{Actor: actorRef(atespace, actorID)}) + resp, err := fenced(ctx, c, func(token *ateapipb.FencingToken) (*ateapipb.SuspendActorResponse, error) { + return c.ControlClient.SuspendActor(ctx, &ateapipb.SuspendActorRequest{Actor: actorRef(atespace, actorID), FencingToken: token}) + }) if err != nil { return nil, err } @@ -244,10 +275,12 @@ func (c *Client) DeleteTag(ctx context.Context, atespace, name string) error { // ActorName is the stable private Actor identity for a Session. func ActorName(sessionID string) string { return "session-" + strings.ToLower(sessionID) } -func (c *Client) DeleteActor(ctx context.Context, atespace, actorID string) error { +// DeleteActor deletes an Actor. Substrate deletes a SUSPENDED or CRASHED +// Actor as it is; anyState asks it to delete an Actor in any other state too. +func (c *Client) DeleteActor(ctx context.Context, atespace, actorID string, anyState bool) error { ctx, cancel := c.callCtx(ctx) defer cancel() - _, err := c.ControlClient.DeleteActor(ctx, &ateapipb.DeleteActorRequest{Actor: actorRef(atespace, actorID)}) + _, err := c.ControlClient.DeleteActor(ctx, &ateapipb.DeleteActorRequest{Actor: actorRef(atespace, actorID), AnyState: anyState}) return err } diff --git a/go/core/internal/substrate/client_test.go b/go/core/internal/substrate/client_test.go index 9ca333cc14..8d773845c1 100644 --- a/go/core/internal/substrate/client_test.go +++ b/go/core/internal/substrate/client_test.go @@ -310,3 +310,13 @@ func TestAdvancePageTokenAllowsTheLastPageAndARealMove(t *testing.T) { require.NoError(t, err) require.Empty(t, next) } + +func TestDeleteActorPassesAnyState(t *testing.T) { + for _, anyState := range []bool{false, true} { + fake := &deleteActorTemplateFake{} + client := &Client{ControlClient: fake, cfg: Config{CallTimeout: time.Second}} + require.NoError(t, client.DeleteActor(t.Context(), "team-a", "session-1", anyState)) + require.Equal(t, &ateapipb.ObjectRef{Atespace: "team-a", Name: "session-1"}, fake.actor.GetActor()) + require.Equal(t, anyState, fake.actor.GetAnyState()) + } +} diff --git a/go/core/internal/substrate/egress.go b/go/core/internal/substrate/egress.go index dd7d0be647..9ef6013196 100644 --- a/go/core/internal/substrate/egress.go +++ b/go/core/internal/substrate/egress.go @@ -32,3 +32,28 @@ func (c *Client) EnsureActorEgressPolicy(ctx context.Context, atespace, name str } return nil } + +// ReplaceActorEgressPolicy gives an Actor the allowlist of the revision it is +// repointed to: it creates the policy, or replaces the rules of the one it has. +func (c *Client) ReplaceActorEgressPolicy(ctx context.Context, atespace, name string, policy *ateapipb.EgressPolicy) error { + ctx, cancel := c.callCtx(ctx) + defer cancel() + actor := actorRef(atespace, name) + _, err := c.CreateActorEgressPolicy(ctx, &ateapipb.CreateActorEgressPolicyRequest{ + Actor: actor, EgressPolicy: policy, + }) + if status.Code(err) != codes.AlreadyExists { + return err + } + existing, err := c.GetActorEgressPolicy(ctx, &ateapipb.GetActorEgressPolicyRequest{Actor: actor}) + if err != nil { + return err + } + if proto.Equal(&ateapipb.EgressPolicy{Rules: existing.GetRules()}, &ateapipb.EgressPolicy{Rules: policy.Rules}) { + return nil + } + // The existing metadata carries the uid and version preconditions. + replacement := &ateapipb.EgressPolicy{Metadata: existing.GetMetadata(), Rules: policy.GetRules()} + _, err = c.UpdateActorEgressPolicy(ctx, &ateapipb.UpdateActorEgressPolicyRequest{Actor: actor, EgressPolicy: replacement}) + return err +} diff --git a/go/core/internal/substrate/fencing.go b/go/core/internal/substrate/fencing.go new file mode 100644 index 0000000000..8e65ed3d1c --- /dev/null +++ b/go/core/internal/substrate/fencing.go @@ -0,0 +1,63 @@ +package substrate + +import ( + "context" + "strconv" + "strings" + + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "github.com/kagent-dev/kagent/go/pkg/logging" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// fencingTokenTag is the protobuf field number of fencing_token on the Pause, +// Suspend and Resume requests. +const fencingTokenTag = 10001 + +type fencingTokenKey struct{} + +// WithFencingToken returns a context whose PauseActor, SuspendActor and +// ResumeActor calls carry token. Substrate records the newest token per Actor +// and refuses a request with an older one (FailedPrecondition), so a holder +// that was taken over cannot change the Actor after its successor settled it. +// A call without a token is admitted as before. +func WithFencingToken(ctx context.Context, token *ateapipb.FencingToken) context.Context { + return context.WithValue(ctx, fencingTokenKey{}, token) +} + +// FencingTokenFrom returns the token WithFencingToken put on ctx, or nil. +func FencingTokenFrom(ctx context.Context) *ateapipb.FencingToken { + token, _ := ctx.Value(fencingTokenKey{}).(*ateapipb.FencingToken) + return token +} + +// fenced sends a request with the fencing token on ctx. A Substrate whose +// Actor API predates the token refuses every field it has no descriptor for; +// the client then remembers that this ate-api takes no token, says so once and +// sends the request, and every later one, without it. The runtime boundary +// works as before the token, only a frozen holder's late Pause or Suspend is +// no longer refused. +func fenced[T any](ctx context.Context, c *Client, call func(*ateapipb.FencingToken) (T, error)) (T, error) { + token := FencingTokenFrom(ctx) + if token == nil || c.fencingUnsupported.Load() { + return call(nil) + } + resp, err := call(token) + if !refusesFencingToken(err) { + return resp, err + } + if c.fencingUnsupported.CompareAndSwap(false, true) { + logging.FromContext(ctx).WarnContext(ctx, "fencing token refused by Substrate: Pause, Suspend and Resume are sent unfenced, so a frozen quiescence holder's late Pause or Suspend is not refused until Substrate is upgraded", + "ate_api", c.cfg.AteAPIEndpoint, "error", err) + } + return call(nil) +} + +// refusesFencingToken reports whether err is ate-api's refusal of the +// fencing_token field as unknown. +func refusesFencingToken(err error) bool { + s, ok := status.FromError(err) + return ok && s.Code() == codes.InvalidArgument && + strings.Contains(s.Message(), "unknown field with protobuf tag "+strconv.Itoa(fencingTokenTag)) +} diff --git a/go/core/internal/substrate/fencing_test.go b/go/core/internal/substrate/fencing_test.go new file mode 100644 index 0000000000..c97fa99b9f --- /dev/null +++ b/go/core/internal/substrate/fencing_test.go @@ -0,0 +1,126 @@ +package substrate + +import ( + "context" + "errors" + "testing" + + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// errTokenRefused is ate-api's refusal of a request carrying a field it has no +// descriptor for, as a Substrate without the fencing token returns it. +var errTokenRefused = status.Error(codes.InvalidArgument, "request: Invalid value: unknown field with protobuf tag 10001") + +// fencingFake records the token of every Pause, Suspend and Resume and refuses +// any token while takesToken is false. +type fencingFake struct { + ateapipb.ControlClient + takesToken bool + tokens []*ateapipb.FencingToken +} + +func (f *fencingFake) answer(token *ateapipb.FencingToken) error { + f.tokens = append(f.tokens, token) + if token != nil && !f.takesToken { + return errTokenRefused + } + return nil +} + +func (f *fencingFake) PauseActor(_ context.Context, req *ateapipb.PauseActorRequest, _ ...grpc.CallOption) (*ateapipb.PauseActorResponse, error) { + if err := f.answer(req.GetFencingToken()); err != nil { + return nil, err + } + return &ateapipb.PauseActorResponse{Actor: &ateapipb.Actor{}}, nil +} + +func (f *fencingFake) SuspendActor(_ context.Context, req *ateapipb.SuspendActorRequest, _ ...grpc.CallOption) (*ateapipb.SuspendActorResponse, error) { + if err := f.answer(req.GetFencingToken()); err != nil { + return nil, err + } + return &ateapipb.SuspendActorResponse{Actor: &ateapipb.Actor{}}, nil +} + +func (f *fencingFake) ResumeActor(_ context.Context, req *ateapipb.ResumeActorRequest, _ ...grpc.CallOption) (*ateapipb.ResumeActorResponse, error) { + if err := f.answer(req.GetFencingToken()); err != nil { + return nil, err + } + return &ateapipb.ResumeActorResponse{Actor: &ateapipb.Actor{}}, nil +} + +func TestFencedCalls(t *testing.T) { + token := &ateapipb.FencingToken{Holder: "executor", Generation: 7} + calls := map[string]func(*Client, context.Context) error{ + "pause": func(c *Client, ctx context.Context) error { + _, err := c.PauseActor(ctx, "kagent", "session-a") + return err + }, + "suspend": func(c *Client, ctx context.Context) error { + _, err := c.SuspendActor(ctx, "kagent", "session-a") + return err + }, + "resume": func(c *Client, ctx context.Context) error { + _, err := c.ResumeActor(ctx, "kagent", "session-a") + return err + }, + } + for name, call := range calls { + t.Run(name+" carries the token to a Substrate that takes it", func(t *testing.T) { + fake := &fencingFake{takesToken: true} + c := &Client{ControlClient: fake} + + require.NoError(t, call(c, WithFencingToken(context.Background(), token))) + require.NoError(t, call(c, WithFencingToken(context.Background(), token))) + require.Equal(t, []*ateapipb.FencingToken{token, token}, fake.tokens) + require.False(t, c.fencingUnsupported.Load()) + }) + + t.Run(name+" falls back to an unfenced call once Substrate refuses the token", func(t *testing.T) { + fake := &fencingFake{} + c := &Client{ControlClient: fake} + + require.NoError(t, call(c, WithFencingToken(context.Background(), token))) + require.Equal(t, []*ateapipb.FencingToken{token, nil}, fake.tokens) + require.True(t, c.fencingUnsupported.Load()) + + // Remembered: the next call is sent unfenced at once. + require.NoError(t, call(c, WithFencingToken(context.Background(), token))) + require.Equal(t, []*ateapipb.FencingToken{token, nil, nil}, fake.tokens) + }) + + t.Run(name+" without a token is sent unfenced", func(t *testing.T) { + fake := &fencingFake{} + c := &Client{ControlClient: fake} + + require.NoError(t, call(c, context.Background())) + require.Equal(t, []*ateapipb.FencingToken{nil}, fake.tokens) + require.False(t, c.fencingUnsupported.Load()) + }) + } +} + +func TestFencedKeepsOtherErrors(t *testing.T) { + token := &ateapipb.FencingToken{Holder: "executor", Generation: 7} + for name, refusal := range map[string]error{ + "a newer holder's token": status.Error(codes.FailedPrecondition, "Actor kagent/session-a is fenced by a newer token"), + "another invalid field": status.Error(codes.InvalidArgument, "request: Invalid value: unknown field with protobuf tag 9999"), + "a transport error": errors.New("connection reset"), + } { + t.Run(name, func(t *testing.T) { + c := &Client{} + var sent []*ateapipb.FencingToken + _, err := fenced(WithFencingToken(context.Background(), token), c, func(token *ateapipb.FencingToken) (*ateapipb.Actor, error) { + sent = append(sent, token) + return nil, refusal + }) + require.ErrorIs(t, err, refusal) + require.Equal(t, []*ateapipb.FencingToken{token}, sent) + require.False(t, c.fencingUnsupported.Load()) + }) + } +} diff --git a/go/core/internal/substrate/lifecycle.go b/go/core/internal/substrate/lifecycle.go index 60d6879738..6531b017be 100644 --- a/go/core/internal/substrate/lifecycle.go +++ b/go/core/internal/substrate/lifecycle.go @@ -18,7 +18,7 @@ type LifecycleClient interface { EnsureActorEgressPolicy(context.Context, string, string, *ateapipb.EgressPolicy) error ResumeActor(context.Context, string, string) (*ateapipb.Actor, error) SuspendActor(context.Context, string, string) (*ateapipb.Actor, error) - DeleteActor(context.Context, string, string) error + DeleteActor(context.Context, string, string, bool) error } var _ LifecycleClient = (*Client)(nil) @@ -163,16 +163,30 @@ func ApplyActorTransition(ctx context.Context, actors LifecycleClient, transitio } case apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_DELETE: if actor != nil { + // Substrate deletes a SUSPENDED or CRASHED Actor as it is and any + // other state only when asked to. A live Actor is suspended first. + // A PAUSED Actor is deleted as it is: its checkpoint is a node-local + // copy that suspending would upload from the node it was taken on, + // and when that node is gone the upload never completes. A live + // Actor whose suspend fails is deleted as it is for the same reason: + // a suspend left half-way on a lost node would otherwise keep the + // Actor from ever being deleted. + anyState := false switch actor.GetStatus().GetState() { case ateapipb.ActorState_ACTOR_STATE_SUSPENDED, ateapipb.ActorState_ACTOR_STATE_CRASHED, ateapipb.ActorState_ACTOR_STATE_DELETING: + case ateapipb.ActorState_ACTOR_STATE_PAUSED: + anyState = true default: - actor, err = actors.SuspendActor(ctx, binding.Atespace, binding.Name) - if err == nil { - err = transition.checkResult(actor, ateapipb.ActorState_ACTOR_STATE_SUSPENDED) + suspended, suspendErr := actors.SuspendActor(ctx, binding.Atespace, binding.Name) + switch { + case suspendErr != nil && status.Code(suspendErr) != codes.NotFound: + anyState = true + case suspendErr == nil: + err = transition.checkResult(suspended, ateapipb.ActorState_ACTOR_STATE_SUSPENDED) } } if err == nil { - err = actors.DeleteActor(ctx, binding.Atespace, binding.Name) + err = actors.DeleteActor(ctx, binding.Atespace, binding.Name, anyState) if status.Code(err) == codes.NotFound { err = nil } diff --git a/go/core/internal/substrate/lifecycle_test.go b/go/core/internal/substrate/lifecycle_test.go index dc1f22af4c..5c9ffa20b7 100644 --- a/go/core/internal/substrate/lifecycle_test.go +++ b/go/core/internal/substrate/lifecycle_test.go @@ -16,10 +16,12 @@ import ( // Unexpected calls use the embedded nil interface and fail the test. type transitionActors struct { LifecycleClient - actor *ateapipb.Actor - calls []string - policyErr error - resumeUID string + actor *ateapipb.Actor + calls []string + policyErr error + resumeUID string + anyState []bool + suspendErr error } func (a *transitionActors) GetActor(context.Context, string, string) (*ateapipb.Actor, error) { @@ -55,12 +57,16 @@ func (a *transitionActors) ResumeActor(context.Context, string, string) (*ateapi func (a *transitionActors) SuspendActor(context.Context, string, string) (*ateapipb.Actor, error) { a.calls = append(a.calls, "suspend") + if a.suspendErr != nil { + return nil, a.suspendErr + } a.actor.Status.State = ateapipb.ActorState_ACTOR_STATE_SUSPENDED return proto.CloneOf(a.actor), nil } -func (a *transitionActors) DeleteActor(context.Context, string, string) error { +func (a *transitionActors) DeleteActor(_ context.Context, _, _ string, anyState bool) error { a.calls = append(a.calls, "delete") + a.anyState = append(a.anyState, anyState) a.actor = nil return nil } @@ -165,6 +171,7 @@ func TestActorDeleteStopsRunningCompute(t *testing.T) { require.NoError(t, err) require.NoError(t, ApplyActorTransition(t.Context(), actors, transition)) require.Equal(t, []string{"suspend", "delete"}, actors.calls) + require.Equal(t, []bool{false}, actors.anyState) actors.calls = nil transition, err = PrepareActorTransition(t.Context(), actors, binding, apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_DELETE, nil) @@ -172,3 +179,47 @@ func TestActorDeleteStopsRunningCompute(t *testing.T) { require.NoError(t, ApplyActorTransition(t.Context(), actors, transition)) require.Empty(t, actors.calls, "a missing Actor already satisfies deletion") } + +// A PAUSED Actor's checkpoint is node-local; suspending it first would upload +// from a node that may be gone, so deletion takes it as it is. +func TestActorDeleteTakesAPausedActorAsItIs(t *testing.T) { + actors := &transitionActors{} + _, err := actors.CreateActor(t.Context(), "team-a", "instance", "team-a", "revision") + require.NoError(t, err) + actors.actor.Status.State = ateapipb.ActorState_ACTOR_STATE_PAUSED + actors.calls = nil + binding := ActorBinding{Atespace: "team-a", Name: "instance", TemplateAtespace: "team-a", TemplateName: "revision"} + transition, err := PrepareActorTransition(t.Context(), actors, binding, apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_DELETE, nil) + require.NoError(t, err) + require.NoError(t, ApplyActorTransition(t.Context(), actors, transition)) + require.Equal(t, []string{"delete"}, actors.calls) + require.Equal(t, []bool{true}, actors.anyState) +} + +// A live Actor whose pre-delete suspend fails is deleted as it is: a suspend +// that cannot upload its checkpoint must not keep the session from deletion. +// A suspend that finds no Actor leaves nothing to delete. +func TestActorDeleteTakesAnUnsuspendableActorAsItIs(t *testing.T) { + for name, test := range map[string]struct { + suspendErr error + anyState []bool + }{ + "upload failed": {suspendErr: status.Error(codes.Internal, "UploadPausedCheckpoint: while getting atelet conn for node"), anyState: []bool{true}}, + "actor is gone": {suspendErr: status.Error(codes.NotFound, "missing"), anyState: []bool{false}}, + } { + t.Run(name, func(t *testing.T) { + actors := &transitionActors{suspendErr: test.suspendErr} + _, err := actors.CreateActor(t.Context(), "team-a", "instance", "team-a", "revision") + require.NoError(t, err) + actors.actor.Status.State = ateapipb.ActorState_ACTOR_STATE_RUNNING + actors.calls = nil + binding := ActorBinding{Atespace: "team-a", Name: "instance", TemplateAtespace: "team-a", TemplateName: "revision"} + transition, err := PrepareActorTransition(t.Context(), actors, binding, apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_DELETE, nil) + require.NoError(t, err) + require.NoError(t, ApplyActorTransition(t.Context(), actors, transition)) + require.Equal(t, []string{"suspend", "delete"}, actors.calls) + require.Equal(t, test.anyState, actors.anyState) + require.Nil(t, actors.actor) + }) + } +} diff --git a/go/core/internal/substrate/list.go b/go/core/internal/substrate/list.go index 841cf20fa3..20ed67f1a0 100644 --- a/go/core/internal/substrate/list.go +++ b/go/core/internal/substrate/list.go @@ -114,3 +114,14 @@ func (c *Client) ListActorTemplates(ctx context.Context, atespace string) ([]*at return c.ListActorTemplatesPage(ctx, atespace, pageToken) }) } + +// ListAllWorkers reads every worker of the pool. The check whether a paused +// Actor's checkpoint node still runs a worker wants the whole set, not a page. +func (c *Client) ListAllWorkers(ctx context.Context) ([]*ateapipb.Worker, error) { + if c == nil { + return nil, nil + } + return drainPages(ctx, func(ctx context.Context, pageToken string) ([]*ateapipb.Worker, string, error) { + return c.ListWorkersPage(ctx, 500, pageToken) + }) +} diff --git a/go/core/internal/substrate/policy.go b/go/core/internal/substrate/policy.go index 64bcb848fa..67dd233f8d 100644 --- a/go/core/internal/substrate/policy.go +++ b/go/core/internal/substrate/policy.go @@ -11,7 +11,6 @@ import ( "github.com/agent-substrate/substrate/pkg/proto/ateapipb" "github.com/kagent-dev/kagent/go/core/internal/egress" - "k8s.io/apimachinery/pkg/util/validation" ) // ActorEgressPolicy compiles HTTP(S) origins into an actor's default allowlist. @@ -25,26 +24,20 @@ func ActorEgressPolicy(atespace string, destinations []string, credentials []egr protocols := map[string]string{} hosts := map[string]bool{} for _, destination := range destinations { - u, err := url.Parse(destination) - if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Hostname() == "" || u.ForceQuery || u.RawQuery != "" || u.Fragment != "" || u.Path != "" { - return nil, fmt.Errorf("invalid egress destination %q: expected an HTTP(S) origin", destination) + if u, err := url.Parse(destination); err == nil { + if _, err := netip.ParseAddr(strings.TrimSuffix(u.Hostname(), ".")); err == nil { + return nil, fmt.Errorf("egress destination %q requires a DNS name: Substrate does not support IP allowlists", destination) + } } - host := strings.TrimSuffix(strings.ToLower(u.Hostname()), ".") - if _, err := netip.ParseAddr(host); err == nil { - return nil, fmt.Errorf("egress destination %q requires a DNS name: Substrate does not support IP allowlists", destination) - } - if len(validation.IsDNS1123Subdomain(host)) != 0 { - return nil, fmt.Errorf("invalid egress destination %q", destination) - } - origin := egress.Origin(u) - u, err = url.Parse(origin) + origin, err := egress.ParseOrigin(destination) if err != nil { - return nil, fmt.Errorf("invalid egress destination %q: %w", destination, err) - } - port, err := strconv.ParseUint(u.Port(), 10, 16) - if err != nil || port == 0 { - return nil, fmt.Errorf("invalid egress port in %q", destination) + return nil, fmt.Errorf("invalid egress destination: %w", err) } + // ParseOrigin returns scheme://host:port with a valid port; the port is + // re-encoded so "0443" and "443" are one origin. + u, _ := url.Parse(origin) + host := u.Hostname() + port, _ := strconv.ParseUint(u.Port(), 10, 16) u.Host = net.JoinHostPort(host, strconv.FormatUint(port, 10)) if protocol, found := protocols[u.Host]; found && protocol != u.Scheme { return nil, fmt.Errorf("egress destination %q uses both HTTP and HTTPS on the same port", u.Host) @@ -72,8 +65,10 @@ func ActorEgressPolicy(atespace string, destinations []string, credentials []egr for _, origin := range names { target := origins[origin] ports := &ateapipb.Ports{Numbers: []int32{target.port}} - // Keep exactly one rule per host and port so an overlapping rule cannot - // bypass credential replacement. + // Keep exactly one rule per host pattern and port. A wildcard rule can + // overlap an exact one, but Substrate decides on the exact hostname + // first (EgressPolicy in proto/ateapi.proto), and credentials bind only + // to exact hostnames, so a wildcard cannot bypass credential replacement. if target.scheme == "https" { policy.Rules = append(policy.Rules, &ateapipb.EgressRule{Https: &ateapipb.HTTPSRule{Hostnames: []string{target.hostname}, Ports: ports, Effects: effects[target.hostname]}}) } else { diff --git a/go/core/internal/translator/adkconfig/builder.go b/go/core/internal/translator/adkconfig/builder.go index 5f5a2a48a5..e730666727 100644 --- a/go/core/internal/translator/adkconfig/builder.go +++ b/go/core/internal/translator/adkconfig/builder.go @@ -107,13 +107,14 @@ func (c *Builder) compileAgent(ctx context.Context, input *v2translator.AgentInp stream = modelConfig.Spec.Stream } cfg := &adk.AgentConfig{Model: modelRuntime.Model, Description: input.Template.Spec.Description, Instruction: input.Instruction, Stream: stream} - pluginConfig, pluginEgress, err := v2translator.CompileSkillResources(input.Template) + skills, err := v2translator.CompileSkillResources(input.Template) if err != nil { return nil, err } - if len(pluginConfig.Skills) > 0 || len(pluginConfig.Plugins) > 0 { - cfg.AgentPlugins = &pluginConfig + if len(skills.Resources.Skills) > 0 || len(skills.Resources.Plugins) > 0 { + cfg.AgentPlugins = &skills.Resources } + modelRuntime.Environment = append(modelRuntime.Environment, skills.Environment...) for _, tool := range input.MCPTools { headers, credentialEnv, err := c.resolveAgentTemplateHeaders(ctx, input.Template.Namespace, tool.Server.Spec.HeadersFrom) if err != nil { @@ -129,7 +130,7 @@ func (c *Builder) compileAgent(ctx context.Context, input *v2translator.AgentInp result := &Result{ Config: cfg, Templates: []*v2translator.TemplateConfiguration{input.Template}, Environment: modelRuntime.Environment, - Egress: append(agentConfigDestinations(cfg, modelConfig, modelRuntime.Model), pluginEgress...), + Egress: append(agentConfigDestinations(cfg, modelConfig, modelRuntime.Model), skills.Egress...), } if modelConfig != nil { result.Models = []*v2translator.ResolvedModelConfig{input.ResolvedModelConfig} diff --git a/go/core/internal/translator/adkconfig/model.go b/go/core/internal/translator/adkconfig/model.go index fd62885c37..ed9a5b476e 100644 --- a/go/core/internal/translator/adkconfig/model.go +++ b/go/core/internal/translator/adkconfig/model.go @@ -168,6 +168,8 @@ func translateModel(resolved *v2translator.ResolvedModelConfig) (adk.Model, *mod if spec.TopK > 0 { anthropic.TopK = &spec.TopK } + anthropic.PromptCaching = spec.PromptCaching + anthropic.CacheTTL = spec.CacheTTL } return anthropic, modelDeploymentData, nil case v1alpha3.ModelProviderAzureOpenAI: @@ -316,23 +318,28 @@ func translateModel(resolved *v2translator.ResolvedModelConfig) (adk.Model, *mod if model.Spec.Ollama == nil { return nil, nil, fmt.Errorf("ollama model config is required") } - // Only set a host when the operator gave one. An empty value keeps - // OLLAMA_API_BASE unset so the runtime applies its own cloud/local - // routing; writing a default here would look operator-chosen and pin - // every cloud model to the local daemon. - if model.Spec.Ollama.Host != "" { - modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ - Name: env.OllamaAPIBase.Name(), - Value: withDefaultScheme(model.Spec.Ollama.Host), - }) - } // Bind the Secret only when the model reaches api.ollama.com; the // gateway injects the key at egress, while the agent sees only a // placeholder. The same predicate decides the credential binding. // Local models and operator-supplied daemon hosts have no cloud // binding, so they must not receive a Secret-backed environment ref. - if !model.Spec.APIKeyPassthrough && model.Spec.APIKeySecret != "" && - models.OllamaReachesCloud(model.Spec.Model, model.Spec.Ollama.Host, true) { + cloud := !model.Spec.APIKeyPassthrough && model.Spec.APIKeySecret != "" && + models.OllamaReachesCloud(model.Spec.Model, model.Spec.Ollama.Host, true) + // OLLAMA_API_BASE is the operator's host, or the cloud endpoint when + // the predicate routes there: the Python runtime has no routing of its + // own and would send a cloud model to the local daemon. A local model + // without a host leaves it unset for the runtime's default. + host := model.Spec.Ollama.Host + if host == "" && cloud { + host = models.OllamaCloudURL + } + if host != "" { + modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ + Name: env.OllamaAPIBase.Name(), + Value: withDefaultScheme(host), + }) + } + if cloud { modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ Name: env.OllamaAPIKey.Name(), ValueFrom: &corev1.EnvVarSource{ @@ -351,6 +358,7 @@ func translateModel(resolved *v2translator.ResolvedModelConfig) (adk.Model, *mod Headers: model.Spec.DefaultHeaders, }, Options: model.Spec.Ollama.Options, + Think: model.Spec.Ollama.Think, } // Populate TLS fields in BaseModel populateTLSFields(&ollama.BaseModel, model.Spec.TLS) diff --git a/go/core/internal/translator/adkconfig/model_test.go b/go/core/internal/translator/adkconfig/model_test.go index c3d7428609..904fc98439 100644 --- a/go/core/internal/translator/adkconfig/model_test.go +++ b/go/core/internal/translator/adkconfig/model_test.go @@ -3,10 +3,12 @@ package adkconfig import ( "testing" + "github.com/kagent-dev/kagent/go/api/adk" "github.com/kagent-dev/kagent/go/api/v1alpha3" v2translator "github.com/kagent-dev/kagent/go/core/internal/translator" "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" ) @@ -57,15 +59,16 @@ func TestRenderBedrockCredentialsFromReferences(t *testing.T) { } // The Ollama key reaches the agent as an environment variable read from a -// Secret, never serialized into the agent config, and the host is written only -// when the operator set one — an empty value lets the runtime apply its own -// cloud/local routing instead of looking like a chosen endpoint. +// Secret, never serialized into the agent config. The host is the operator's, +// or the cloud endpoint for a model the routing predicate sends there, since +// the Python runtime does no routing of its own. func TestTranslateOllamaEnvironment(t *testing.T) { tests := []struct { name string spec v1alpha3.ModelConfigSpec wantNames []string noEnv string + wantHost string }{ { name: "cloud model mounts the key from a Secret", @@ -74,8 +77,8 @@ func TestTranslateOllamaEnvironment(t *testing.T) { APIKeySecret: "ollama-cloud", APIKeySecretKey: "OLLAMA_API_KEY", Ollama: &v1alpha3.OllamaConfig{}, }, - wantNames: []string{env.OllamaAPIKey.Name()}, - noEnv: env.OllamaAPIBase.Name(), + wantNames: []string{env.OllamaAPIBase.Name(), env.OllamaAPIKey.Name()}, + wantHost: "https://api.ollama.com", }, { name: "an explicit host is passed through", @@ -178,6 +181,9 @@ func TestTranslateOllamaEnvironment(t *testing.T) { } require.ElementsMatch(t, tt.wantNames, names) require.NotContains(t, names, tt.noEnv) + if tt.wantHost != "" { + require.Contains(t, data.EnvVars, corev1.EnvVar{Name: env.OllamaAPIBase.Name(), Value: tt.wantHost}) + } }) } } @@ -209,3 +215,67 @@ func TestWithDefaultScheme(t *testing.T) { }) } } + +func TestTranslateAnthropicPromptCaching(t *testing.T) { + tests := []struct { + name string + spec *v1alpha3.AnthropicConfig + wantCaching bool + wantTTL string + }{ + {name: "no provider block", spec: nil}, + // cacheTTL is passed through as configured; the runtime only reads it when caching is on. + {name: "disabled", spec: &v1alpha3.AnthropicConfig{CacheTTL: "5m"}, wantTTL: "5m"}, + {name: "enabled with the default TTL", spec: &v1alpha3.AnthropicConfig{PromptCaching: true, CacheTTL: "5m"}, wantCaching: true, wantTTL: "5m"}, + {name: "enabled with the 1h TTL", spec: &v1alpha3.AnthropicConfig{PromptCaching: true, CacheTTL: "1h"}, wantCaching: true, wantTTL: "1h"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resolved := &v2translator.ResolvedModelConfig{ + Config: &v1alpha3.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Namespace: "test"}, + Spec: v1alpha3.ModelConfigSpec{ + Provider: v1alpha3.ModelProviderAnthropic, Model: "claude-sonnet-4-6", + Anthropic: tt.spec, + }, + }, + } + got, _, err := translateModel(resolved) + require.NoError(t, err) + anthropic, ok := got.(*adk.Anthropic) + require.True(t, ok, "model is %T, want *adk.Anthropic", got) + require.Equal(t, tt.wantCaching, anthropic.PromptCaching) + require.Equal(t, tt.wantTTL, anthropic.CacheTTL) + }) + } +} + +func TestTranslateOllamaThink(t *testing.T) { + off, on := false, true + tests := []struct { + name string + think *bool + }{ + {name: "unset", think: nil}, + {name: "off", think: &off}, + {name: "on", think: &on}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resolved := &v2translator.ResolvedModelConfig{ + Config: &v1alpha3.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Namespace: "test"}, + Spec: v1alpha3.ModelConfigSpec{ + Provider: v1alpha3.ModelProviderOllama, Model: "qwen3.5:2b", + Ollama: &v1alpha3.OllamaConfig{Host: "http://ollama:11434", Think: tt.think}, + }, + }, + } + got, _, err := translateModel(resolved) + require.NoError(t, err) + ollama, ok := got.(*adk.Ollama) + require.True(t, ok, "model is %T, want *adk.Ollama", got) + require.Equal(t, tt.think, ollama.Think) + }) + } +} diff --git a/go/core/internal/translator/claude/compiler.go b/go/core/internal/translator/claude/compiler.go index 455fa825d5..0a1ee505f1 100644 --- a/go/core/internal/translator/claude/compiler.go +++ b/go/core/internal/translator/claude/compiler.go @@ -10,9 +10,11 @@ import ( "net/url" "reflect" "slices" + "strconv" "strings" "github.com/a2aproject/a2a-go/v2/a2apb/v1/pbconv" + "github.com/kagent-dev/kagent/go/api/adk" "github.com/kagent-dev/kagent/go/api/v1alpha3" "github.com/kagent-dev/kagent/go/core/internal/egress" v2translator "github.com/kagent-dev/kagent/go/core/internal/translator" @@ -57,7 +59,7 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput if err != nil { return nil, err } - skillResources, skillEgress, err := v2translator.CompileSkillResources(input.Root.Template) + skills, err := v2translator.CompileSkillResources(input.Root.Template) if err != nil { return nil, err } @@ -67,6 +69,7 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput } environment := append([]corev1.EnvVar(nil), providerEnvironment...) environment = append(environment, mcp.environment...) + environment = append(environment, skills.Environment...) harnessAttributes := v2translator.HarnessResourceAttributes(input.Harness) for _, variable := range input.Harness.Spec.Env { if v2translator.IsResourceAttributesVariable(variable.Name) { @@ -84,6 +87,12 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput corev1.EnvVar{Name: env.KagentName.Name(), Value: input.AgentName}, corev1.EnvVar{Name: env.KagentNamespace.Name(), Value: template.Namespace}, corev1.EnvVar{Name: env.KagentAPIURL.Name(), Value: fmt.Sprintf("http://%s.%s:8083", utils.GetControllerName(), utils.GetResourceNamespace())}, + // Claude sends these on every model call: the identity the Go ADK runtime + // sends from KAGENT_AGENT_TEMPLATE and KAGENT_NAMESPACE. The Claude harness + // knows no user per turn, so it names the agent only. + corev1.EnvVar{Name: claudeconfig.AnthropicCustomHeadersEnvName, Value: claudeconfig.CustomHeaders(map[string]string{ + adk.AgentHeader: input.Root.Template.Name, adk.AgentNamespaceHeader: input.Root.Template.Namespace, + })}, ) environment = append(environment, telemetryConfig.TelemetryEnvironment(runtimeTelemetry, harnessAttributes)...) // The adapter derives Claude Code's own telemetry flags; raw bodies have no @@ -99,10 +108,14 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput config := claudeconfig.Production(model.Spec.Model, input.Root.Instruction) config.Agents = localAgents config.RuntimeTelemetry = runtimeTelemetry - if len(skillResources.Skills) != 0 || len(skillResources.Plugins) != 0 { - config.SkillResources = &skillResources + if len(skills.Resources.Skills) != 0 || len(skills.Resources.Plugins) != 0 { + config.SkillResources = &skills.Resources } config.MCPServers = mcp.servers + if limits := input.Harness.Spec.Claude.Limits; limits != nil { + config.MaxBudgetUSD = limits.BudgetUSD + config.MaxTurns = int(limits.MaxTurns) + } if err := config.Validate(); err != nil { return nil, v2translator.NewValidationError("invalid compiled Claude configuration: %v", err) } @@ -123,7 +136,7 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput return nil, err } - egress = append(egress, skillEgress...) + egress = append(egress, skills.Egress...) egress = append(egress, mcp.egress...) egress = append(egress, telemetryConfig.Destinations()...) egress = append(egress, "http://"+utils.GetControllerName()+"."+utils.GetResourceNamespace()+":8083") @@ -137,11 +150,18 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput WorkerPoolName: harness.Spec.Substrate.WorkerPoolRef.Name, SnapshotLocation: harness.Spec.Substrate.SnapshotPolicy.Location, Credentials: credentials, Provenance: provenance, EgressDestinations: egress, + Capabilities: unprivilegedClaudeCapabilities, }, Warnings: mcp.warnings, }, nil } +// unprivilegedClaudeCapabilities let the harness, which Substrate starts as +// root, hand the workspace and Claude's state to the image's unprivileged user +// and start Claude Code as that user, so Claude and its tools cannot read the +// harness process that holds the turn's credential. +var unprivilegedClaudeCapabilities = []string{"CHOWN", "SETGID", "SETUID"} + func (c *Compiler) compileLocalAgents(root *v2translator.AgentInput) (map[string]claudeconfig.Agent, error) { if len(root.Shared) == 0 { return nil, nil @@ -177,6 +197,16 @@ func (c *Compiler) compileLocalAgents(root *v2translator.AgentInput) (map[string return agents, nil } +// maxOutputTokens caps the output of every model request Claude Code makes, as +// maxTokens caps a Go ADK model call. Claude Code lowers a value above the +// model's own cap to that cap. +func maxOutputTokens(tokens int) []corev1.EnvVar { + if tokens <= 0 { + return nil + } + return []corev1.EnvVar{{Name: claudeconfig.MaxOutputTokensEnvName, Value: strconv.Itoa(tokens)}} +} + func sameProviderConfiguration(root, child v1alpha3.ModelConfigSpec) bool { root.Model, child.Model = "", "" return reflect.DeepEqual(root, child) @@ -186,12 +216,23 @@ func (c *Compiler) provider(ctx context.Context, model *v1alpha3.ModelConfig) ([ switch model.Spec.Provider { case v1alpha3.ModelProviderAnthropic: var baseURL string + var maxTokens int if model.Spec.Anthropic != nil { options := *model.Spec.Anthropic baseURL = strings.TrimSpace(options.BaseURL) options.BaseURL = "" + maxTokens = options.MaxTokens + options.MaxTokens = 0 + // "5m" is the CRD default and matches Claude Code's native cache TTL. + if options.CacheTTL == "5m" { + options.CacheTTL = "" + } + // Claude Code caches prompts on its own, so promptCaching asks for + // what already happens; a ModelConfig shared with the Go ADK runtime + // carries it. Nothing is passed on. + options.PromptCaching = false if !reflect.DeepEqual(options, v1alpha3.AnthropicConfig{}) { - return nil, nil, v2translator.NewValidationError("Claude does not support Anthropic provider options beyond baseUrl yet") + return nil, nil, v2translator.NewValidationError("Claude does not support Anthropic provider options beyond baseUrl, maxTokens, promptCaching and a 5m cacheTTL yet") } } if err := c.requireSecretKey(ctx, model, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey, false); err != nil { @@ -207,7 +248,7 @@ func (c *Compiler) provider(ctx context.Context, model *v1alpha3.ModelConfig) ([ environment = append(environment, corev1.EnvVar{Name: claudeconfig.AnthropicBaseURLEnvName, Value: baseURL}) egress = []string{hostname} } - return environment, egress, nil + return append(environment, maxOutputTokens(maxTokens)...), egress, nil case v1alpha3.ModelProviderBedrock: if model.Spec.Bedrock == nil || strings.TrimSpace(model.Spec.Bedrock.Region) == "" { @@ -219,8 +260,10 @@ func (c *Compiler) provider(ctx context.Context, model *v1alpha3.ModelConfig) ([ if options.CacheTTL == "5m" { options.CacheTTL = "" } + // Claude Code caches prompts on its own, as for Anthropic. + options.PromptCaching = false if !reflect.DeepEqual(options, v1alpha3.BedrockConfig{}) { - return nil, nil, v2translator.NewValidationError("Claude does not support Bedrock provider options beyond region yet") + return nil, nil, v2translator.NewValidationError("Claude does not support Bedrock provider options beyond region, promptCaching and a 5m cacheTTL yet") } if model.Spec.APIKeySecret == "" { return nil, nil, v2translator.NewValidationError("Claude Bedrock requires apiKeySecret with AWS credentials") @@ -253,18 +296,18 @@ func (c *Compiler) provider(ctx context.Context, model *v1alpha3.ModelConfig) ([ return nil, nil, v2translator.NewValidationError("Claude Vertex requires anthropicVertexAI.projectID and location") } options := *model.Spec.AnthropicVertexAI - options.ProjectID, options.Location = "", "" + options.ProjectID, options.Location, options.MaxTokens = "", "", 0 if !reflect.DeepEqual(options, v1alpha3.AnthropicVertexAIConfig{}) { - return nil, nil, v2translator.NewValidationError("Claude does not support AnthropicVertexAI provider options beyond projectID and location yet") + return nil, nil, v2translator.NewValidationError("Claude does not support AnthropicVertexAI provider options beyond projectID, location and maxTokens yet") } if err := c.requireGoogleCredentials(ctx, model); err != nil { return nil, nil, err } cfg := model.Spec.AnthropicVertexAI - return []corev1.EnvVar{ + return append([]corev1.EnvVar{ {Name: claudeconfig.UseVertexEnvName, Value: "1"}, {Name: claudeconfig.VertexProjectEnvName, Value: cfg.ProjectID}, {Name: claudeconfig.VertexRegionEnvName, Value: cfg.Location}, secretEnvironment(claudeconfig.GoogleCredentialsJSONEnvName, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey), - }, []string{"https://" + vertexHostname(cfg.Location) + ":443", "https://oauth2.googleapis.com:443"}, nil + }, maxOutputTokens(cfg.MaxTokens)...), []string{"https://" + vertexHostname(cfg.Location) + ":443", "https://oauth2.googleapis.com:443"}, nil default: return nil, nil, v2translator.NewValidationError("Claude does not support ModelConfig provider %q", model.Spec.Provider) } diff --git a/go/core/internal/translator/claude/compiler_test.go b/go/core/internal/translator/claude/compiler_test.go index 2fcb87eba6..7da31bf36b 100644 --- a/go/core/internal/translator/claude/compiler_test.go +++ b/go/core/internal/translator/claude/compiler_test.go @@ -44,12 +44,25 @@ func TestCompileProviderCredentials(t *testing.T) { name: "Anthropic gateway", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropic, Model: "claude-sonnet-4-5", APIKeySecret: "model-auth", APIKeySecretKey: "api-key", - Anthropic: &v1alpha3.AnthropicConfig{BaseURL: "http://host.docker.internal:8090/anthropic"}}, + // cacheTTL is defaulted to "5m" by the CRD whenever the anthropic block is set. + Anthropic: &v1alpha3.AnthropicConfig{BaseURL: "http://host.docker.internal:8090/anthropic", CacheTTL: "5m"}}, secretData: map[string][]byte{"api-key": []byte(credentialValue)}, wantEnv: map[string]string{claudeconfig.AnthropicAPIKeyEnvName: v2translator.CredentialPlaceholder, claudeconfig.AnthropicBaseURLEnvName: "http://host.docker.internal:8090/anthropic"}, wantEgress: []string{"http://host.docker.internal:8090", "http://kagent-controller.kagent:8083"}, }, + { + // An installation's default ModelConfig sets maxTokens for the Go ADK + // runtime; the same ModelConfig caps Claude Code's output per request. + name: "Anthropic gateway with the shared maxTokens and prompt caching options", + model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropic, Model: "claude-sonnet-4-6", + APIKeySecret: "model-auth", APIKeySecretKey: "api-key", + Anthropic: &v1alpha3.AnthropicConfig{BaseURL: "http://host.docker.internal:8090/anthropic", MaxTokens: 32000, PromptCaching: true, CacheTTL: "5m"}}, + secretData: map[string][]byte{"api-key": []byte(credentialValue)}, + wantEnv: map[string]string{claudeconfig.AnthropicAPIKeyEnvName: v2translator.CredentialPlaceholder, + claudeconfig.AnthropicBaseURLEnvName: "http://host.docker.internal:8090/anthropic", claudeconfig.MaxOutputTokensEnvName: "32000"}, + wantEgress: []string{"http://host.docker.internal:8090", "http://kagent-controller.kagent:8083"}, + }, { name: "Bedrock IAM", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderBedrock, Model: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", @@ -65,6 +78,14 @@ func TestCompileProviderCredentials(t *testing.T) { wantEnv: map[string]string{claudeconfig.UseBedrockEnvName: "1", claudeconfig.AWSRegionEnvName: "us-west-2", claudeconfig.AWSBedrockTokenEnvName: v2translator.CredentialPlaceholder}, wantEgress: []string{"http://kagent-controller.kagent:8083", "https://bedrock-runtime.us-west-2.amazonaws.com:443"}, }, + { + name: "Bedrock API key with the shared prompt caching options", + model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderBedrock, Model: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + APIKeySecret: "model-auth", Bedrock: &v1alpha3.BedrockConfig{Region: "us-west-2", PromptCaching: true, CacheTTL: "5m"}}, + secretData: map[string][]byte{claudeconfig.AWSBedrockTokenEnvName: []byte(credentialValue)}, + wantEnv: map[string]string{claudeconfig.UseBedrockEnvName: "1", claudeconfig.AWSRegionEnvName: "us-west-2", claudeconfig.AWSBedrockTokenEnvName: v2translator.CredentialPlaceholder}, + wantEgress: []string{"http://kagent-controller.kagent:8083", "https://bedrock-runtime.us-west-2.amazonaws.com:443"}, + }, { name: "Anthropic Vertex AI", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, Model: "claude-sonnet-4-5@20250929", @@ -110,6 +131,10 @@ func TestCompileProviderCredentials(t *testing.T) { if gotEnvironment[claudeconfig.SandboxEnvName] != "1" { t.Errorf("environment[%s] = %q, want %q", claudeconfig.SandboxEnvName, gotEnvironment[claudeconfig.SandboxEnvName], "1") } + // The template's identity on every model call, in Claude's "Name: Value" lines. + if want := "x-kagent-agent: assistant\nx-kagent-agent-namespace: test"; gotEnvironment[claudeconfig.AnthropicCustomHeadersEnvName] != want { + t.Errorf("environment[%s] = %q, want %q", claudeconfig.AnthropicCustomHeadersEnvName, gotEnvironment[claudeconfig.AnthropicCustomHeadersEnvName], want) + } if !reflect.DeepEqual(revision.EgressDestinations, tt.wantEgress) { t.Errorf("egress = %v", revision.EgressDestinations) } @@ -142,6 +167,9 @@ func TestCompileTracing(t *testing.T) { if err != nil { t.Fatal(err) } + if !reflect.DeepEqual(revision.Capabilities, []string{"CHOWN", "SETGID", "SETUID"}) { + t.Fatalf("a Claude revision asks for what running Claude Code unprivileged needs, got %v", revision.Capabilities) + } if !reflect.DeepEqual(revision.EgressDestinations, []string{"http://collector:4317", "http://kagent-controller.kagent:8083", "https://api.anthropic.com:443"}) { t.Fatalf("egress = %v", revision.EgressDestinations) } @@ -235,6 +263,35 @@ func TestCompileLogging(t *testing.T) { } } +func TestCompileAcceptsTheSharedPromptCachingModelConfig(t *testing.T) { + // The platform's default ModelConfig carries the Go ADK runtime's prompt + // caching options; Claude Code caches on its own, so the same ModelConfig + // serves both runtimes. + model := v1alpha3.ModelConfigSpec{ + Provider: v1alpha3.ModelProviderAnthropic, Model: "claude-sonnet-4-6", + APIKeySecret: "model-auth", APIKeySecretKey: "api-key", + Anthropic: &v1alpha3.AnthropicConfig{PromptCaching: true, CacheTTL: "5m"}, + } + input, reader := testInput(t, model, map[string][]byte{"api-key": []byte("secret")}) + if _, err := NewCompiler(krt.TestingDummyContext{}, reader).Compile(context.Background(), input); err != nil { + t.Fatalf("Compile() error = %v; promptCaching with the default cacheTTL is accepted", err) + } +} + +func TestCompileRejectsAHarnessSettingClaudesOutputCap(t *testing.T) { + model := v1alpha3.ModelConfigSpec{ + Provider: v1alpha3.ModelProviderAnthropic, Model: "claude-sonnet-4-6", + APIKeySecret: "model-auth", APIKeySecretKey: "api-key", + } + input, reader := testInput(t, model, map[string][]byte{"api-key": []byte("secret")}) + input.Harness.Spec.Env = []v1alpha3.RuntimeEnvVar{{Name: claudeconfig.MaxOutputTokensEnvName, Value: "64000"}} + _, err := NewCompiler(krt.TestingDummyContext{}, reader).Compile(t.Context(), input) + var validation *v2translator.ValidationError + if !errors.As(err, &validation) { + t.Fatalf("Compile() error = %v, want validation error: the ModelConfig owns the output cap", err) + } +} + func TestCompileRejectsUnsupportedConfiguration(t *testing.T) { tests := []struct { name string @@ -244,9 +301,10 @@ func TestCompileRejectsUnsupportedConfiguration(t *testing.T) { {name: "passthrough", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropic, Model: "claude", APIKeyPassthrough: true}}, {name: "headers", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropic, Model: "claude", DefaultHeaders: map[string]string{"x": "y"}}}, {name: "Anthropic options", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropic, Model: "claude", Anthropic: &v1alpha3.AnthropicConfig{Temperature: "0.5"}}}, + {name: "Anthropic cache TTL", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropic, Model: "claude", APIKeySecret: "model-auth", APIKeySecretKey: "api-key", Anthropic: &v1alpha3.AnthropicConfig{CacheTTL: "1h"}}}, {name: "Anthropic relative base URL", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropic, Model: "claude", APIKeySecret: "model-auth", APIKeySecretKey: "api-key", Anthropic: &v1alpha3.AnthropicConfig{BaseURL: "/v1"}}}, {name: "Anthropic base URL credentials", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropic, Model: "claude", APIKeySecret: "model-auth", APIKeySecretKey: "api-key", Anthropic: &v1alpha3.AnthropicConfig{BaseURL: "https://user:password@example.com"}}}, - {name: "Bedrock options", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderBedrock, Model: "claude", APIKeySecret: "model-auth", Bedrock: &v1alpha3.BedrockConfig{Region: "us-east-1", PromptCaching: true}}}, + {name: "Bedrock options", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderBedrock, Model: "claude", APIKeySecret: "model-auth", Bedrock: &v1alpha3.BedrockConfig{Region: "us-east-1", PromptCaching: true, CacheTTL: "1h"}}}, {name: "Vertex options", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, Model: "claude", APIKeySecret: "model-auth", APIKeySecretKey: "credentials.json", AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "global", Temperature: "0.5"}}}}, } for _, tt := range tests { @@ -267,12 +325,14 @@ func TestCompileRejectsProviderOwnedHarnessEnvironment(t *testing.T) { APIKeySecret: "model-auth", APIKeySecretKey: "api-key", } input, reader := testInput(t, model, map[string][]byte{"api-key": []byte("secret")}) - value := "http://mock.example.com" - input.Harness.Spec.Env = []v1alpha3.RuntimeEnvVar{{Name: claudeconfig.AnthropicBaseURLEnvName, Value: value}} - _, err := NewCompiler(krt.TestingDummyContext{}, reader).Compile(context.Background(), input) - var validation *v2translator.ValidationError - if !errors.As(err, &validation) { - t.Fatalf("Compile() error = %v, want validation error", err) + for _, name := range []string{claudeconfig.AnthropicBaseURLEnvName, claudeconfig.AnthropicCustomHeadersEnvName} { + value := "http://mock.example.com" + input.Harness.Spec.Env = []v1alpha3.RuntimeEnvVar{{Name: name, Value: value}} + _, err := NewCompiler(krt.TestingDummyContext{}, reader).Compile(context.Background(), input) + var validation *v2translator.ValidationError + if !errors.As(err, &validation) { + t.Fatalf("Compile() with Harness env %s: error = %v, want validation error", name, err) + } } } @@ -744,3 +804,27 @@ func TestCompiledTelemetryFitsTheActorEnvironmentBudget(t *testing.T) { } t.Logf("worst-case Claude actor uses %d of 32 environment variables", len(template.GetContainers()[0].GetEnv())) } + +func TestCompileTurnLimits(t *testing.T) { + model := v1alpha3.ModelConfigSpec{ + Provider: v1alpha3.ModelProviderAnthropic, Model: "claude-sonnet-4-5", + APIKeySecret: "model-auth", APIKeySecretKey: "api-key", + } + input, reader := testInput(t, model, map[string][]byte{"api-key": []byte("secret")}) + input.Harness.Spec.Claude.Limits = &v1alpha3.ClaudeHarnessLimits{BudgetUSD: "2.50", MaxTurns: 40} + revision, err := NewCompiler(krt.TestingDummyContext{}, reader).Compile(context.Background(), input) + if err != nil { + t.Fatal(err) + } + config, err := claudeconfig.Parse(revision.ConfigJSON) + if err != nil { + t.Fatal(err) + } + if config.MaxBudgetUSD != "2.50" || config.MaxTurns != 40 { + t.Fatalf("compiled limits = %q, %d", config.MaxBudgetUSD, config.MaxTurns) + } + input.Harness.Spec.Claude.Limits = &v1alpha3.ClaudeHarnessLimits{BudgetUSD: "0"} + if _, err := NewCompiler(krt.TestingDummyContext{}, reader).Compile(context.Background(), input); err == nil { + t.Fatal("a zero budget compiled") + } +} diff --git a/go/core/internal/translator/codex/compiler.go b/go/core/internal/translator/codex/compiler.go index 13a51cef5e..674c63abc1 100644 --- a/go/core/internal/translator/codex/compiler.go +++ b/go/core/internal/translator/codex/compiler.go @@ -73,7 +73,7 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput if err != nil { return nil, err } - skillResources, skillEgress, err := v2translator.CompileSkillResources(input.Root.Template) + skills, err := v2translator.CompileSkillResources(input.Root.Template) if err != nil { return nil, err } @@ -82,6 +82,7 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput return nil, err } environment := append(providerEnvironment, mcp.environment...) + environment = append(environment, skills.Environment...) harnessAttributes := v2translator.HarnessResourceAttributes(input.Harness) for _, variable := range input.Harness.Spec.Env { if v2translator.IsResourceAttributesVariable(variable.Name) { @@ -115,8 +116,8 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput cfg.Telemetry.Logs = &codexconfig.OTLPExporter{Endpoint: logConfig.Endpoint, Protocol: logConfig.Protocol} } } - if len(skillResources.Skills) != 0 || len(skillResources.Plugins) != 0 { - cfg.SkillResources = &skillResources + if len(skills.Resources.Skills) != 0 || len(skills.Resources.Plugins) != 0 { + cfg.SkillResources = &skills.Resources } if err := cfg.Validate(); err != nil { return nil, v2translator.NewValidationError("invalid compiled Codex configuration: %v", err) @@ -137,7 +138,7 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput if err != nil { return nil, err } - egress = append(egress, skillEgress...) + egress = append(egress, skills.Egress...) egress = append(egress, mcp.egress...) egress = append(egress, telemetryConfig.Destinations()...) egress = append(egress, "http://"+utils.GetControllerName()+"."+utils.GetResourceNamespace()+":8083") diff --git a/go/core/internal/translator/compiler.go b/go/core/internal/translator/compiler.go index c64977cb6f..bb33788d98 100644 --- a/go/core/internal/translator/compiler.go +++ b/go/core/internal/translator/compiler.go @@ -5,8 +5,10 @@ import ( "encoding/json" "fmt" "maps" + "slices" "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/internal/egress" "istio.io/istio/pkg/kube/krt" "k8s.io/apimachinery/pkg/types" ) @@ -126,6 +128,10 @@ func (c *Compiler) CompileAgent(ctx context.Context, agent *v1alpha3.Agent) (*Co return nil, err } result.AgentUID = string(agent.UID) + result.EgressDestinations, err = withAgentEgress(result.EgressDestinations, agent.Spec.Egress) + if err != nil { + return nil, err + } result.Provenance, err = json.Marshal(struct { AgentName string `json:"agentName"` AgentUID string `json:"agentUID"` @@ -138,6 +144,26 @@ func (c *Compiler) CompileAgent(ctx context.Context, agent *v1alpha3.Agent) (*Co return result, nil } +// withAgentEgress adds the Agent's declared origins to the destinations its +// runtime compiled, in canonical form and once each. Every runtime gets the +// same treatment, so no runtime compiler reads the Agent's egress. +func withAgentEgress(compiled, declared []string) ([]string, error) { + if len(declared) == 0 { + return compiled, nil + } + destinations := slices.Clone(compiled) + for _, value := range declared { + origin, err := egress.ParseOrigin(value) + if err != nil { + return nil, NewValidationError("Agent egress: %v", err) + } + if !slices.Contains(destinations, origin) { + destinations = append(destinations, origin) + } + } + return destinations, nil +} + // compileConfiguration compiles resolved configuration for the named Agent. // The Agent name owns runtime identity; template and Harness names are provenance. func (c *Compiler) compileConfiguration(ctx context.Context, agentName string, harness *HarnessConfiguration, template *TemplateConfiguration) (*CompileResult, error) { diff --git a/go/core/internal/translator/compiler_test.go b/go/core/internal/translator/compiler_test.go index 00469ab8e2..637666ccc8 100644 --- a/go/core/internal/translator/compiler_test.go +++ b/go/core/internal/translator/compiler_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "slices" "strings" "testing" @@ -11,6 +12,7 @@ import ( atev1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1" "github.com/kagent-dev/kagent/go/api/adk" "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/internal/egress" "github.com/kagent-dev/kagent/go/core/internal/substrate" v2translator "github.com/kagent-dev/kagent/go/core/internal/translator" byotranslator "github.com/kagent-dev/kagent/go/core/internal/translator/byo" @@ -672,6 +674,46 @@ func TestCompileAgentForwardsOtelEnvironment(t *testing.T) { } } +// The controller tells the runtime which AgentTemplate it executes, so every +// model call the runtime makes can carry that identity. +func TestCompileAgentTemplateIdentifiesTheTemplateToTheRuntime(t *testing.T) { + harness := &v1alpha3.Harness{ + ObjectMeta: metav1.ObjectMeta{Name: "kagent", Namespace: "test"}, + Spec: v1alpha3.HarnessSpec{ + Kagent: &v1alpha3.KagentHarness{}, + Workload: v1alpha3.HarnessWorkload{Image: "example.com/kagent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + Substrate: v1alpha3.RuntimeSubstratePolicy{ + WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, + SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}, + }, + }, + } + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "helper", Namespace: "test"}, + Spec: v1alpha3.AgentTemplateSpec{ + ModelConfig: &corev1.LocalObjectReference{Name: "default-model"}, + SystemPrompt: "help", + }, + } + agent := &v1alpha3.Agent{ObjectMeta: metav1.ObjectMeta{Namespace: "test", Name: "runnable-agent"}, Spec: v1alpha3.AgentSpec{ + TemplateRef: &corev1.LocalObjectReference{Name: template.Name}, HarnessRef: &corev1.LocalObjectReference{Name: harness.Name}, + }} + spec, err := compiler(t, modelConfig(), template, harness).CompileAgent(t.Context(), agent) + if err != nil { + t.Fatal(err) + } + environment := map[string]string{} + for _, variable := range spec.Environment { + environment[variable.Name] = variable.Value + } + want := map[string]string{"KAGENT_NAME": "runnable-agent", "KAGENT_AGENT_TEMPLATE": "helper", "KAGENT_NAMESPACE": "test"} + for name, value := range want { + if environment[name] != value { + t.Errorf("environment[%s] = %q, want %q", name, environment[name], value) + } + } +} + func TestCompileAgentSharedADKConfig(t *testing.T) { harness := &v1alpha3.Harness{ ObjectMeta: metav1.ObjectMeta{Name: "kagent", Namespace: "test"}, @@ -978,3 +1020,170 @@ func TestResolveModelConfigMistral(t *testing.T) { resolved = mockCollections(t, model).ResolvedModelConfigs.List()[0] require.Equal(t, "APIKeySecretNotFound", resolved.Failure().Reason) } + +func TestCompileAgentTemplateKeepsSkillCredentialsOutOfConfig(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "skills-git-auth", Namespace: "test", UID: types.UID("secret-uid")}, + Data: map[string][]byte{"token": []byte("ghp_private-skills-token")}, + } + harness := &v1alpha3.Harness{ + ObjectMeta: metav1.ObjectMeta{Name: "kagent", Namespace: "test"}, + Spec: v1alpha3.HarnessSpec{ + Kagent: &v1alpha3.KagentHarness{}, + Workload: v1alpha3.HarnessWorkload{Image: "example.com/kagent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + Substrate: v1alpha3.RuntimeSubstratePolicy{ + WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}, + }, + }, + } + credentialRef := &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: secret.Name}, Key: "token"} + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "helper", Namespace: "test"}, + Spec: v1alpha3.AgentTemplateSpec{ + ModelConfig: &corev1.LocalObjectReference{Name: "default-model"}, + Skills: []v1alpha3.AgentTemplateSkill{ + {Name: "runbooks", Source: v1alpha3.ArtifactSource{Git: &v1alpha3.GitArtifact{ + URL: "https://github.com/acme/private-skills", Commit: "cccccccccccccccccccccccccccccccccccccccc", CredentialRef: credentialRef, + }, Path: "runbooks"}}, + {Name: "public", Source: v1alpha3.ArtifactSource{Git: &v1alpha3.GitArtifact{ + URL: "https://github.com/acme/public-skills", Commit: "dddddddddddddddddddddddddddddddddddddddd", + }}}, + }, + Plugins: []v1alpha3.PluginBundle{{ + Source: v1alpha3.ArtifactSource{Git: &v1alpha3.GitArtifact{ + URL: "https://github.com/acme/private-plugins", Commit: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", CredentialRef: credentialRef, + }}, + Skills: []string{"deploy"}, + }}, + }, + } + spec, err := compiler(t, modelConfig(), secret).CompileAgent(t.Context(), inlineAgent(harness, template)) + require.NoError(t, err) + + if bytes.Contains(spec.ConfigJSON, secret.Data["token"]) || bytes.Contains(spec.Provenance, secret.Data["token"]) { + t.Fatal("runtime revision contains the skill credential value") + } + var config adk.AgentConfig + require.NoError(t, json.Unmarshal(spec.ConfigJSON, &config)) + require.NotNil(t, config.AgentPlugins) + require.Len(t, config.AgentPlugins.Skills, 2) + require.Len(t, config.AgentPlugins.Plugins, 1) + authenticated := map[string]bool{} + for _, skill := range config.AgentPlugins.Skills { + authenticated[skill.Source.Git.URL] = skill.Source.Git.Authenticated + } + authenticated[config.AgentPlugins.Plugins[0].Source.Git.URL] = config.AgentPlugins.Plugins[0].Source.Git.Authenticated + require.Equal(t, map[string]bool{ + "https://github.com/acme/private-skills": true, + "https://github.com/acme/public-skills": false, + "https://github.com/acme/private-plugins": true, + }, authenticated, "only a source with a credentialRef sends the placeholder the gateway replaces") + + var artifactCredentials []corev1.EnvVar + for _, variable := range spec.Environment { + require.Nil(t, variable.ValueFrom, "runtime revision environment contains unresolved valueFrom: %+v", variable) + if strings.HasPrefix(variable.Name, v2translator.ArtifactCredentialEnvPrefix) { + artifactCredentials = append(artifactCredentials, variable) + } + } + require.Len(t, artifactCredentials, 1, "one Secret key compiles to one variable") + require.Equal(t, v2translator.CredentialPlaceholder, artifactCredentials[0].Value, "the actor holds a placeholder, the gateway the credential") + require.Contains(t, spec.Credentials, egress.Credential{ + Hostname: "github.com", Header: "authorization", Prefix: "Basic ", URI: "ate-secret://k8s.io/default/test/" + secret.Name + "/token", + }, "credentials: %+v", spec.Credentials) + require.Zero(t, bytes.Count(spec.Provenance, []byte(`"kind":"Secret"`)), "a rotation of the credential is not a new revision; the gateway reads the Secret live: %s", spec.Provenance) + require.Equal(t, []string{"http://kagent-controller.kagent:8083", "https://api.openai.com:443", "https://github.com:443"}, spec.EgressDestinations) +} + +func TestCompileAgentTemplateReportsAMissingSkillCredentialSecret(t *testing.T) { + harness := &v1alpha3.Harness{ + ObjectMeta: metav1.ObjectMeta{Name: "kagent", Namespace: "test"}, + Spec: v1alpha3.HarnessSpec{ + Kagent: &v1alpha3.KagentHarness{}, + Workload: v1alpha3.HarnessWorkload{Image: "example.com/kagent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + Substrate: v1alpha3.RuntimeSubstratePolicy{ + WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}, + }, + }, + } + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "helper", Namespace: "test"}, + Spec: v1alpha3.AgentTemplateSpec{ + ModelConfig: &corev1.LocalObjectReference{Name: "default-model"}, + Skills: []v1alpha3.AgentTemplateSkill{{Name: "runbooks", Source: v1alpha3.ArtifactSource{Git: &v1alpha3.GitArtifact{ + URL: "https://github.com/acme/private-skills", Commit: "cccccccccccccccccccccccccccccccccccccccc", + CredentialRef: &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: "missing"}, Key: "token"}, + }}}}, + }, + } + _, err := compiler(t, modelConfig()).CompileAgent(t.Context(), inlineAgent(harness, template)) + require.ErrorContains(t, err, `secret "missing" not found`) + var validation *v2translator.ValidationError + require.False(t, errors.As(err, &validation), "a missing Secret is a reference failure the controller retries, not a validation error") +} +func TestCompileAgentAddsTheAgentEgress(t *testing.T) { + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "go-coder", Namespace: "test"}, + Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &corev1.LocalObjectReference{Name: "default-model"}, SystemPrompt: "help"}, + } + harness := &v1alpha3.Harness{ + ObjectMeta: metav1.ObjectMeta{Name: "kagent", Namespace: "test"}, + Spec: v1alpha3.HarnessSpec{ + Kagent: &v1alpha3.KagentHarness{}, + Workload: v1alpha3.HarnessWorkload{Image: "example.com/runtime@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + Substrate: v1alpha3.RuntimeSubstratePolicy{WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}}, + }, + } + declared := []string{"https://proxy.golang.org", "https://*.githubusercontent.com", "https://Proxy.Golang.org.", "https://git.internal:8443"} + want := []string{"https://proxy.golang.org:443", "https://*.githubusercontent.com:443", "https://git.internal:8443"} + c := compiler(t, modelConfig(), template, harness) + + referenced := &v1alpha3.Agent{ObjectMeta: metav1.ObjectMeta{Name: "coder", Namespace: "test"}, Spec: v1alpha3.AgentSpec{ + TemplateRef: &corev1.LocalObjectReference{Name: template.Name}, HarnessRef: &corev1.LocalObjectReference{Name: harness.Name}, Egress: declared, + }} + inline := inlineAgent(harness, template) + inline.Spec.Egress = declared + for name, agent := range map[string]*v1alpha3.Agent{"references": referenced, "inline": inline} { + t.Run(name, func(t *testing.T) { + result, err := c.CompileAgent(t.Context(), agent) + require.NoError(t, err) + require.Subset(t, result.EgressDestinations, want) + require.Equal(t, 1, countOf(result.EgressDestinations, "https://proxy.golang.org:443"), "an origin is listed once") + policy, err := substrate.ActorEgressPolicy("test", result.EgressDestinations, result.Credentials) + require.NoError(t, err) + var hostnames []string + for _, rule := range policy.Rules { + hostnames = append(hostnames, rule.GetHttps().GetHostnames()...) + } + require.Subset(t, hostnames, []string{"proxy.golang.org", "*.githubusercontent.com", "git.internal"}) + }) + } + + withEgress, err := c.CompileAgent(t.Context(), referenced) + require.NoError(t, err) + plain := referenced.DeepCopy() + plain.Spec.Egress = nil + withoutEgress, err := c.CompileAgent(t.Context(), plain) + require.NoError(t, err) + // The destinations are a digest input, so the revision identity follows them. + require.Subset(t, withEgress.EgressDestinations, want) + for _, origin := range want { + require.NotContains(t, withoutEgress.EgressDestinations, origin) + } + + invalid := referenced.DeepCopy() + invalid.Spec.Egress = []string{"https://proxy.golang.org/path"} + _, err = c.CompileAgent(t.Context(), invalid) + var validation *v2translator.ValidationError + require.ErrorAs(t, err, &validation) +} + +func countOf(values []string, want string) int { + n := 0 + for _, value := range values { + if value == want { + n++ + } + } + return n +} diff --git a/go/core/internal/translator/credentials.go b/go/core/internal/translator/credentials.go index 6872047f52..7d343dd1b3 100644 --- a/go/core/internal/translator/credentials.go +++ b/go/core/internal/translator/credentials.go @@ -3,6 +3,7 @@ package translator import ( "fmt" "net/url" + "slices" "strings" "github.com/kagent-dev/kagent/go/adk/pkg/models" @@ -23,6 +24,8 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, var bindings []egress.Credential boundModels := map[string]bool{} boundMCP := map[string]bool{} + boundArtifacts := map[string]bool{} + var callerOwnedMCP []*v1alpha3.RemoteMCPServer bind := func(rawURL, header, prefix, namespace, name, key string) error { u, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil || u.Fragment != "" { @@ -39,6 +42,11 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, models = append(models, agent.ResolvedModelConfig) } for _, tool := range agent.MCPTools { + if !slices.ContainsFunc(tool.Server.Spec.HeadersFrom, func(ref v1alpha3.ValueRef) bool { + return strings.EqualFold(ref.Name, "authorization") + }) { + callerOwnedMCP = append(callerOwnedMCP, tool.Server) + } for _, ref := range tool.Server.Spec.HeadersFrom { if ref.ValueFrom != nil && ref.ValueFrom.Type == v1alpha3.SecretValueSource { boundMCP[ref.ValueFrom.Name+"\x00"+ref.ValueFrom.Key] = true @@ -48,6 +56,28 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, } } } + if agent.Template != nil { + // Git speaks Basic authentication, so the Secret key holds the + // base64 of ":", ready to follow the scheme. + bindArtifact := func(source v1alpha3.ArtifactSource) error { + if source.Git == nil || source.Git.CredentialRef == nil { + return nil + } + ref := source.Git.CredentialRef + boundArtifacts[ref.Name+"\x00"+ref.Key] = true + return bind(source.Git.URL, "authorization", "Basic ", agent.Template.Namespace, ref.Name, ref.Key) + } + for _, skill := range agent.Template.Spec.Skills { + if err := bindArtifact(skill.Source); err != nil { + return err + } + } + for _, plugin := range agent.Template.Spec.Plugins { + if err := bindArtifact(plugin.Source); err != nil { + return err + } + } + } for _, child := range agent.Shared { if err := visit(child.Agent); err != nil { return err @@ -105,6 +135,23 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, } } } + // With KAGENT_PROPAGATE_TOKEN a server that configures no Authorization + // receives the caller's token. The gateway replaces a header the request + // carries, so an Authorization binding on the same host would overwrite it. + if propagatesCallerToken(environment) { + for _, server := range callerOwnedMCP { + u, err := url.Parse(strings.TrimSpace(server.Spec.URL)) + if err != nil { + return nil, nil, NewValidationError("invalid MCP server URL for %q", server.Name) + } + hostname := strings.TrimSuffix(strings.ToLower(u.Hostname()), ".") + for _, binding := range bindings { + if binding.Hostname == hostname && binding.Header == "authorization" { + return nil, nil, NewValidationError("MCP server %q: destination %q cannot combine caller-token propagation with a gateway Authorization credential", server.Name, hostname) + } + } + } + } result := append([]corev1.EnvVar(nil), environment...) for i, variable := range result { if variable.ValueFrom == nil { @@ -112,7 +159,8 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, } ref := variable.ValueFrom.SecretKeyRef isMCP := strings.HasPrefix(variable.Name, "KAGENT_CREDENTIAL_") || strings.HasPrefix(variable.Name, "KAGENT_CODEX_MCP_CREDENTIAL_") || strings.HasPrefix(variable.Name, "KAGENT_CLAUDE_MCP_CREDENTIAL_") - if ref == nil || (!boundModels[variable.Name+"\x00"+ref.Name+"\x00"+ref.Key] && (!isMCP || !boundMCP[ref.Name+"\x00"+ref.Key])) { + isArtifact := strings.HasPrefix(variable.Name, ArtifactCredentialEnvPrefix) + if ref == nil || (!boundModels[variable.Name+"\x00"+ref.Name+"\x00"+ref.Key] && (!isMCP || !boundMCP[ref.Name+"\x00"+ref.Key]) && (!isArtifact || !boundArtifacts[ref.Name+"\x00"+ref.Key])) { return nil, nil, NewValidationError("environment credential %q cannot use gateway header injection; local signing and arbitrary secret environment variables are unsupported", variable.Name) } result[i].Value, result[i].ValueFrom = CredentialPlaceholder, nil @@ -120,6 +168,16 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, return result, bindings, nil } +func propagatesCallerToken(environment []corev1.EnvVar) bool { + propagates := false + for _, variable := range environment { + if variable.Name == env.KagentPropagateToken.Name() { + propagates = strings.EqualFold(strings.TrimSpace(variable.Value), "true") + } + } + return propagates +} + func modelCredentialTarget(resolved *ResolvedModelConfig) (name, endpoint, header, prefix string) { spec := resolved.Config.Spec switch spec.Provider { diff --git a/go/core/internal/translator/credentials_test.go b/go/core/internal/translator/credentials_test.go index fb52d53aa4..3c5c918f2a 100644 --- a/go/core/internal/translator/credentials_test.go +++ b/go/core/internal/translator/credentials_test.go @@ -79,6 +79,65 @@ func TestCompileCredentialsPreservesPassthrough(t *testing.T) { require.ErrorContains(t, err, "cannot combine caller-token passthrough") } +func TestCompileCredentialsRejectsPropagationWithGatewayAuthorization(t *testing.T) { + secretHeader := func(name string) v1alpha3.ValueRef { + return v1alpha3.ValueRef{Name: name, ValueFrom: &v1alpha3.ValueSource{Type: v1alpha3.SecretValueSource, Name: "mcp-auth", Key: "token"}} + } + server := func(name, rawURL string, headers ...v1alpha3.ValueRef) ResolvedMCPTool { + return ResolvedMCPTool{Server: &v1alpha3.RemoteMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "team"}, + Spec: v1alpha3.RemoteMCPServerSpec{URL: rawURL, HeadersFrom: headers}, + }} + } + propagate := corev1.EnvVar{Name: "KAGENT_PROPAGATE_TOKEN", Value: "true"} + for _, test := range []struct { + name string + tools []ResolvedMCPTool + environment []corev1.EnvVar + wantErr string + }{ + { + name: "caller-owned server shares the host of a gateway Authorization", + tools: []ResolvedMCPTool{server("gateway", "https://mcp.example.com/a", secretHeader("Authorization")), server("caller", "https://MCP.example.com./b")}, + environment: []corev1.EnvVar{propagate}, + wantErr: `MCP server "caller": destination "mcp.example.com" cannot combine caller-token propagation with a gateway Authorization credential`, + }, + { + name: "propagation is off", + tools: []ResolvedMCPTool{server("gateway", "https://mcp.example.com/a", secretHeader("authorization")), server("caller", "https://mcp.example.com/b")}, + environment: []corev1.EnvVar{{Name: "KAGENT_PROPAGATE_TOKEN", Value: "false"}}, + }, + { + name: "gateway-owned server only", + tools: []ResolvedMCPTool{server("gateway", "https://mcp.example.com/a", secretHeader("authorization"))}, + environment: []corev1.EnvVar{propagate}, + }, + { + name: "caller-owned server on another host", + tools: []ResolvedMCPTool{server("gateway", "https://mcp.example.com/a", secretHeader("Authorization")), server("caller", "https://other.example.com/b")}, + environment: []corev1.EnvVar{propagate}, + }, + { + name: "gateway credential in another header", + tools: []ResolvedMCPTool{server("caller", "https://mcp.example.com/a", secretHeader("X-Api-Key"))}, + environment: []corev1.EnvVar{propagate}, + }, + } { + t.Run(test.name, func(t *testing.T) { + input := credentialInput(v1alpha3.ModelConfigSpec{}) + input.Root.MCPTools = test.tools + _, _, err := CompileCredentials(input, nil, test.environment) + if test.wantErr == "" { + require.NoError(t, err) + return + } + require.EqualError(t, err, test.wantErr) + var validation *ValidationError + require.ErrorAs(t, err, &validation) + }) + } +} + func credentialInput(spec v1alpha3.ModelConfigSpec) *HarnessInput { resolved := &ResolvedModelConfig{Config: &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Namespace: "team"}, Spec: spec}} if spec.Foundry != nil { diff --git a/go/core/internal/translator/kagent/compiler.go b/go/core/internal/translator/kagent/compiler.go index c171fc480b..27b27acadf 100644 --- a/go/core/internal/translator/kagent/compiler.go +++ b/go/core/internal/translator/kagent/compiler.go @@ -50,6 +50,7 @@ func (c *Compiler) Compile(ctx context.Context, input *v2translator.HarnessInput environment := append(compiled.Environment, harnessEnvironment...) environment = append(environment, corev1.EnvVar{Name: env.KagentName.Name(), Value: input.AgentName}, + corev1.EnvVar{Name: env.KagentAgentTemplate.Name(), Value: template.Name}, corev1.EnvVar{Name: env.KagentNamespace.Name(), Value: template.Namespace}, corev1.EnvVar{Name: env.KagentAPIURL.Name(), Value: fmt.Sprintf("http://%s.%s:8083", utils.GetControllerName(), utils.GetResourceNamespace())}, corev1.EnvVar{Name: env.KagentGatewayURL.Name(), Value: fmt.Sprintf("http://%s.%s:8083", utils.GetControllerName(), utils.GetResourceNamespace())}, diff --git a/go/core/internal/translator/revision.go b/go/core/internal/translator/revision.go index a072c1e51d..cefd1db3f9 100644 --- a/go/core/internal/translator/revision.go +++ b/go/core/internal/translator/revision.go @@ -66,6 +66,9 @@ type Revision struct { Credentials []egress.Credential // EgressDestinations is the HTTP(S) origin allowlist required by this revision. EgressDestinations []string + // Capabilities are added to Substrate's default set for the runtime + // container, unprefixed (e.g. "SETUID"). + Capabilities []string } // Equals compares the Agent Card's contents without inspecting protobuf caches. @@ -77,6 +80,12 @@ func (r Revision) Equals(other Revision) bool { return reflect.DeepEqual(r, other) } +// actorTemplateFormat changes when ActorTemplateForRevision renders a +// different template from unchanged revision inputs, so the immutable template +// is re-created under a new name instead of conflicting with the stored one. +// 2 = default_egress_policy on the template. +const actorTemplateFormat = 2 + // Digest returns the immutable identity of every input that affects runtime // behavior. The full digest is the database key; Kubernetes names use a short // prefix only for readability. @@ -90,26 +99,28 @@ func (r *Revision) Digest() (RevisionID, error) { return RevisionID{}, fmt.Errorf("unsupported sandbox class %q", sandboxClass) } raw, err := json.Marshal(struct { - AgentName string `json:"agentName"` - AgentUID string `json:"agentUID"` - Namespace string `json:"namespace"` - Image string `json:"image"` - Command []string `json:"command,omitempty"` - Args []string `json:"args,omitempty"` - Environment []corev1.EnvVar `json:"environment"` - ConfigJSON json.RawMessage `json:"config"` - WorkerPoolName string `json:"workerPoolName"` - SnapshotLocation string `json:"snapshotLocation"` - Provenance json.RawMessage `json:"provenance"` - Credentials []egress.Credential `json:"credentials,omitempty"` - EgressDestinations []string `json:"egressDestinations"` - SandboxClass atev1alpha1.SandboxClass `json:"sandboxClass"` + AgentName string `json:"agentName"` + AgentUID string `json:"agentUID"` + Namespace string `json:"namespace"` + Image string `json:"image"` + Command []string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + Environment []corev1.EnvVar `json:"environment"` + ConfigJSON json.RawMessage `json:"config"` + WorkerPoolName string `json:"workerPoolName"` + SnapshotLocation string `json:"snapshotLocation"` + Provenance json.RawMessage `json:"provenance"` + Credentials []egress.Credential `json:"credentials,omitempty"` + EgressDestinations []string `json:"egressDestinations"` + Capabilities []string `json:"capabilities,omitempty"` + SandboxClass atev1alpha1.SandboxClass `json:"sandboxClass"` + ActorTemplateFormat int `json:"actorTemplateFormat"` }{ AgentName: r.AgentName, AgentUID: r.AgentUID, Namespace: r.Namespace, Image: r.Image, Command: r.Command, Args: r.Args, Environment: r.Environment, ConfigJSON: r.ConfigJSON, WorkerPoolName: r.WorkerPoolName, SnapshotLocation: r.SnapshotLocation, Provenance: r.Provenance, - Credentials: r.Credentials, EgressDestinations: r.EgressDestinations, - SandboxClass: sandboxClass, + Credentials: r.Credentials, EgressDestinations: r.EgressDestinations, Capabilities: r.Capabilities, + SandboxClass: sandboxClass, ActorTemplateFormat: actorTemplateFormat, }) if err != nil { return RevisionID{}, fmt.Errorf("marshal runtime revision inputs: %w", err) diff --git a/go/core/internal/translator/skills.go b/go/core/internal/translator/skills.go index 39b6d8fd8b..afb8ebd2a1 100644 --- a/go/core/internal/translator/skills.go +++ b/go/core/internal/translator/skills.go @@ -1,53 +1,92 @@ package translator import ( + "crypto/sha256" + "fmt" "net/url" "strings" "github.com/kagent-dev/kagent/go/api/agentplugin" "github.com/kagent-dev/kagent/go/api/v1alpha3" "github.com/kagent-dev/kagent/go/core/internal/egress" + corev1 "k8s.io/api/core/v1" ) +// ArtifactCredentialEnvPrefix starts the environment variables that name the +// Secret keys artifact sources authenticate with. The suffix identifies the +// Secret key, so sources sharing a credential share one variable. The actor +// receives the variable with an inert placeholder: the egress gateway injects +// the credential into the request to the source's host. +const ArtifactCredentialEnvPrefix = "KAGENT_ARTIFACT_CREDENTIAL_" + +// CompiledSkillResources is the harness-neutral result of compiling skills and +// plugins: the resources to materialize, the hosts they are fetched from, and +// the Secret-backed environment the credentials need. Credential values stay +// out of Resources; only the variable names are serialized. +type CompiledSkillResources struct { + Resources agentplugin.Resources + Egress []string + Environment []corev1.EnvVar +} + // CompileSkillResources translates portable AgentTemplate skill selections // into the runtime-neutral resource contract shared by Harness adapters. -func CompileSkillResources(template *TemplateConfiguration) (agentplugin.Resources, []string, error) { - resources := agentplugin.Resources{ +func CompileSkillResources(template *TemplateConfiguration) (CompiledSkillResources, error) { + compiled := CompiledSkillResources{Resources: agentplugin.Resources{ Skills: make([]agentplugin.Skill, 0, len(template.Spec.Skills)), Plugins: make([]agentplugin.Bundle, 0, len(template.Spec.Plugins)), - } + }} selected := make(map[string]struct{}) - var egress []string + credentials := map[string]struct{}{} + compile := func(artifact v1alpha3.ArtifactSource) agentplugin.Source { + source, credential := compileArtifactSource(template.Namespace, artifact) + compiled.Egress = appendArtifactSourceDestination(compiled.Egress, source) + if credential != nil { + if _, exists := credentials[credential.Name]; !exists { + credentials[credential.Name] = struct{}{} + compiled.Environment = append(compiled.Environment, *credential) + } + } + return source + } for _, skill := range template.Spec.Skills { if _, exists := selected[skill.Name]; exists { - return agentplugin.Resources{}, nil, NewValidationError("duplicate skill name %q", skill.Name) + return CompiledSkillResources{}, NewValidationError("duplicate skill name %q", skill.Name) } selected[skill.Name] = struct{}{} - source := compileArtifactSource(skill.Source) - resources.Skills = append(resources.Skills, agentplugin.Skill{Name: skill.Name, Source: source}) - egress = appendArtifactSourceDestination(egress, source) + compiled.Resources.Skills = append(compiled.Resources.Skills, agentplugin.Skill{Name: skill.Name, Source: compile(skill.Source)}) } for _, plugin := range template.Spec.Plugins { for _, name := range plugin.Skills { if _, exists := selected[name]; exists { - return agentplugin.Resources{}, nil, NewValidationError("duplicate skill name %q", name) + return CompiledSkillResources{}, NewValidationError("duplicate skill name %q", name) } selected[name] = struct{}{} } - source := compileArtifactSource(plugin.Source) - resources.Plugins = append(resources.Plugins, agentplugin.Bundle{ - Source: source, + compiled.Resources.Plugins = append(compiled.Resources.Plugins, agentplugin.Bundle{ + Source: compile(plugin.Source), Skills: append([]string(nil), plugin.Skills...), }) - egress = appendArtifactSourceDestination(egress, source) } - return resources, egress, nil + return compiled, nil } -func compileArtifactSource(source v1alpha3.ArtifactSource) agentplugin.Source { +// compileArtifactSource returns the runtime source and, for a git source with +// a credentialRef, the Secret-backed environment variable that records the +// credential in the revision. The variable name derives from the Secret +// identity so the same credential compiles to the same variable in every +// revision. +func compileArtifactSource(namespace string, source v1alpha3.ArtifactSource) (agentplugin.Source, *corev1.EnvVar) { result := agentplugin.Source{OCI: source.OCI, Path: source.Path} + var credential *corev1.EnvVar if source.Git != nil { result.Git = &agentplugin.GitSource{URL: source.Git.URL, Commit: source.Git.Commit} + if ref := source.Git.CredentialRef; ref != nil { + result.Git.Authenticated = true + sum := sha256.Sum256([]byte(namespace + "\x00" + ref.Name + "\x00" + ref.Key)) + name := ArtifactCredentialEnvPrefix + strings.ToUpper(fmt.Sprintf("%x", sum[:8])) + credential = &corev1.EnvVar{Name: name, ValueFrom: &corev1.EnvVarSource{SecretKeyRef: ref.DeepCopy()}} + } } if source.Bucket != nil { result.S3 = &agentplugin.S3Source{ @@ -58,7 +97,7 @@ func compileArtifactSource(source v1alpha3.ArtifactSource) agentplugin.Source { Region: source.Bucket.S3.Region, } } - return result + return result, credential } func appendArtifactSourceDestination(destinations []string, source agentplugin.Source) []string { diff --git a/go/core/pkg/agentplugins/materialize.go b/go/core/pkg/agentplugins/materialize.go index 89e4a9db10..a74b4befc9 100644 --- a/go/core/pkg/agentplugins/materialize.go +++ b/go/core/pkg/agentplugins/materialize.go @@ -231,7 +231,7 @@ func fetchSource(ctx context.Context, source agentplugin.Source, destination str return "", err } case source.Git != nil: - if err := skillsinit.CloneGitCommit(source.Git.URL, source.Git.Commit, destination); err != nil { + if err := skillsinit.CloneGitCommit(source.Git.URL, source.Git.Commit, destination, source.Git.Authenticated); err != nil { return "", err } case source.S3 != nil: diff --git a/go/core/pkg/app/app.go b/go/core/pkg/app/app.go index 50f3ca1e1c..6ff4f761d9 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -311,7 +311,7 @@ func Run(ctx context.Context, opts Options) error { prompts := prompttemplateservice.NewService(manager.GetClient(), authorizer) system := systemservice.NewService(manager.GetClient(), watchNamespaces, authorizer, actors) memory := memoryservice.NewService(store) - sessionWorkflow := sessionsvc.NewActorWorkflow(store, actors) + sessionWorkflow := sessionsvc.NewActorWorkflow(store, actors, sessionsvc.WithPausedRuntimeTTL(kagentenv.SessionPausedRuntimeTTL.Get())) runtimeTasks := taskstore.NewService(store) if err := manager.Add(sessionWorkflow); err != nil { return fmt.Errorf("register idle session worker: %w", err) @@ -328,6 +328,20 @@ func Run(ctx context.Context, opts Options) error { return fmt.Errorf("%s must not be negative", kagentenv.SessionShareMaxTTL.Name()) } sessions := sessionsvc.NewService(store, authorizer, sessionWorkflow, sessionsvc.WithShareMaxTTL(shareMaxTTL)) + stalledTurns, err := sessionsvc.NewStalledTurnWorker(store, sessions, kagentenv.SessionStalledTurnTimeout.Get(), kagentenv.SessionExpirationPollInterval.Get()) + if err != nil { + return err + } + if err := manager.Add(stalledTurns); err != nil { + return fmt.Errorf("register stalled turn worker: %w", err) + } + repoints, err := sessionsvc.NewRevisionRepointWorker(store, sessionWorkflow, kagentenv.SessionRevisionRepointInterval.Get()) + if err != nil { + return err + } + if err := manager.Add(repoints); err != nil { + return fmt.Errorf("register revision repoint worker: %w", err) + } checkpoints := checkpoint.NewService(store, authorizer, actors, sessionWorkflow) gatewayDialer, err := a2agateway.NewRuntimeDialer( kagentenv.SubstrateAtenetRouterURL.Get(), diff --git a/go/core/pkg/env/cli.go b/go/core/pkg/env/cli.go index 3ae71f4e7d..fda1578ba5 100644 --- a/go/core/pkg/env/cli.go +++ b/go/core/pkg/env/cli.go @@ -29,4 +29,11 @@ var ( "Additional arguments to pass to Helm commands.", ComponentCLI, ) + + KagentToken = RegisterStringVar( + "KAGENT_TOKEN", + "", + "Caller token the CLI sends as the bearer of every controller call, by which a controller in trusted-proxy mode identifies the caller. --caller-token overrides it; when both are unset the CLI sends the credential of the current kubeconfig context (an exec plugin, an id-token or a token).", + ComponentCLI, + ) ) diff --git a/go/core/pkg/env/kagent.go b/go/core/pkg/env/kagent.go index b5b9ce65c4..569b56f82b 100644 --- a/go/core/pkg/env/kagent.go +++ b/go/core/pkg/env/kagent.go @@ -50,6 +50,15 @@ var ( ComponentAgentRuntime, ) + KagentAgentTemplate = RegisterStringVar( + "KAGENT_AGENT_TEMPLATE", + "", + "Name of the AgentTemplate the runtime executes. With KAGENT_NAMESPACE it is the identity "+ + "the runtime sends on every model call, as the request headers x-kagent-agent and "+ + "x-kagent-agent-namespace. Injected into agent runtimes via the controller.", + ComponentAgentRuntime, + ) + KagentAPIURL = RegisterStringVar( "KAGENT_API_URL", "", diff --git a/go/core/pkg/env/session.go b/go/core/pkg/env/session.go index cf702331eb..a2515cc5bf 100644 --- a/go/core/pkg/env/session.go +++ b/go/core/pkg/env/session.go @@ -4,6 +4,11 @@ import "time" var SessionIdleTTL = RegisterDurationVar("KAGENT_SESSION_IDLE_TTL", 7*24*time.Hour, "Delete sessions after this idle duration. Zero disables expiration; running and waiting tasks are retained.", ComponentController) -var SessionExpirationPollInterval = RegisterDurationVar("KAGENT_SESSION_EXPIRATION_POLL_INTERVAL", time.Minute, "Interval between idle session expiration sweeps. Must be positive.", ComponentController) +var SessionExpirationPollInterval = RegisterDurationVar("KAGENT_SESSION_EXPIRATION_POLL_INTERVAL", time.Minute, "Interval between the expiration sweeps, which delete idle sessions and expired session shares. Must be positive.", ComponentController) var SessionShareMaxTTL = RegisterDurationVar("KAGENT_SESSION_SHARE_MAX_TTL", 0, "Longest lifetime a session share may request. Shares created without a ttl receive it. Zero leaves shares unbounded.", ComponentController) +var SessionStalledTurnTimeout = RegisterDurationVar("KAGENT_STALLED_TURN_TIMEOUT", time.Hour, "Fail a submitted or working turn whose runtime recorded no event for this long, so the session takes its next message. The sweep runs on the session expiration poll interval. Zero disables it.", ComponentController) + +var SessionRevisionRepointInterval = RegisterDurationVar("KAGENT_REVISION_REPOINT_INTERVAL", 5*time.Minute, "How often the controller moves the quiesced runtimes of sessions whose revision their agent has superseded onto the agent's current revision, so a conversation nobody writes to again does not keep the old revision and its ActorTemplate alive. A turn moves its own runtime regardless. Zero disables the sweep.", ComponentController) + +var SessionPausedRuntimeTTL = RegisterDurationVar("KAGENT_PAUSED_RUNTIME_TTL", 2*time.Minute, "Suspend a runtime paused for input once its pause is older than this, so the reply restores it from a durable snapshot instead of the node the pause was taken on. Zero keeps every pause in place until the reply.", ComponentController) diff --git a/go/core/pkg/env/testing.go b/go/core/pkg/env/testing.go index 0fd85a2010..660784cd2e 100644 --- a/go/core/pkg/env/testing.go +++ b/go/core/pkg/env/testing.go @@ -41,6 +41,8 @@ var ( _ = RegisterStringVar("KAGENT_E2E_CODEX_IMAGE", "", "Digest-pinned Codex harness image substituted into E2E lifecycle manifests.", ComponentTesting) E2ESandboxNamespace = RegisterStringVar("KAGENT_E2E_SANDBOX_NAMESPACE", "kagent", "Namespace for sandbox E2E resources.", ComponentTesting) E2ESandboxWorkerPool = RegisterStringVar("KAGENT_E2E_SANDBOX_WORKER_POOL", "kagent-default", "Worker pool used by sandbox E2E resources.", ComponentTesting) + E2EEgressCAFile = RegisterStringVar("KAGENT_E2E_EGRESS_CA_FILE", "", "PEM CA certificate that Substrate's egress gateway trusts on upstream HTTPS (atenetEgress.upstreamTrust.caBundle). Egress credential E2E tests sign their HTTPS upstreams with it and fail when it is unset.", ComponentTesting) + E2EEgressCAKeyFile = RegisterStringVar("KAGENT_E2E_EGRESS_CA_KEY_FILE", "", "PEM private key of KAGENT_E2E_EGRESS_CA_FILE.", ComponentTesting) E2EOTLPListenAddress = RegisterStringVar("KAGENT_E2E_OTLP_LISTEN_ADDRESS", "", "Listen address for the E2E suite's OTLP trace receiver. Unset disables the shared receiver.", ComponentTesting) E2ERequireTracing = RegisterStringVar("KAGENT_E2E_REQUIRE_TRACING", "false", "Fail instead of skip when expected native harness tracing support is unavailable. Enabled by true, ignoring case and surrounding whitespace.", ComponentTesting) E2ERunUpgradeTests = RegisterStringVar("KAGENT_E2E_RUN_UPGRADE_TESTS", "false", "Run upgrade integration tests when exactly true.", ComponentTesting) diff --git a/go/core/pkg/migrations/core/000002_quiescence_claim_lease.sql b/go/core/pkg/migrations/core/000002_quiescence_claim_lease.sql new file mode 100644 index 0000000000..3f4b036c49 --- /dev/null +++ b/go/core/pkg/migrations/core/000002_quiescence_claim_lease.sql @@ -0,0 +1,12 @@ +-- +goose Up + +-- A quiescence claim's lease. The holder renews it while it works; a claim whose +-- lease has run out, or that has none, is claimed again to settle its boundary. +ALTER TABLE session_task_event ADD COLUMN quiescence_claimed_until TIMESTAMPTZ, + ADD CONSTRAINT session_task_event_quiescence_lease_held + CHECK (quiescence_claimed_until IS NULL OR quiescence_executor_id IS NOT NULL); + +-- +goose Down + +ALTER TABLE session_task_event DROP CONSTRAINT session_task_event_quiescence_lease_held, + DROP COLUMN quiescence_claimed_until; diff --git a/go/core/pkg/migrations/core/000003_session_deletion_reason_agent_deleted.sql b/go/core/pkg/migrations/core/000003_session_deletion_reason_agent_deleted.sql new file mode 100644 index 0000000000..130b9fe983 --- /dev/null +++ b/go/core/pkg/migrations/core/000003_session_deletion_reason_agent_deleted.sql @@ -0,0 +1,13 @@ +-- +goose Up + +-- The expiration sweep deletes the sessions of a deleted Agent with a reason +-- of their own, kept apart from an idle expiry. +ALTER TABLE session DROP CONSTRAINT session_deletion_reason_check, + ADD CONSTRAINT session_deletion_reason_check + CHECK (deletion_reason IN ('user_requested', 'idle_timeout', 'agent_deleted')); + +-- +goose Down + +ALTER TABLE session DROP CONSTRAINT session_deletion_reason_check, + ADD CONSTRAINT session_deletion_reason_check + CHECK (deletion_reason IN ('user_requested', 'idle_timeout')); diff --git a/go/core/pkg/migrations/core/000004_quiescence_fencing_generation.sql b/go/core/pkg/migrations/core/000004_quiescence_fencing_generation.sql new file mode 100644 index 0000000000..b6a9f57ebf --- /dev/null +++ b/go/core/pkg/migrations/core/000004_quiescence_fencing_generation.sql @@ -0,0 +1,11 @@ +-- +goose Up + +-- The generation of a quiescence holder's fencing token. Every Pause, Suspend +-- and fencing Resume a holder sends takes the next value, so a holder that took +-- a claim over always outranks the holder it replaced, and Substrate refuses a +-- request of the replaced one that lands late. +CREATE SEQUENCE quiescence_fencing_generation; + +-- +goose Down + +DROP SEQUENCE quiescence_fencing_generation; diff --git a/go/core/pkg/migrations/legacy_baseline_test.go b/go/core/pkg/migrations/legacy_baseline_test.go new file mode 100644 index 0000000000..6c48bf6bd1 --- /dev/null +++ b/go/core/pkg/migrations/legacy_baseline_test.go @@ -0,0 +1,46 @@ +package migrations + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +// releasedSource replays the migrations a 1.x release created its databases +// with, recorded under the same tracking table and version as the tree's own. +func releasedSource(t *testing.T, dir string) Source { + t.Helper() + return Source{ + Name: "core", + TrackingTable: coreTrackingTable, + FS: os.DirFS("testdata"), + Dir: dir, + } +} + +func TestRunUpRefusesA1xDatabase(t *testing.T) { + dsn := startTestDB(t) + require.NoError(t, RunUp(t.Context(), dsn, []Source{releasedSource(t, "core-1.2")})) + require.True(t, testTableExists(t, dsn, "agent_instance")) + released := testVersions(t, dsn, coreTrackingTable) + require.Contains(t, released, int64(1)) + + err := RunUp(t.Context(), dsn, BuiltinSources(false)) + require.ErrorIs(t, err, ErrLegacyBaseline) + require.EqualError(t, err, `core precheck: the database holds the kagent 1.x schema (agent_instance tables); this line does not migrate it: point the controller at a fresh database and keep the old one for retention (see FORK.md, "Cut-over from the 1.x line")`) + require.False(t, testTableExists(t, dsn, "session"), "the refusal must leave the 1.x database untouched") + require.Equal(t, released, testVersions(t, dsn, coreTrackingTable)) + + require.ErrorIs(t, VerifyMigrated(t.Context(), dsn, BuiltinSources(false)), ErrLegacyBaseline) +} + +func TestRunUpMigratesAFreshDatabaseAndStartsOnACurrentOne(t *testing.T) { + dsn := startTestDB(t) + require.NoError(t, RunUp(t.Context(), dsn, BuiltinSources(false))) + require.True(t, testTableExists(t, dsn, "session")) + require.False(t, testTableExists(t, dsn, "agent_instance")) + + require.NoError(t, RunUp(t.Context(), dsn, BuiltinSources(false))) + require.NoError(t, VerifyMigrated(t.Context(), dsn, BuiltinSources(false))) +} diff --git a/go/core/pkg/migrations/runner.go b/go/core/pkg/migrations/runner.go index 50b85995cf..839946b25d 100644 --- a/go/core/pkg/migrations/runner.go +++ b/go/core/pkg/migrations/runner.go @@ -47,6 +47,7 @@ func BuiltinSources(vectorEnabled bool) []Source { TrackingTable: coreTrackingTable, FS: FS, Dir: "core", + PreCheck: checkLegacyBaseline, }} if vectorEnabled { sources = append(sources, Source{ @@ -71,17 +72,8 @@ func RunUp(ctx context.Context, url string, sources []Source) error { if err := checkResolvedSchemaCollisions(ctx, url, sources); err != nil { return err } - - for _, src := range sources { - if src.PreCheck == nil { - continue - } - if err := ctx.Err(); err != nil { - return fmt.Errorf("cancel before %s precheck: %w", src.Name, err) - } - if err := src.PreCheck(url); err != nil { - return fmt.Errorf("%s precheck: %w", src.Name, err) - } + if err := runPrechecks(ctx, url, sources); err != nil { + return err } for _, src := range sources { @@ -110,6 +102,9 @@ func VerifyMigrated(ctx context.Context, url string, sources []Source) error { if err := checkResolvedSchemaCollisions(ctx, url, sources); err != nil { return err } + if err := runPrechecks(ctx, url, sources); err != nil { + return err + } db, err := sql.Open("pgx", url) if err != nil { @@ -408,6 +403,43 @@ func checkResolvedSchemaCollisions(ctx context.Context, url string, sources []So return nil } +func runPrechecks(ctx context.Context, url string, sources []Source) error { + for _, src := range sources { + if src.PreCheck == nil { + continue + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("cancel before %s precheck: %w", src.Name, err) + } + if err := src.PreCheck(url); err != nil { + return fmt.Errorf("%s precheck: %w", src.Name, err) + } + } + return nil +} + +// ErrLegacyBaseline is the refusal to run on a database created by the 1.x +// line. Every 1.x release recorded its baseline as core version 1, the same +// version this tree's baseline carries, so Goose alone would take such a +// database for current and the controller would start on the wrong tables. +var ErrLegacyBaseline = errors.New(`the database holds the kagent 1.x schema (agent_instance tables); this line does not migrate it: point the controller at a fresh database and keep the old one for retention (see FORK.md, "Cut-over from the 1.x line")`) + +func checkLegacyBaseline(url string) error { + db, err := sql.Open("pgx", url) + if err != nil { + return fmt.Errorf("open database: %w", err) + } + defer db.Close() + exists, err := tableExists(context.Background(), db, quoteIdentifier("agent_instance")) + if err != nil { + return fmt.Errorf("check for the 1.x schema: %w", err) + } + if exists { + return ErrLegacyBaseline + } + return nil +} + func checkPgvector(url string) error { db, err := sql.Open("pgx", url) if err != nil { diff --git a/go/core/pkg/migrations/runner_test.go b/go/core/pkg/migrations/runner_test.go index 435e05febb..2a106f76d9 100644 --- a/go/core/pkg/migrations/runner_test.go +++ b/go/core/pkg/migrations/runner_test.go @@ -202,8 +202,9 @@ func TestBuiltinMigrationsRoundTrip(t *testing.T) { if err := VerifyMigrated(context.Background(), dsn, sources); err != nil { t.Fatalf("initial VerifyMigrated: %v", err) } + latest := map[string][]int64{"core": {0, 1, 2, 3, 4}, "vector": {0, 1}} for _, source := range sources { - expected := []int64{0, 1} + expected := latest[source.Name] if versions := testVersions(t, dsn, source.TrackingTable); !slices.Equal(versions, expected) { t.Fatalf("%s versions = %v, want %v", source.Name, versions, expected) } @@ -211,6 +212,12 @@ func TestBuiltinMigrationsRoundTrip(t *testing.T) { if !testColumnExists(t, dsn, "session", "deletion_reason") { t.Fatal("session deletion reason is missing") } + if !testColumnExists(t, dsn, "session_task_event", "quiescence_claimed_until") { + t.Fatal("the quiescence claim lease is missing") + } + if !testTableExists(t, dsn, "quiescence_fencing_generation") { + t.Fatal("the quiescence fencing generation is missing") + } if !testTableExists(t, dsn, "agent_definition") || testTableExists(t, dsn, "agent_template_harness_pair") { t.Fatal("initial migration must create explicit Agent definitions without legacy pairs") } diff --git a/go/core/pkg/migrations/testdata/core-1.2/000001_initial.sql b/go/core/pkg/migrations/testdata/core-1.2/000001_initial.sql new file mode 100644 index 0000000000..0a86455ae3 --- /dev/null +++ b/go/core/pkg/migrations/testdata/core-1.2/000001_initial.sql @@ -0,0 +1,287 @@ +-- +goose Up + +-- Unreleased Kagent 1.0 baseline. Fold schema changes into this migration +-- until release; development databases must be recreated when it changes. + +CREATE TABLE tool ( + id TEXT NOT NULL, + server_name TEXT NOT NULL, + group_kind TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + description TEXT NOT NULL DEFAULT '', + PRIMARY KEY (id, server_name, group_kind) +); +CREATE INDEX idx_tool_deleted_at ON tool(deleted_at); + +CREATE TABLE toolserver ( + name TEXT NOT NULL, + group_kind TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + description TEXT NOT NULL DEFAULT '', + last_connected TIMESTAMPTZ, + PRIMARY KEY (name, group_kind) +); +CREATE INDEX idx_toolserver_deleted_at ON toolserver(deleted_at); + +CREATE TABLE runtime_revision ( + revision TEXT PRIMARY KEY, + namespace TEXT NOT NULL, + agent_template_name TEXT NOT NULL, + agent_template_uid TEXT NOT NULL, + harness_name TEXT NOT NULL, + harness_uid TEXT NOT NULL, + source_snapshot JSONB NOT NULL, + egress_destinations TEXT[] NOT NULL DEFAULT '{}', + credentials JSONB NOT NULL DEFAULT '[]' CHECK (jsonb_typeof(credentials) = 'array'), + actor_template_atespace TEXT CONSTRAINT runtime_revision_actor_template_namespace_not_null NOT NULL, + actor_template_name TEXT NOT NULL, + actor_template_uid TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + agent_card BYTEA NOT NULL, + -- Logical deletion; retain the row until ActorTemplate cleanup completes. + deleted_at TIMESTAMPTZ, + CONSTRAINT runtime_revision_actor_template_namespace_actor_template_na_key + UNIQUE (actor_template_atespace, actor_template_name) +); + +CREATE TABLE agent_template_harness_pair ( + namespace TEXT NOT NULL, + agent_template_name TEXT NOT NULL, + agent_template_uid TEXT NOT NULL, + harness_name TEXT NOT NULL, + harness_uid TEXT NOT NULL, + desired_revision TEXT NOT NULL, + latest_successful_revision TEXT REFERENCES runtime_revision(revision) ON DELETE RESTRICT, + retired_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (namespace, agent_template_uid, harness_uid) +); +CREATE INDEX agent_template_harness_pair_name_idx + ON agent_template_harness_pair (namespace, agent_template_name, harness_name); + +CREATE TABLE a2a_context ( + id UUID PRIMARY KEY, + user_id TEXT NOT NULL CHECK (user_id <> ''), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + context_id UUID NOT NULL, + CONSTRAINT a2a_context_binding_key UNIQUE (id, context_id) +); + +CREATE TABLE agent_instance_checkpoint ( + id UUID PRIMARY KEY, + source_instance_id UUID NOT NULL, + user_id TEXT NOT NULL, + request_id TEXT NOT NULL, + head_task_id TEXT NOT NULL, + history_sequence BIGINT NOT NULL, + snapshot_atespace TEXT NOT NULL, + snapshot_uri TEXT NOT NULL, + snapshot_content_scope TEXT NOT NULL, + tag_uid TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL, + data BYTEA NOT NULL, + source_history_id UUID NOT NULL REFERENCES a2a_context(id) ON DELETE RESTRICT, + prepared_revision TEXT REFERENCES runtime_revision(revision) ON DELETE RESTRICT, + CHECK (snapshot_content_scope IN ('FULL', 'DATA')), + CHECK (state IN ('CREATING', 'READY', 'FAILED', 'DELETING')), + UNIQUE (user_id, request_id) +); +CREATE INDEX agent_instance_checkpoint_list_idx + ON agent_instance_checkpoint (source_instance_id, id); +CREATE UNIQUE INDEX agent_instance_checkpoint_one_creating_idx + ON agent_instance_checkpoint (source_instance_id) + WHERE state = 'CREATING'; + +CREATE TABLE agent_instance ( + id UUID PRIMARY KEY, + user_id TEXT NOT NULL CHECK (user_id <> ''), + request_id TEXT NOT NULL, + prepared_revision TEXT REFERENCES runtime_revision(revision) ON DELETE RESTRICT, + state TEXT NOT NULL, + data BYTEA NOT NULL, + operation TEXT NOT NULL DEFAULT 'AGENT_INSTANCE_OPERATION_UNSPECIFIED', + context_id UUID NOT NULL, + -- Retain fork request identity after deletion without retaining the checkpoint. + source_checkpoint_id UUID, + pinned_checkpoint_id UUID GENERATED ALWAYS AS ( + CASE WHEN state <> 'AGENT_INSTANCE_STATE_DELETED' THEN source_checkpoint_id END + ) STORED REFERENCES agent_instance_checkpoint(id) ON DELETE RESTRICT, + operation_id UUID, + executor_id UUID, + CHECK (executor_id IS NULL OR (operation_id IS NOT NULL + AND operation <> 'AGENT_INSTANCE_OPERATION_UNSPECIFIED' + AND state <> 'AGENT_INSTANCE_STATE_DELETED')), + CHECK (state <> 'AGENT_INSTANCE_STATE_DELETED' OR + (prepared_revision IS NULL AND operation = 'AGENT_INSTANCE_OPERATION_UNSPECIFIED')), + history_id UUID NOT NULL, + CONSTRAINT agent_instance_context_binding_fkey + FOREIGN KEY (history_id, context_id) REFERENCES a2a_context(id, context_id) ON DELETE RESTRICT, + CONSTRAINT agent_instance_history_key UNIQUE (history_id), + CONSTRAINT agent_instance_operation_check + CHECK (operation IN ('AGENT_INSTANCE_OPERATION_UNSPECIFIED', 'AGENT_INSTANCE_OPERATION_CREATE', + 'AGENT_INSTANCE_OPERATION_SUSPEND', 'AGENT_INSTANCE_OPERATION_RESUME', 'AGENT_INSTANCE_OPERATION_DELETE')), + CHECK (state IN ('AGENT_INSTANCE_STATE_CREATING', 'AGENT_INSTANCE_STATE_READY', + 'AGENT_INSTANCE_STATE_SUSPENDED', 'AGENT_INSTANCE_STATE_FAILED', 'AGENT_INSTANCE_STATE_DELETED')), + UNIQUE (user_id, request_id) +); +CREATE INDEX agent_instance_user_id_id_idx + ON agent_instance (user_id, id) WHERE state <> 'AGENT_INSTANCE_STATE_DELETED'; + +CREATE TABLE agent_instance_share ( + id UUID PRIMARY KEY, + instance_id UUID NOT NULL REFERENCES agent_instance(id) ON DELETE CASCADE, + permission TEXT NOT NULL CHECK (permission IN ( + 'AGENT_INSTANCE_SHARE_PERMISSION_READ_ONLY', 'AGENT_INSTANCE_SHARE_PERMISSION_READ_WRITE')), + token_hash BYTEA NOT NULL UNIQUE, + data BYTEA NOT NULL +); +CREATE INDEX agent_instance_share_instance_idx + ON agent_instance_share (instance_id, id); + +CREATE TABLE agent_instance_task ( + history_id UUID CONSTRAINT agent_instance_task_instance_id_not_null NOT NULL REFERENCES a2a_context(id) ON DELETE CASCADE, + id TEXT NOT NULL, + state TEXT NOT NULL, + status_timestamp TIMESTAMPTZ, + data BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + initial_message_id TEXT, + request_hash BYTEA, + snapshot_atespace TEXT, + snapshot_uri TEXT, + snapshot_content_scope TEXT, + history_sequence BIGINT, + position BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY, + PRIMARY KEY (history_id, id) +); +CREATE UNIQUE INDEX agent_instance_one_active_task_idx + ON agent_instance_task (history_id) + WHERE state NOT IN ( + 'TASK_STATE_COMPLETED', + 'TASK_STATE_CANCELED', + 'TASK_STATE_FAILED', + 'TASK_STATE_REJECTED', + 'TASK_STATE_INPUT_REQUIRED', + 'TASK_STATE_AUTH_REQUIRED' + ); +CREATE UNIQUE INDEX agent_instance_task_list_idx + ON agent_instance_task (history_id, position); +CREATE UNIQUE INDEX agent_instance_task_message_idx + ON agent_instance_task (history_id, initial_message_id) + WHERE initial_message_id IS NOT NULL; + +CREATE TABLE agent_instance_task_event ( + sequence BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + history_id UUID CONSTRAINT agent_instance_task_event_instance_id_not_null NOT NULL REFERENCES a2a_context(id) ON DELETE CASCADE, + task_id TEXT, + data BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + message_id TEXT, + -- Creation events retain task indexes; admitted reply messages retain retry hashes. + task_position BIGINT, + initial_message_id TEXT, + request_hash BYTEA, + snapshot_atespace TEXT, + snapshot_uri TEXT, + snapshot_content_scope TEXT, + CHECK ((snapshot_atespace IS NULL AND snapshot_uri IS NULL AND snapshot_content_scope IS NULL) + OR (snapshot_atespace IS NOT NULL AND snapshot_uri IS NOT NULL AND snapshot_content_scope IS NOT NULL)), + CHECK (task_position IS NULL OR (task_position > 0 AND task_id IS NOT NULL AND message_id IS NULL)), + CHECK (task_position IS NOT NULL OR initial_message_id IS NULL), + CHECK (request_hash IS NULL OR task_position IS NOT NULL OR message_id IS NOT NULL) +); +CREATE UNIQUE INDEX agent_instance_task_event_creation_idx + ON agent_instance_task_event (history_id, task_id) WHERE task_position IS NOT NULL; +CREATE UNIQUE INDEX agent_instance_task_event_position_idx + ON agent_instance_task_event (history_id, task_position) WHERE task_position IS NOT NULL; +CREATE INDEX agent_instance_task_event_instance_sequence_idx + ON agent_instance_task_event (history_id, sequence); +CREATE UNIQUE INDEX agent_instance_task_event_message_idx + ON agent_instance_task_event (history_id, task_id, message_id) + WHERE message_id IS NOT NULL; + +CREATE TABLE scheduled_run ( + id UUID PRIMARY KEY, + creator TEXT NOT NULL CHECK (creator <> ''), + request_id TEXT NOT NULL CHECK (char_length(request_id) BETWEEN 1 AND 128), + request_hash BYTEA NOT NULL CHECK (octet_length(request_hash) = 32), + data BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT statement_timestamp(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT statement_timestamp(), + next_execution_time TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + UNIQUE (creator, request_id), + CHECK (deleted_at IS NULL OR next_execution_time IS NULL) +); +CREATE INDEX scheduled_run_owner_idx ON scheduled_run (creator, id) WHERE deleted_at IS NULL; +CREATE INDEX scheduled_run_due_idx ON scheduled_run (next_execution_time, id) + WHERE deleted_at IS NULL AND next_execution_time IS NOT NULL; + +-- The optional instance ID is historical provenance, without a foreign key. +CREATE TABLE scheduled_run_execution ( + id UUID PRIMARY KEY, + scheduled_run_id UUID NOT NULL REFERENCES scheduled_run(id) ON DELETE RESTRICT, + scheduled_time TIMESTAMPTZ, + manual_request_id TEXT CHECK (char_length(manual_request_id) BETWEEN 1 AND 128), + data BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT statement_timestamp(), + deadline TIMESTAMPTZ NOT NULL CHECK (deadline > created_at), + agent_instance_id UUID, + task_id TEXT, + completed_at TIMESTAMPTZ, + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + lease_token UUID, + state TEXT NOT NULL DEFAULT 'SCHEDULED_RUN_EXECUTION_STATE_PENDING' CHECK (state IN ( + 'SCHEDULED_RUN_EXECUTION_STATE_PENDING', 'SCHEDULED_RUN_EXECUTION_STATE_RUNNING', + 'SCHEDULED_RUN_EXECUTION_STATE_SUCCEEDED', 'SCHEDULED_RUN_EXECUTION_STATE_FAILED', + 'SCHEDULED_RUN_EXECUTION_STATE_TIMED_OUT')), + CHECK ((scheduled_time IS NULL) <> (manual_request_id IS NULL)), + CHECK (state NOT IN ('SCHEDULED_RUN_EXECUTION_STATE_RUNNING', 'SCHEDULED_RUN_EXECUTION_STATE_SUCCEEDED') + OR agent_instance_id IS NOT NULL), + CHECK (task_id IS NULL OR agent_instance_id IS NOT NULL), + CHECK ((completed_at IS NOT NULL) = (state IN ('SCHEDULED_RUN_EXECUTION_STATE_SUCCEEDED', + 'SCHEDULED_RUN_EXECUTION_STATE_FAILED', 'SCHEDULED_RUN_EXECUTION_STATE_TIMED_OUT'))), + UNIQUE (scheduled_run_id, scheduled_time), + UNIQUE (scheduled_run_id, manual_request_id) +); +CREATE INDEX scheduled_run_execution_history_idx ON scheduled_run_execution (scheduled_run_id, id); +CREATE INDEX scheduled_run_execution_pending_idx ON scheduled_run_execution (next_attempt_at, id) + WHERE state IN ('SCHEDULED_RUN_EXECUTION_STATE_PENDING', 'SCHEDULED_RUN_EXECUTION_STATE_RUNNING'); + +CREATE VIEW unreferenced_runtime_revision AS +SELECT r.revision FROM runtime_revision r +WHERE NOT EXISTS ( + SELECT 1 FROM agent_template_harness_pair p + WHERE p.retired_at IS NULL + AND (p.desired_revision = r.revision OR p.latest_successful_revision = r.revision) +) +AND NOT EXISTS ( + SELECT 1 FROM agent_instance i WHERE i.prepared_revision = r.revision +) +AND NOT EXISTS ( + SELECT 1 FROM agent_instance_checkpoint c WHERE c.prepared_revision = r.revision +); + +-- +goose Down + +DROP TABLE scheduled_run_execution; +DROP TABLE scheduled_run; +DROP VIEW unreferenced_runtime_revision; + +DROP TABLE agent_instance_share; +DROP TABLE agent_instance_task_event; +DROP TABLE agent_instance_task; +DROP TABLE agent_instance; +DROP TABLE agent_instance_checkpoint; +DROP TABLE a2a_context; +DROP TABLE agent_template_harness_pair; +DROP TABLE runtime_revision; +DROP TABLE toolserver; +DROP TABLE tool; diff --git a/go/core/pkg/migrations/testdata/core-1.2/000002_upgrade_1_0_baseline.sql b/go/core/pkg/migrations/testdata/core-1.2/000002_upgrade_1_0_baseline.sql new file mode 100644 index 0000000000..b7715e326b --- /dev/null +++ b/go/core/pkg/migrations/testdata/core-1.2/000002_upgrade_1_0_baseline.sql @@ -0,0 +1,62 @@ +-- +goose Up + +-- Kagent 1.0 recorded version 1 with an earlier 000001 baseline: no runtime +-- revision credentials, no agent instance operation and executor, no pinned +-- checkpoint, no DELETED state. This brings that baseline to the current +-- one and leaves a database created by the current 000001 unchanged. A +-- schema that matches neither baseline fails the migration. +-- +-- agent_instance_checkpoint.source_name stays: the 1.0 controller reads and +-- writes it, the current controller never names it, so a migrated database +-- runs either release. + +-- +goose StatementBegin +DO $$ +DECLARE + current_columns integer; +BEGIN + SELECT count(*) INTO current_columns + FROM pg_attribute + WHERE NOT attisdropped AND ( + (attrelid = 'runtime_revision'::regclass AND attname = 'credentials') OR + (attrelid = 'agent_instance'::regclass + AND attname IN ('pinned_checkpoint_id', 'operation_id', 'executor_id'))); + IF current_columns = 4 THEN + RETURN; + END IF; + IF current_columns <> 0 THEN + RAISE EXCEPTION 'core schema version 1 matches neither the Kagent 1.0 nor the current baseline: % of 4 current columns exist', + current_columns; + END IF; + + ALTER TABLE runtime_revision + ADD COLUMN credentials JSONB NOT NULL DEFAULT '[]' CHECK (jsonb_typeof(credentials) = 'array'); + + ALTER TABLE agent_instance + DROP CONSTRAINT agent_instance_source_checkpoint_id_fkey, + DROP CONSTRAINT agent_instance_state_check, + ADD COLUMN pinned_checkpoint_id UUID GENERATED ALWAYS AS ( + CASE WHEN state <> 'AGENT_INSTANCE_STATE_DELETED' THEN source_checkpoint_id END + ) STORED REFERENCES agent_instance_checkpoint(id) ON DELETE RESTRICT, + ADD COLUMN operation_id UUID, + ADD COLUMN executor_id UUID, + ADD CONSTRAINT agent_instance_check CHECK (executor_id IS NULL OR (operation_id IS NOT NULL + AND operation <> 'AGENT_INSTANCE_OPERATION_UNSPECIFIED' + AND state <> 'AGENT_INSTANCE_STATE_DELETED')), + ADD CONSTRAINT agent_instance_check1 CHECK (state <> 'AGENT_INSTANCE_STATE_DELETED' OR + (prepared_revision IS NULL AND operation = 'AGENT_INSTANCE_OPERATION_UNSPECIFIED')), + ADD CONSTRAINT agent_instance_state_check + CHECK (state IN ('AGENT_INSTANCE_STATE_CREATING', 'AGENT_INSTANCE_STATE_READY', + 'AGENT_INSTANCE_STATE_SUSPENDED', 'AGENT_INSTANCE_STATE_FAILED', 'AGENT_INSTANCE_STATE_DELETED')); + + DROP INDEX agent_instance_user_id_id_idx; + CREATE INDEX agent_instance_user_id_id_idx + ON agent_instance (user_id, id) WHERE state <> 'AGENT_INSTANCE_STATE_DELETED'; +END +$$; +-- +goose StatementEnd + +-- +goose Down + +-- Nothing to undo: both baselines meet here, the result still runs the 1.0 +-- controller, and 000001's Down drops the tables. diff --git a/go/core/test/e2e/README.md b/go/core/test/e2e/README.md index f5b5ae4a65..8758011a52 100644 --- a/go/core/test/e2e/README.md +++ b/go/core/test/e2e/README.md @@ -165,6 +165,14 @@ atelet: - --localhost-registry-replacement=kind-registry:5000 ``` +The egress credential tests also need a CA that the egress gateway trusts on +upstream HTTPS. Create one, install Substrate with +`--set-file atenetEgress.upstreamTrust.caBundle=egress-ca.crt`, and export +`KAGENT_E2E_EGRESS_CA_FILE` and `KAGENT_E2E_EGRESS_CA_KEY_FILE` with the paths +of its certificate and key, as the CI job does on every run. Golden boots fetch +git sources in atespace `ate-golden`, so its credential provider policy must +allow namespace `kagent`, next to the `kagent` atespace's. + ```bash KAGENT_E2E_RUNTIME_IMAGE=/kagent-dev/kagent/golang-adk@sha256: \ KAGENT_E2E_BYO_IMAGE=/kagent-dev/kagent/byo-a2a@sha256: \ @@ -188,6 +196,19 @@ reachable IPv4 address. Its Service and EndpointSlice route to the receiver. `TestMCPInteraction` starts `mockmcp` on the same reachable host, registers it as a `RemoteMCPServer`, and verifies an actual `tools/call` request. +The credential tests prove the egress credential contract through Substrate's +egress gateway for every client kind: model keys (`Authorization` and +Anthropic `X-Api-Key`), `RemoteMCPServer` `headersFrom` on every harness and on +Claude with `KAGENT_PROPAGATE_TOKEN` (with and without a caller token, which +must not displace the Secret), and git skill and plugin sources with a +`credentialRef`. A client sends its binding's header carrying a placeholder, +and the gateway replaces the value with the Secret's. The gateway replaces only +a header the request carries, so each test records what its upstream received +and requires the Secret value on every request and the placeholder on none; a +client that sends no header fails. The git upstream is `git http-backend` on +the test host (which needs `git`), served over HTTPS under a cluster Service +name the binding matches, with a certificate from the CA above. + `TestSessionContextCompaction` clones the `kagent` Harness into one whose `spec.kagent.compaction` fires a sliding window after two turns, with a dedicated summarizer `ModelConfig` pointing at the same mock LLM behind a diff --git a/go/core/test/e2e/claude_limits_test.go b/go/core/test/e2e/claude_limits_test.go new file mode 100644 index 0000000000..df7d87b2eb --- /dev/null +++ b/go/core/test/e2e/claude_limits_test.go @@ -0,0 +1,99 @@ +package e2e_test + +import ( + "bytes" + "strings" + "testing" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + apia2a "github.com/kagent-dev/kagent/go/api/a2a" + "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/harness/runtime" + harnessa2a "github.com/kagent-dev/kagent/go/harness/runtime/a2a" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/metadata" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// A Claude Harness with a turn cap ends a turn that reaches it as completed, +// names the limit on the status message and reports the usage it spent. +func TestClaudeTurnStopsAtItsTurnLimit(t *testing.T) { + t.Parallel() + target := interactionTarget(t) + kube := interactionKubeClient(t) + harness := cloneHarness(t, kube, claudeE2EHarness, "claude-limits-", func(harness *v1alpha3.Harness) { + harness.Spec.Claude.Limits = &v1alpha3.ClaudeHarnessLimits{MaxTurns: 1} + }) + modelURL := reachableServerURL(t, startMockLLMServer(t, claudeInteractionMocks, "mocks/invoke_claude_builtin_tools.json"), "") + model := createClaudeMockModel(t, kube, modelURL) + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "claude-limits-", Namespace: "kagent", + Labels: map[string]string{"kagent.dev/e2e-runtime": "claude"}, + }, + Spec: v1alpha3.AgentTemplateSpec{ + ModelConfig: &corev1.LocalObjectReference{Name: model.Name}, + Description: "Claude turn limit E2E fixture", SystemPrompt: "Use your tools and report what they return.", + }, + } + createAndWaitInteractionTemplateForHarness(t, kube, template, harness.Name) + fixture := newInteractionFixtureForHarnessTemplate(t, target, harness.Name, template.Name) + + _, _, task := fixture.send(t, "Create the requested file and read it back.") + require.Equal(t, a2atype.TaskStateCompleted, task.Status.State, "a turn stopped by its limit completes: %s", taskText(task)) + require.NotNil(t, task.Status.Message, "a limited turn carries a status message") + require.Equal(t, runtime.LimitTurns, task.Status.Message.Metadata[harnessa2a.StoppedByMetadataKey], "metadata = %#v", task.Status.Message.Metadata) + usage, ok := task.Status.Message.Metadata[apia2a.UsageMetadataKey].(map[string]any) + require.True(t, ok, "usage metadata = %#v", task.Status.Message.Metadata[apia2a.UsageMetadataKey]) + require.Contains(t, usage, "numTurns") + require.Contains(t, strings.ToLower(taskText(task)), "turn", "the completion names the limit: %s", taskText(task)) +} + +// With KAGENT_PROPAGATE_TOKEN the Claude harness calls an MCP server as the +// person whose A2A call runs the turn: the upstream sees the caller's bearer, +// not a static credential and not the loopback token. +func TestClaudeHarnessForwardsTheCallerCredential(t *testing.T) { + t.Parallel() + target := interactionTarget(t) + kube := interactionKubeClient(t) + harness := cloneHarness(t, kube, claudeE2EHarness, "claude-caller-", func(harness *v1alpha3.Harness) { + harness.Spec.Env = append(harness.Spec.Env, v1alpha3.RuntimeEnvVar{Name: "KAGENT_PROPAGATE_TOKEN", Value: "true"}) + }) + mcpURL, mcpMock := startMCPMock(t) + mcpServer := createClaudeMCPServer(t, kube, mcpURL) + toolName := "mcp__" + mcpServer.Name + "__add_numbers" + model := createClaudeMockModel(t, kube, startClaudeResourceMockLLM(t, toolName)) + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "claude-caller-", Namespace: "kagent", + Labels: map[string]string{"kagent.dev/e2e-runtime": "claude"}, + }, + Spec: v1alpha3.AgentTemplateSpec{ + ModelConfig: &corev1.LocalObjectReference{Name: model.Name}, + Description: "Claude caller credential E2E fixture", + SystemPrompt: "Use the configured MCP tool. Do not calculate the answer yourself.", + Tools: []v1alpha3.ToolBinding{{MCP: &v1alpha3.MCPToolBinding{ + Server: corev1.TypedLocalObjectReference{Kind: "RemoteMCPServer", Name: mcpServer.Name}, + }}}, + }, + } + createAndWaitInteractionTemplateForHarness(t, kube, template, harness.Name) + fixture := newInteractionFixtureForHarnessTemplate(t, target, harness.Name, template.Name) + const credential = "Bearer e2e-person-token" + fixture.ctx = metadata.AppendToOutgoingContext(fixture.ctx, "authorization", credential) + + streamed := sendStreaming(t, fixture, "Add 3 and 5 using the configured MCP server.") + require.Equal(t, a2atype.TaskStateCompleted, streamed.state, "text = %q, failure = %q", streamed.text, streamed.failureText) + require.Contains(t, streamed.text, "CLAUDE_MCP_DONE result is 8") + + var calls int + for _, request := range mcpMock.Requests() { + if !bytes.Contains(request.Body, []byte(`"method":"tools/call"`)) { + continue + } + calls++ + require.Equal(t, credential, request.Headers.Get("Authorization"), "the MCP server must see the caller's credential, not %q", request.Headers.Get("Authorization")) + } + require.NotZero(t, calls, "the mock MCP server received no tool call") +} diff --git a/go/core/test/e2e/credentials_test.go b/go/core/test/e2e/credentials_test.go index ca540a194f..ba8476877d 100644 --- a/go/core/test/e2e/credentials_test.go +++ b/go/core/test/e2e/credentials_test.go @@ -2,20 +2,50 @@ package e2e_test import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "errors" + "fmt" "io" + "math/big" + "net" "net/http" + "net/http/cgi" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" "strings" + "sync" "testing" + "time" a2atype "github.com/a2aproject/a2a-go/v2/a2a" "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/internal/translator" + kagentenv "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/kagent-dev/mockllm" + "github.com/kagent-dev/mockmcp" "github.com/stretchr/testify/require" + "google.golang.org/grpc/metadata" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" ) +// The egress credential contract: a client covered by a Secret-backed binding +// sends the binding's header carrying a placeholder, and Substrate's egress +// gateway replaces the value with the Secret's. The gateway replaces only a +// header the request carries, so a client that sends none reaches its upstream +// without the credential. Each case records what its upstream received and +// requires the Secret value on every request and the placeholder on none. + func TestModelCredentialDelivery(t *testing.T) { t.Parallel() forEachHarness(t, func(t *testing.T, harness testHarness) { @@ -27,11 +57,8 @@ func TestModelCredentialDelivery(t *testing.T) { } target := interactionTarget(t) kube := interactionKubeClient(t) - // Each compiler must deliver the configured credential to its model origin. - secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{GenerateName: "gateway-auth-", Namespace: "kagent"}, StringData: map[string]string{"token": token}} - require.NoError(t, kube.Create(t.Context(), secret)) - t.Cleanup(func() { require.NoError(t, kube.Delete(context.Background(), secret)) }) - origin := startCredentialMock(t, header, wantCredential) + secret := createCredentialSecret(t, kube, token) + origin, received := startCredentialMock(t, header, wantCredential) model := harness.createModel(t, kube, reachableModelURL(t, origin), nil) before := model.DeepCopy() model.Spec.APIKeySecret, model.Spec.APIKeySecretKey = secret.Name, "token" @@ -44,26 +71,380 @@ func TestModelCredentialDelivery(t *testing.T) { fixture := newInteractionFixtureForTemplate(t, harness, target, template.Name) _, _, task := fixture.send(t, "What is 2+2?") require.Equal(t, a2atype.TaskStateCompleted, task.Status.State, "task status: %+v", task.Status) + // Claude Code also calls the model host's /api routes, some without a key. + requireEgressCredential(t, received.Requests(), header, wantCredential, modelAPIRoute) }) } -// Match credentials at the model origin itself. A separate reverse proxy adds -// another streaming connection that can fail independently of credential delivery. -func startCredentialMock(t *testing.T, header, value string) string { +func TestMCPCredentialDelivery(t *testing.T) { + t.Parallel() + forEachHarness(t, func(t *testing.T, harness testHarness) { + t.Parallel() + testMCPCredentialDelivery(t, harness, harness.name, "") + }) +} + +// With KAGENT_PROPAGATE_TOKEN, Claude reaches MCP servers through a loopback +// forwarder. A server whose Authorization is Secret-backed owns it: the +// upstream gets the Secret on a turn without a caller credential and on a turn +// with one, never the caller's token. +func TestClaudePropagatingMCPCredentialDelivery(t *testing.T) { + t.Parallel() + interactionTarget(t) + propagating := cloneHarness(t, interactionKubeClient(t), claudeE2EHarness, "claude-propagate-", func(harness *v1alpha3.Harness) { + harness.Spec.Env = append(harness.Spec.Env, v1alpha3.RuntimeEnvVar{Name: "KAGENT_PROPAGATE_TOKEN", Value: "true"}) + }) + testMCPCredentialDelivery(t, testHarness{name: claudeE2EHarness, runtimeLabel: "claude"}, propagating.Name, "", "Bearer e2e-caller-token") +} + +// testMCPCredentialDelivery sends one turn per caller credential, each in its +// own session; an empty credential sends none. +func testMCPCredentialDelivery(t *testing.T, harness testHarness, harnessName string, callerCredentials ...string) { t.Helper() - cfg, err := mockllm.LoadConfigFromFile("mocks/invoke_agent.json", interactionMocks) + const credential = "Bearer mcp-egress-e2e-token" + target := interactionTarget(t) + secret := createCredentialSecret(t, interactionKubeClient(t), credential) + mcpURL, mcpServer := startMCPMock(t) + headersFrom := []v1alpha3.ValueRef{{ + Name: "Authorization", + ValueFrom: &v1alpha3.ValueSource{Type: v1alpha3.SecretValueSource, Name: secret.Name, Key: "token"}, + }} + template, _ := createMCPServerInteractionTemplate(t, harness, harnessName, mcpURL, headersFrom, false) + for _, callerCredential := range callerCredentials { + fixture := newInteractionFixtureForHarnessTemplate(t, target, harnessName, template) + if callerCredential != "" { + fixture.ctx = metadata.AppendToOutgoingContext(fixture.ctx, "authorization", callerCredential) + } + _, _, task := fixture.send(t, "Add 3 and 5 using the configured MCP server.") + require.Equal(t, a2atype.TaskStateCompleted, task.Status.State, "caller credential %q: task text: %q", callerCredential, taskText(task)) + require.Contains(t, taskText(task), "result is 8") + } + requireEgressCredential(t, mcpServer.Requests(), "Authorization", credential, everyRoute) +} + +// Every runtime fetches skills and plugins with git at golden boot, and git +// sends no Authorization before a challenge. +func TestGitArtifactCredentialDelivery(t *testing.T) { + t.Parallel() + forEachHarness(t, func(t *testing.T, harness testHarness) { + t.Parallel() + target := interactionTarget(t) + kube := interactionKubeClient(t) + // The Secret holds the base64 of ":"; the binding + // prefixes it with the Basic scheme. + credential := base64.StdEncoding.EncodeToString([]byte("x-access-token:git-egress-e2e-token")) + wantAuthorization := "Basic " + credential + secret := createCredentialSecret(t, kube, credential) + repositories := newGitFixture(t, wantAuthorization) + server := repositories.serveThroughEgress(t) + credentialRef := &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: secret.Name}, Key: "token"} + gitSource := func(repository, path string) v1alpha3.ArtifactSource { + return v1alpha3.ArtifactSource{ + Git: &v1alpha3.GitArtifact{URL: server + "/" + repository, Commit: repositories.commit, CredentialRef: credentialRef}, + Path: path, + } + } + model := harness.createModel(t, kube, startInteractionMock(t), nil) + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{GenerateName: "git-credentials-", Namespace: "kagent", Labels: harness.labels()}, + Spec: v1alpha3.AgentTemplateSpec{ + ModelConfig: &corev1.LocalObjectReference{Name: model.Name}, + SystemPrompt: "Reply briefly.", + Skills: []v1alpha3.AgentTemplateSkill{{Name: gitFixtureSkill, Source: gitSource(gitSkillRepository, "skills/"+gitFixtureSkill)}}, + Plugins: []v1alpha3.PluginBundle{{Source: gitSource(gitPluginRepository, "plugin"), Skills: []string{gitFixturePluginSkill}}}, + }, + } + createAndWaitInteractionTemplate(t, harness, kube, template) + fixture := newInteractionFixtureForTemplate(t, harness, target, template.Name) + _, _, task := fixture.send(t, "What is 2+2?") + require.Equal(t, a2atype.TaskStateCompleted, task.Status.State, "task text: %q", taskText(task)) + for _, repository := range []string{gitSkillRepository, gitPluginRepository} { + requireEgressCredential(t, repositories.received(repository), "Authorization", wantAuthorization, everyRoute) + } + }) +} + +func requireEgressCredential(t *testing.T, received []mockmcp.RecordedRequest, header, credential string, authenticated func(path string) bool) { + t.Helper() + require.NoError(t, egressCredentialViolation(received, header, credential, authenticated)) +} + +// egressCredentialViolation reports the first request that breaks the +// contract: at least one request on an authenticated route, each with the +// credential as the only value of header, and no request with the +// placeholder in any header. +func egressCredentialViolation(received []mockmcp.RecordedRequest, header, credential string, authenticated func(path string) bool) error { + var routed int + for i, request := range received { + where := fmt.Sprintf("request %d of %d (%s %s)", i+1, len(received), request.Method, request.Path) + for name, values := range request.Headers { + for _, value := range values { + if strings.Contains(value, translator.CredentialPlaceholder) { + return fmt.Errorf("%s: header %s carries the placeholder", where, name) + } + } + } + if !authenticated(request.Path) { + continue + } + routed++ + if values := request.Headers.Values(header); len(values) != 1 || values[0] != credential { + return fmt.Errorf("%s: %s = %q, want only the Secret value", where, header, values) + } + } + if routed == 0 { + return errors.New("no authenticated request reached the upstream") + } + return nil +} + +func everyRoute(string) bool { return true } + +func modelAPIRoute(path string) bool { return strings.HasPrefix(path, "/v1/") } + +func createCredentialSecret(t *testing.T, kube ctrlclient.Client, value string) *corev1.Secret { + t.Helper() + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{GenerateName: "egress-credential-", Namespace: "kagent"}, StringData: map[string]string{"token": value}} + require.NoError(t, kube.Create(t.Context(), secret)) + t.Cleanup(func() { require.NoError(t, kube.Delete(context.Background(), secret)) }) + return secret +} + +// requestRecorder keeps every request an upstream receives, without its body. +type requestRecorder struct { + mu sync.Mutex + requests []mockmcp.RecordedRequest +} + +func (r *requestRecorder) record(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + r.mu.Lock() + r.requests = append(r.requests, mockmcp.RecordedRequest{ + Method: request.Method, Path: request.URL.Path, Headers: request.Header.Clone(), ReceivedAt: time.Now(), + }) + r.mu.Unlock() + next.ServeHTTP(w, request) + }) +} + +func (r *requestRecorder) Requests() []mockmcp.RecordedRequest { + r.mu.Lock() + defer r.mu.Unlock() + return append([]mockmcp.RecordedRequest(nil), r.requests...) +} + +// startCredentialMock serves the mock LLM routes itself rather than behind a +// proxy: a second streaming connection could fail independently of +// credential delivery. A request without the credential matches no mock. +func startCredentialMock(t *testing.T, header, value string) (string, *requestRecorder) { + t.Helper() + config, err := mockllm.LoadConfigFromFile("mocks/invoke_agent.json", interactionMocks) require.NoError(t, err) match := mockllm.HeaderMatch{Name: header, Value: value, MatchType: mockllm.MatchTypeExact} - for i := range cfg.OpenAI { - cfg.OpenAI[i].Match.Headers = append(cfg.OpenAI[i].Match.Headers, match) + for i := range config.OpenAI { + config.OpenAI[i].Match.Headers = append(config.OpenAI[i].Match.Headers, match) } - for i := range cfg.OpenAIResponse { - cfg.OpenAIResponse[i].Match.Headers = append(cfg.OpenAIResponse[i].Match.Headers, match) + for i := range config.OpenAIResponse { + config.OpenAIResponse[i].Match.Headers = append(config.OpenAIResponse[i].Match.Headers, match) } - for i := range cfg.Anthropic { - cfg.Anthropic[i].Match.Headers = append(cfg.Anthropic[i].Match.Headers, match) + for i := range config.Anthropic { + config.Anthropic[i].Match.Headers = append(config.Anthropic[i].Match.Headers, match) } - return startMockLLMConfig(t, cfg) + routes := http.NewServeMux() + routes.HandleFunc("POST /v1/chat/completions", mockllm.NewOpenAIProvider(config.OpenAI).Handle) + routes.HandleFunc("POST /v1/responses", mockllm.NewOpenAIResponseProvider(config.OpenAIResponse).Handle) + routes.HandleFunc("POST /v1/messages", mockllm.NewAnthropicProvider(config.Anthropic).Handle) + received := &requestRecorder{} + return startHostServer(t, received.record(routes)).URL, received +} + +// startHostServer listens on every interface so runtimes reach the server +// through the host address. +func startHostServer(t *testing.T, handler http.Handler) *httptest.Server { + t.Helper() + server := httptest.NewUnstartedServer(handler) + require.NoError(t, server.Listener.Close()) + listener, err := net.Listen("tcp", "0.0.0.0:0") + require.NoError(t, err) + server.Listener = listener + server.Start() + t.Cleanup(server.Close) + return server +} + +const ( + gitSkillRepository = "skill.git" + gitPluginRepository = "plugin.git" + gitFixtureSkill = "egress-skill" + gitFixturePluginSkill = "egress-plugin-skill" +) + +// gitFixture serves smart-HTTP repositories through git http-backend and +// answers 401 to any request without the expected Authorization, as a private +// forge does. Both repositories hold the same commit: a standalone skill under +// skills/ and an Agent Plugins package under plugin/. +type gitFixture struct { + commit string + handler http.Handler + recorders map[string]*requestRecorder +} + +func newGitFixture(t *testing.T, authorization string) *gitFixture { + t.Helper() + gitPath, err := exec.LookPath("git") + require.NoError(t, err, "the git fixture needs git and its http-backend") + root := t.TempDir() + work, repositories := filepath.Join(root, "work"), filepath.Join(root, "repositories") + for name, content := range map[string]string{ + "skills/" + gitFixtureSkill + "/SKILL.md": "---\nname: " + gitFixtureSkill + "\ndescription: Fetched through the egress gateway.\n---\n", + "plugin/plugin.json": `{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"egress.e2e"}`, + "plugin/skills/" + gitFixturePluginSkill + "/SKILL.md": "---\nname: " + gitFixturePluginSkill + "\ndescription: Fetched through the egress gateway.\n---\n", + } { + path := filepath.Join(work, filepath.FromSlash(name)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + } + runFixtureGit(t, work, "init", "--quiet", "--initial-branch=main") + runFixtureGit(t, work, "add", ".") + runFixtureGit(t, work, "-c", "user.name=kagent e2e", "-c", "user.email=e2e@kagent.dev", "commit", "--quiet", "--message", "Egress credential fixture") + fixture := &gitFixture{commit: strings.TrimSpace(runFixtureGit(t, work, "rev-parse", "HEAD")), recorders: map[string]*requestRecorder{}} + for _, repository := range []string{gitSkillRepository, gitPluginRepository} { + bare := filepath.Join(repositories, repository) + runFixtureGit(t, root, "clone", "--quiet", "--bare", work, bare) + // The runtime fetches its pinned commit by ID, not by ref. + runFixtureGit(t, bare, "config", "uploadpack.allowAnySHA1InWant", "true") + fixture.recorders[repository] = &requestRecorder{} + } + backend := &cgi.Handler{ + Path: gitPath, Args: []string{"http-backend"}, Dir: root, + Env: append(isolatedGitEnvironment(), "GIT_PROJECT_ROOT="+repositories, "GIT_HTTP_EXPORT_ALL=1"), + } + fixture.handler = http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + repository, _, _ := strings.Cut(strings.TrimPrefix(request.URL.Path, "/"), "/") + recorder := fixture.recorders[repository] + if recorder == nil { + http.NotFound(w, request) + return + } + recorder.record(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.Header.Get("Authorization") != authorization { + w.Header().Set("WWW-Authenticate", `Basic realm="kagent-e2e"`) + http.Error(w, "credential required", http.StatusUnauthorized) + return + } + backend.ServeHTTP(w, request) + })).ServeHTTP(w, request) + }) + return fixture +} + +func (f *gitFixture) received(repository string) []mockmcp.RecordedRequest { + return f.recorders[repository].Requests() +} + +// serveThroughEgress serves the fixture over HTTPS on the host under a cluster +// Service name, which a credential binding can match, and returns its base +// URL. The egress gateway dials the upstream itself, so the certificate comes +// from the CA it trusts there (atenetEgress.upstreamTrust). +func (f *gitFixture) serveThroughEgress(t *testing.T) string { + t.Helper() + listener, err := net.Listen("tcp", "0.0.0.0:0") + require.NoError(t, err) + _, port, err := net.SplitHostPort(listener.Addr().String()) + require.NoError(t, err) + base, err := url.Parse(reachableServerURL(t, "https://127.0.0.1:"+port, "")) + require.NoError(t, err) + server := httptest.NewUnstartedServer(f.handler) + require.NoError(t, server.Listener.Close()) + server.Listener = listener + server.TLS = &tls.Config{Certificates: []tls.Certificate{egressUpstreamCertificate(t, base.Hostname())}} + server.StartTLS() + t.Cleanup(server.Close) + return base.String() +} + +func egressUpstreamCertificate(t *testing.T, hostname string) tls.Certificate { + t.Helper() + certificateFile, keyFile := kagentenv.E2EEgressCAFile.Get(), kagentenv.E2EEgressCAKeyFile.Get() + if certificateFile == "" || keyFile == "" { + t.Fatalf("%s and %s must name the CA that Substrate's egress gateway trusts on upstream HTTPS", kagentenv.E2EEgressCAFile.Name(), kagentenv.E2EEgressCAKeyFile.Name()) + } + authority, err := tls.LoadX509KeyPair(certificateFile, keyFile) + require.NoError(t, err) + return issueServerCertificate(t, authority, hostname) +} + +func issueServerCertificate(t *testing.T, authority tls.Certificate, hostname string) tls.Certificate { + t.Helper() + issuer, err := x509.ParseCertificate(authority.Certificate[0]) + require.NoError(t, err) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 127)) + require.NoError(t, err) + now := time.Now() + der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + SerialNumber: serial, Subject: pkix.Name{CommonName: hostname}, DNSNames: []string{hostname}, + NotBefore: now.Add(-time.Hour), NotAfter: now.Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + }, issuer, &key.PublicKey, authority.PrivateKey) + require.NoError(t, err) + return tls.Certificate{Certificate: [][]byte{der, authority.Certificate[0]}, PrivateKey: key} +} + +// isolatedGitEnvironment keeps the runner's git configuration, such as the +// credential header actions/checkout leaves in it, out of fixture commands. +func isolatedGitEnvironment() []string { + return []string{"GIT_CONFIG_GLOBAL=" + os.DevNull, "GIT_CONFIG_NOSYSTEM=1", "GIT_TERMINAL_PROMPT=0"} +} + +func runFixtureGit(t *testing.T, dir string, args ...string) string { + t.Helper() + command := exec.CommandContext(t.Context(), "git", args...) + command.Dir = dir + command.Env = append(os.Environ(), isolatedGitEnvironment()...) + output, err := command.CombinedOutput() + require.NoError(t, err, "git %s: %s", strings.Join(args, " "), output) + return string(output) +} + +func TestGitFixtureRequiresCredential(t *testing.T) { + const authorization = "Basic Zml4dHVyZTpjcmVkZW50aWFs" + fixture := newGitFixture(t, authorization) + server := httptest.NewServer(fixture.handler) + t.Cleanup(server.Close) + for _, test := range []struct { + name, header string + wantFetch bool + }{ + {name: "missing"}, + {name: "placeholder", header: "Basic " + translator.CredentialPlaceholder}, + {name: "credential", header: authorization, wantFetch: true}, + } { + t.Run(test.name, func(t *testing.T) { + destination := t.TempDir() + runFixtureGit(t, destination, "init", "--quiet") + args := []string{"fetch", "--depth", "1", server.URL + "/" + gitSkillRepository, fixture.commit} + if test.header != "" { + args = append([]string{"-c", "http.extraHeader=Authorization: " + test.header}, args...) + } + command := exec.CommandContext(t.Context(), "git", args...) + command.Dir = destination + command.Env = append(os.Environ(), isolatedGitEnvironment()...) + output, err := command.CombinedOutput() + if !test.wantFetch { + require.Error(t, err, "%s", output) + return + } + require.NoError(t, err, "%s", output) + runFixtureGit(t, destination, "checkout", "--quiet", "--detach", "FETCH_HEAD") + _, err = os.Stat(filepath.Join(destination, "plugin", "skills", gitFixturePluginSkill, "SKILL.md")) + require.NoError(t, err) + }) + } + received := fixture.received(gitSkillRepository) + require.NotEmpty(t, received) + require.Empty(t, fixture.received(gitPluginRepository)) } func TestCredentialMockRequiresHeader(t *testing.T) { @@ -88,15 +469,16 @@ func TestCredentialMockRequiresHeader(t *testing.T) { }, } { t.Run(provider.name, func(t *testing.T) { - origin := startCredentialMock(t, provider.header, provider.value) - for _, test := range []struct { + origin, received := startCredentialMock(t, provider.header, provider.value) + tests := []struct { name, value string wantStatus int }{ {name: "missing", wantStatus: provider.missingStatus}, {name: "wrong", value: provider.value + "-wrong", wantStatus: http.StatusNotFound}, {name: "correct", value: provider.value, wantStatus: http.StatusOK}, - } { + } + for _, test := range tests { t.Run(test.name, func(t *testing.T) { request, err := http.NewRequestWithContext(t.Context(), http.MethodPost, origin+provider.path, strings.NewReader(provider.body)) require.NoError(t, err) @@ -118,6 +500,70 @@ func TestCredentialMockRequiresHeader(t *testing.T) { } }) } + require.Len(t, received.Requests(), len(tests)) + }) + } +} + +func TestEgressCredentialViolation(t *testing.T) { + const credential = "Bearer secret" + placeholder := "Bearer " + translator.CredentialPlaceholder + request := func(path string, headers http.Header) mockmcp.RecordedRequest { + return mockmcp.RecordedRequest{Method: http.MethodPost, Path: path, Headers: headers} + } + for _, test := range []struct { + name string + received []mockmcp.RecordedRequest + authenticated func(string) bool + wantErr string + }{ + {name: "replaced", received: []mockmcp.RecordedRequest{request("/v1/messages", http.Header{"Authorization": {credential}})}}, + {name: "no request", wantErr: "no authenticated request reached the upstream"}, + { + name: "header never sent", + received: []mockmcp.RecordedRequest{request("/v1/messages", http.Header{"Authorization": {credential}}), request("/v1/messages", http.Header{})}, + wantErr: "request 2 of 2 (POST /v1/messages)", + }, + {name: "placeholder kept", received: []mockmcp.RecordedRequest{request("/v1/messages", http.Header{"Authorization": {placeholder}})}, wantErr: "carries the placeholder"}, + { + name: "credential appended", + received: []mockmcp.RecordedRequest{request("/v1/messages", http.Header{"Authorization": {credential, credential}})}, + wantErr: "want only the Secret value", + }, + { + name: "placeholder in another header", + received: []mockmcp.RecordedRequest{request("/v1/messages", http.Header{"Authorization": {credential}, "X-Api-Key": {placeholder}})}, + wantErr: "header X-Api-Key carries the placeholder", + }, + { + name: "unauthenticated route without the header", + received: []mockmcp.RecordedRequest{request("/api/hello", http.Header{}), request("/v1/messages", http.Header{"Authorization": {credential}})}, + authenticated: modelAPIRoute, + }, + { + name: "unauthenticated route with the placeholder", + received: []mockmcp.RecordedRequest{request("/api/hello", http.Header{"Authorization": {placeholder}}), request("/v1/messages", http.Header{"Authorization": {credential}})}, + authenticated: modelAPIRoute, + wantErr: "request 1 of 2 (POST /api/hello): header Authorization carries the placeholder", + }, + { + name: "only unauthenticated routes", + received: []mockmcp.RecordedRequest{request("/api/hello", http.Header{})}, + authenticated: modelAPIRoute, + wantErr: "no authenticated request reached the upstream", + }, + } { + t.Run(test.name, func(t *testing.T) { + authenticated := test.authenticated + if authenticated == nil { + authenticated = everyRoute + } + err := egressCredentialViolation(test.received, "Authorization", credential, authenticated) + if test.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, test.wantErr) }) } } diff --git a/go/core/test/e2e/interaction_test.go b/go/core/test/e2e/interaction_test.go index c716426c1a..989cf6e2f6 100644 --- a/go/core/test/e2e/interaction_test.go +++ b/go/core/test/e2e/interaction_test.go @@ -1024,6 +1024,13 @@ func createStructuredOutputInteractionTemplate(t *testing.T, harness testHarness } func createMCPInteractionTemplate(t *testing.T, harness testHarness, mcpURL string, requireApproval bool) (string, string) { + t.Helper() + return createMCPServerInteractionTemplate(t, harness, harness.name, mcpURL, nil, requireApproval) +} + +// createMCPServerInteractionTemplate runs the template on harnessName, which +// may be a Harness cloned from harness with the same runtime. +func createMCPServerInteractionTemplate(t *testing.T, harness testHarness, harnessName, mcpURL string, headersFrom []v1alpha3.ValueRef, requireApproval bool) (string, string) { t.Helper() kube := interactionKubeClient(t) server := &v1alpha3.RemoteMCPServer{ @@ -1032,6 +1039,7 @@ func createMCPInteractionTemplate(t *testing.T, harness testHarness, mcpURL stri Description: "MCP interaction E2E fixture", Protocol: v1alpha3.RemoteMCPServerProtocolStreamableHttp, URL: mcpURL, + HeadersFrom: headersFrom, }, } if err := kube.Create(t.Context(), server); err != nil { @@ -1077,7 +1085,7 @@ func createMCPInteractionTemplate(t *testing.T, harness testHarness, mcpURL stri }}}, }, } - createAndWaitInteractionTemplateForHarness(t, kube, template, harness.name) + createAndWaitInteractionTemplateForHarness(t, kube, template, harnessName) return template.Name, toolName } diff --git a/go/core/test/e2e/runtime_resilience_test.go b/go/core/test/e2e/runtime_resilience_test.go new file mode 100644 index 0000000000..0e60c0ead4 --- /dev/null +++ b/go/core/test/e2e/runtime_resilience_test.go @@ -0,0 +1,269 @@ +package e2e_test + +import ( + "context" + "maps" + "slices" + "strings" + "testing" + "time" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + a2agrpc "github.com/a2aproject/a2a-go/v2/a2agrpc/v1" + "github.com/agent-substrate/substrate/pkg/proto/ateapipb" + adka2a "github.com/kagent-dev/kagent/go/adk/pkg/a2a" + apia2a "github.com/kagent-dev/kagent/go/api/a2a" + apiv1alpha1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1" + "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/internal/substrate" + kagentenv "github.com/kagent-dev/kagent/go/core/pkg/env" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + utilrand "k8s.io/apimachinery/pkg/util/rand" + "k8s.io/apimachinery/pkg/util/wait" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +var goADKHarness = testHarness{name: "kagent", runtimeLabel: "kagent"} + +// A turn parked on input-required pauses the Actor on its worker's node. Once +// the pause is older than KAGENT_PAUSED_RUNTIME_TTL the controller suspends +// the Actor durably, and the reply resumes it from the external snapshot. +func TestSessionPausedRuntimeSuspendsAfterTTL(t *testing.T) { + target := interactionTarget(t) + withControllerEnv(t, target, map[string]string{kagentenv.SessionPausedRuntimeTTL.Name(): "20s"}) + + fixture := newInteractionFixture(t, goADKHarness, target, startMockLLM(t, "mocks/invoke_golang_hitl_ask_user.json")) + fixture.ctx = metadata.AppendToOutgoingContext(fixture.ctx, strings.ToLower(a2atype.SvcParamExtensions), adka2a.HITLExtensionURI) + _, _, waiting := fixture.send(t, "Which database should we use for storage?") + require.Equal(t, a2atype.TaskStateInputRequired, waiting.Status.State) + request := adka2a.GetAskUserRequest(waiting.Status.Message) + require.NotNil(t, request, "INPUT_REQUIRED task has no ask_user request") + + waitForActorState(t, fixture, ateapipb.ActorState_ACTOR_STATE_SUSPENDED, 2*time.Minute) + + reply := adka2a.AttachHitlExtension(a2atype.NewMessage(a2atype.MessageRoleUser, a2atype.NewTextPart("PostgreSQL")), &apia2a.AskUserResponse{ + Type: adka2a.HITLTypeAskUserResponse, ID: request.ID, + Answers: []apia2a.AskUserAnswer{{Answer: []string{"PostgreSQL"}}}, + }) + reply.TaskID, reply.ContextID = waiting.ID, waiting.ContextID + response, err := a2agrpc.NewGRPCTransportFromClient(fixture.client).SendMessage(fixture.ctx, nil, &a2atype.SendMessageRequest{Tenant: fixture.tenant, Message: reply}) + require.NoError(t, err, "resume after the durable suspend") + completed, ok := response.(*a2atype.Task) + require.True(t, ok, "resumed response = %T", response) + require.Equal(t, a2atype.TaskStateCompleted, completed.Status.State, taskText(completed)) + require.Contains(t, taskText(completed), "Using PostgreSQL") +} + +// A paused Actor whose worker goes away cannot be resumed or suspended. The +// next send marks the session FAILED with the reason RuntimeLost and answers +// with that message instead of waiting out Substrate's parking budget again. +func TestSessionRuntimeLostFailsTheSession(t *testing.T) { + target := interactionTarget(t) + kube := interactionKubeClient(t) + + pool := createIsolatedWorkerPool(t, kube) + harness := cloneHarness(t, kube, "kagent", "runtime-lost-", func(harness *v1alpha3.Harness) { + harness.Spec.Substrate.WorkerPoolRef.Name = pool + }) + model := goADKHarness.createModel(t, kube, startMockLLM(t, "mocks/invoke_golang_hitl_ask_user.json"), nil) + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{GenerateName: "runtime-lost-", Namespace: "kagent", Labels: goADKHarness.labels()}, + Spec: v1alpha3.AgentTemplateSpec{ + ModelConfig: &corev1.LocalObjectReference{Name: model.Name}, + Description: "Runtime loss E2E fixture", SystemPrompt: "Reply briefly.", + }, + } + createAndWaitInteractionTemplateForHarness(t, kube, template, harness.Name) + fixture := newInteractionFixtureForHarnessTemplate(t, target, harness.Name, template.Name) + fixture.ctx = metadata.AppendToOutgoingContext(fixture.ctx, strings.ToLower(a2atype.SvcParamExtensions), adka2a.HITLExtensionURI) + + _, _, waiting := fixture.send(t, "Which database should we use for storage?") + require.Equal(t, a2atype.TaskStateInputRequired, waiting.Status.State) + + actor, err := findSubstrateActor(fixture.ctx, fixture.system, "", substrate.ActorName(fixture.sessionID)) + require.NoError(t, err) + require.NotNil(t, actor, "the paused session has no Actor") + assignment := actor.GetStatus().GetWorkerAssignment() + require.NotEmpty(t, assignment.GetWorkerPod(), "the paused Actor has no worker pod") + require.Equal(t, pool, assignment.GetWorkerPool(), "the Actor runs outside its isolated pool") + worker := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Namespace: assignment.GetWorkerNamespace(), Name: assignment.GetWorkerPod()}} + require.NoError(t, kube.Delete(fixture.ctx, worker, ctrlclient.GracePeriodSeconds(0))) + + // The first send after the loss asks the workflow whether the runtime is + // lost; Substrate needs a moment to notice the worker is gone. A send the + // dying sandbox still takes is answered with the loss once its stream + // breaks; should the gateway not know the runtime lost yet, the send's own + // deadline ends it, so one send cannot use up the window. + var lostErr error + err = wait.PollUntilContextTimeout(fixture.ctx, 5*time.Second, 4*time.Minute, true, func(ctx context.Context) (bool, error) { + sendCtx, cancel := context.WithTimeout(ctx, time.Minute) + defer cancel() + message, request := newMessageRequest(t, "Are you still there?") + message.ContextID, request.Message.ContextId, request.Tenant = fixture.sessionID, fixture.sessionID, fixture.tenant + _, lostErr = fixture.client.SendMessage(sendCtx, request) + return lostErr != nil && strings.Contains(lostErr.Error(), "runtime lost"), nil + }) + require.NoErrorf(t, err, "a send after the worker's loss must report the runtime lost; the last send answered: %v", lostErr) + + session, err := fixture.sessions.GetSession(fixture.ctx, &apiv1alpha1.GetSessionRequest{SessionId: fixture.sessionID}) + require.NoError(t, err) + require.Equal(t, apiv1alpha1.RuntimeState_RUNTIME_STATE_FAILED, session.GetSession().GetState()) + require.Equal(t, apia2a.FailureReasonRuntimeLost, session.GetSession().GetFailure().GetReason()) + require.Equal(t, apiv1alpha1.RuntimeOperation_RUNTIME_OPERATION_NONE, session.GetSession().GetOperation(), "the failure leaves no lifecycle operation pending") + + // The transcript stays readable and the session deletable; the fixture's + // cleanup deletes it. + _, err = fixture.sessions.ResumeSession(fixture.ctx, &apiv1alpha1.ResumeSessionRequest{SessionId: fixture.sessionID}) + require.Error(t, err, "a lost runtime cannot be resumed") +} + +// waitForActorState polls the session's Substrate Actor until it reaches the +// state or the timeout passes. +func waitForActorState(t *testing.T, fixture *interactionFixture, want ateapipb.ActorState, timeout time.Duration) { + t.Helper() + actorID := substrate.ActorName(fixture.sessionID) + var last ateapipb.ActorState + err := wait.PollUntilContextTimeout(fixture.ctx, 2*time.Second, timeout, true, func(ctx context.Context) (bool, error) { + actor, err := findSubstrateActor(ctx, fixture.system, "", actorID) + if err != nil { + return false, err + } + if actor == nil { + return false, nil + } + last = actor.GetStatus().GetState() + return last == want, nil + }) + require.NoError(t, err, "Actor %s did not reach %s, last state %s", actorID, want, last) +} + +// withControllerEnv sets variables on the controller Deployment for the test +// and restores the original environment afterwards, waiting out both rollouts. +func withControllerEnv(t *testing.T, target string, values map[string]string) { + t.Helper() + kube := interactionKubeClient(t) + require.NoError(t, appsv1.AddToScheme(kube.Scheme())) + deployments := &appsv1.DeploymentList{} + require.NoError(t, kube.List(t.Context(), deployments, ctrlclient.InNamespace("kagent"), ctrlclient.MatchingLabels{"app.kubernetes.io/component": "controller"})) + require.Len(t, deployments.Items, 1) + deployment := &deployments.Items[0] + index := slices.IndexFunc(deployment.Spec.Template.Spec.Containers, func(container corev1.Container) bool { return container.Name == "controller" }) + require.NotEqual(t, -1, index) + original := slices.Clone(deployment.Spec.Template.Spec.Containers[index].Env) + updated := slices.DeleteFunc(slices.Clone(original), func(env corev1.EnvVar) bool { + _, replaced := values[env.Name] + return replaced + }) + for _, name := range slices.Sorted(maps.Keys(values)) { + updated = append(updated, corev1.EnvVar{Name: name, Value: values[name]}) + } + key := ctrlclient.ObjectKeyFromObject(deployment) + apply := func(env []corev1.EnvVar) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + current := &appsv1.Deployment{} + require.NoError(t, kube.Get(ctx, key, current)) + base := current.DeepCopy() + current.Spec.Template.Spec.Containers[index].Env = env + require.NoError(t, kube.Patch(ctx, current, ctrlclient.MergeFrom(base))) + require.NoError(t, wait.PollUntilContextCancel(ctx, time.Second, true, func(ctx context.Context) (bool, error) { + observed := &appsv1.Deployment{} + if err := kube.Get(ctx, key, observed); err != nil { + return false, err + } + // AvailableReplicas counts the outgoing pod until the Deployment + // scales its ReplicaSet down, so the rollout is over only when the + // total matches too; a probe before that can land on the old pod, + // whose termination then refuses the next test's connections. + return observed.Status.ObservedGeneration >= current.Generation && observed.Status.UpdatedReplicas == *observed.Spec.Replicas && observed.Status.AvailableReplicas == *observed.Spec.Replicas && observed.Status.Replicas == *observed.Spec.Replicas, nil + })) + conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer func() { require.NoError(t, conn.Close()) }() + require.NoError(t, waitForControllerAPI(ctx, conn), "controller API did not become reachable after rollout") + } + t.Cleanup(func() { apply(original) }) + apply(updated) +} + +// cloneHarness copies an installed Harness into a generated one the test owns +// and lets the caller change its spec before it is created. +func cloneHarness(t *testing.T, kube ctrlclient.Client, base, prefix string, mutate func(*v1alpha3.Harness)) *v1alpha3.Harness { + t.Helper() + source := &v1alpha3.Harness{} + require.NoError(t, kube.Get(t.Context(), ctrlclient.ObjectKey{Namespace: "kagent", Name: base}, source), "get %s Harness", base) + harness := &v1alpha3.Harness{ + ObjectMeta: metav1.ObjectMeta{GenerateName: prefix, Namespace: "kagent"}, + Spec: *source.Spec.DeepCopy(), + } + mutate(harness) + require.NoError(t, kube.Create(t.Context(), harness), "create %s Harness", prefix) + t.Cleanup(func() { + if err := kube.Delete(context.Background(), harness); err != nil && !apierrors.IsNotFound(err) { + t.Errorf("delete %s Harness: %v", harness.Name, err) + } + }) + return harness +} + +// createIsolatedWorkerPool clones the chart's WorkerPool into a one-worker pool +// the test may destroy without touching the Actors of other tests, and waits +// for its worker to be ready. +func createIsolatedWorkerPool(t *testing.T, kube ctrlclient.Client) string { + t.Helper() + gvk := unstructuredWorkerPoolGVK() + source := &unstructured.Unstructured{} + source.SetGroupVersionKind(gvk) + require.NoError(t, kube.Get(t.Context(), ctrlclient.ObjectKey{Namespace: "kagent", Name: "kagent-default"}, source), "get the chart's WorkerPool") + spec, _, err := unstructured.NestedMap(source.Object, "spec") + require.NoError(t, err) + // A worker carries its WorkerPool's labels, and a template selects its + // workers by the pool label the chart sets on the WorkerPool, so the + // isolated pool needs that label with its own name or no worker matches. + name := "e2e-lost-" + utilrand.String(5) + pool := &unstructured.Unstructured{} + pool.SetGroupVersionKind(gvk) + pool.SetNamespace("kagent") + pool.SetName(name) + pool.SetLabels(map[string]string{"kagent.dev/worker-pool": name}) + require.NoError(t, unstructured.SetNestedMap(pool.Object, spec, "spec")) + require.NoError(t, unstructured.SetNestedField(pool.Object, int64(1), "spec", "replicas")) + require.NoError(t, kube.Create(t.Context(), pool), "create the isolated WorkerPool") + t.Cleanup(func() { + if err := kube.Delete(context.Background(), pool); err != nil && !apierrors.IsNotFound(err) { + t.Errorf("delete WorkerPool %s: %v", pool.GetName(), err) + } + }) + err = wait.PollUntilContextTimeout(t.Context(), 2*time.Second, 3*time.Minute, true, func(ctx context.Context) (bool, error) { + pods := &corev1.PodList{} + if err := kube.List(ctx, pods, ctrlclient.InNamespace("kagent")); err != nil { + return false, err + } + for _, pod := range pods.Items { + if !strings.HasPrefix(pod.Name, name+"-") { + continue + } + for _, condition := range pod.Status.Conditions { + if condition.Type == corev1.PodReady && condition.Status == corev1.ConditionTrue { + return true, nil + } + } + } + return false, nil + }) + require.NoError(t, err, "the isolated WorkerPool %s has no ready worker", name) + return name +} + +func unstructuredWorkerPoolGVK() schema.GroupVersionKind { + return schema.GroupVersionKind{Group: "ate.dev", Version: "v1alpha1", Kind: "WorkerPool"} +} diff --git a/go/go.mod b/go/go.mod index d4fc28ed34..497735290b 100644 --- a/go/go.mod +++ b/go/go.mod @@ -495,4 +495,4 @@ require ( tool sigs.k8s.io/kube-api-linter/cmd/golangci-lint-kube-api-linter -replace github.com/agent-substrate/substrate => github.com/kagent-dev/substrate v0.3.0-alpha3 +replace github.com/agent-substrate/substrate => github.com/giantswarm/substrate v1.5.0 diff --git a/go/go.sum b/go/go.sum index 05487a2e41..103193e548 100644 --- a/go/go.sum +++ b/go/go.sum @@ -379,6 +379,8 @@ github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcP github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0= github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI= +github.com/giantswarm/substrate v1.5.0 h1:2hodTV8oqiNoehkgv4mupVnf9wtVqgMZ/wjCPG5V2gY= +github.com/giantswarm/substrate v1.5.0/go.mod h1:0T+elmPJY/TPQtOU9YcgNGuBub8M3nZZNI74z8WNArw= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= @@ -701,8 +703,6 @@ github.com/kagent-dev/mockllm v0.1.0 h1:EkBE7BhMnL4Unfu78OazQVDAWi00aWXiswErkVoE github.com/kagent-dev/mockllm v0.1.0/go.mod h1:RLp2ZL/UPY6qqbHNogvf70apXX9R670tESJdDuIli9A= github.com/kagent-dev/mockmcp v0.0.0-20260520211643-dcd475b74085 h1:5bYxRc6K6+JiFMYGSJpQ7y18PzTGXUmUtVXY7Fqh0Ew= github.com/kagent-dev/mockmcp v0.0.0-20260520211643-dcd475b74085/go.mod h1:vTMste7c+XiDOQyhe6iGPKLbMy3chY/h2fL3Dox5fAE= -github.com/kagent-dev/substrate v0.3.0-alpha3 h1:oYbBJAOdxPEkIvlILvhfVYhJmG1RqpptxRV1UB/EJIE= -github.com/kagent-dev/substrate v0.3.0-alpha3/go.mod h1:ksApwzcnhkTv0IKnwdNYwcQcYGgSsPR4wdYw97+q2wk= github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0= github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= diff --git a/go/govulncheck.json b/go/govulncheck.json new file mode 100644 index 0000000000..b53aad168e --- /dev/null +++ b/go/govulncheck.json @@ -0,0 +1,46004 @@ +{ + "config": { + "protocol_version": "v1.0.0", + "scanner_name": "govulncheck", + "scanner_version": "v1.8.0", + "db": "https://vuln.go.dev", + "db_last_modified": "2026-09-10T14:48:42Z", + "go_version": "go1.27.0-X:nodwarf5", + "scan_level": "symbol", + "scan_mode": "source" + } +} +{ + "SBOM": { + "go_version": "go", + "modules": [ + { + "path": "github.com/kagent-dev/kagent/go" + }, + { + "path": "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go", + "version": "v1.36.11-20260709200747-435963d16310.1" + }, + { + "path": "buf.build/go/protovalidate", + "version": "v1.3.0" + }, + { + "path": "cel.dev/expr", + "version": "v0.25.3" + }, + { + "path": "cloud.google.com/go", + "version": "v0.123.0" + }, + { + "path": "cloud.google.com/go/auth", + "version": "v0.23.1" + }, + { + "path": "cloud.google.com/go/auth/oauth2adapt", + "version": "v0.2.8" + }, + { + "path": "cloud.google.com/go/compute/metadata", + "version": "v0.9.0" + }, + { + "path": "dario.cat/mergo", + "version": "v1.0.2" + }, + { + "path": "github.com/Azure/azure-sdk-for-go/sdk/azcore", + "version": "v1.23.0" + }, + { + "path": "github.com/Azure/azure-sdk-for-go/sdk/azidentity", + "version": "v1.14.0" + }, + { + "path": "github.com/Azure/azure-sdk-for-go/sdk/internal", + "version": "v1.12.0" + }, + { + "path": "github.com/AzureAD/microsoft-authentication-library-for-go", + "version": "v1.8.0" + }, + { + "path": "github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp", + "version": "v1.35.0" + }, + { + "path": "github.com/Masterminds/goutils", + "version": "v1.1.1" + }, + { + "path": "github.com/Masterminds/semver/v3", + "version": "v3.5.0" + }, + { + "path": "github.com/Masterminds/sprig/v3", + "version": "v3.3.0" + }, + { + "path": "github.com/a2aproject/a2a-go/v2", + "version": "v2.5.0" + }, + { + "path": "github.com/anthropics/anthropic-sdk-go", + "version": "v1.66.0" + }, + { + "path": "github.com/antlr4-go/antlr/v4", + "version": "v4.13.1" + }, + { + "path": "github.com/atotto/clipboard", + "version": "v0.1.4" + }, + { + "path": "github.com/aws/aws-sdk-go-v2", + "version": "v1.43.6" + }, + { + "path": "github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream", + "version": "v1.7.18" + }, + { + "path": "github.com/aws/aws-sdk-go-v2/config", + "version": "v1.32.37" + }, + { + "path": "github.com/aws/aws-sdk-go-v2/credentials", + "version": "v1.19.36" + }, + { + "path": "github.com/aws/aws-sdk-go-v2/feature/ec2/imds", + "version": "v1.18.37" + }, + { + "path": "github.com/aws/aws-sdk-go-v2/internal/configsources", + "version": "v1.4.37" + }, + { + "path": "github.com/aws/aws-sdk-go-v2/internal/endpoints/v2", + "version": "v2.7.37" + }, + { + "path": "github.com/aws/aws-sdk-go-v2/internal/v4a", + "version": "v1.4.38" + }, + { + "path": "github.com/aws/aws-sdk-go-v2/service/bedrockruntime", + "version": "v1.57.3" + }, + { + "path": "github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding", + "version": "v1.13.17" + }, + { + "path": "github.com/aws/aws-sdk-go-v2/service/internal/checksum", + "version": "v1.9.30" + }, + { + "path": "github.com/aws/aws-sdk-go-v2/service/internal/presigned-url", + "version": "v1.13.37" + }, + { + "path": "github.com/aws/aws-sdk-go-v2/service/internal/s3shared", + "version": "v1.19.38" + }, + { + "path": "github.com/aws/aws-sdk-go-v2/service/s3", + "version": "v1.107.2" + }, + { + "path": "github.com/aws/aws-sdk-go-v2/service/signin", + "version": "v1.5.6" + }, + { + "path": "github.com/aws/aws-sdk-go-v2/service/sso", + "version": "v1.33.6" + }, + { + "path": "github.com/aws/aws-sdk-go-v2/service/ssooidc", + "version": "v1.38.6" + }, + { + "path": "github.com/aws/aws-sdk-go-v2/service/sts", + "version": "v1.45.6" + }, + { + "path": "github.com/aws/smithy-go", + "version": "v1.27.8" + }, + { + "path": "github.com/aymanbagabas/go-osc52/v2", + "version": "v2.0.1" + }, + { + "path": "github.com/bahlo/generic-list-go", + "version": "v0.2.0" + }, + { + "path": "github.com/beorn7/perks", + "version": "v1.0.1" + }, + { + "path": "github.com/briandowns/spinner", + "version": "v1.23.2" + }, + { + "path": "github.com/buger/jsonparser", + "version": "v1.6.1" + }, + { + "path": "github.com/cenkalti/backoff/v4", + "version": "v4.3.0" + }, + { + "path": "github.com/cenkalti/backoff/v5", + "version": "v5.0.3" + }, + { + "path": "github.com/cespare/xxhash/v2", + "version": "v2.3.0" + }, + { + "path": "github.com/charmbracelet/bubbles", + "version": "v1.0.0" + }, + { + "path": "github.com/charmbracelet/bubbletea", + "version": "v1.3.10" + }, + { + "path": "github.com/charmbracelet/colorprofile", + "version": "v0.4.3" + }, + { + "path": "github.com/charmbracelet/lipgloss", + "version": "v1.1.0" + }, + { + "path": "github.com/charmbracelet/x/ansi", + "version": "v0.11.8" + }, + { + "path": "github.com/charmbracelet/x/cellbuf", + "version": "v0.0.15" + }, + { + "path": "github.com/charmbracelet/x/term", + "version": "v0.2.2" + }, + { + "path": "github.com/clipperhouse/displaywidth", + "version": "v0.11.0" + }, + { + "path": "github.com/clipperhouse/uax29/v2", + "version": "v2.7.0" + }, + { + "path": "github.com/cncf/xds/go", + "version": "v0.0.0-20260202195803-dba9d589def2" + }, + { + "path": "github.com/containerd/errdefs", + "version": "v1.0.0" + }, + { + "path": "github.com/containerd/errdefs/pkg", + "version": "v0.3.0" + }, + { + "path": "github.com/containerd/log", + "version": "v0.1.0" + }, + { + "path": "github.com/containerd/platforms", + "version": "v0.2.1" + }, + { + "path": "github.com/cpuguy83/dockercfg", + "version": "v0.3.2" + }, + { + "path": "github.com/davecgh/go-spew", + "version": "v1.1.2-0.20180830191138-d8f796af33cc" + }, + { + "path": "github.com/desertbit/timer", + "version": "v0.0.0-20180107155436-c41aec40b27f" + }, + { + "path": "github.com/distribution/reference", + "version": "v0.6.0" + }, + { + "path": "github.com/docker/cli", + "version": "v29.7.2+incompatible" + }, + { + "path": "github.com/docker/docker-credential-helpers", + "version": "v0.9.8" + }, + { + "path": "github.com/docker/go-connections", + "version": "v0.8.1" + }, + { + "path": "github.com/docker/go-units", + "version": "v0.5.0" + }, + { + "path": "github.com/dustin/go-humanize", + "version": "v1.0.1" + }, + { + "path": "github.com/emicklei/go-restful/v3", + "version": "v3.13.0" + }, + { + "path": "github.com/envoyproxy/go-control-plane/contrib", + "version": "v1.36.1-0.20260731231718-6c0b035a1609" + }, + { + "path": "github.com/envoyproxy/go-control-plane/envoy", + "version": "v1.37.1-0.20260812071801-353463cc7248" + }, + { + "path": "github.com/envoyproxy/protoc-gen-validate", + "version": "v1.3.3" + }, + { + "path": "github.com/evanphx/json-patch/v5", + "version": "v5.9.11" + }, + { + "path": "github.com/fatih/color", + "version": "v1.19.0" + }, + { + "path": "github.com/felixge/httpsnoop", + "version": "v1.1.0" + }, + { + "path": "github.com/fsnotify/fsnotify", + "version": "v1.10.1" + }, + { + "path": "github.com/fxamacker/cbor/v2", + "version": "v2.9.3" + }, + { + "path": "github.com/glebarez/go-sqlite", + "version": "v1.23.0" + }, + { + "path": "github.com/glebarez/sqlite", + "version": "v1.11.0" + }, + { + "path": "github.com/go-logr/logr", + "version": "v1.4.4" + }, + { + "path": "github.com/go-logr/stdr", + "version": "v1.2.2" + }, + { + "path": "github.com/go-openapi/jsonpointer", + "version": "v1.0.0" + }, + { + "path": "github.com/go-openapi/jsonreference", + "version": "v1.0.0" + }, + { + "path": "github.com/go-openapi/swag", + "version": "v0.29.0" + }, + { + "path": "github.com/go-openapi/swag/cmdutils", + "version": "v0.29.0" + }, + { + "path": "github.com/go-openapi/swag/conv", + "version": "v0.29.0" + }, + { + "path": "github.com/go-openapi/swag/fileutils", + "version": "v0.29.0" + }, + { + "path": "github.com/go-openapi/swag/jsonutils", + "version": "v0.29.0" + }, + { + "path": "github.com/go-openapi/swag/loading", + "version": "v0.29.0" + }, + { + "path": "github.com/go-openapi/swag/mangling", + "version": "v0.29.0" + }, + { + "path": "github.com/go-openapi/swag/netutils", + "version": "v0.29.0" + }, + { + "path": "github.com/go-openapi/swag/pools", + "version": "v0.29.0" + }, + { + "path": "github.com/go-openapi/swag/stringutils", + "version": "v0.29.0" + }, + { + "path": "github.com/go-openapi/swag/typeutils", + "version": "v0.29.0" + }, + { + "path": "github.com/go-openapi/swag/yamlutils", + "version": "v0.29.0" + }, + { + "path": "github.com/golang-jwt/jwt/v5", + "version": "v5.3.1" + }, + { + "path": "github.com/golang/protobuf", + "version": "v1.5.4" + }, + { + "path": "github.com/google/btree", + "version": "v1.1.3" + }, + { + "path": "github.com/google/cel-go", + "version": "v0.31.0" + }, + { + "path": "github.com/google/gnostic-models", + "version": "v0.7.1" + }, + { + "path": "github.com/google/go-cmp", + "version": "v0.7.0" + }, + { + "path": "github.com/google/go-containerregistry", + "version": "v0.21.9" + }, + { + "path": "github.com/google/jsonschema-go", + "version": "v0.4.3" + }, + { + "path": "github.com/google/s2a-go", + "version": "v0.1.9" + }, + { + "path": "github.com/google/safehtml", + "version": "v0.1.0" + }, + { + "path": "github.com/google/uuid", + "version": "v1.6.0" + }, + { + "path": "github.com/googleapis/enterprise-certificate-proxy", + "version": "v0.3.21" + }, + { + "path": "github.com/googleapis/gax-go/v2", + "version": "v2.23.0" + }, + { + "path": "github.com/gorilla/mux", + "version": "v1.8.1" + }, + { + "path": "github.com/gorilla/websocket", + "version": "v1.5.4-0.20250319132907-e064f32e3674" + }, + { + "path": "github.com/grpc-ecosystem/go-grpc-middleware/v2", + "version": "v2.3.3" + }, + { + "path": "github.com/grpc-ecosystem/grpc-gateway/v2", + "version": "v2.29.0" + }, + { + "path": "github.com/hashicorp/errwrap", + "version": "v1.1.0" + }, + { + "path": "github.com/hashicorp/go-multierror", + "version": "v1.1.1" + }, + { + "path": "github.com/huandu/xstrings", + "version": "v1.5.0" + }, + { + "path": "github.com/improbable-eng/grpc-web", + "version": "v0.15.0" + }, + { + "path": "github.com/invopop/jsonschema", + "version": "v0.14.0" + }, + { + "path": "github.com/jackc/pgpassfile", + "version": "v1.0.0" + }, + { + "path": "github.com/jackc/pgservicefile", + "version": "v0.0.0-20240606120523-5a60cdf6a761" + }, + { + "path": "github.com/jackc/pgx/v5", + "version": "v5.10.0" + }, + { + "path": "github.com/jackc/puddle/v2", + "version": "v2.2.2" + }, + { + "path": "github.com/jedib0t/go-pretty/v6", + "version": "v6.8.3" + }, + { + "path": "github.com/jinzhu/inflection", + "version": "v1.0.0" + }, + { + "path": "github.com/jinzhu/now", + "version": "v1.1.5" + }, + { + "path": "github.com/json-iterator/go", + "version": "v1.1.12" + }, + { + "path": "github.com/kagent-dev/kmcp", + "version": "v0.3.0" + }, + { + "path": "github.com/kagent-dev/mockllm", + "version": "v0.0.7" + }, + { + "path": "github.com/kagent-dev/substrate", + "version": "v0.0.26" + }, + { + "path": "github.com/kagent-dev/substrate", + "version": "v0.0.26" + }, + { + "path": "github.com/klauspost/compress", + "version": "v1.19.2" + }, + { + "path": "github.com/kylelemons/godebug", + "version": "v1.1.0" + }, + { + "path": "github.com/lestrrat-go/backoff/v2", + "version": "v2.0.8" + }, + { + "path": "github.com/lestrrat-go/blackmagic", + "version": "v1.0.4" + }, + { + "path": "github.com/lestrrat-go/httpcc", + "version": "v1.0.1" + }, + { + "path": "github.com/lestrrat-go/iter", + "version": "v1.0.2" + }, + { + "path": "github.com/lestrrat-go/jwx", + "version": "v1.2.31" + }, + { + "path": "github.com/lestrrat-go/option", + "version": "v1.0.1" + }, + { + "path": "github.com/lucasb-eyer/go-colorful", + "version": "v1.4.1" + }, + { + "path": "github.com/magiconair/properties", + "version": "v1.18.11" + }, + { + "path": "github.com/mailru/easyjson", + "version": "v0.9.2" + }, + { + "path": "github.com/mattn/go-colorable", + "version": "v0.1.15" + }, + { + "path": "github.com/mattn/go-isatty", + "version": "v0.0.24" + }, + { + "path": "github.com/mattn/go-runewidth", + "version": "v0.0.28" + }, + { + "path": "github.com/mfridman/interpolate", + "version": "v0.0.2" + }, + { + "path": "github.com/mitchellh/copystructure", + "version": "v1.2.0" + }, + { + "path": "github.com/mitchellh/reflectwalk", + "version": "v1.0.2" + }, + { + "path": "github.com/moby/docker-image-spec", + "version": "v1.3.1" + }, + { + "path": "github.com/moby/go-archive", + "version": "v0.3.3" + }, + { + "path": "github.com/moby/moby/api", + "version": "v1.55.0" + }, + { + "path": "github.com/moby/moby/client", + "version": "v0.5.1" + }, + { + "path": "github.com/moby/patternmatcher", + "version": "v0.6.1" + }, + { + "path": "github.com/moby/spdystream", + "version": "v0.5.1" + }, + { + "path": "github.com/moby/sys/sequential", + "version": "v0.7.0" + }, + { + "path": "github.com/moby/sys/user", + "version": "v0.4.1" + }, + { + "path": "github.com/moby/sys/userns", + "version": "v0.2.0" + }, + { + "path": "github.com/moby/term", + "version": "v0.5.2" + }, + { + "path": "github.com/modelcontextprotocol/go-sdk", + "version": "v1.7.0" + }, + { + "path": "github.com/modern-go/concurrent", + "version": "v0.0.0-20180306012644-bacd9c7ef1dd" + }, + { + "path": "github.com/modern-go/reflect2", + "version": "v1.0.3-0.20250322232337-35a7c28c31ee" + }, + { + "path": "github.com/muesli/ansi", + "version": "v0.0.0-20230316100256-276c6243b2f6" + }, + { + "path": "github.com/muesli/cancelreader", + "version": "v0.2.2" + }, + { + "path": "github.com/muesli/reflow", + "version": "v0.3.0" + }, + { + "path": "github.com/muesli/termenv", + "version": "v0.16.0" + }, + { + "path": "github.com/munnerz/goautoneg", + "version": "v0.0.0-20191010083416-a7dc8b61c822" + }, + { + "path": "github.com/ollama/ollama", + "version": "v0.32.15" + }, + { + "path": "github.com/openai/openai-go/v3", + "version": "v3.52.0" + }, + { + "path": "github.com/opencontainers/go-digest", + "version": "v1.0.0" + }, + { + "path": "github.com/opencontainers/image-spec", + "version": "v1.1.1" + }, + { + "path": "github.com/pb33f/ordered-map/v2", + "version": "v2.3.1" + }, + { + "path": "github.com/pelletier/go-toml/v2", + "version": "v2.4.3" + }, + { + "path": "github.com/peterbourgon/diskv", + "version": "v2.0.1+incompatible" + }, + { + "path": "github.com/pgvector/pgvector-go", + "version": "v0.4.1" + }, + { + "path": "github.com/pgvector/pgvector-go/pgx", + "version": "v0.4.1" + }, + { + "path": "github.com/pkg/browser", + "version": "v0.0.0-20240102092130-5ac0b6a4141c" + }, + { + "path": "github.com/pkg/errors", + "version": "v0.9.1" + }, + { + "path": "github.com/pmezard/go-difflib", + "version": "v1.0.1-0.20181226105442-5d4384ee4fb2" + }, + { + "path": "github.com/pressly/goose/v3", + "version": "v3.27.3" + }, + { + "path": "github.com/prometheus/client_golang", + "version": "v1.24.1" + }, + { + "path": "github.com/prometheus/client_model", + "version": "v0.6.2" + }, + { + "path": "github.com/prometheus/common", + "version": "v0.70.1" + }, + { + "path": "github.com/prometheus/otlptranslator", + "version": "v1.0.0" + }, + { + "path": "github.com/prometheus/procfs", + "version": "v0.21.1" + }, + { + "path": "github.com/remyoudompheng/bigfft", + "version": "v0.0.0-20230129092748-24d4a6f8daec" + }, + { + "path": "github.com/rivo/uniseg", + "version": "v0.4.7" + }, + { + "path": "github.com/robfig/cron/v3", + "version": "v3.0.1" + }, + { + "path": "github.com/rs/cors", + "version": "v1.7.0" + }, + { + "path": "github.com/sahilm/fuzzy", + "version": "v0.1.3" + }, + { + "path": "github.com/segmentio/asm", + "version": "v1.2.1" + }, + { + "path": "github.com/segmentio/encoding", + "version": "v0.5.4" + }, + { + "path": "github.com/sethvargo/go-retry", + "version": "v0.4.0" + }, + { + "path": "github.com/shirou/gopsutil/v4", + "version": "v4.26.7" + }, + { + "path": "github.com/shopspring/decimal", + "version": "v1.4.0" + }, + { + "path": "github.com/sirupsen/logrus", + "version": "v1.10.1" + }, + { + "path": "github.com/spf13/cast", + "version": "v1.10.0" + }, + { + "path": "github.com/spf13/cobra", + "version": "v1.10.2" + }, + { + "path": "github.com/spf13/pflag", + "version": "v1.0.10" + }, + { + "path": "github.com/standard-webhooks/standard-webhooks/libraries", + "version": "v0.0.1" + }, + { + "path": "github.com/stoewer/go-strcase", + "version": "v1.3.1" + }, + { + "path": "github.com/stretchr/testify", + "version": "v1.12.1" + }, + { + "path": "github.com/testcontainers/testcontainers-go", + "version": "v0.44.0" + }, + { + "path": "github.com/testcontainers/testcontainers-go/modules/postgres", + "version": "v0.44.0" + }, + { + "path": "github.com/tidwall/gjson", + "version": "v1.19.0" + }, + { + "path": "github.com/tidwall/match", + "version": "v1.2.0" + }, + { + "path": "github.com/tidwall/pretty", + "version": "v1.2.1" + }, + { + "path": "github.com/tidwall/sjson", + "version": "v1.2.5" + }, + { + "path": "github.com/tklauser/go-sysconf", + "version": "v0.4.0" + }, + { + "path": "github.com/tklauser/numcpus", + "version": "v0.12.0" + }, + { + "path": "github.com/wk8/go-ordered-map/v2", + "version": "v2.1.8" + }, + { + "path": "github.com/x448/float16", + "version": "v0.8.4" + }, + { + "path": "github.com/xo/terminfo", + "version": "v1.0.0" + }, + { + "path": "github.com/yosida95/uritemplate/v3", + "version": "v3.0.2" + }, + { + "path": "go.opentelemetry.io/auto/sdk", + "version": "v1.2.1" + }, + { + "path": "go.opentelemetry.io/contrib/bridges/prometheus", + "version": "v0.69.0" + }, + { + "path": "go.opentelemetry.io/contrib/detectors/gcp", + "version": "v1.44.0" + }, + { + "path": "go.opentelemetry.io/contrib/exporters/autoexport", + "version": "v0.69.0" + }, + { + "path": "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc", + "version": "v0.69.0" + }, + { + "path": "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp", + "version": "v0.69.0" + }, + { + "path": "go.opentelemetry.io/otel", + "version": "v1.44.0" + }, + { + "path": "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc", + "version": "v0.20.0" + }, + { + "path": "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp", + "version": "v0.20.0" + }, + { + "path": "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc", + "version": "v1.44.0" + }, + { + "path": "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp", + "version": "v1.44.0" + }, + { + "path": "go.opentelemetry.io/otel/exporters/otlp/otlptrace", + "version": "v1.44.0" + }, + { + "path": "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc", + "version": "v1.44.0" + }, + { + "path": "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp", + "version": "v1.44.0" + }, + { + "path": "go.opentelemetry.io/otel/exporters/prometheus", + "version": "v0.66.0" + }, + { + "path": "go.opentelemetry.io/otel/exporters/stdout/stdoutlog", + "version": "v0.20.0" + }, + { + "path": "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric", + "version": "v1.44.0" + }, + { + "path": "go.opentelemetry.io/otel/exporters/stdout/stdouttrace", + "version": "v1.44.0" + }, + { + "path": "go.opentelemetry.io/otel/log", + "version": "v0.20.0" + }, + { + "path": "go.opentelemetry.io/otel/metric", + "version": "v1.44.0" + }, + { + "path": "go.opentelemetry.io/otel/sdk", + "version": "v1.44.0" + }, + { + "path": "go.opentelemetry.io/otel/sdk/log", + "version": "v0.20.0" + }, + { + "path": "go.opentelemetry.io/otel/sdk/metric", + "version": "v1.44.0" + }, + { + "path": "go.opentelemetry.io/otel/trace", + "version": "v1.44.0" + }, + { + "path": "go.opentelemetry.io/proto/otlp", + "version": "v1.11.0" + }, + { + "path": "go.uber.org/atomic", + "version": "v1.11.0" + }, + { + "path": "go.uber.org/multierr", + "version": "v1.11.0" + }, + { + "path": "go.uber.org/zap", + "version": "v1.28.0" + }, + { + "path": "go.yaml.in/yaml/v2", + "version": "v2.4.4" + }, + { + "path": "go.yaml.in/yaml/v3", + "version": "v3.0.5" + }, + { + "path": "go.yaml.in/yaml/v4", + "version": "v4.0.0-rc.6" + }, + { + "path": "golang.org/x/crypto", + "version": "v0.56.0" + }, + { + "path": "golang.org/x/exp", + "version": "v0.0.0-20260813180055-c1d0aacb2297" + }, + { + "path": "golang.org/x/mod", + "version": "v0.40.0" + }, + { + "path": "golang.org/x/net", + "version": "v0.58.0" + }, + { + "path": "golang.org/x/oauth2", + "version": "v0.36.0" + }, + { + "path": "golang.org/x/sync", + "version": "v0.22.0" + }, + { + "path": "golang.org/x/sys", + "version": "v0.47.0" + }, + { + "path": "golang.org/x/term", + "version": "v0.45.0" + }, + { + "path": "golang.org/x/text", + "version": "v0.41.0" + }, + { + "path": "golang.org/x/time", + "version": "v0.15.0" + }, + { + "path": "gomodules.xyz/jsonpatch/v2", + "version": "v2.5.0" + }, + { + "path": "google.golang.org/adk/v2", + "version": "v2.2.0" + }, + { + "path": "google.golang.org/api", + "version": "v0.293.0" + }, + { + "path": "google.golang.org/genai", + "version": "v1.69.0" + }, + { + "path": "google.golang.org/genproto/googleapis/api", + "version": "v0.0.0-20260819154853-08b0e4226688" + }, + { + "path": "google.golang.org/genproto/googleapis/rpc", + "version": "v0.0.0-20260819154853-08b0e4226688" + }, + { + "path": "google.golang.org/grpc", + "version": "v1.83.2" + }, + { + "path": "google.golang.org/protobuf", + "version": "v1.36.12" + }, + { + "path": "gopkg.in/evanphx/json-patch.v4", + "version": "v4.13.0" + }, + { + "path": "gopkg.in/inf.v0", + "version": "v0.9.1" + }, + { + "path": "gopkg.in/natefinch/lumberjack.v2", + "version": "v2.2.1" + }, + { + "path": "gopkg.in/yaml.v3", + "version": "v3.0.1" + }, + { + "path": "gorm.io/gorm", + "version": "v1.31.2" + }, + { + "path": "istio.io/api", + "version": "v1.31.0-alpha.1.0.20260819121012-5803fb6accf7" + }, + { + "path": "istio.io/client-go", + "version": "v1.31.0-alpha.0.0.20260807010324-676a810f2c1f" + }, + { + "path": "istio.io/istio", + "version": "v0.0.0-20260820015531-47320ba1a73b" + }, + { + "path": "k8s.io/api", + "version": "v0.37.0" + }, + { + "path": "k8s.io/apiextensions-apiserver", + "version": "v0.36.3" + }, + { + "path": "k8s.io/apimachinery", + "version": "v0.37.0" + }, + { + "path": "k8s.io/apiserver", + "version": "v0.36.3" + }, + { + "path": "k8s.io/client-go", + "version": "v0.37.0" + }, + { + "path": "k8s.io/klog/v2", + "version": "v2.140.0" + }, + { + "path": "k8s.io/kube-openapi", + "version": "v0.0.0-20260721132016-d427ff9ee9ad" + }, + { + "path": "k8s.io/streaming", + "version": "v0.37.0" + }, + { + "path": "k8s.io/utils", + "version": "v0.0.0-20260707023825-cf1189d6abe3" + }, + { + "path": "modernc.org/libc", + "version": "v1.75.3" + }, + { + "path": "modernc.org/mathutil", + "version": "v1.7.1" + }, + { + "path": "modernc.org/memory", + "version": "v1.12.1" + }, + { + "path": "modernc.org/sqlite", + "version": "v1.57.0" + }, + { + "path": "nhooyr.io/websocket", + "version": "v1.8.6" + }, + { + "path": "rsc.io/omap", + "version": "v1.2.0" + }, + { + "path": "rsc.io/ordered", + "version": "v1.1.1" + }, + { + "path": "sigs.k8s.io/controller-runtime", + "version": "v0.24.1" + }, + { + "path": "sigs.k8s.io/gateway-api", + "version": "v1.6.1" + }, + { + "path": "sigs.k8s.io/gateway-api-inference-extension", + "version": "v1.6.0" + }, + { + "path": "sigs.k8s.io/json", + "version": "v0.0.0-20250730193827-2d320260d730" + }, + { + "path": "sigs.k8s.io/mcs-api", + "version": "v0.5.2" + }, + { + "path": "sigs.k8s.io/randfill", + "version": "v1.0.0" + }, + { + "path": "sigs.k8s.io/structured-merge-diff/v6", + "version": "v6.4.2" + }, + { + "path": "sigs.k8s.io/yaml", + "version": "v1.6.0" + }, + { + "path": "stdlib" + } + ], + "roots": [ + "github.com/kagent-dev/kagent/go/adk/pkg/auth", + "github.com/kagent-dev/kagent/go/adk/pkg/internal/azureai", + "github.com/kagent-dev/kagent/go/adk/pkg/telemetry", + "github.com/kagent-dev/kagent/go/pkg/logging", + "github.com/kagent-dev/kagent/go/adk/pkg/models", + "github.com/kagent-dev/kagent/go/api/a2a", + "github.com/kagent-dev/kagent/go/adk/pkg/a2a", + "github.com/kagent-dev/kagent/go/adk/pkg/a2a/server", + "github.com/kagent-dev/kagent/go/adk/pkg/app", + "github.com/kagent-dev/kagent/go/api/agentplugin", + "github.com/kagent-dev/kagent/go/api/adk", + "github.com/kagent-dev/kagent/go/core/internal/skillsinit", + "github.com/kagent-dev/kagent/go/core/pkg/agentplugins", + "github.com/kagent-dev/kagent/go/adk/pkg/config", + "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1alpha1", + "github.com/kagent-dev/kagent/go/adk/pkg/controllerclient", + "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "github.com/kagent-dev/kagent/go/adk/pkg/memory", + "github.com/kagent-dev/kagent/go/adk/pkg/constants", + "github.com/kagent-dev/kagent/go/adk/pkg/mcp", + "github.com/kagent-dev/kagent/go/adk/pkg/sts", + "github.com/kagent-dev/kagent/go/adk/pkg/tools", + "github.com/kagent-dev/kagent/go/adk/pkg/agent", + "github.com/kagent-dev/kagent/go/adk/pkg/runner", + "github.com/kagent-dev/kagent/go/adk/pkg/session", + "github.com/kagent-dev/kagent/go/core/pkg/env", + "github.com/kagent-dev/kagent/go/adk/cmd", + "github.com/kagent-dev/kagent/go/adk/examples/byo", + "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "github.com/kagent-dev/kagent/go/api/client", + "github.com/kagent-dev/kagent/go/api/utils", + "github.com/kagent-dev/kagent/go/api/v1alpha3", + "github.com/kagent-dev/kagent/go/api/clientset/versioned/scheme", + "github.com/kagent-dev/kagent/go/api/clientset/versioned/typed/api/v1alpha3", + "github.com/kagent-dev/kagent/go/api/clientset/versioned", + "github.com/kagent-dev/kagent/go/api/clientset/versioned/typed/api/v1alpha3/fake", + "github.com/kagent-dev/kagent/go/api/clientset/versioned/fake", + "github.com/kagent-dev/kagent/go/api/scheduledrun", + "github.com/kagent-dev/kagent/go/api/structuredobject", + "github.com/kagent-dev/kagent/go/core/cli/internal/common/exec", + "github.com/kagent-dev/kagent/go/core/cli/internal/common/k8s", + "github.com/kagent-dev/kagent/go/core/cli/internal/connection", + "github.com/kagent-dev/kagent/go/core/cli/internal/output", + "github.com/kagent-dev/kagent/go/core/cli/internal/profiles", + "github.com/kagent-dev/kagent/go/core/internal/version", + "github.com/kagent-dev/kagent/go/core/cli/internal/commands", + "github.com/kagent-dev/kagent/go/core/pkg/auth", + "github.com/kagent-dev/kagent/go/core/internal/a2a", + "github.com/kagent-dev/kagent/go/core/cli/internal/a2a", + "github.com/kagent-dev/kagent/go/core/cli/internal/commands/agentinstance", + "github.com/kagent-dev/kagent/go/core/pkg/migrations", + "github.com/kagent-dev/kagent/go/core/cli/internal/db/migrate", + "github.com/kagent-dev/kagent/go/core/cli/internal/db", + "github.com/kagent-dev/kagent/go/core/cli/internal/commands/db", + "github.com/kagent-dev/kagent/go/core/cli/internal/common/fs", + "github.com/kagent-dev/kagent/go/core/cli/internal/common/prompt", + "github.com/kagent-dev/kagent/go/core/cli/internal/mcp/manifests", + "github.com/kagent-dev/kagent/go/core/cli/internal/mcp", + "github.com/kagent-dev/kagent/go/core/cli/internal/mcp/builder", + "github.com/kagent-dev/kagent/go/core/cli/internal/common/generator", + "github.com/kagent-dev/kagent/go/core/cli/internal/mcp/frameworks/common", + "github.com/kagent-dev/kagent/go/core/cli/internal/mcp/frameworks/golang", + "github.com/kagent-dev/kagent/go/core/cli/internal/mcp/frameworks/java", + "github.com/kagent-dev/kagent/go/core/cli/internal/mcp/frameworks/python", + "github.com/kagent-dev/kagent/go/core/cli/internal/mcp/frameworks/typescript", + "github.com/kagent-dev/kagent/go/core/cli/internal/mcp/frameworks", + "github.com/kagent-dev/kagent/go/core/cli/internal/commands/mcp", + "github.com/kagent-dev/kagent/go/core/cli/internal/tui/instance", + "github.com/kagent-dev/kagent/go/core/cli/internal/tui/theme", + "github.com/kagent-dev/kagent/go/core/cli/internal/tui", + "github.com/kagent-dev/kagent/go/core/cli", + "github.com/kagent-dev/kagent/go/core/cli/cmd/kagent", + "github.com/kagent-dev/kagent/go/core/internal/database/internal/dbgen", + "github.com/kagent-dev/kagent/go/core/internal/database", + "github.com/kagent-dev/kagent/go/core/internal/a2agateway", + "github.com/kagent-dev/kagent/go/core/internal/controller/apiclient", + "github.com/kagent-dev/kagent/go/core/internal/translator", + "github.com/kagent-dev/kagent/go/core/internal/substrate", + "github.com/kagent-dev/kagent/go/core/internal/utils", + "github.com/kagent-dev/kagent/go/core/internal/translator/adkconfig", + "github.com/kagent-dev/kagent/go/core/internal/translator/byo", + "github.com/kagent-dev/kagent/go/harness/claude/config", + "github.com/kagent-dev/kagent/go/core/internal/translator/claude", + "github.com/kagent-dev/kagent/go/harness/codex/config", + "github.com/kagent-dev/kagent/go/core/internal/translator/codex", + "github.com/kagent-dev/kagent/go/core/internal/translator/kagent", + "github.com/kagent-dev/kagent/go/core/internal/controller", + "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors", + "github.com/kagent-dev/kagent/go/core/internal/service/secretmaterial", + "github.com/kagent-dev/kagent/go/core/internal/service/tool", + "github.com/kagent-dev/kagent/go/core/internal/controller/toolcatalog", + "github.com/kagent-dev/kagent/go/core/pkg/consts", + "github.com/kagent-dev/kagent/go/core/internal/controller/mcpserver", + "github.com/kagent-dev/kagent/go/core/internal/controller/remotemcpserver", + "github.com/kagent-dev/kagent/go/core/internal/controller/scheduledrun", + "github.com/kagent-dev/kagent/go/core/internal/service/agentinstance", + "github.com/kagent-dev/kagent/go/core/internal/service/checkpoint", + "github.com/kagent-dev/kagent/go/core/internal/service/kubecrud", + "github.com/kagent-dev/kagent/go/core/internal/service/memory", + "github.com/kagent-dev/kagent/go/core/internal/service/model", + "github.com/kagent-dev/kagent/go/core/internal/service/prompttemplate", + "github.com/kagent-dev/kagent/go/core/internal/service/scheduledrun", + "github.com/kagent-dev/kagent/go/core/internal/service/system", + "github.com/kagent-dev/kagent/go/core/internal/grpcserver", + "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth", + "github.com/kagent-dev/kagent/go/core/internal/mcp", + "github.com/kagent-dev/kagent/go/core/internal/telemetry", + "github.com/kagent-dev/kagent/go/core/pkg/app", + "github.com/kagent-dev/kagent/go/core/cmd/controller", + "github.com/kagent-dev/kagent/go/core/hack/makeagentconfig", + "github.com/kagent-dev/kagent/go/core/test/e2e/mocks", + "github.com/kagent-dev/kagent/go/core/hack/mockllm", + "github.com/kagent-dev/kagent/go/core/internal/dbtest", + "github.com/kagent-dev/kagent/go/core/internal/egress", + "github.com/kagent-dev/kagent/go/core/test/byoa2a", + "github.com/kagent-dev/kagent/go/core/test/e2e", + "github.com/kagent-dev/kagent/go/core/test/upgrade", + "github.com/kagent-dev/kagent/go/harness/internal/utils", + "github.com/kagent-dev/kagent/go/harness/runtime", + "github.com/kagent-dev/kagent/go/harness/claude/internal/driver", + "github.com/kagent-dev/kagent/go/harness/claude/internal/adapter", + "github.com/kagent-dev/kagent/go/harness/runtime/a2a", + "github.com/kagent-dev/kagent/go/harness/runtime/continuation", + "github.com/kagent-dev/kagent/go/harness/claude/cmd", + "github.com/kagent-dev/kagent/go/harness/codex/internal/driver", + "github.com/kagent-dev/kagent/go/harness/codex/internal/adapter", + "github.com/kagent-dev/kagent/go/harness/codex/cmd" + ] + } +} +{ + "progress": { + "message": "Fetching vulnerabilities from the database..." + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-2331", + "modified": "2025-11-20T22:03:19Z", + "published": "2024-06-27T18:00:06Z", + "aliases": [ + "CVE-2023-47108", + "GHSA-8pgv-569h-w5rw" + ], + "summary": "Denial of service in go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc", + "details": "The grpc Unary Server Interceptor created by the otelgrpc package added the labels net.peer.sock.addr and net.peer.sock.port with unbounded cardinality. This can lead to the server's potential memory exhaustion when many malicious requests are sent. This leads to a denial-of-service.", + "affected": [ + { + "package": { + "name": "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0.37.0" + }, + { + "fixed": "0.46.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc", + "symbols": [ + "StreamClientInterceptor", + "StreamServerInterceptor", + "UnaryClientInterceptor", + "UnaryServerInterceptor", + "spanInfo" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/open-telemetry/opentelemetry-go-contrib/security/advisories/GHSA-8pgv-569h-w5rw" + }, + { + "type": "FIX", + "url": "https://github.com/open-telemetry/opentelemetry-go-contrib/commit/b44dfc9092b157625a5815cb437583cee663333b" + }, + { + "type": "FIX", + "url": "https://github.com/open-telemetry/opentelemetry-go-contrib/pull/4322" + }, + { + "type": "WEB", + "url": "https://pkg.go.dev/go.opentelemetry.io/otel/metric/noop#NewMeterProvider" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-2331", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2020-0015", + "modified": "2024-05-20T16:03:47Z", + "published": "2021-04-14T20:04:52Z", + "aliases": [ + "CVE-2020-14040", + "GHSA-5rcv-m4m3-hfh7" + ], + "summary": "Infinite loop when decoding some inputs in golang.org/x/text", + "details": "An attacker could provide a single byte to a UTF16 decoder instantiated with UseBOM or ExpectBOM to trigger an infinite loop if the String function on the Decoder is called, or the Decoder is passed to transform.String. If used to parse user supplied input, this may be used as a denial of service vector.", + "affected": [ + { + "package": { + "name": "golang.org/x/text", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.3.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/text/encoding/unicode", + "symbols": [ + "bomOverride.Transform", + "utf16Decoder.Transform" + ] + }, + { + "path": "golang.org/x/text/transform", + "symbols": [ + "String" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/238238" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/text/+/23ae387dee1f90d29a23c0e87ee0b46038fbed0e" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/39491" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/bXVeAmGOqz0" + } + ], + "credits": [ + { + "name": "@abacabadabacaba" + }, + { + "name": "Anton Gyllenberg" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2020-0015", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0113", + "modified": "2024-05-20T16:03:47Z", + "published": "2021-10-06T17:51:21Z", + "aliases": [ + "CVE-2021-38561", + "GHSA-ppp9-7jff-5vj2" + ], + "summary": "Out-of-bounds read in golang.org/x/text/language", + "details": "Due to improper index calculation, an incorrectly formatted language tag can cause Parse to panic via an out of bounds read. If Parse is used to process untrusted user inputs, this may be used as a vector for a denial of service attack.", + "affected": [ + { + "package": { + "name": "golang.org/x/text", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.3.7" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/text/language", + "symbols": [ + "MatchStrings", + "MustParse", + "Parse", + "ParseAcceptLanguage" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/340830" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/text/+/383b2e75a7a4198c42f8f87833eefb772868a56f" + } + ], + "credits": [ + { + "name": "Guido Vranken" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0113", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-1059", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-10-11T18:16:24Z", + "aliases": [ + "CVE-2022-32149", + "GHSA-69ch-w2m2-3vjp" + ], + "summary": "Denial of service via crafted Accept-Language header in golang.org/x/text/language", + "details": "An attacker may cause a denial of service by crafting an Accept-Language header which ParseAcceptLanguage will take significant time to parse.", + "affected": [ + { + "package": { + "name": "golang.org/x/text", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.3.8" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/text/language", + "symbols": [ + "MatchStrings", + "ParseAcceptLanguage" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/56152" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/442235" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/-hjNw559_tE/m/KlGTfid5CAAJ" + } + ], + "credits": [ + { + "name": "Adam Korczynski (ADA Logics)" + }, + { + "name": "OSS-Fuzz" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-1059", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5970", + "modified": "2026-08-10T22:42:36Z", + "published": "2026-07-14T17:29:56Z", + "aliases": [ + "CVE-2026-56852" + ], + "summary": "Infinite loop on invalid input in golang.org/x/text", + "details": "A norm.Iter can enter an infinite loop when handling input containing invalid UTF-8 bytes.", + "affected": [ + { + "package": { + "name": "golang.org/x/text", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.39.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/text/unicode/norm", + "symbols": [ + "Form.Append", + "Form.AppendString", + "Form.Bytes", + "Form.FirstBoundary", + "Form.FirstBoundaryInString", + "Form.IsNormal", + "Form.IsNormalString", + "Form.LastBoundary", + "Form.NextBoundary", + "Form.NextBoundaryInString", + "Form.Properties", + "Form.PropertiesString", + "Form.QuickSpan", + "Form.QuickSpanString", + "Form.Span", + "Form.SpanString", + "Form.String", + "Form.Transform", + "Iter.Init", + "Iter.InitString", + "Iter.Next", + "Iter.Seek", + "nextComposed", + "normReader.Read", + "normWriter.Write" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/80142" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/794100" + } + ], + "credits": [ + { + "name": "Viky Choi (\"vikychoi\" on GitHub)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5970", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4569", + "modified": "2026-03-10T18:28:01Z", + "published": "2026-03-10T18:28:01Z", + "aliases": [ + "CVE-2026-27896", + "GHSA-wvj2-96wp-fq3f" + ], + "summary": "MCP Go SDK Vulnerable to Improper Handling of Case Sensitivity in github.com/modelcontextprotocol/go-sdk", + "details": "MCP Go SDK Vulnerable to Improper Handling of Case Sensitivity in github.com/modelcontextprotocol/go-sdk", + "affected": [ + { + "package": { + "name": "github.com/modelcontextprotocol/go-sdk", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.3.1" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/modelcontextprotocol/go-sdk/security/advisories/GHSA-wvj2-96wp-fq3f" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27896" + }, + { + "type": "FIX", + "url": "https://github.com/modelcontextprotocol/go-sdk/commit/7b8d81c264074404abdf5aa16e2cf0c2d9c64cc0" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4569", + "review_status": "UNREVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4770", + "modified": "2026-03-23T18:14:51Z", + "published": "2026-03-23T18:14:51Z", + "aliases": [ + "GHSA-q382-vc8q-7jhj" + ], + "summary": "Improper handling of null Unicode character when parsing JSON in github.com/modelcontextprotocol/go-sdk", + "details": "Improper handling of null Unicode character when parsing JSON in github.com/modelcontextprotocol/go-sdk", + "affected": [ + { + "package": { + "name": "github.com/modelcontextprotocol/go-sdk", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.4.1" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/modelcontextprotocol/go-sdk/security/advisories/GHSA-q382-vc8q-7jhj" + }, + { + "type": "FIX", + "url": "https://github.com/modelcontextprotocol/go-sdk/commit/724dd47aa3431b9d4cf9ac2eebbf7b38a629afca" + }, + { + "type": "WEB", + "url": "https://github.com/segmentio/encoding/commit/7d5a25dbc5da13aed3cb047a127e4d0e96f536fb" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4770", + "review_status": "UNREVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4773", + "modified": "2026-03-23T18:14:51Z", + "published": "2026-03-23T18:14:51Z", + "aliases": [ + "CVE-2026-33252", + "GHSA-89xv-2j6f-qhc8" + ], + "summary": "Cross-Site Tool Execution for HTTP Servers without Authorizatrion in github.com/modelcontextprotocol/go-sdk", + "details": "Cross-Site Tool Execution for HTTP Servers without Authorizatrion in github.com/modelcontextprotocol/go-sdk", + "affected": [ + { + "package": { + "name": "github.com/modelcontextprotocol/go-sdk", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.4.1" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/modelcontextprotocol/go-sdk/security/advisories/GHSA-89xv-2j6f-qhc8" + }, + { + "type": "FIX", + "url": "https://github.com/modelcontextprotocol/go-sdk/commit/a433a831d6e5d5ac3b9e625a8095aa8eaa040dfc" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4773", + "review_status": "UNREVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5771", + "modified": "2026-06-25T22:34:41Z", + "published": "2026-06-25T22:34:41Z", + "aliases": [ + "CVE-2026-34742", + "GHSA-xw59-hvm2-8pj6" + ], + "summary": "DNS Rebinding Protection Disabled by Default in Model Context Protocol Go SDK for Servers Running on Localhost in github.com/modelcontextprotocol/go-sdk", + "details": "DNS Rebinding Protection Disabled by Default in Model Context Protocol Go SDK for Servers Running on Localhost in github.com/modelcontextprotocol/go-sdk", + "affected": [ + { + "package": { + "name": "github.com/modelcontextprotocol/go-sdk", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.4.0" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/modelcontextprotocol/go-sdk/security/advisories/GHSA-xw59-hvm2-8pj6" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-34742" + }, + { + "type": "FIX", + "url": "https://github.com/modelcontextprotocol/go-sdk/commit/67bd3f2e2b53ce11a16db8d976cdb8ff1e986b6d" + }, + { + "type": "FIX", + "url": "https://github.com/modelcontextprotocol/go-sdk/pull/760" + }, + { + "type": "WEB", + "url": "https://github.com/modelcontextprotocol/go-sdk/releases/tag/v1.4.0" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5771", + "review_status": "UNREVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5764", + "modified": "2026-07-07T16:52:19Z", + "published": "2026-07-07T16:52:19Z", + "aliases": [ + "GHSA-xmrv-pmrh-hhx2" + ], + "summary": "DoS due to Panic in AWS SDK for Go v2 SDK EventStream Decoder in github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream", + "details": "DoS due to Panic in AWS SDK for Go v2 SDK EventStream Decoder in github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream", + "affected": [ + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.7.8" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/bedrockagentcore", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.15.2" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/bedrockagentruntime", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.51.8" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/bedrockruntime", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.50.4" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.65.0" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/iotsitewise", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.52.19" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/kinesis", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.43.5" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/lambda", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.88.5" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/lexruntimev2", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.35.15" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/s3", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.97.3" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/sagemakerruntime", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.39.6" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/transcribestreaming", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.34.5" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/aws/aws-sdk-go-v2/security/advisories/GHSA-xmrv-pmrh-hhx2" + }, + { + "type": "WEB", + "url": "https://github.com/aws/aws-sdk-go-v2/releases/tag/release-2026-03-23" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5764", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-6094", + "modified": "2026-08-18T16:38:10Z", + "published": "2026-08-18T16:38:10Z", + "aliases": [ + "GHSA-gcjh-h69q-9w9g" + ], + "summary": "JSON private fields exposed via NativeTypes and ParseStructTag in github.com/google/cel-go", + "details": "JSON private fields exposed via NativeTypes and ParseStructTag in github.com/google/cel-go", + "affected": [ + { + "package": { + "name": "github.com/google/cel-go", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0.22.0" + }, + { + "fixed": "0.30.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/google/cel-go/ext", + "symbols": [ + "NativeTypes", + "ParseStructTag" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/cel-expr/cel-go/security/advisories/GHSA-gcjh-h69q-9w9g" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-6094", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4985", + "modified": "2026-05-26T22:48:49Z", + "published": "2026-05-26T22:48:49Z", + "aliases": [ + "CVE-2026-39882", + "GHSA-w8rr-5gcm-pp58" + ], + "summary": "Oversized OTLP HTTP response bodies can cause memory exhaustion in go.opentelemetry.io/otel/exporters/otlp", + "details": "The OTLP HTTP exporters (traces, metrics, and logs) do not limit the size of the HTTP response body read from the collector. A malicious or misconfigured collector can send a large response body, leading to excessive memory consumption and potential process termination (OOM).", + "affected": [ + { + "package": { + "name": "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.19.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp", + "symbols": [ + "Exporter.Export", + "client.UploadLogs" + ] + } + ] + } + }, + { + "package": { + "name": "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.43.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp", + "symbols": [ + "Exporter.Export", + "client.UploadMetrics" + ] + } + ] + } + }, + { + "package": { + "name": "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.43.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp", + "symbols": [ + "New", + "NewUnstarted", + "client.UploadTraces" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://github.com/open-telemetry/opentelemetry-go/pull/8108" + }, + { + "type": "ADVISORY", + "url": "https://github.com/open-telemetry/opentelemetry-go/security/advisories/GHSA-w8rr-5gcm-pp58" + }, + { + "type": "WEB", + "url": "http://github.com/open-telemetry/opentelemetry-go/releases/tag/v1.43.0" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4985", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-2567", + "modified": "2024-07-09T20:04:43Z", + "published": "2024-07-02T19:20:38Z", + "aliases": [ + "GHSA-fqpg-rq76-99pq" + ], + "summary": "Panic in Pipeline when PgConn is busy or closed in github.com/jackc/pgx", + "details": "Pipeline can panic when PgConn is busy or closed.", + "affected": [ + { + "package": { + "name": "github.com/jackc/pgx/v5", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "5.0.0-alpha.5" + }, + { + "fixed": "5.5.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/jackc/pgx/v5/pgconn", + "symbols": [ + "Pipeline.Sync" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://github.com/jackc/pgx/commit/dfd198003a03dbb96e4607b0d3a0bb9a7398ccb7" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-2567", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-2606", + "modified": "2024-09-13T14:17:49Z", + "published": "2024-03-14T17:12:43Z", + "aliases": [ + "CVE-2024-27304", + "GHSA-mrww-27vc-gghv", + "GHSA-7jwh-3vrq-q3m8" + ], + "summary": "SQL injection in github.com/jackc/pgproto3 and github.com/jackc/pgx", + "details": "An integer overflow in the calculated message size of a query or bind message could allow a single large message to be sent as multiple messages under the attacker's control. This could lead to SQL injection if an attacker can cause a single query or bind message to exceed 4 GB in size.", + "affected": [ + { + "package": { + "name": "github.com/jackc/pgproto3/v2", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "2.3.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/jackc/pgproto3/v2", + "symbols": [ + "AuthenticationCleartextPassword.Encode", + "AuthenticationGSS.Encode", + "AuthenticationGSSContinue.Encode", + "AuthenticationMD5Password.Encode", + "AuthenticationOk.Encode", + "AuthenticationSASL.Encode", + "AuthenticationSASLContinue.Encode", + "AuthenticationSASLFinal.Encode", + "Backend.Send", + "BackendKeyData.Encode", + "Bind.Encode", + "BindComplete.Encode", + "CancelRequest.Encode", + "Close.Encode", + "CloseComplete.Encode", + "CommandComplete.Encode", + "CopyBothResponse.Encode", + "CopyData.Encode", + "CopyDone.Encode", + "CopyFail.Encode", + "CopyInResponse.Encode", + "CopyOutResponse.Encode", + "DataRow.Encode", + "Describe.Encode", + "EmptyQueryResponse.Encode", + "ErrorResponse.Encode", + "ErrorResponse.marshalBinary", + "Execute.Encode", + "Flush.Encode", + "Frontend.Send", + "FunctionCall.Encode", + "FunctionCallResponse.Encode", + "GSSEncRequest.Encode", + "GSSResponse.Encode", + "NoData.Encode", + "NoticeResponse.Encode", + "NotificationResponse.Encode", + "ParameterDescription.Encode", + "ParameterStatus.Encode", + "Parse.Encode", + "ParseComplete.Encode", + "PasswordMessage.Encode", + "PortalSuspended.Encode", + "Query.Encode", + "ReadyForQuery.Encode", + "RowDescription.Encode", + "SASLInitialResponse.Encode", + "SASLResponse.Encode", + "SSLRequest.Encode", + "StartupMessage.Encode", + "Sync.Encode", + "Terminate.Encode" + ] + }, + { + "path": "github.com/jackc/pgproto3/v2/example/pgfortune", + "symbols": [ + "PgFortuneBackend.Run", + "PgFortuneBackend.handleStartup", + "main" + ] + } + ] + } + }, + { + "package": { + "name": "github.com/jackc/pgx", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/jackc/pgx/internal/sanitize", + "symbols": [ + "Query.Sanitize", + "SanitizeSQL" + ] + } + ] + } + }, + { + "package": { + "name": "github.com/jackc/pgx/v4", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "4.18.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/jackc/pgx/v4/internal/sanitize", + "symbols": [ + "Query.Sanitize", + "SanitizeSQL" + ] + } + ] + } + }, + { + "package": { + "name": "github.com/jackc/pgx/v5", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "5.0.0" + }, + { + "fixed": "5.5.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/jackc/pgx/v5/internal/sanitize", + "symbols": [ + "Query.Sanitize", + "SanitizeSQL" + ] + }, + { + "path": "github.com/jackc/pgx/v5/pgproto3", + "symbols": [ + "AuthenticationCleartextPassword.Encode", + "AuthenticationGSS.Encode", + "AuthenticationGSSContinue.Encode", + "AuthenticationMD5Password.Encode", + "AuthenticationOk.Encode", + "AuthenticationSASL.Encode", + "AuthenticationSASLContinue.Encode", + "AuthenticationSASLFinal.Encode", + "Backend.Flush", + "Backend.Send", + "BackendKeyData.Encode", + "Bind.Encode", + "BindComplete.Encode", + "CancelRequest.Encode", + "Close.Encode", + "CloseComplete.Encode", + "CommandComplete.Encode", + "CopyBothResponse.Encode", + "CopyData.Encode", + "CopyDone.Encode", + "CopyFail.Encode", + "CopyInResponse.Encode", + "CopyOutResponse.Encode", + "DataRow.Encode", + "Describe.Encode", + "EmptyQueryResponse.Encode", + "ErrorResponse.Encode", + "ErrorResponse.marshalBinary", + "Execute.Encode", + "Flush.Encode", + "Frontend.Flush", + "Frontend.Send", + "Frontend.SendBind", + "Frontend.SendClose", + "Frontend.SendDescribe", + "Frontend.SendExecute", + "Frontend.SendParse", + "Frontend.SendQuery", + "Frontend.SendSync", + "Frontend.SendUnbufferedEncodedCopyData", + "FunctionCall.Encode", + "FunctionCallResponse.Encode", + "GSSEncRequest.Encode", + "GSSResponse.Encode", + "NoData.Encode", + "NoticeResponse.Encode", + "NotificationResponse.Encode", + "ParameterDescription.Encode", + "ParameterStatus.Encode", + "Parse.Encode", + "ParseComplete.Encode", + "PasswordMessage.Encode", + "PortalSuspended.Encode", + "Query.Encode", + "ReadyForQuery.Encode", + "RowDescription.Encode", + "SASLInitialResponse.Encode", + "SASLResponse.Encode", + "SSLRequest.Encode", + "StartupMessage.Encode", + "Sync.Encode", + "Terminate.Encode" + ] + }, + { + "path": "github.com/jackc/pgx/v5/pgconn", + "symbols": [ + "Batch.ExecParams", + "Batch.ExecPrepared", + "Connect", + "ConnectConfig", + "ConnectWithOptions", + "MultiResultReader.Close", + "MultiResultReader.NextResult", + "MultiResultReader.ReadAll", + "PgConn.CheckConn", + "PgConn.Close", + "PgConn.CopyFrom", + "PgConn.CopyTo", + "PgConn.Deallocate", + "PgConn.Exec", + "PgConn.ExecBatch", + "PgConn.ExecParams", + "PgConn.ExecPrepared", + "PgConn.Ping", + "PgConn.Prepare", + "PgConn.ReceiveMessage", + "PgConn.SyncConn", + "PgConn.WaitForNotification", + "Pipeline.Close", + "Pipeline.Flush", + "Pipeline.GetResults", + "Pipeline.SendDeallocate", + "Pipeline.SendPrepare", + "Pipeline.SendQueryParams", + "Pipeline.SendQueryPrepared", + "Pipeline.Sync", + "ResultReader.Close", + "ResultReader.NextRow", + "ResultReader.Read", + "ValidateConnectTargetSessionAttrsPreferStandby", + "ValidateConnectTargetSessionAttrsPrimary", + "ValidateConnectTargetSessionAttrsReadOnly", + "ValidateConnectTargetSessionAttrsReadWrite", + "ValidateConnectTargetSessionAttrsStandby" + ] + }, + { + "path": "github.com/jackc/pgx/v5/pgproto3/example/pgfortune", + "symbols": [ + "PgFortuneBackend.Run", + "PgFortuneBackend.handleStartup", + "main" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/jackc/pgx/security/advisories/GHSA-mrww-27vc-gghv" + }, + { + "type": "FIX", + "url": "https://github.com/jackc/pgproto3/commit/945c2126f6db8f3bea7eeebe307c01fe92bca007" + }, + { + "type": "FIX", + "url": "https://github.com/jackc/pgx/commit/adbb38f298c76e283ffc7c7a3f571036fea47fd4" + }, + { + "type": "FIX", + "url": "https://github.com/jackc/pgx/commit/c543134753a0c5d22881c12404025724cb05ffd8" + }, + { + "type": "FIX", + "url": "https://github.com/jackc/pgx/commit/f94eb0e2f96782042c96801b5ac448f44f0a81df" + } + ], + "credits": [ + { + "name": "paul-gerste-sonarsource" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-2606", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4771", + "modified": "2026-04-17T14:29:55Z", + "published": "2026-04-07T14:58:41Z", + "aliases": [ + "CVE-2026-33815", + "GHSA-xgrm-4fwx-7qm8" + ], + "summary": "CVE-2026-33815 in github.com/jackc/pgx", + "details": "Memory-safety vulnerability in github.com/jackc/pgx/v5.", + "affected": [ + { + "package": { + "name": "github.com/jackc/pgx/v5", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "5.9.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/jackc/pgx/v5/pgproto3", + "symbols": [ + "Backend.Receive", + "Bind.Decode" + ] + } + ] + } + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4771", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4772", + "modified": "2026-04-17T19:01:28Z", + "published": "2026-04-07T14:58:41Z", + "aliases": [ + "CVE-2026-33816", + "GHSA-9jj7-4m8r-rfcm" + ], + "summary": "CVE-2026-33816 in github.com/jackc/pgx", + "details": "Memory-safety vulnerability in github.com/jackc/pgx/v5.", + "affected": [ + { + "package": { + "name": "github.com/jackc/pgx/v5", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "5.9.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/jackc/pgx/v5/pgproto3", + "symbols": [ + "Backend.Receive", + "FunctionCall.Decode" + ] + } + ] + } + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4772", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5004", + "modified": "2026-06-22T18:40:49Z", + "published": "2026-06-22T18:40:49Z", + "aliases": [ + "CVE-2026-41889", + "GHSA-j88v-2chj-qfwx" + ], + "summary": "SQL Injection via placeholder confusion with dollar quoted string literals in github.com/jackc/pgx", + "details": "SQL Injection can occur when using the non-default simple protocol with a dollar quoted string literal in the SQL query. If that string literal contains text that would be interpreted as a placeholder outside of a string literal, and the value of that placeholder is controllable by the attacker, an injection may be possible.\n\nFor example, an attacker could provide a value that includes a closing dollar quote followed by malicious SQL commands. This is unlikely to occur outside of a contrived scenario.", + "affected": [ + { + "package": { + "name": "github.com/jackc/pgx", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/jackc/pgx/internal/sanitize", + "symbols": [ + "Query.Sanitize", + "SanitizeSQL" + ] + } + ] + } + }, + { + "package": { + "name": "github.com/jackc/pgx/v4", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/jackc/pgx/v4/internal/sanitize", + "symbols": [ + "Query.Sanitize", + "SanitizeSQL" + ] + } + ] + } + }, + { + "package": { + "name": "github.com/jackc/pgx/v5", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "5.9.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/jackc/pgx/v5/internal/sanitize", + "symbols": [ + "Query.Sanitize", + "SanitizeSQL" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/jackc/pgx/security/advisories/GHSA-j88v-2chj-qfwx" + }, + { + "type": "FIX", + "url": "https://github.com/jackc/pgx/commit/60644f84918a8af66d14a4b0d865d4edafd955da" + }, + { + "type": "WEB", + "url": "https://github.com/jackc/pgx/releases/tag/v5.9.2" + } + ], + "credits": [ + { + "name": "Jack Christensen" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5004", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3488", + "modified": "2025-02-26T02:51:51Z", + "published": "2025-02-26T02:51:51Z", + "aliases": [ + "CVE-2025-22868" + ], + "summary": "Unexpected memory consumption during token parsing in golang.org/x/oauth2", + "details": "An attacker can pass a malicious malformed token which causes unexpected memory to be consumed during parsing.", + "affected": [ + { + "package": { + "name": "golang.org/x/oauth2", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.27.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/oauth2/jws", + "symbols": [ + "Verify" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/652155" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/71490" + } + ], + "credits": [ + { + "name": "jub0bs" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3488", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-1631", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-03-14T16:47:00Z", + "aliases": [ + "CVE-2023-24535", + "GHSA-hw7c-3rfg-p46j" + ], + "summary": "Panic when parsing invalid messages in google.golang.org/protobuf", + "details": "Parsing invalid messages can panic.\n\nParsing a text-format message which contains a potential number consisting of a minus sign, one or more characters of whitespace, and no further input will cause a panic.", + "affected": [ + { + "package": { + "name": "google.golang.org/protobuf", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.29.0" + }, + { + "fixed": "1.29.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "google.golang.org/protobuf/encoding/prototext", + "symbols": [ + "Unmarshal", + "UnmarshalOptions.Unmarshal", + "UnmarshalOptions.unmarshal" + ] + }, + { + "path": "google.golang.org/protobuf/internal/encoding/text", + "symbols": [ + "Decoder.Peek", + "Decoder.Read", + "parseNumber" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/475995" + }, + { + "type": "REPORT", + "url": "https://github.com/golang/protobuf/issues/1530" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-1631", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-2611", + "modified": "2024-05-20T16:03:47Z", + "published": "2024-03-05T20:24:05Z", + "aliases": [ + "CVE-2024-24786", + "GHSA-8r3f-844c-mc37" + ], + "summary": "Infinite loop in JSON unmarshaling in google.golang.org/protobuf", + "details": "The protojson.Unmarshal function can enter an infinite loop when unmarshaling certain forms of invalid JSON. This condition can occur when unmarshaling into a message which contains a google.protobuf.Any value, or when the UnmarshalOptions.DiscardUnknown option is set.", + "affected": [ + { + "package": { + "name": "google.golang.org/protobuf", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.33.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "google.golang.org/protobuf/encoding/protojson", + "symbols": [ + "Unmarshal", + "UnmarshalOptions.Unmarshal", + "UnmarshalOptions.unmarshal" + ] + }, + { + "path": "google.golang.org/protobuf/internal/encoding/json", + "symbols": [ + "Decoder.Peek", + "Decoder.Read" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/569356" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-2611", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0493", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-15T23:30:12Z", + "aliases": [ + "CVE-2022-29526", + "GHSA-p782-xgp4-8hr8" + ], + "summary": "Incorrect privilege reporting in syscall and golang.org/x/sys/unix", + "details": "When called with a non-zero flags parameter, the Faccessat function can incorrectly report that a file is accessible.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.17.10" + }, + { + "introduced": "1.18.0-0" + }, + { + "fixed": "1.18.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "syscall", + "symbols": [ + "Faccessat" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/sys", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20220412211240-33da011f77ad" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/sys/unix", + "symbols": [ + "Faccessat" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/399539" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/52313" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/400074" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/Y5qrqw_lWdU" + } + ], + "credits": [ + { + "name": "Joël Gähwiler (@256dpi)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0493", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5024", + "modified": "2026-05-22T18:28:47Z", + "published": "2026-05-22T18:28:47Z", + "aliases": [ + "CVE-2026-39824" + ], + "summary": "Invoking integer overflow in NewNTUnicodeString in golang.org/x/sys/windows", + "details": "NewNTUnicodeString does not check for string length overflow. When provided with a string that overflows the maximum size of a NTUnicodeString (a 16-bit number of bytes), it returns a truncated string rather than an error.", + "affected": [ + { + "package": { + "name": "golang.org/x/sys", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.44.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/sys/windows", + "goos": [ + "windows" + ], + "symbols": [ + "NewNTUnicodeString" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/78916" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/770080" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/6MMI8Lj-Atg" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5024", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0619", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-08-15T18:05:29Z", + "aliases": [ + "CVE-2022-1996", + "GHSA-r48q-9g5r-8q2h" + ], + "summary": "Authorization bypass in github.com/emicklei/go-restful, go-restful/v2 and go-restful/v3", + "details": "CORS filters that use an AllowedDomains configuration parameter can match domains outside the specified set, permitting an attacker to avoid the CORS policy.\n\nThe AllowedDomains configuration parameter is documented as a list of allowed origin domains, but values in this list are applied as regular expression matches. For example, an allowed domain of \"example.com\" will match the Origin header \"example.com.malicious.domain\".", + "affected": [ + { + "package": { + "name": "github.com/emicklei/go-restful", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "2.16.0+incompatible" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/emicklei/go-restful", + "symbols": [ + "CrossOriginResourceSharing.Filter", + "CrossOriginResourceSharing.isOriginAllowed" + ] + } + ] + } + }, + { + "package": { + "name": "github.com/emicklei/go-restful/v2", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "2.7.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/emicklei/go-restful/v2", + "symbols": [ + "CrossOriginResourceSharing.Filter", + "CrossOriginResourceSharing.isOriginAllowed" + ] + } + ] + } + }, + { + "package": { + "name": "github.com/emicklei/go-restful/v3", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "3.0.0" + }, + { + "fixed": "3.8.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/emicklei/go-restful/v3", + "symbols": [ + "CrossOriginResourceSharing.Filter", + "CrossOriginResourceSharing.isOriginAllowed" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://github.com/emicklei/go-restful/commit/f292efff46ae17e9d104f865a60a39a2ae9402f1" + }, + { + "type": "WEB", + "url": "https://github.com/emicklei/go-restful/issues/489" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0619", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4985", + "modified": "2026-05-26T22:48:49Z", + "published": "2026-05-26T22:48:49Z", + "aliases": [ + "CVE-2026-39882", + "GHSA-w8rr-5gcm-pp58" + ], + "summary": "Oversized OTLP HTTP response bodies can cause memory exhaustion in go.opentelemetry.io/otel/exporters/otlp", + "details": "The OTLP HTTP exporters (traces, metrics, and logs) do not limit the size of the HTTP response body read from the collector. A malicious or misconfigured collector can send a large response body, leading to excessive memory consumption and potential process termination (OOM).", + "affected": [ + { + "package": { + "name": "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.19.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp", + "symbols": [ + "Exporter.Export", + "client.UploadLogs" + ] + } + ] + } + }, + { + "package": { + "name": "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.43.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp", + "symbols": [ + "Exporter.Export", + "client.UploadMetrics" + ] + } + ] + } + }, + { + "package": { + "name": "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.43.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp", + "symbols": [ + "New", + "NewUnstarted", + "client.UploadTraces" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://github.com/open-telemetry/opentelemetry-go/pull/8108" + }, + { + "type": "ADVISORY", + "url": "https://github.com/open-telemetry/opentelemetry-go/security/advisories/GHSA-w8rr-5gcm-pp58" + }, + { + "type": "WEB", + "url": "http://github.com/open-telemetry/opentelemetry-go/releases/tag/v1.43.0" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4985", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0965", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-09-02T21:12:51Z", + "aliases": [ + "GHSA-74fp-r6jw-h4mp" + ], + "summary": "Unbounded recursion in JSON parsing in k8s.io/apimachinery", + "details": "Unbounded recursion in JSON parsing allows malicious JSON input to cause excessive memory consumption or panics.", + "affected": [ + { + "package": { + "name": "k8s.io/apimachinery", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20190927203648-9ce6eca90e73" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "k8s.io/apimachinery/pkg/runtime/serializer/json", + "symbols": [ + "Serializer.Decode", + "Serializer.Encode", + "customNumberDecoder.Decode" + ] + }, + { + "path": "k8s.io/apimachinery/pkg/util/json", + "symbols": [ + "Unmarshal" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://github.com/kubernetes/kubernetes/pull/83261" + }, + { + "type": "WEB", + "url": "https://github.com/advisories/GHSA-pmqp-h87c-mr78" + }, + { + "type": "WEB", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2019-11253" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0965", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-2748", + "modified": "2024-05-20T19:46:32Z", + "published": "2024-05-20T19:46:32Z", + "aliases": [ + "CVE-2020-8559", + "GHSA-33c5-9fx5-fvjm" + ], + "summary": "Privilege Escalation in Kubernetes in k8s.io/apimachinery", + "details": "The Kubernetes kube-apiserver is vulnerable to an unvalidated redirect on proxied upgrade requests that could allow an attacker to escalate privileges from a node compromise to a full cluster compromise.", + "affected": [ + { + "package": { + "name": "k8s.io/apimachinery", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.16.13" + }, + { + "introduced": "0.17.0" + }, + { + "fixed": "0.17.9" + }, + { + "introduced": "0.18.0" + }, + { + "fixed": "0.18.7-rc.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "k8s.io/apimachinery/pkg/util/net", + "symbols": [ + "ConnectWithRedirects" + ] + }, + { + "path": "k8s.io/apimachinery/pkg/util/proxy", + "symbols": [ + "UpgradeAwareHandler.ServeHTTP", + "UpgradeAwareHandler.tryUpgrade" + ] + } + ] + } + }, + { + "package": { + "name": "k8s.io/kubernetes", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.16.13" + }, + { + "introduced": "1.17.0" + }, + { + "fixed": "1.17.9" + }, + { + "introduced": "1.18.0" + }, + { + "fixed": "1.18.7" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/advisories/GHSA-33c5-9fx5-fvjm" + }, + { + "type": "WEB", + "url": "https://bugzilla.redhat.com/show_bug.cgi?id=1851422" + }, + { + "type": "WEB", + "url": "https://github.com/kubernetes/kubernetes/issues/92914" + }, + { + "type": "WEB", + "url": "https://github.com/kubernetes/kubernetes/pull/92941" + }, + { + "type": "WEB", + "url": "https://github.com/tdwyer/CVE-2020-8559" + }, + { + "type": "WEB", + "url": "https://groups.google.com/d/msg/kubernetes-security-announce/JAIGG5yNROs/19nHQ5wkBwAJ" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/kubernetes-security-announce/c/JAIGG5yNROs" + }, + { + "type": "WEB", + "url": "https://security.netapp.com/advisory/ntap-20200810-0004" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-2748", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5158", + "modified": "2026-07-24T18:35:55Z", + "published": "2026-07-24T18:35:55Z", + "aliases": [ + "CVE-2026-41178", + "GHSA-5wrp-cwcj-q835" + ], + "summary": "Opentelemetry-go's baggage parsing no longer caps raw header length in go.opentelemetry.io/otel", + "details": "Opentelemetry-go's baggage parsing no longer caps raw header length in go.opentelemetry.io/otel", + "affected": [ + { + "package": { + "name": "go.opentelemetry.io/otel", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.41.0" + }, + { + "fixed": "1.42.0" + }, + { + "introduced": "1.43.0" + }, + { + "fixed": "1.44.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go.opentelemetry.io/otel/baggage", + "symbols": [ + "New", + "Parse" + ] + }, + { + "path": "go.opentelemetry.io/otel/propagation", + "symbols": [ + "Baggage.Extract" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/open-telemetry/opentelemetry-go/security/advisories/GHSA-5wrp-cwcj-q835" + }, + { + "type": "WEB", + "url": "https://github.com/open-telemetry/opentelemetry-go/pull/7880" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5158", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5506", + "modified": "2026-07-24T18:35:55Z", + "published": "2026-07-24T18:35:55Z", + "aliases": [ + "CVE-2026-29181", + "GHSA-mh2q-q3fh-2475" + ], + "summary": "OpenTelemetry-Go: Multi-value baggage header extraction causes excessive allocations in go.opentelemetry.io/otel", + "details": "OpenTelemetry-Go: Multi-value baggage header extraction causes excessive allocations in go.opentelemetry.io/otel", + "affected": [ + { + "package": { + "name": "go.opentelemetry.io/otel", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.36.0" + }, + { + "fixed": "1.41.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go.opentelemetry.io/otel/internal/global", + "symbols": [ + "Debug", + "ErrDelegator.Handle", + "Error", + "GetErrorHandler", + "Info", + "SetErrorHandler", + "SetMeterProvider", + "SetTextMapPropagator", + "SetTracerProvider", + "Warn", + "textMapPropagator.SetDelegate" + ] + }, + { + "path": "go.opentelemetry.io/otel/baggage", + "symbols": [ + "New", + "Parse" + ] + }, + { + "path": "go.opentelemetry.io/otel/propagation", + "symbols": [ + "Baggage.Extract" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/open-telemetry/opentelemetry-go/security/advisories/GHSA-mh2q-q3fh-2475" + }, + { + "type": "FIX", + "url": "https://github.com/open-telemetry/opentelemetry-go/commit/aa1894e09e3fe66860c7885cb40f98901b35277f" + }, + { + "type": "WEB", + "url": "https://github.com/open-telemetry/opentelemetry-go/pull/7880" + }, + { + "type": "WEB", + "url": "https://github.com/open-telemetry/opentelemetry-go/releases/tag/v1.41.0" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5506", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0064", + "modified": "2024-07-19T16:04:58Z", + "published": "2021-04-14T20:04:52Z", + "aliases": [ + "CVE-2020-8565", + "GHSA-8cfg-vx93-jvxw" + ], + "summary": "Unauthorized credential disclosure via debug logs in k8s.io/kubernetes and k8s.io/client-go", + "details": "Authorization tokens may be inappropriately logged if the verbosity level is set to a debug level. This is due to an incomplete fix for CVE-2019-11250.", + "affected": [ + { + "package": { + "name": "k8s.io/client-go", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.20.0-alpha.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "k8s.io/client-go/transport", + "symbols": [ + "basicAuthRoundTripper.RoundTrip", + "bearerAuthRoundTripper.RoundTrip", + "debuggingRoundTripper.RoundTrip", + "impersonatingRoundTripper.RoundTrip", + "requestInfo.toCurl", + "userAgentRoundTripper.RoundTrip" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://github.com/kubernetes/kubernetes/pull/95316" + }, + { + "type": "FIX", + "url": "https://github.com/kubernetes/kubernetes/commit/e99df0e5a75eb6e86123b56d53e9b7ca0fd00419" + }, + { + "type": "WEB", + "url": "https://github.com/kubernetes/kubernetes/issues/95623" + } + ], + "credits": [ + { + "name": "@sfowl" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0064", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0065", + "modified": "2024-07-19T16:04:58Z", + "published": "2021-04-14T20:04:52Z", + "aliases": [ + "CVE-2019-11250", + "GHSA-jmrx-5g74-6v2f" + ], + "summary": "Unauthorized credential disclosure in k8s.io/kubernetes and k8s.io/client-go", + "details": "Authorization tokens may be inappropriately logged if the verbosity level is set to a debug level.", + "affected": [ + { + "package": { + "name": "k8s.io/client-go", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.17.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "k8s.io/client-go/transport", + "symbols": [ + "basicAuthRoundTripper.RoundTrip", + "bearerAuthRoundTripper.RoundTrip", + "debuggingRoundTripper.RoundTrip", + "impersonatingRoundTripper.RoundTrip", + "userAgentRoundTripper.RoundTrip" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://github.com/kubernetes/kubernetes/pull/81330" + }, + { + "type": "FIX", + "url": "https://github.com/kubernetes/kubernetes/commit/4441f1d9c3e94d9a3d93b4f184a591cab02a5245" + }, + { + "type": "WEB", + "url": "https://github.com/kubernetes/kubernetes/issues/81114" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0065", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0067", + "modified": "2024-05-20T16:03:47Z", + "published": "2021-04-14T20:04:52Z", + "aliases": [ + "CVE-2021-27919" + ], + "summary": "Panic when opening archives in archive/zip", + "details": "Using Reader.Open on an archive containing a file with a path prefixed by \"../\" will cause a panic due to a stack overflow. If parsing user supplied archives, this may be used as a denial of service vector.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.16.0-0" + }, + { + "fixed": "1.16.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "archive/zip", + "symbols": [ + "toValidName" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/300489" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/cd3b4ca9f20fd14187ed4cdfdee1a02ea87e5cd8" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/44916" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/MfiLYjG-RAw/m/zzhWj5jPAQAJ" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0067", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0069", + "modified": "2024-05-20T16:03:47Z", + "published": "2021-04-14T20:04:52Z", + "aliases": [ + "CVE-2020-28362" + ], + "summary": "Panic during division of very large numbers in math/big", + "details": "A number of math/big.Int methods can panic when provided large inputs due to a flawed division method.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.14.0-0" + }, + { + "fixed": "1.14.12" + }, + { + "introduced": "1.15.0-0" + }, + { + "fixed": "1.15.5" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "math/big", + "symbols": [ + "nat.divRecursiveStep" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/269657" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/1e1fa5903b760c6714ba17e50bf850b01f49135c" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/42552" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/NpBGTTmKzpM/m/fLguyiM2CAAJ" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0069", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0142", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-01T20:11:09Z", + "aliases": [ + "CVE-2020-16845", + "GHSA-q6gq-997w-f55g" + ], + "summary": "Unbounded read from invalid inputs in encoding/binary", + "details": "ReadUvarint and ReadVarint can read an unlimited number of bytes from invalid inputs.\n\nCertain invalid inputs to ReadUvarint or ReadVarint can cause these functions to read an unlimited number of bytes from the ByteReader parameter before returning an error. This can lead to processing more input than expected when the caller is reading directly from a network and depends on ReadUvarint or ReadVarint only consuming a small, bounded number of bytes, even from invalid inputs.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.13.15" + }, + { + "introduced": "1.14.0-0" + }, + { + "fixed": "1.14.7" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "encoding/binary", + "symbols": [ + "ReadUvarint", + "ReadVarint" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/247120" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/027d7241ce050d197e7fabea3d541ffbe3487258" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/40618" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/NyPIaucMgXo" + } + ], + "credits": [ + { + "name": "Diederik Loerakker" + }, + { + "name": "Jonny Rhea" + }, + { + "name": "Raúl Kripalani" + }, + { + "name": "Preston Van Loon" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0142", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0154", + "modified": "2024-06-03T20:51:31Z", + "published": "2022-05-25T21:11:41Z", + "aliases": [ + "CVE-2014-7189" + ], + "summary": "Man-in-the-middle attack with SessionTicketsDisabled in crypto/tls", + "details": "When SessionTicketsDisabled is enabled, crypto/tls allowed man-in-the-middle attackers to spoof clients via unspecified vectors.\n\nIf the server enables TLS client authentication using certificates (this is rare) and explicitly sets SessionTicketsDisabled to true in the tls.Config, then a malicious client can falsely assert ownership of any client certificate it wishes.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.1.0-0" + }, + { + "fixed": "1.3.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/tls", + "symbols": [ + "checkForResumption", + "decryptTicket" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/148080043" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/53085" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-nuts/c/eeOHNw_shwU/m/OHALUmroA5kJ" + } + ], + "credits": [ + { + "name": "Go Team" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0154", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0159", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-01-05T21:39:14Z", + "aliases": [ + "CVE-2015-5739", + "CVE-2015-5740", + "CVE-2015-5741" + ], + "summary": "Request smuggling due to improper header parsing in net/http", + "details": "HTTP headers were not properly parsed, which allows remote attackers to conduct HTTP request smuggling attacks via a request that contains Content-Length and Transfer-Encoding header fields.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.4.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "CanonicalMIMEHeaderKey", + "body.readLocked", + "canonicalMIMEHeaderKey", + "chunkWriter.writeHeader", + "fixLength", + "fixTransferEncoding", + "readTransfer", + "transferWriter.shouldSendContentLength", + "validHeaderFieldByte" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/13148" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/26049f6f9171d1190f3bbe05ec304845cfe6399f" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/11772" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/11810" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/12865" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/117ddcb83d7f42d6aa72241240af99ded81118e9" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/300d9a21583e7cf0149a778a0611e76ff7c6680f" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/c2db5f4ccc61ba7df96a747e268a277b802cbb87" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/12027" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/11930" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/iSIyW4lM4hY/m/ADuQR4DiDwAJ" + } + ], + "credits": [ + { + "name": "Jed Denlea" + }, + { + "name": "Régis Leroy" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0159", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0160", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-01-05T15:31:16Z", + "aliases": [ + "CVE-2015-8618" + ], + "summary": "Incorrect calculation affecting RSA computations in math/big", + "details": "Int.Exp Montgomery mishandled carry propagation and produced an incorrect output, which makes it easier for attackers to obtain private RSA keys via unspecified vectors.\n\nThis issue can affect RSA computations in crypto/rsa, which is used by crypto/tls. TLS servers on 32-bit systems could plausibly leak their RSA private key due to this issue. Other protocol implementations that create many RSA signatures could also be impacted in the same way.\n\nSpecifically, incorrect results in one part of the RSA Chinese Remainder computation can cause the result to be incorrect in such a way that it leaks one of the primes. While RSA blinding should prevent an attacker from crafting specific inputs that trigger the bug, on 32-bit systems the bug can be expected to occur at random around one in 2^26 times. Thus collecting around 64 million signatures (of known data) from an affected server should be enough to extract the private key used.\n\nNote that on 64-bit systems, the frequency of the bug is so low (less than one in 2^50) that it would be very difficult to exploit.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.5.0-0" + }, + { + "fixed": "1.5.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "math/big", + "symbols": [ + "nat.expNNMontgomery", + "nat.montgomery" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/18491" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/1e066cad1ba23f4064545355b8737e4762dd6838" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/4306352182bf94f86f0cfc6a8b0ed461cbf1d82c" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/17672" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/13515" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/MEATuOi_ei4" + } + ], + "credits": [ + { + "name": "Nick Craig-Wood" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0160", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0163", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-01-05T22:41:50Z", + "aliases": [ + "CVE-2016-3958" + ], + "summary": "Privilege escalation on Windows via malicious DLL in syscall", + "details": "Untrusted search path vulnerability on Windows related to LoadLibrary allows local users to gain privileges via a malicious DLL in the current working directory.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.5.4" + }, + { + "introduced": "1.6.0-0" + }, + { + "fixed": "1.6.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "syscall", + "symbols": [ + "LoadLibrary" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/21428" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/6a0bb87bd0bf0fdf8ddbd35f77a75ebd412f61b0" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/14959" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/9eqIHqaWvck" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0163", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0172", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-02-15T23:56:14Z", + "aliases": [ + "CVE-2017-1000098" + ], + "summary": "Denial of service when parsing large forms in mime/multipart", + "details": "When parsing large multipart/form-data, an attacker can cause a HTTP server to open a large number of file descriptors. This may be used as a denial-of-service vector.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.6.4" + }, + { + "introduced": "1.7.0-0" + }, + { + "fixed": "1.7.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "mime/multipart", + "symbols": [ + "Reader.readForm" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/30410" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/7478ea5dba7ed02ddffd91c1d17ec8141f7cf184" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/16296" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-dev/c/4NdLzS8sls8/m/uIz8QlnIBQAJ" + } + ], + "credits": [ + { + "name": "Simon Rawet" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0172", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0178", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-01-07T20:35:00Z", + "aliases": [ + "CVE-2017-15042" + ], + "summary": "Cleartext transmission of credentials in net/smtp", + "details": "SMTP clients using net/smtp can use the PLAIN authentication scheme on network connections not secured with TLS, exposing passwords to man-in-the-middle SMTP servers.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.1.0-0" + }, + { + "fixed": "1.8.4" + }, + { + "introduced": "1.9.0-0" + }, + { + "fixed": "1.9.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/smtp", + "symbols": [ + "plainAuth.Start" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/68170" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/ec3b6131de8f9c9c25283260c95c616c74f6d790" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/22134" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-dev/c/RinSE3EiJBI/m/kYL7zb07AgAJ" + } + ], + "credits": [ + { + "name": "Stevie Johnstone" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0178", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0223", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-02-17T17:46:03Z", + "aliases": [ + "CVE-2020-14039" + ], + "summary": "Certificate verification error on Windows in crypto/x509", + "details": "On Windows, if VerifyOptions.Roots is nil, Certificate.Verify does not check the EKU requirements specified in VerifyOptions.KeyUsages. This may allow a certificate to be used for an unintended purpose.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.13.13" + }, + { + "introduced": "1.14.0-0" + }, + { + "fixed": "1.14.5" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/x509", + "goos": [ + "windows" + ], + "symbols": [ + "Certificate.systemVerify" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/242597" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/82175e699a2e2cd83d3aa34949e9b922d66d52f5" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/39360" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/XZNfaiwgt2w" + } + ], + "credits": [ + { + "name": "Niall Newman" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0223", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0224", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-02-17T17:36:04Z", + "aliases": [ + "CVE-2020-15586" + ], + "summary": "Data race and crash in net/http", + "details": "HTTP servers where the Handler concurrently reads the request body and writes a response can encounter a data race and crash. The httputil.ReverseProxy Handler is affected.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.13.13" + }, + { + "introduced": "1.14.0-0" + }, + { + "fixed": "1.14.5" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "expectContinueReader.Read" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/242598" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/fa98f46741f818913a8c11b877520a548715131f" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/34902" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/XZNfaiwgt2w" + } + ], + "credits": [ + { + "name": "Mikael Manukyan" + }, + { + "name": "Andrew Kutz" + }, + { + "name": "Dave McClure" + }, + { + "name": "Tim Downey" + }, + { + "name": "Clay Kauzlaric" + }, + { + "name": "Gabe Rosenhouse" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0224", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0226", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-01-13T03:44:58Z", + "aliases": [ + "CVE-2020-24553" + ], + "summary": "Cross-site scripting in net/http/cgi and net/http/fcgi", + "details": "When a Handler does not explicitly set the Content-Type header, the the package would default to “text/html”, which could cause a Cross-Site Scripting vulnerability if an attacker can control any part of the contents of a response.\n\nThe Content-Type header is now set based on the contents of the first Write using http.DetectContentType, which is consistent with the behavior of the net/http package.\n\nAlthough this protects some applications that validate the contents of uploaded files, not setting the Content-Type header explicitly on any attacker-controlled file is unsafe and should be avoided.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.14.8" + }, + { + "introduced": "1.15.0-0" + }, + { + "fixed": "1.15.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http/cgi", + "symbols": [ + "response.Write", + "response.WriteHeader", + "response.writeCGIHeader" + ] + }, + { + "path": "net/http/fcgi", + "symbols": [ + "response.Write", + "response.WriteHeader", + "response.writeCGIHeader" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/252179" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/4f5cd0c0331943c7ec72df3b827d972584f77833" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/8wqlSbkLdPs" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/40928" + } + ], + "credits": [ + { + "name": "RedTeam Pentesting GmbH" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0226", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0234", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-02-17T17:34:24Z", + "aliases": [ + "CVE-2021-27918" + ], + "summary": "Infinite loop when decoding inputs in encoding/xml", + "details": "The Decode, DecodeElement, and Skip methods of an xml.Decoder provided by xml.NewTokenDecoder may enter an infinite loop when operating on a custom xml.TokenReader which returns an EOF in the middle of an open XML element.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.15.9" + }, + { + "introduced": "1.16.0-0" + }, + { + "fixed": "1.16.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "encoding/xml", + "symbols": [ + "Decoder.Token" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/300391" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/d0b79e3513a29628f3599dc8860666b6eed75372" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/44913" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/MfiLYjG-RAw" + } + ], + "credits": [ + { + "name": "Sam Whited" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0234", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0235", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-02-17T17:34:14Z", + "aliases": [ + "CVE-2021-3114" + ], + "summary": "Incorrect operations on the P-224 curve in crypto/elliptic", + "details": "The P224() Curve implementation can in rare circumstances generate incorrect outputs, including returning invalid points from ScalarMult.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.14.14" + }, + { + "introduced": "1.15.0-0" + }, + { + "fixed": "1.15.7" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/elliptic", + "symbols": [ + "p224Contract" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/284779" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/d95ca9138026cbe40e0857d76a81a16d03230871" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/43786" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/mperVMGa98w" + } + ], + "credits": [ + { + "name": "The elliptic-curve-differential-fuzzer project running on OSS-Fuzz" + }, + { + "name": "Philippe Antoine (Catena cyber)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0235", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0239", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-02-17T17:33:35Z", + "aliases": [ + "CVE-2021-33195" + ], + "summary": "Improper sanitization when resolving values from DNS in net", + "details": "The LookupCNAME, LookupSRV, LookupMX, LookupNS, and LookupAddr functions and their respective methods on the Resolver type may return arbitrary values retrieved from DNS which do not follow the established RFC 1035 rules for domain names. If these names are used without further sanitization, for instance unsafely included in HTML, they may allow for injection of unexpected content. Note that LookupTXT may still return arbitrary values that could require sanitization before further use.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.15.13" + }, + { + "introduced": "1.16.0-0" + }, + { + "fixed": "1.16.5" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net", + "symbols": [ + "Resolver.LookupAddr", + "Resolver.LookupCNAME", + "Resolver.LookupMX", + "Resolver.LookupNS", + "Resolver.LookupSRV" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/320949" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/c89f1224a544cde464fcb86e78ebb0cc97eedba2" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/RgCMkAEQjSI" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/46241" + } + ], + "credits": [ + { + "name": "Philipp Jeitner" + }, + { + "name": "Haya Shulman from Fraunhofer SIT" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0239", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0240", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-02-17T17:33:25Z", + "aliases": [ + "CVE-2021-33196" + ], + "summary": "Panic when reading certain archives in archive/zip", + "details": "NewReader and OpenReader can cause a panic or an unrecoverable fatal error when reading an archive that claims to contain a large number of files, regardless of its actual size.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.15.13" + }, + { + "introduced": "1.16.0-0" + }, + { + "fixed": "1.16.5" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "archive/zip", + "symbols": [ + "Reader.init" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/318909" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/74242baa4136c7a9132a8ccd9881354442788c8c" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/RgCMkAEQjSI" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/46242" + } + ], + "credits": [ + { + "name": "OSS-Fuzz (discovery)" + }, + { + "name": "Emmanuel Odeke (reporter)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0240", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0241", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-02-17T17:33:16Z", + "aliases": [ + "CVE-2021-33197" + ], + "summary": "Attacker can drop certain headers in net/http/httputil", + "details": "ReverseProxy can be made to forward certain hop-by-hop headers, including Connection. If the target of the ReverseProxy is itself a reverse proxy, this lets an attacker drop arbitrary headers, including those set by the ReverseProxy.Director.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.15.13" + }, + { + "introduced": "1.16.0-0" + }, + { + "fixed": "1.16.5" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http/httputil", + "symbols": [ + "ReverseProxy.ServeHTTP" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/321929" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/950fa11c4cb01a145bb07eeb167d90a1846061b3" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/RgCMkAEQjSI" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/46313" + } + ], + "credits": [ + { + "name": "Mattias Grenfeldt (https://grenfeldt.dev)" + }, + { + "name": "Asta Olofsson" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0241", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0242", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-02-17T17:33:07Z", + "aliases": [ + "CVE-2021-33198" + ], + "summary": "Panic on inputs with large exponents in math/big", + "details": "Rat.SetString and Rat.UnmarshalText may cause a panic or an unrecoverable fatal error if passed inputs with very large exponents.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.15.13" + }, + { + "introduced": "1.16.0-0" + }, + { + "fixed": "1.16.5" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "math/big", + "symbols": [ + "Rat.SetString" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/316149" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/6c591f79b0b5327549bd4e94970f7a279efb4ab0" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/RgCMkAEQjSI" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/45910" + } + ], + "credits": [ + { + "name": "The OSS-Fuzz project (discovery)" + }, + { + "name": "Emmanuel Odeke (reporter)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0242", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0243", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-02-17T17:32:57Z", + "aliases": [ + "CVE-2021-34558" + ], + "summary": "Panic on certain certificates in crypto/tls", + "details": "crypto/tls clients can panic when provided a certificate of the wrong type for the negotiated parameters. net/http clients performing HTTPS requests are also affected.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.15.14" + }, + { + "introduced": "1.16.0-0" + }, + { + "fixed": "1.16.6" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/tls", + "symbols": [ + "rsaKeyAgreement.generateClientKeyExchange" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/334031" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/a98589711da5e9d935e8d690cfca92892e86d557" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/n9FxMelZGAQ" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/47143" + } + ], + "credits": [ + { + "name": "Imre Rad" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0243", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0245", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-02-17T17:32:24Z", + "aliases": [ + "CVE-2021-36221" + ], + "summary": "Panic in ReverseProxy in net/http/httputil", + "details": "ReverseProxy can panic after encountering a problem copying a proxied response body.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.15.15" + }, + { + "introduced": "1.16.0-0" + }, + { + "fixed": "1.16.7" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http/httputil", + "symbols": [ + "ReverseProxy.ServeHTTP" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/333191" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/b7a85e0003cedb1b48a1fd3ae5b746ec6330102e" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/uHACNfXAZqk" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/46866" + } + ], + "credits": [ + { + "name": "Andrew Crump" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0245", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0263", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-01-13T03:45:03Z", + "aliases": [ + "CVE-2021-41771" + ], + "summary": "Panic on invalid symbol tables in debug/macho", + "details": "Calling File.ImportedSymbols on a loaded file which contains an invalid dynamic symbol table command can cause a panic, in particular if the encoded number of undefined symbols is larger than the number of symbols in the symbol table.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.16.10" + }, + { + "introduced": "1.17.0-0" + }, + { + "fixed": "1.17.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "debug/macho", + "symbols": [ + "NewFile" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/367075" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/61536ec03063b4951163bd09609c86d82631fa27" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/0fM21h43arc" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/48990" + } + ], + "credits": [ + { + "name": "Burak Çarıkçı - Yunus Yıldırım (CT-Zer0 Crypttech)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0263", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0264", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-01-13T20:54:43Z", + "aliases": [ + "CVE-2021-41772" + ], + "summary": "Panic when opening certain archives in archive/zip", + "details": "Previously, opening a zip with (*Reader).Open could result in a panic if the zip contained a file whose name was exclusively made up of slash characters or \"..\" path elements.\n\nOpen could also panic if passed the empty string directly as an argument.\n\nNow, any files in the zip whose name could not be made valid for fs.FS.Open will be skipped, and no longer added to the fs.FS file list, although they are still accessible through (*Reader).File.\n\nNote that it was already the case that a file could be accessible from (*Reader).Open with a name different from the one in (*Reader).File, as the former is the cleaned name, while the latter is the original one.\n\nFinally, the actual panic site was made robust as a defense-in-depth measure.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.16.10" + }, + { + "introduced": "1.17.0-0" + }, + { + "fixed": "1.17.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "archive/zip", + "symbols": [ + "Reader.Open", + "split" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/349770" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/b24687394b55a93449e2be4e6892ead58ea9a10f" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/0fM21h43arc" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/48085" + } + ], + "credits": [ + { + "name": "Colin Arnott (SiteHost)" + }, + { + "name": "Noah Santschi-Cooney (Sourcegraph Code Intelligence Team)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0264", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0317", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-05-23T22:15:42Z", + "aliases": [ + "CVE-2022-23772" + ], + "summary": "Uncontrolled memory consumption in math/big", + "details": "Rat.SetString had an overflow issue that can lead to uncontrolled memory consumption.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.16.14" + }, + { + "introduced": "1.17.0-0" + }, + { + "fixed": "1.17.7" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "math/big", + "symbols": [ + "Rat.SetString" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/379537" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/ad345c265916bbf6c646865e4642eafce6d39e78" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/SUsQn0aSgPQ" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/50699" + } + ], + "credits": [ + { + "name": "Emmanuel Odeke" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0317", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0319", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-05-23T22:15:21Z", + "aliases": [ + "CVE-2022-23806" + ], + "summary": "Incorrect computation for some invalid field elements in crypto/elliptic", + "details": "Some big.Int values that are not valid field elements (negative or overflowing) might cause Curve.IsOnCurve to incorrectly return true. Operating on those values may cause a panic or an invalid curve operation. Note that Unmarshal will never return such values.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.16.14" + }, + { + "introduced": "1.17.0-0" + }, + { + "fixed": "1.17.7" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/elliptic", + "symbols": [ + "CurveParams.IsOnCurve", + "p384PointFromAffine", + "p521PointFromAffine" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/382455" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/7f9494c277a471f6f47f4af3036285c0b1419816" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/SUsQn0aSgPQ" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/50974" + } + ], + "credits": [ + { + "name": "Guido Vranken" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0319", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0347", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-05-23T22:15:47Z", + "aliases": [ + "CVE-2022-24921" + ], + "summary": "Stack exhaustion when compiling deeply nested expressions in regexp", + "details": "On 64-bit platforms, an extremely deeply nested expression can cause regexp.Compile to cause goroutine stack exhaustion, forcing the program to exit. Note this applies to very large expressions, on the order of 2MB.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.16.15" + }, + { + "introduced": "1.17.0-0" + }, + { + "fixed": "1.17.8" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "regexp", + "symbols": [ + "regexp.Compile" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/384616" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/452f24ae94f38afa3704d4361d91d51218405c0a" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/51112" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/RP1hfrBYVuk" + } + ], + "credits": [ + { + "name": "Juho Nurminen" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0347", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0166", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-05-24T22:06:33Z", + "aliases": [ + "CVE-2016-3959" + ], + "summary": "Denial of service due to unchecked parameters in crypto/dsa", + "details": "The Verify function in crypto/dsa passed certain parameters unchecked to the underlying big integer library, possibly leading to extremely long-running computations, which in turn makes Go programs vulnerable to remote denial of service attacks. Programs using HTTPS client certificates or the Go SSH server libraries are both exposed to this vulnerability.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.5.4" + }, + { + "introduced": "1.6.0-0" + }, + { + "fixed": "1.6.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/dsa", + "symbols": [ + "Verify" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/21533" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/eb876dd83cb8413335d64e50aae5d38337d1ebb4" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/15184" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/9eqIHqaWvck" + } + ], + "credits": [ + { + "name": "David Wong" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0166", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0171", + "modified": "2024-06-03T20:51:31Z", + "published": "2022-05-24T20:17:59Z", + "aliases": [ + "CVE-2017-1000097" + ], + "summary": "Mishandled trust preferences for root certificates on Darwin in crypto/x509", + "details": "On Darwin, user's trust preferences for root certificates were not honored. If the user had a root certificate loaded in their Keychain that was explicitly not trusted, a Go program would still verify a connection using that root certificate.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.6.4" + }, + { + "introduced": "1.7.0-0" + }, + { + "fixed": "1.7.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/x509", + "goos": [ + "darwin" + ], + "symbols": [ + "FetchPEMRoots", + "execSecurityRoots" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/7e5b2e0ec144d5f5b2923a7d5db0b9143f79a35a" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/18141" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-dev/c/4NdLzS8sls8/m/uIz8QlnIBQAJ" + } + ], + "credits": [ + { + "name": "Xy Ziemba" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0171", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0187", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-01T20:11:15Z", + "aliases": [ + "CVE-2017-8932" + ], + "summary": "Incorrect computation for P-256 curves in crypto/elliptic", + "details": "The ScalarMult implementation of curve P-256 for amd64 architectures generates incorrect results for certain specific input points. An adaptive attack can progressively extract the scalar input to ScalarMult by submitting crafted points and observing failures to derive correct output. This leads to a full key recovery attack against static ECDH, as used in popular JWT libraries.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.6.0-0" + }, + { + "fixed": "1.7.6" + }, + { + "introduced": "1.8.0-0" + }, + { + "fixed": "1.8.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/elliptic", + "goarch": [ + "amd64" + ], + "symbols": [ + "p256SubInternal" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/41070" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/9294fa2749ffee7edbbb817a0ef9fe633136fa9c" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/20040" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/B5ww0iFt1_Q/m/TgUFJV14BgAJ" + } + ], + "credits": [ + { + "name": "Vlad Krasnov" + }, + { + "name": "Filippo Valsorda at Cloudflare" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0187", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0191", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-15T23:03:26Z", + "aliases": [ + "CVE-2018-16875" + ], + "summary": "Denial of service in chain verification in crypto/x509", + "details": "The crypto/x509 package does not limit the amount of work performed for each chain verification, which might allow attackers to craft pathological inputs leading to a CPU denial of service. Go TLS servers accepting client certificates and TLS clients verifying certificates are affected.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.10.6" + }, + { + "introduced": "1.11.0-0" + }, + { + "fixed": "1.11.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/x509", + "symbols": [ + "CertPool.findVerifiedParents", + "Certificate.buildChains" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/154105" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/770130659b6fb2acf271476579a3644e093dda7f" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/29233" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/Kw31K8G7Fi0" + } + ], + "credits": [ + { + "name": "Netflix" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0191", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0211", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-01T20:15:30Z", + "aliases": [ + "CVE-2019-14809" + ], + "summary": "Incorrect parsing validation in net/url", + "details": "The url.Parse function accepts URLs with malformed hosts, such that the Host field can have arbitrary suffixes that appear in neither Hostname() nor Port(), allowing authorization bypasses in certain applications.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.11.13" + }, + { + "introduced": "1.12.0-0" + }, + { + "fixed": "1.12.8" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/url", + "symbols": [ + "URL.Hostname", + "URL.Port", + "parseHost" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/189258" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/61bb56ad63992a3199acc55b2537c8355ef887b6" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/29098" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/65QixT3tcmg" + } + ], + "credits": [ + { + "name": "Julian Hector" + }, + { + "name": "Nikolai Krein from Cure53" + }, + { + "name": "Adi Cohen (adico.me)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0211", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0212", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-05-23T22:46:20Z", + "aliases": [ + "CVE-2019-16276" + ], + "summary": "Request smuggling due to accepting invalid headers in net/http via net/textproto", + "details": "net/http (through net/textproto) used to accept and normalize invalid HTTP/1.1 headers with a space before the colon, in violation of RFC 7230.\n\nIf a Go server is used behind an uncommon reverse proxy that accepts and forwards but doesn't normalize such invalid headers, the reverse proxy and the server can interpret the headers differently. This can lead to filter bypasses or request smuggling, the latter if requests from separate clients are multiplexed onto the same upstream connection by the proxy. Such invalid headers are now rejected by Go servers, and passed without normalization to Go client applications.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.12.10" + }, + { + "introduced": "1.13.0-0" + }, + { + "fixed": "1.13.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/textproto", + "symbols": [ + "Reader.ReadMimeHeader" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/197503" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/41b1f88efab9d263408448bf139659119002ea50" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/34540" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/cszieYyuL9Q/m/g4Z7pKaqAgAJ" + } + ], + "credits": [ + { + "name": "Andrew Stucki (99designs.com)" + }, + { + "name": "Adam Scarr (99designs.com)" + }, + { + "name": "Jan Masarik (masarik.sh)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0212", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0213", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-05-24T20:14:11Z", + "aliases": [ + "CVE-2019-17596" + ], + "summary": "Panic on invalid DSA public keys in crypto/dsa", + "details": "Invalid DSA public keys can cause a panic in dsa.Verify. In particular, using crypto/x509.Verify on a crafted X.509 certificate chain can lead to a panic, even if the certificates don't chain to a trusted root. The chain can be delivered via a crypto/tls connection to a client, or to a server that accepts and verifies client certificates. net/http clients can be made to crash by an HTTPS server, while net/http servers that accept client certificates will recover the panic and are unaffected.\n\nMoreover, an application might crash invoking crypto/x509.(*CertificateRequest).CheckSignature on an X.509 certificate request, parsing a golang.org/x/crypto/openpgp Entity, or during a golang.org/x/crypto/otr conversation. Finally, a golang.org/x/crypto/ssh client can panic due to a malformed host key, while a server could panic if either PublicKeyCallback accepts a malformed public key, or if IsUserAuthority accepts a certificate with a malformed public key.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.12.11" + }, + { + "introduced": "1.13.0-0" + }, + { + "fixed": "1.13.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/dsa", + "symbols": [ + "Verify" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/205441" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/552987fdbf4c2bc9641016fd323c3ae5d3a0d9a3" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/34960" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/lVEm7llp0w0/m/VbafyRkgCgAJ" + } + ], + "credits": [ + { + "name": "Daniel M" + }, + { + "name": "ragona" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0213", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0217", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-05-24T15:21:01Z", + "aliases": [ + "CVE-2019-6486" + ], + "summary": "Denial of service affecting P-521 and P-384 curves in crypto/elliptic", + "details": "A DoS vulnerability in the crypto/elliptic implementations of the P-521 and P-384 elliptic curves may let an attacker craft inputs that consume excessive amounts of CPU.\n\nThese inputs might be delivered via TLS handshakes, X.509 certificates, JWT tokens, ECDH shares or ECDSA signatures. In some cases, if an ECDH private key is reused more than once, the attack can also lead to key recovery.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.10.8" + }, + { + "introduced": "1.11.0-0" + }, + { + "fixed": "1.11.5" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/elliptic", + "symbols": [ + "curve.doubleJacobian" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/159218" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/193c16a3648b8670a762e925b6ac6e074f468a20" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/29903" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/mVeX35iXuSw" + } + ], + "credits": [ + { + "name": "Wycheproof Project" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0217", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0220", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-05-25T18:01:46Z", + "aliases": [ + "CVE-2019-9634" + ], + "summary": "DLL injection on Windows in runtime and syscall", + "details": "Go on Windows misused certain LoadLibrary functionality, leading to DLL injection.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.11.10" + }, + { + "introduced": "1.12.0-0" + }, + { + "fixed": "1.12.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "runtime", + "goos": [ + "windows" + ] + }, + { + "path": "syscall", + "goos": [ + "windows" + ], + "symbols": [ + "LoadDLL" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/165798" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/9b6e9f0c8c66355c0f0575d808b32f52c8c6d21c" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/28978" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/z9eTD34GEIs/m/Z_XmhTrVAwAJ" + } + ], + "credits": [ + { + "name": "Samuel Cochran" + }, + { + "name": "Jason Donenfeld" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0220", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0229", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-06T18:23:48Z", + "aliases": [ + "CVE-2020-7919", + "GHSA-cjjc-xp8v-855w" + ], + "summary": "Panic in certificate parsing in crypto/x509 and golang.org/x/crypto/cryptobyte", + "details": "On 32-bit architectures, a malformed input to crypto/x509 or the ASN.1 parsing functions of golang.org/x/crypto/cryptobyte can lead to a panic.\n\nThe malformed certificate can be delivered via a crypto/tls connection to a client, or to a server that accepts client certificates. net/http clients can be made to crash by an HTTPS server, while net/http servers that accept client certificates will recover the panic and are unaffected.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.12.16" + }, + { + "introduced": "1.13.0-0" + }, + { + "fixed": "1.13.7" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/x509" + } + ] + } + }, + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20200124225646-8b5121be2f68" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/cryptobyte" + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/216680" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/b13ce14c4a6aa59b7b041ad2b6eed2d23e15b574" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/216677" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/36837" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/Hsw4mHYc470" + } + ], + "credits": [ + { + "name": "Project Wycheproof" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0229", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0236", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-15T23:04:18Z", + "aliases": [ + "CVE-2021-31525", + "GHSA-h86h-8ppg-mxmh" + ], + "summary": "Panic due to large headers in net/http and golang.org/x/net/http/httpguts", + "details": "A malicious HTTP server or client can cause the net/http client or server to panic.\n\nReadRequest and ReadResponse can hit an unrecoverable panic when reading a very large header (over 7MB on 64-bit architectures, or over 4MB on 32-bit ones). Transport and Client are vulnerable and the program can be made to crash by a malicious server. Server is not vulnerable by default, but can be if the default max header of 1MB is overridden by setting Server.MaxHeaderBytes to a higher value, in which case the program can be made to crash by a malicious client.\n\nThis also affects golang.org/x/net/http2/h2c and HeaderValuesContainsToken in golang.org/x/net/http/httpguts.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.15.12" + }, + { + "introduced": "1.16.0-0" + }, + { + "fixed": "1.16.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "http2clientStream.writeRequest", + "http2isConnectionCloseRequest", + "isProtocolSwitchHeader", + "shouldClose" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20210428140749-89ef3d95e781" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http/httpguts", + "symbols": [ + "HeaderValuesContainsToken", + "headerValueContainsToken" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/313069" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/net/+/89ef3d95e781148a0951956029c92a211477f7f9" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/45710" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/cu9SP4eSXMc" + } + ], + "credits": [ + { + "name": "Guido Vranken" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0236", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0273", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-05-18T18:23:31Z", + "aliases": [ + "CVE-2021-39293" + ], + "summary": "Panic due to crafted inputs in archive/zip", + "details": "The NewReader and OpenReader functions in archive/zip can cause a panic or an unrecoverable fatal error when reading an archive that claims to contain a large number of files, regardless of its actual size. This is caused by an incomplete fix for CVE-2021-33196.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.16.8" + }, + { + "introduced": "1.17.0-0" + }, + { + "fixed": "1.17.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "archive/zip", + "symbols": [ + "NewReader", + "OpenReader" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/343434" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/bacbc33439b124ffd7392c91a5f5d96eca8c0c0b" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/47801" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/dx9d7IOseHw" + } + ], + "credits": [ + { + "name": "OSS-Fuzz Project" + }, + { + "name": "Emmanuel Odeke" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0273", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0288", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-15T23:08:33Z", + "aliases": [ + "CVE-2021-44716", + "GHSA-vc3p-29h2-gpcp" + ], + "summary": "Unbounded memory growth in net/http and golang.org/x/net/http2", + "details": "An attacker can cause unbounded memory growth in servers accepting HTTP/2 requests.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.16.12" + }, + { + "introduced": "1.17.0-0" + }, + { + "fixed": "1.17.5" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "http2serverConn.canonicalHeader" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20211209124913-491a49abca63" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http2", + "symbols": [ + "Server.ServeConn", + "serverConn.canonicalHeader" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/369794" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/50058" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/hcmEScgc00k" + } + ], + "credits": [ + { + "name": "murakmii" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0288", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0289", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-05-18T18:23:23Z", + "aliases": [ + "CVE-2021-44717" + ], + "summary": "Misdirected I/O in syscall", + "details": "When a Go program running on a Unix system is out of file descriptors and calls syscall.ForkExec (including indirectly by using the os/exec package), syscall.ForkExec can close file descriptor 0 as it fails. If this happens (or can be provoked) repeatedly, it can result in misdirected I/O such as writing network traffic intended for one connection to a different connection, or content intended for one file to a different one.\n\nFor users who cannot immediately update to the new release, the bug can be mitigated by raising the per-process file descriptor limit.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.16.12" + }, + { + "introduced": "1.17.0-0" + }, + { + "fixed": "1.17.5" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "syscall", + "symbols": [ + "ForkExec" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/370576" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/a76511f3a40ea69ee4f5cd86e735e1c8a84f0aa2" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/50057" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/hcmEScgc00k" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/370577" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/370795" + } + ], + "credits": [ + { + "name": "Tomasz Maczukin" + }, + { + "name": "Kamil Trzciński of GitLab" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0289", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0433", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-05-20T21:17:25Z", + "aliases": [ + "CVE-2022-24675" + ], + "summary": "Stack overflow from a large amount of PEM data in encoding/pem", + "details": "encoding/pem in Go before 1.17.9 and 1.18.x before 1.18.1 has a Decode stack overflow via a large amount of PEM data.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.17.9" + }, + { + "introduced": "1.18.0-0" + }, + { + "fixed": "1.18.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "encoding/pem", + "symbols": [ + "Decode" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/399820" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/45c3387d777caf28f4b992ad9a6216e3085bb8fe" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/51853" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/oecdBNLOml8" + } + ], + "credits": [ + { + "name": "Juho Nurminen of Mattermost" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0433", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0434", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-05-23T21:59:00Z", + "aliases": [ + "CVE-2022-27536" + ], + "summary": "Panic during certificate parsing on Darwin in crypto/x509", + "details": "Verifying certificate chains containing certificates which are not compliant with RFC 5280 causes Certificate.Verify to panic on macOS.\n\nThese chains can be delivered through TLS and can cause a crypto/tls or net/http client to crash.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.18.0-0" + }, + { + "fixed": "1.18.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/x509", + "goos": [ + "darwin" + ], + "symbols": [ + "Certificate.Verify" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/393655" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/0fca8a8f25cf4636fd980e72ba0bded4230922de" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/51759" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/oecdBNLOml8" + } + ], + "credits": [ + { + "name": "Tailscale" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0434", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0435", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-05-20T21:17:46Z", + "aliases": [ + "CVE-2022-28327" + ], + "summary": "Panic due to large inputs affecting P-256 curves in crypto/elliptic", + "details": "A crafted scalar input longer than 32 bytes can cause P256().ScalarMult or P256().ScalarBaseMult to panic. Indirect uses through crypto/ecdsa and crypto/tls are unaffected. amd64, arm64, ppc64le, and s390x are unaffected.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.17.9" + }, + { + "introduced": "1.18.0-0" + }, + { + "fixed": "1.18.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/elliptic", + "symbols": [ + "CurveParams.ScalarBaseMult", + "CurveParams.ScalarMult", + "p256Curve.CombinedMult", + "p256Curve.ScalarBaseMult", + "p256Curve.ScalarMult", + "p256GetScalar" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/397135" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/37065847d87df92b5eb246c88ba2085efcf0b331" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/52075" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/oecdBNLOml8" + } + ], + "credits": [ + { + "name": "Project Wycheproof" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0435", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0477", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-06-09T01:43:37Z", + "aliases": [ + "CVE-2022-30634" + ], + "summary": "Indefinite hang with large buffers on Windows in crypto/rand", + "details": "On Windows, rand.Read will hang indefinitely if passed a buffer larger than 1 \u003c\u003c 32 - 1 bytes.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.17.11" + }, + { + "introduced": "1.18.0-0" + }, + { + "fixed": "1.18.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/rand", + "goos": [ + "windows" + ], + "symbols": [ + "Read" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/402257" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/bb1f4416180511231de6d17a1f2f55c82aafc863" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/52561" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/TzIC9-t8Ytg/m/IWz5T6x7AAAJ" + } + ], + "credits": [ + { + "name": "Davis Goodin" + }, + { + "name": "Quim Muntal of Microsoft" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0477", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0493", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-15T23:30:12Z", + "aliases": [ + "CVE-2022-29526", + "GHSA-p782-xgp4-8hr8" + ], + "summary": "Incorrect privilege reporting in syscall and golang.org/x/sys/unix", + "details": "When called with a non-zero flags parameter, the Faccessat function can incorrectly report that a file is accessible.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.17.10" + }, + { + "introduced": "1.18.0-0" + }, + { + "fixed": "1.18.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "syscall", + "symbols": [ + "Faccessat" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/sys", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20220412211240-33da011f77ad" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/sys/unix", + "symbols": [ + "Faccessat" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/399539" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/52313" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/400074" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/Y5qrqw_lWdU" + } + ], + "credits": [ + { + "name": "Joël Gähwiler (@256dpi)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0493", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0515", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-20T17:01:45Z", + "aliases": [ + "CVE-2022-1962" + ], + "summary": "Stack exhaustion due to deeply nested types in go/parser", + "details": "Calling any of the Parse functions on Go source code which contains deeply nested types or declarations can cause a panic due to stack exhaustion.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.17.12" + }, + { + "introduced": "1.18.0-0" + }, + { + "fixed": "1.18.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go/parser", + "symbols": [ + "ParseExprFrom", + "ParseFile", + "parser.parseBinaryExpr", + "parser.parseIfStmt", + "parser.parsePrimaryExpr", + "parser.parseStmt", + "parser.parseUnaryExpr", + "parser.tryIdentOrType", + "resolver.closeScope", + "resolver.openScope" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/417063" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/695be961d57508da5a82217f7415200a11845879" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/53616" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/nqrv9fbR0zE" + } + ], + "credits": [ + { + "name": "Juho Nurminen of Mattermost" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0515", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0520", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-28T17:23:05Z", + "aliases": [ + "CVE-2022-32148" + ], + "summary": "Exposure of client IP addresses in net/http", + "details": "Client IP adresses may be unintentionally exposed via X-Forwarded-For headers.\n\nWhen httputil.ReverseProxy.ServeHTTP is called with a Request.Header map containing a nil value for the X-Forwarded-For header, ReverseProxy sets the client IP as the value of the X-Forwarded-For header, contrary to its documentation.\n\nIn the more usual case where a Director function sets the X-Forwarded-For header value to nil, ReverseProxy leaves the header unmodified as expected.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.17.12" + }, + { + "introduced": "1.18.0-0" + }, + { + "fixed": "1.18.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "Header.Clone" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/412857" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/b2cc0fecc2ccd80e6d5d16542cc684f97b3a9c8a" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/53423" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/nqrv9fbR0zE" + } + ], + "credits": [ + { + "name": "Christian Mehlmauer" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0520", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0521", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-20T17:02:04Z", + "aliases": [ + "CVE-2022-28131" + ], + "summary": "Stack exhaustion from deeply nested XML documents in encoding/xml", + "details": "Calling Decoder.Skip when parsing a deeply nested XML document can cause a panic due to stack exhaustion.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.17.12" + }, + { + "introduced": "1.18.0-0" + }, + { + "fixed": "1.18.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "encoding/xml", + "symbols": [ + "Decoder.Skip" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/417062" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/08c46ed43d80bbb67cb904944ea3417989be4af3" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/53614" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/nqrv9fbR0zE" + } + ], + "credits": [ + { + "name": "Go Security Team" + }, + { + "name": "Juho Nurminen of Mattermost" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0521", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0522", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-20T17:02:29Z", + "aliases": [ + "CVE-2022-30632" + ], + "summary": "Stack exhaustion on crafted paths in path/filepath", + "details": "Calling Glob on a path which contains a large number of path separators can cause a panic due to stack exhaustion.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.17.12" + }, + { + "introduced": "1.18.0-0" + }, + { + "fixed": "1.18.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "path/filepath", + "symbols": [ + "Glob" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/417066" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/ac68c6c683409f98250d34ad282b9e1b0c9095ef" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/53416" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/nqrv9fbR0zE" + } + ], + "credits": [ + { + "name": "Juho Nurminen of Mattermost" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0522", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0523", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-20T20:52:06Z", + "aliases": [ + "CVE-2022-30633" + ], + "summary": "Stack exhaustion when unmarshaling certain documents in encoding/xml", + "details": "Unmarshaling an XML document into a Go struct which has a nested field that uses the 'any' field tag can panic due to stack exhaustion.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.17.12" + }, + { + "introduced": "1.18.0-0" + }, + { + "fixed": "1.18.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "encoding/xml", + "symbols": [ + "Decoder.DecodeElement", + "Decoder.unmarshal", + "Decoder.unmarshalPath" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/417061" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/c4c1993fd2a5b26fe45c09592af6d3388a3b2e08" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/53611" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/nqrv9fbR0zE" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0523", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0524", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-20T20:52:11Z", + "aliases": [ + "CVE-2022-30631" + ], + "summary": "Stack exhaustion when reading certain archives in compress/gzip", + "details": "Calling Reader.Read on an archive containing a large number of concatenated 0-length compressed files can cause a panic due to stack exhaustion.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.17.12" + }, + { + "introduced": "1.18.0-0" + }, + { + "fixed": "1.18.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "compress/gzip", + "symbols": [ + "Reader.Read" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/417067" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/b2b8872c876201eac2d0707276c6999ff3eb185e" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/53168" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/nqrv9fbR0zE" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0524", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0525", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-25T17:34:18Z", + "aliases": [ + "CVE-2022-1705" + ], + "summary": "Improper sanitization of Transfer-Encoding headers in net/http", + "details": "The HTTP/1 client accepted some invalid Transfer-Encoding headers as indicating a \"chunked\" encoding. This could potentially allow for request smuggling, but only if combined with an intermediate server that also improperly failed to reject the header as invalid.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.17.12" + }, + { + "introduced": "1.18.0-0" + }, + { + "fixed": "1.18.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "transferReader.parseTransferEncoding" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/409874" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/e5017a93fcde94f09836200bca55324af037ee5f" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/53188" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/410714" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/nqrv9fbR0zE" + } + ], + "credits": [ + { + "name": "Zeyu Zhang (https://www.zeyu2001.com/)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0525", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0526", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-20T20:52:17Z", + "aliases": [ + "CVE-2022-30635" + ], + "summary": "Stack exhaustion when decoding certain messages in encoding/gob", + "details": "Calling Decoder.Decode on a message which contains deeply nested structures can cause a panic due to stack exhaustion.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.17.12" + }, + { + "introduced": "1.18.0-0" + }, + { + "fixed": "1.18.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "encoding/gob", + "symbols": [ + "Decoder.compileDec", + "Decoder.compileIgnoreSingle", + "Decoder.decIgnoreOpFor" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/417064" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/6fa37e98ea4382bf881428ee0c150ce591500eb7" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/53615" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/nqrv9fbR0zE" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0526", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0527", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-20T20:52:22Z", + "aliases": [ + "CVE-2022-30630" + ], + "summary": "Stack exhaustion in Glob on certain paths in io/fs", + "details": "Calling Glob on a path which contains a large number of path separators can cause a panic due to stack exhaustion.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.17.12" + }, + { + "introduced": "1.18.0-0" + }, + { + "fixed": "1.18.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "io/fs", + "symbols": [ + "Glob" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/417065" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/fa2d41d0ca736f3ad6b200b2a4e134364e9acc59" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/53415" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/nqrv9fbR0zE" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0527", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0531", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-28T17:24:57Z", + "aliases": [ + "CVE-2022-30629" + ], + "summary": "Session tickets lack random ticket_age_add in crypto/tls", + "details": "An attacker can correlate a resumed TLS session with a previous connection.\n\nSession tickets generated by crypto/tls do not contain a randomly generated ticket_age_add, which allows an attacker that can observe TLS handshakes to correlate successive connections by comparing ticket ages during session resumption.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.17.11" + }, + { + "introduced": "1.18.0-0" + }, + { + "fixed": "1.18.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/tls", + "symbols": [ + "serverHandshakeStateTLS13.sendSessionTickets" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/405994" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/fe4de36198794c447fbd9d7cc2d7199a506c76a5" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/52814" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/TzIC9-t8Ytg/m/IWz5T6x7AAAJ" + } + ], + "credits": [ + { + "name": "Github user @nervuri" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0531", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0532", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-26T21:41:20Z", + "aliases": [ + "CVE-2022-30580" + ], + "summary": "Empty Cmd.Path can trigger unintended binary in os/exec on Windows", + "details": "On Windows, executing Cmd.Run, Cmd.Start, Cmd.Output, or Cmd.CombinedOutput when Cmd.Path is unset will unintentionally trigger execution of any binaries in the working directory named either \"..com\" or \"..exe\".", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.17.11" + }, + { + "introduced": "1.18.0-0" + }, + { + "fixed": "1.18.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "os/exec", + "goos": [ + "windows" + ], + "symbols": [ + "Cmd.Start" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/403759" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/960ffa98ce73ef2c2060c84c7ac28d37a83f345e" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/52574" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/TzIC9-t8Ytg/m/IWz5T6x7AAAJ" + } + ], + "credits": [ + { + "name": "Chris Darroch (chrisd8088@github.com)" + }, + { + "name": "brian m. carlson (bk2204@github.com)" + }, + { + "name": "Mikhail Shcherbakov (https://twitter.com/yu5k3)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0532", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0533", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-28T17:25:07Z", + "aliases": [ + "CVE-2022-29804" + ], + "summary": "Path traversal via Clean on Windows in path/filepath", + "details": "On Windows, the filepath.Clean function can convert certain invalid paths to valid, absolute paths, potentially allowing a directory traversal attack.\n\nFor example, Clean(\".\\c:\") returns \"c:\".", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.17.11" + }, + { + "introduced": "1.18.0-0" + }, + { + "fixed": "1.18.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "path/filepath", + "goos": [ + "windows" + ], + "symbols": [ + "Clean" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/401595" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/9cd1818a7d019c02fa4898b3e45a323e35033290" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/52476" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/TzIC9-t8Ytg/m/IWz5T6x7AAAJ" + } + ], + "credits": [ + { + "name": "Unrud" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0533", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0535", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-08-01T22:21:17Z", + "aliases": [ + "CVE-2020-0601" + ], + "summary": "Certificate validation bypass on Windows in crypto/x509", + "details": "A Windows vulnerability allows attackers to spoof valid certificate chains when the system root store is in use.\n\nA workaround is present in Go 1.12.6+ and Go 1.13.7+, but affected users should additionally install the Windows security update to protect their system.\n\nSee https://msrc.microsoft.com/update-guide/en-US/vulnerability/CVE-2020-0601 for details on the Windows vulnerability.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.12.16" + }, + { + "introduced": "1.13.0-0" + }, + { + "fixed": "1.13.7" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/x509", + "goos": [ + "windows" + ], + "symbols": [ + "Certificate.systemVerify" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/215905" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/953bc8f391a63adf00bac2515dba62abe8a1e2c2" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/36834" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/Hsw4mHYc470/m/WJeW5wguEgAJ" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0535", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0536", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-08-01T22:20:53Z", + "aliases": [ + "CVE-2019-9512", + "CVE-2019-9514", + "GHSA-39qc-96h7-956f", + "GHSA-hgr8-6h9x-f7q9" + ], + "summary": "Reset flood in net/http and golang.org/x/net/http", + "details": "Some HTTP/2 implementations are vulnerable to a reset flood, potentially leading to a denial of service.\n\nServers that accept direct connections from untrusted clients could be remotely made to allocate an unlimited amount of memory, until the program crashes. The attacker opens a number of streams and sends an invalid request over each stream that should solicit a stream of RST_STREAM frames from the peer. Depending on how the peer queues the RST_STREAM frames, this can consume excess memory, CPU, or both.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.11.13" + }, + { + "introduced": "1.12.0-0" + }, + { + "fixed": "1.12.8" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "http2serverConn.scheduleFrameWrite", + "http2serverConn.serve", + "http2serverConn.writeFrame" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20190813141303-74dc4d7220e7" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http2", + "symbols": [ + "Server.ServeConn", + "serverConn.scheduleFrameWrite", + "serverConn.serve", + "serverConn.writeFrame" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/190137" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/145e193131eb486077b66009beb051aba07c52a5" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/33606" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/65QixT3tcmg/m/DrFiG6vvCwAJ" + } + ], + "credits": [ + { + "name": "Jonathan Looney of Netflix" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0536", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0537", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-08-01T22:21:06Z", + "aliases": [ + "CVE-2022-32189" + ], + "summary": "Panic when decoding Float and Rat types in math/big", + "details": "Decoding big.Float and big.Rat types can panic if the encoded message is too short, potentially allowing a denial of service.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.17.13" + }, + { + "introduced": "1.18.0-0" + }, + { + "fixed": "1.18.5" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "math/big", + "symbols": [ + "Float.GobDecode", + "Rat.GobDecode" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/417774" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/055113ef364337607e3e72ed7d48df67fde6fc66" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/53871" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/YqYYG87xB10" + } + ], + "credits": [ + { + "name": "@catenacyber" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0537", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0761", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-08-09T17:05:15Z", + "aliases": [ + "CVE-2016-5386" + ], + "summary": "Improper input validation in net/http and net/http/cgi", + "details": "An input validation flaw in the CGI components allows the HTTP_PROXY environment variable to be set by the incoming Proxy header, which changes where Go by default proxies all outbound HTTP requests.\n\nThis environment variable is also used to set the outgoing proxy, enabling an attacker to insert a proxy into outgoing requests of a CGI program.\n\nRead more about \"httpoxy\" here: https://httpoxy.org.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.6.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "Handler.ServeHTTP" + ] + }, + { + "path": "net/http/cgi", + "symbols": [ + "ProxyFromEnvironment" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/25010" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/b97df54c31d6c4cc2a28a3c83725366d52329223" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/16405" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/7jZDOQ8f8tM/m/eWRWHnc8CgAJ" + } + ], + "credits": [ + { + "name": "Dominic Scheirlinck" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0761", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0969", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-09-12T20:23:06Z", + "aliases": [ + "CVE-2022-27664", + "GHSA-69cg-p879-7622" + ], + "summary": "Denial of service in net/http and golang.org/x/net/http2", + "details": "HTTP/2 server connections can hang forever waiting for a clean shutdown that was preempted by a fatal error. This condition can be exploited by a malicious client to cause a denial of service.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.18.6" + }, + { + "introduced": "1.19.0-0" + }, + { + "fixed": "1.19.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "ListenAndServe", + "ListenAndServeTLS", + "Serve", + "ServeTLS", + "Server.ListenAndServe", + "Server.ListenAndServeTLS", + "Server.Serve", + "Server.ServeTLS", + "http2Server.ServeConn", + "http2serverConn.goAway" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20220906165146-f3363e06e74c" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http2", + "symbols": [ + "Server.ServeConn", + "serverConn.goAway" + ] + } + ] + } + } + ], + "references": [ + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/x49AQzIVX-s" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/54658" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/428735" + } + ], + "credits": [ + { + "name": "Bahruz Jabiyev" + }, + { + "name": "Tommaso Innocenti" + }, + { + "name": "Anthony Gavazzi" + }, + { + "name": "Steven Sprecher" + }, + { + "name": "Kaan Onarlioglu" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0969", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0988", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-09-12T20:23:15Z", + "aliases": [ + "CVE-2022-32190" + ], + "summary": "Failure to strip relative path components in net/url", + "details": "JoinPath and URL.JoinPath do not remove ../ path elements appended to a relative path. For example, JoinPath(\"https://go.dev\", \"../go\") returns the URL \"https://go.dev/../go\", despite the JoinPath documentation stating that ../ path elements are removed from the result.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.19.0-0" + }, + { + "fixed": "1.19.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/url", + "symbols": [ + "JoinPath", + "URL.JoinPath" + ] + } + ] + } + } + ], + "references": [ + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/x49AQzIVX-s" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/54385" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/423514" + } + ], + "credits": [ + { + "name": "@q0jt" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0988", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-1037", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-10-06T16:26:05Z", + "aliases": [ + "CVE-2022-2879" + ], + "summary": "Unbounded memory consumption when reading headers in archive/tar", + "details": "Reader.Read does not set a limit on the maximum size of file headers. A maliciously crafted archive could cause Read to allocate unbounded amounts of memory, potentially causing resource exhaustion or panics. After fix, Reader.Read limits the maximum size of header blocks to 1 MiB.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.18.7" + }, + { + "introduced": "1.19.0-0" + }, + { + "fixed": "1.19.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "archive/tar", + "symbols": [ + "Reader.Next", + "Reader.next", + "Writer.WriteHeader", + "Writer.writePAXHeader", + "parsePAX" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/54853" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/439355" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/xtuG5faxtaU" + } + ], + "credits": [ + { + "name": "Adam Korczynski (ADA Logics)" + }, + { + "name": "OSS-Fuzz" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-1037", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-1038", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-10-06T16:42:43Z", + "aliases": [ + "CVE-2022-2880" + ], + "summary": "Incorrect sanitization of forwarded query parameters in net/http/httputil", + "details": "Requests forwarded by ReverseProxy include the raw query parameters from the inbound request, including unparsable parameters rejected by net/http. This could permit query parameter smuggling when a Go proxy forwards a parameter with an unparsable value.\n\nAfter fix, ReverseProxy sanitizes the query parameters in the forwarded query when the outbound request's Form field is set after the ReverseProxy. Director function returns, indicating that the proxy has parsed the query parameters. Proxies which do not parse query parameters continue to forward the original query parameters unchanged.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.18.7" + }, + { + "introduced": "1.19.0-0" + }, + { + "fixed": "1.19.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http/httputil", + "symbols": [ + "ReverseProxy.ServeHTTP" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/54663" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/432976" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/xtuG5faxtaU" + } + ], + "credits": [ + { + "name": "Gal Goldstein (Security Researcher, Oxeye)" + }, + { + "name": "Daniel Abeles (Head of Research, Oxeye)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-1038", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-1039", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-10-06T16:42:07Z", + "aliases": [ + "CVE-2022-41715" + ], + "summary": "Memory exhaustion when compiling regular expressions in regexp/syntax", + "details": "Programs which compile regular expressions from untrusted sources may be vulnerable to memory exhaustion or denial of service.\n\nThe parsed regexp representation is linear in the size of the input, but in some cases the constant factor can be as high as 40,000, making relatively small regexps consume much larger amounts of memory.\n\nAfter fix, each regexp being parsed is limited to a 256 MB memory footprint. Regular expressions whose representation would use more space than that are rejected. Normal use of regular expressions is unaffected.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.18.7" + }, + { + "introduced": "1.19.0-0" + }, + { + "fixed": "1.19.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "regexp/syntax", + "symbols": [ + "Parse", + "parse", + "parser.factor", + "parser.push", + "parser.repeat" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/55949" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/439356" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/xtuG5faxtaU" + } + ], + "credits": [ + { + "name": "Adam Korczynski (ADA Logics)" + }, + { + "name": "OSS-Fuzz" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-1039", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-1095", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-11-01T23:55:57Z", + "aliases": [ + "CVE-2022-41716" + ], + "summary": "Unsanitized NUL in environment variables on Windows in syscall and os/exec", + "details": "Due to unsanitized NUL values, attackers may be able to maliciously set environment variables on Windows.\n\nIn syscall.StartProcess and os/exec.Cmd, invalid environment variable values containing NUL values are not properly checked for. A malicious environment variable value can exploit this behavior to set a value for a different environment variable. For example, the environment variable string \"A=B\\x00C=D\" sets the variables \"A=B\" and \"C=D\".", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.18.8" + }, + { + "introduced": "1.19.0-0" + }, + { + "fixed": "1.19.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "syscall", + "goos": [ + "windows" + ], + "symbols": [ + "StartProcess" + ] + }, + { + "path": "os/exec", + "goos": [ + "windows" + ], + "symbols": [ + "Cmd.CombinedOutput", + "Cmd.Environ", + "Cmd.Output", + "Cmd.Run", + "Cmd.Start", + "Cmd.environ", + "dedupEnv", + "dedupEnvCase" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/56284" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/446916" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/mbHY1UY3BaM/m/hSpmRzk-AgAJ" + } + ], + "credits": [ + { + "name": "RyotaK (https://twitter.com/ryotkak)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-1095", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-1143", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-12-07T16:08:45Z", + "aliases": [ + "CVE-2022-41720" + ], + "summary": "Restricted file access on Windows in os and net/http", + "details": "On Windows, restricted files can be accessed via os.DirFS and http.Dir.\n\nThe os.DirFS function and http.Dir type provide access to a tree of files rooted at a given directory. These functions permit access to Windows device files under that root. For example, os.DirFS(\"C:/tmp\").Open(\"COM1\") opens the COM1 device. Both os.DirFS and http.Dir only provide read-only filesystem access.\n\nIn addition, on Windows, an os.DirFS for the directory (the root of the current drive) can permit a maliciously crafted path to escape from the drive and access any path on the system.\n\nWith fix applied, the behavior of os.DirFS(\"\") has changed. Previously, an empty root was treated equivalently to \"/\", so os.DirFS(\"\").Open(\"tmp\") would open the path \"/tmp\". This now returns an error.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.18.9" + }, + { + "introduced": "1.19.0-0" + }, + { + "fixed": "1.19.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "os", + "goos": [ + "windows" + ], + "symbols": [ + "DirFS", + "dirFS.Open", + "dirFS.Stat" + ] + }, + { + "path": "net/http", + "goos": [ + "windows" + ], + "symbols": [ + "Dir.Open", + "ServeFile", + "fileHandler.ServeHTTP", + "fileTransport.RoundTrip" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/56694" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/455716" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/L_3rmdT0BMU/m/yZDrXjIiBQAJ" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-1143", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-1144", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-12-08T19:01:21Z", + "aliases": [ + "CVE-2022-41717", + "GHSA-xrjj-mj9h-534m" + ], + "summary": "Excessive memory growth in net/http and golang.org/x/net/http2", + "details": "An attacker can cause excessive memory growth in a Go server accepting HTTP/2 requests.\n\nHTTP/2 server connections contain a cache of HTTP header keys sent by the client. While the total number of entries in this cache is capped, an attacker sending very large keys can cause the server to allocate approximately 64 MiB per open connection.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.18.9" + }, + { + "introduced": "1.19.0-0" + }, + { + "fixed": "1.19.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "ListenAndServe", + "ListenAndServeTLS", + "Serve", + "ServeTLS", + "Server.ListenAndServe", + "Server.ListenAndServeTLS", + "Server.Serve", + "Server.ServeTLS", + "http2Server.ServeConn", + "http2serverConn.canonicalHeader" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.4.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http2", + "symbols": [ + "Server.ServeConn", + "serverConn.canonicalHeader" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/56350" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/455717" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/455635" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/L_3rmdT0BMU/m/yZDrXjIiBQAJ" + } + ], + "credits": [ + { + "name": "Josselin Costanzi" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-1144", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-1568", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-02-16T19:49:19Z", + "aliases": [ + "CVE-2022-41722" + ], + "summary": "Path traversal on Windows in path/filepath", + "details": "A path traversal vulnerability exists in filepath.Clean on Windows.\n\nOn Windows, the filepath.Clean function could transform an invalid path such as \"a/../c:/b\" into the valid path \"c:\\b\". This transformation of a relative (if invalid) path into an absolute path could enable a directory traversal attack.\n\nAfter fix, the filepath.Clean function transforms this path into the relative (but still invalid) path \".\\c:\\b\".", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.19.6" + }, + { + "introduced": "1.20.0-0" + }, + { + "fixed": "1.20.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "path/filepath", + "goos": [ + "windows" + ], + "symbols": [ + "Abs", + "Clean", + "Dir", + "EvalSymlinks", + "Glob", + "IsLocal", + "Join", + "Rel", + "Walk", + "WalkDir" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/57274" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/468123" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/V0aBFqaFs_E" + } + ], + "credits": [ + { + "name": "RyotaK (https://ryotak.net)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-1568", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-1569", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-02-21T20:44:30Z", + "aliases": [ + "CVE-2022-41725" + ], + "summary": "Excessive resource consumption in mime/multipart", + "details": "A denial of service is possible from excessive resource consumption in net/http and mime/multipart.\n\nMultipart form parsing with mime/multipart.Reader.ReadForm can consume largely unlimited amounts of memory and disk files. This also affects form parsing in the net/http package with the Request methods FormFile, FormValue, ParseMultipartForm, and PostFormValue.\n\nReadForm takes a maxMemory parameter, and is documented as storing \"up to maxMemory bytes +10MB (reserved for non-file parts) in memory\". File parts which cannot be stored in memory are stored on disk in temporary files. The unconfigurable 10MB reserved for non-file parts is excessively large and can potentially open a denial of service vector on its own. However, ReadForm did not properly account for all memory consumed by a parsed form, such as map entry overhead, part names, and MIME headers, permitting a maliciously crafted form to consume well over 10MB. In addition, ReadForm contained no limit on the number of disk files created, permitting a relatively small request body to create a large number of disk temporary files.\n\nWith fix, ReadForm now properly accounts for various forms of memory overhead, and should now stay within its documented limit of 10MB + maxMemory bytes of memory consumption. Users should still be aware that this limit is high and may still be hazardous.\n\nIn addition, ReadForm now creates at most one on-disk temporary file, combining multiple form parts into a single temporary file. The mime/multipart.File interface type's documentation states, \"If stored on disk, the File's underlying concrete type will be an *os.File.\". This is no longer the case when a form contains more than one file part, due to this coalescing of parts into a single file. The previous behavior of using distinct files for each form part may be reenabled with the environment variable GODEBUG=multipartfiles=distinct.\n\nUsers should be aware that multipart.ReadForm and the http.Request methods that call it do not limit the amount of disk consumed by temporary files. Callers can limit the size of form data with http.MaxBytesReader.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.19.6" + }, + { + "introduced": "1.20.0-0" + }, + { + "fixed": "1.20.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "mime/multipart", + "symbols": [ + "Reader.ReadForm" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/58006" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/468124" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/V0aBFqaFs_E" + } + ], + "credits": [ + { + "name": "Arpad Ryszka" + }, + { + "name": "Jakob Ackermann (@das7pad)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-1569", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-1570", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-02-16T22:24:51Z", + "aliases": [ + "CVE-2022-41724" + ], + "summary": "Panic on large handshake records in crypto/tls", + "details": "Large handshake records may cause panics in crypto/tls.\n\nBoth clients and servers may send large TLS handshake records which cause servers and clients, respectively, to panic when attempting to construct responses.\n\nThis affects all TLS 1.3 clients, TLS 1.2 clients which explicitly enable session resumption (by setting Config.ClientSessionCache to a non-nil value), and TLS 1.3 servers which request client certificates (by setting Config.ClientAuth \u003e= RequestClientCert).", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.19.6" + }, + { + "introduced": "1.20.0-0" + }, + { + "fixed": "1.20.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/tls", + "symbols": [ + "Conn.Handshake", + "Conn.HandshakeContext", + "Conn.Read", + "Conn.Write", + "Conn.clientHandshake", + "Conn.handleKeyUpdate", + "Conn.handlePostHandshakeMessage", + "Conn.handleRenegotiation", + "Conn.loadSession", + "Conn.readClientHello", + "Conn.readHandshake", + "Conn.writeRecord", + "ConnectionState.ExportKeyingMaterial", + "Dial", + "DialWithDialer", + "Dialer.Dial", + "Dialer.DialContext", + "certificateMsg.marshal", + "certificateMsgTLS13.marshal", + "certificateRequestMsg.marshal", + "certificateRequestMsgTLS13.marshal", + "certificateStatusMsg.marshal", + "certificateVerifyMsg.marshal", + "cipherSuiteTLS13.expandLabel", + "clientHandshakeState.doFullHandshake", + "clientHandshakeState.handshake", + "clientHandshakeState.readFinished", + "clientHandshakeState.readSessionTicket", + "clientHandshakeState.sendFinished", + "clientHandshakeStateTLS13.handshake", + "clientHandshakeStateTLS13.processHelloRetryRequest", + "clientHandshakeStateTLS13.readServerCertificate", + "clientHandshakeStateTLS13.readServerFinished", + "clientHandshakeStateTLS13.readServerParameters", + "clientHandshakeStateTLS13.sendClientCertificate", + "clientHandshakeStateTLS13.sendClientFinished", + "clientHandshakeStateTLS13.sendDummyChangeCipherSpec", + "clientHelloMsg.marshal", + "clientHelloMsg.marshalWithoutBinders", + "clientHelloMsg.updateBinders", + "clientKeyExchangeMsg.marshal", + "encryptedExtensionsMsg.marshal", + "endOfEarlyDataMsg.marshal", + "finishedMsg.marshal", + "handshakeMessage.marshal", + "helloRequestMsg.marshal", + "keyUpdateMsg.marshal", + "newSessionTicketMsg.marshal", + "newSessionTicketMsgTLS13.marshal", + "serverHandshakeState.doFullHandshake", + "serverHandshakeState.doResumeHandshake", + "serverHandshakeState.readFinished", + "serverHandshakeState.sendFinished", + "serverHandshakeState.sendSessionTicket", + "serverHandshakeStateTLS13.checkForResumption", + "serverHandshakeStateTLS13.doHelloRetryRequest", + "serverHandshakeStateTLS13.readClientCertificate", + "serverHandshakeStateTLS13.readClientFinished", + "serverHandshakeStateTLS13.sendDummyChangeCipherSpec", + "serverHandshakeStateTLS13.sendServerCertificate", + "serverHandshakeStateTLS13.sendServerFinished", + "serverHandshakeStateTLS13.sendServerParameters", + "serverHandshakeStateTLS13.sendSessionTickets", + "serverHelloDoneMsg.marshal", + "serverHelloMsg.marshal", + "serverKeyExchangeMsg.marshal", + "sessionState.marshal", + "sessionStateTLS13.marshal" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/58001" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/468125" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/V0aBFqaFs_E" + } + ], + "credits": [ + { + "name": "Marten Seemann" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-1570", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-1571", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-02-16T22:31:36Z", + "aliases": [ + "CVE-2022-41723", + "GHSA-vvpx-j8f3-3w6h" + ], + "summary": "Denial of service via crafted HTTP/2 stream in net/http and golang.org/x/net", + "details": "A maliciously crafted HTTP/2 stream could cause excessive CPU consumption in the HPACK decoder, sufficient to cause a denial of service from a small number of small requests.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.19.6" + }, + { + "introduced": "1.20.0-0" + }, + { + "fixed": "1.20.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "Client.Do", + "Client.Get", + "Client.Head", + "Client.Post", + "Client.PostForm", + "Get", + "Head", + "ListenAndServe", + "ListenAndServeTLS", + "Post", + "PostForm", + "Serve", + "ServeTLS", + "Server.ListenAndServe", + "Server.ListenAndServeTLS", + "Server.Serve", + "Server.ServeTLS", + "Transport.RoundTrip" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.7.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http2", + "symbols": [ + "ClientConn.Close", + "ClientConn.Ping", + "ClientConn.RoundTrip", + "ClientConn.Shutdown", + "ConfigureServer", + "ConfigureTransport", + "ConfigureTransports", + "ConnectionError.Error", + "ErrCode.String", + "FrameHeader.String", + "FrameType.String", + "FrameWriteRequest.String", + "Framer.ReadFrame", + "Framer.WriteContinuation", + "Framer.WriteData", + "Framer.WriteDataPadded", + "Framer.WriteGoAway", + "Framer.WriteHeaders", + "Framer.WritePing", + "Framer.WritePriority", + "Framer.WritePushPromise", + "Framer.WriteRSTStream", + "Framer.WriteRawFrame", + "Framer.WriteSettings", + "Framer.WriteSettingsAck", + "Framer.WriteWindowUpdate", + "GoAwayError.Error", + "ReadFrameHeader", + "Server.ServeConn", + "Setting.String", + "SettingID.String", + "SettingsFrame.ForeachSetting", + "StreamError.Error", + "Transport.CloseIdleConnections", + "Transport.NewClientConn", + "Transport.RoundTrip", + "Transport.RoundTripOpt", + "bufferedWriter.Flush", + "bufferedWriter.Write", + "chunkWriter.Write", + "clientConnPool.GetClientConn", + "connError.Error", + "dataBuffer.Read", + "duplicatePseudoHeaderError.Error", + "gzipReader.Close", + "gzipReader.Read", + "headerFieldNameError.Error", + "headerFieldValueError.Error", + "noDialClientConnPool.GetClientConn", + "noDialH2RoundTripper.RoundTrip", + "pipe.Read", + "priorityWriteScheduler.CloseStream", + "priorityWriteScheduler.OpenStream", + "pseudoHeaderError.Error", + "requestBody.Close", + "requestBody.Read", + "responseWriter.Flush", + "responseWriter.FlushError", + "responseWriter.Push", + "responseWriter.SetReadDeadline", + "responseWriter.SetWriteDeadline", + "responseWriter.Write", + "responseWriter.WriteHeader", + "responseWriter.WriteString", + "serverConn.CloseConn", + "serverConn.Flush", + "stickyErrWriter.Write", + "transportResponseBody.Close", + "transportResponseBody.Read", + "writeData.String" + ] + }, + { + "path": "golang.org/x/net/http2/hpack", + "symbols": [ + "Decoder.DecodeFull", + "Decoder.Write", + "Decoder.parseFieldLiteral", + "Decoder.readString" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/57855" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/468135" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/468295" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/V0aBFqaFs_E" + } + ], + "credits": [ + { + "name": "Philippe Antoine (Catena cyber)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-1571", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-1621", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-03-08T19:30:53Z", + "aliases": [ + "CVE-2023-24532" + ], + "summary": "Incorrect calculation on P256 curves in crypto/internal/nistec", + "details": "The ScalarMult and ScalarBaseMult methods of the P256 Curve may return an incorrect result if called with some specific unreduced scalars (a scalar larger than the order of the curve).\n\nThis does not impact usages of crypto/ecdsa or crypto/ecdh.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.19.7" + }, + { + "introduced": "1.20.0-0" + }, + { + "fixed": "1.20.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/internal/nistec", + "symbols": [ + "P256OrdInverse", + "P256Point.ScalarBaseMult", + "P256Point.ScalarMult" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/58647" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/471255" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/3-TpUx48iQY" + } + ], + "credits": [ + { + "name": "Guido Vranken, via the Ethereum Foundation bug bounty program" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-1621", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-1702", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-04-05T21:05:07Z", + "aliases": [ + "CVE-2023-24537" + ], + "summary": "Infinite loop in parsing in go/scanner", + "details": "Calling any of the Parse functions on Go source code which contains //line directives with very large line numbers can cause an infinite loop due to integer overflow.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.19.8" + }, + { + "introduced": "1.20.0-0" + }, + { + "fixed": "1.20.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go/scanner", + "symbols": [ + "Scanner.Scan", + "Scanner.updateLineInfo" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/59180" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/482078" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/Xdv6JL9ENs8" + } + ], + "credits": [ + { + "name": "Philippe Antoine (Catena cyber)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-1702", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-1703", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-04-05T21:05:27Z", + "aliases": [ + "CVE-2023-24538" + ], + "summary": "Backticks not treated as string delimiters in html/template", + "details": "Templates do not properly consider backticks (`) as Javascript string delimiters, and do not escape them as expected.\n\nBackticks are used, since ES6, for JS template literals. If a template contains a Go template action within a Javascript template literal, the contents of the action can be used to terminate the literal, injecting arbitrary Javascript code into the Go template.\n\nAs ES6 template literals are rather complex, and themselves can do string interpolation, the decision was made to simply disallow Go template actions from being used inside of them (e.g. \"var a = {{.}}\"), since there is no obviously safe way to allow this behavior. This takes the same approach as github.com/google/safehtml.\n\nWith fix, Template.Parse returns an Error when it encounters templates like this, with an ErrorCode of value 12. This ErrorCode is currently unexported, but will be exported in the release of Go 1.21.\n\nUsers who rely on the previous behavior can re-enable it using the GODEBUG flag jstmpllitinterp=1, with the caveat that backticks will now be escaped. This should be used with caution.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.19.8" + }, + { + "introduced": "1.20.0-0" + }, + { + "fixed": "1.20.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "html/template", + "symbols": [ + "Template.Execute", + "Template.ExecuteTemplate", + "tJS", + "tJSDelimited" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/59234" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/482079" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/Xdv6JL9ENs8" + } + ], + "credits": [ + { + "name": "Sohom Datta, Manipal Institute of Technology" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-1703", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-1704", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-04-05T21:04:28Z", + "aliases": [ + "CVE-2023-24534" + ], + "summary": "Excessive memory allocation in net/http and net/textproto", + "details": "HTTP and MIME header parsing can allocate large amounts of memory, even when parsing small inputs, potentially leading to a denial of service.\n\nCertain unusual patterns of input data can cause the common function used to parse HTTP and MIME headers to allocate substantially more memory than required to hold the parsed headers. An attacker can exploit this behavior to cause an HTTP server to allocate large amounts of memory from a small request, potentially leading to memory exhaustion and a denial of service.\n\nWith fix, header parsing now correctly allocates only the memory required to hold parsed headers.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.19.8" + }, + { + "introduced": "1.20.0-0" + }, + { + "fixed": "1.20.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/textproto", + "symbols": [ + "Reader.ReadMIMEHeader", + "Reader.upcomingHeaderNewlines", + "readMIMEHeader" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/58975" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/481994" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/Xdv6JL9ENs8" + } + ], + "credits": [ + { + "name": "Jakob Ackermann (@das7pad)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-1704", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-1705", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-04-05T21:04:39Z", + "aliases": [ + "CVE-2023-24536" + ], + "summary": "Excessive resource consumption in net/http, net/textproto and mime/multipart", + "details": "Multipart form parsing can consume large amounts of CPU and memory when processing form inputs containing very large numbers of parts.\n\nThis stems from several causes:\n\n1. mime/multipart.Reader.ReadForm limits the total memory a parsed multipart form can consume. ReadForm can undercount the amount of memory consumed, leading it to accept larger inputs than intended.\n2. Limiting total memory does not account for increased pressure on the garbage collector from large numbers of small allocations in forms with many parts.\n3. ReadForm can allocate a large number of short-lived buffers, further increasing pressure on the garbage collector.\n\nThe combination of these factors can permit an attacker to cause an program that parses multipart forms to consume large amounts of CPU and memory, potentially resulting in a denial of service. This affects programs that use mime/multipart.Reader.ReadForm, as well as form parsing in the net/http package with the Request methods FormFile, FormValue, ParseMultipartForm, and PostFormValue.\n\nWith fix, ReadForm now does a better job of estimating the memory consumption of parsed forms, and performs many fewer short-lived allocations.\n\nIn addition, the fixed mime/multipart.Reader imposes the following limits on the size of parsed forms:\n\n1. Forms parsed with ReadForm may contain no more than 1000 parts. This limit may be adjusted with the environment variable GODEBUG=multipartmaxparts=.\n2. Form parts parsed with NextPart and NextRawPart may contain no more than 10,000 header fields. In addition, forms parsed with ReadForm may contain no more than 10,000 header fields across all parts. This limit may be adjusted with the environment variable GODEBUG=multipartmaxheaders=.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.19.8" + }, + { + "introduced": "1.20.0-0" + }, + { + "fixed": "1.20.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "mime/multipart", + "symbols": [ + "Part.populateHeaders", + "Reader.NextPart", + "Reader.NextRawPart", + "Reader.ReadForm", + "Reader.nextPart", + "Reader.readForm", + "mimeHeaderSize", + "newPart", + "readMIMEHeader" + ] + }, + { + "path": "net/textproto", + "symbols": [ + "Reader.ReadMIMEHeader", + "readMIMEHeader" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/59153" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/482076" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/482075" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/482077" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/Xdv6JL9ENs8" + } + ], + "credits": [ + { + "name": "Jakob Ackermann (@das7pad)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-1705", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-1751", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-05-05T21:10:20Z", + "aliases": [ + "CVE-2023-24539" + ], + "summary": "Improper sanitization of CSS values in html/template", + "details": "Angle brackets (\u003c\u003e) are not considered dangerous characters when inserted into CSS contexts. Templates containing multiple actions separated by a '/' character can result in unexpectedly closing the CSS context and allowing for injection of unexpected HTML, if executed with untrusted input.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.19.9" + }, + { + "introduced": "1.20.0-0" + }, + { + "fixed": "1.20.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "html/template", + "symbols": [ + "Template.Execute", + "Template.ExecuteTemplate", + "cssValueFilter", + "escaper.commit" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/59720" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/491615" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/MEb0UyuSMsU" + } + ], + "credits": [ + { + "name": "Juho Nurminen of Mattermost" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-1751", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-1752", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-05-05T21:10:22Z", + "aliases": [ + "CVE-2023-24540" + ], + "summary": "Improper handling of JavaScript whitespace in html/template", + "details": "Not all valid JavaScript whitespace characters are considered to be whitespace. Templates containing whitespace characters outside of the character set \"\\t\\n\\f\\r\\u0020\\u2028\\u2029\" in JavaScript contexts that also contain actions may not be properly sanitized during execution.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.19.9" + }, + { + "introduced": "1.20.0-0" + }, + { + "fixed": "1.20.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "html/template", + "symbols": [ + "Template.Execute", + "Template.ExecuteTemplate", + "nextJSCtx" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/59721" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/491616" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/MEb0UyuSMsU" + } + ], + "credits": [ + { + "name": "Juho Nurminen of Mattermost" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-1752", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-1753", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-05-05T21:10:24Z", + "aliases": [ + "CVE-2023-29400" + ], + "summary": "Improper handling of empty HTML attributes in html/template", + "details": "Templates containing actions in unquoted HTML attributes (e.g. \"attr={{.}}\") executed with empty input can result in output with unexpected results when parsed due to HTML normalization rules. This may allow injection of arbitrary attributes into tags.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.19.9" + }, + { + "introduced": "1.20.0-0" + }, + { + "fixed": "1.20.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "html/template", + "symbols": [ + "Template.Execute", + "Template.ExecuteTemplate", + "appendCmd", + "htmlNospaceEscaper" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/59722" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/491617" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/MEb0UyuSMsU" + } + ], + "credits": [ + { + "name": "Juho Nurminen of Mattermost" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-1753", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-1840", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-06-08T20:16:06Z", + "aliases": [ + "CVE-2023-29403" + ], + "summary": "Unsafe behavior in setuid/setgid binaries in runtime", + "details": "On Unix platforms, the Go runtime does not behave differently when a binary is run with the setuid/setgid bits. This can be dangerous in certain cases, such as when dumping memory state, or assuming the status of standard i/o file descriptors.\n\nIf a setuid/setgid binary is executed with standard I/O file descriptors closed, opening any files can result in unexpected content being read or written with elevated privileges. Similarly, if a setuid/setgid program is terminated, either via panic or signal, it may leak the contents of its registers.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.19.10" + }, + { + "introduced": "1.20.0-0" + }, + { + "fixed": "1.20.5" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "runtime" + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/60272" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/501223" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/q5135a9d924/m/j0ZoAJOHAwAJ" + } + ], + "credits": [ + { + "name": "Vincent Dehors from Synacktiv" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-1840", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-1878", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-07-11T19:19:08Z", + "aliases": [ + "CVE-2023-29406" + ], + "summary": "Insufficient sanitization of Host header in net/http", + "details": "The HTTP/1 client does not fully validate the contents of the Host header. A maliciously crafted Host header can inject additional headers or entire requests.\n\nWith fix, the HTTP/1 client now refuses to send requests containing an invalid Request.Host or Request.URL.Host value.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.19.11" + }, + { + "introduced": "1.20.0-0" + }, + { + "fixed": "1.20.6" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "Client.CloseIdleConnections", + "Client.Do", + "Client.Get", + "Client.Head", + "Client.Post", + "Client.PostForm", + "Get", + "Head", + "Post", + "PostForm", + "Request.Write", + "Request.WriteProxy", + "Request.write", + "Transport.CancelRequest", + "Transport.CloseIdleConnections", + "Transport.RoundTrip" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/60374" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/506996" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/2q13H6LEEx0" + } + ], + "credits": [ + { + "name": "Bartek Nowotarski" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-1878", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-1987", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-08-02T17:25:58Z", + "aliases": [ + "CVE-2023-29409" + ], + "summary": "Large RSA keys can cause high CPU usage in crypto/tls", + "details": "Extremely large RSA keys in certificate chains can cause a client/server to expend significant CPU time verifying signatures.\n\nWith fix, the size of RSA keys transmitted during handshakes is restricted to \u003c= 8192 bits.\n\nBased on a survey of publicly trusted RSA keys, there are currently only three certificates in circulation with keys larger than this, and all three appear to be test certificates that are not actively deployed. It is possible there are larger keys in use in private PKIs, but we target the web PKI, so causing breakage here in the interests of increasing the default safety of users of crypto/tls seems reasonable.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.19.12" + }, + { + "introduced": "1.20.0-0" + }, + { + "fixed": "1.20.7" + }, + { + "introduced": "1.21.0-0" + }, + { + "fixed": "1.21.0-rc.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/tls", + "symbols": [ + "Conn.Handshake", + "Conn.HandshakeContext", + "Conn.Read", + "Conn.Write", + "Conn.processCertsFromClient", + "Conn.verifyServerCertificate", + "Dial", + "DialWithDialer", + "Dialer.Dial", + "Dialer.DialContext" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/61460" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/515257" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/X0b6CsSAaYI/m/Efv5DbZ9AwAJ" + } + ], + "credits": [ + { + "name": "Mateusz Poliwczak" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-1987", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-2041", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-09-07T16:11:17Z", + "aliases": [ + "CVE-2023-39318" + ], + "summary": "Improper handling of HTML-like comments in script contexts in html/template", + "details": "The html/template package does not properly handle HTML-like \"\" comment tokens, nor hashbang \"#!\" comment tokens, in \u003cscript\u003e contexts. This may cause the template parser to improperly interpret the contents of \u003cscript\u003e contexts, causing actions to be improperly escaped. This may be leveraged to perform an XSS attack.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.20.8" + }, + { + "introduced": "1.21.0-0" + }, + { + "fixed": "1.21.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "html/template", + "symbols": [ + "Template.Execute", + "Template.ExecuteTemplate", + "escaper.escapeText", + "isComment", + "tJS", + "tLineCmt" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/62196" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/526156" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-dev/c/2C5vbR-UNkI/m/L1hdrPhfBAAJ" + } + ], + "credits": [ + { + "name": "Takeshi Kaneko (GMO Cybersecurity by Ierae, Inc.)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-2041", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-2043", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-09-07T16:11:59Z", + "aliases": [ + "CVE-2023-39319" + ], + "summary": "Improper handling of special tags within script contexts in html/template", + "details": "The html/template package does not apply the proper rules for handling occurrences of \"\u003cscript\", \"\u003c!--\", and \"\u003c/script\" within JS literals in \u003cscript\u003e contexts. This may cause the template parser to improperly consider script contexts to be terminated early, causing actions to be improperly escaped. This could be leveraged to perform an XSS attack.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.20.8" + }, + { + "introduced": "1.21.0-0" + }, + { + "fixed": "1.21.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "html/template", + "symbols": [ + "Template.Execute", + "Template.ExecuteTemplate", + "escaper.escapeText", + "indexTagEnd", + "tSpecialTagEnd" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/62197" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/526157" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-dev/c/2C5vbR-UNkI/m/L1hdrPhfBAAJ" + } + ], + "credits": [ + { + "name": "Takeshi Kaneko (GMO Cybersecurity by Ierae, Inc.)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-2043", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-2044", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-09-07T16:12:03Z", + "aliases": [ + "CVE-2023-39321" + ], + "summary": "Panic when processing post-handshake message on QUIC connections in crypto/tls", + "details": "Processing an incomplete post-handshake message for a QUIC connection can cause a panic.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.21.0-0" + }, + { + "fixed": "1.21.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/tls", + "symbols": [ + "QUICConn.HandleData" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/62266" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/523039" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-dev/c/2C5vbR-UNkI/m/L1hdrPhfBAAJ" + } + ], + "credits": [ + { + "name": "Marten Seemann" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-2044", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-2045", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-09-07T16:12:01Z", + "aliases": [ + "CVE-2023-39322" + ], + "summary": "Memory exhaustion in QUIC connection handling in crypto/tls", + "details": "QUIC connections do not set an upper bound on the amount of data buffered when reading post-handshake messages, allowing a malicious QUIC connection to cause unbounded memory growth.\n\nWith fix, connections now consistently reject messages larger than 65KiB in size.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.21.0-0" + }, + { + "fixed": "1.21.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/tls", + "symbols": [ + "QUICConn.HandleData" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/62266" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/523039" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-dev/c/2C5vbR-UNkI/m/L1hdrPhfBAAJ" + } + ], + "credits": [ + { + "name": "Marten Seemann" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-2045", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-2102", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-10-11T16:49:53Z", + "aliases": [ + "CVE-2023-39325", + "GHSA-4374-p667-p6c8" + ], + "summary": "HTTP/2 rapid reset can cause excessive work in net/http", + "details": "A malicious HTTP/2 client which rapidly creates requests and immediately resets them can cause excessive server resource consumption. While the total number of requests is bounded by the http2.Server.MaxConcurrentStreams setting, resetting an in-progress request allows the attacker to create a new request while the existing one is still executing.\n\nWith the fix applied, HTTP/2 servers now bound the number of simultaneously executing handler goroutines to the stream concurrency limit (MaxConcurrentStreams). New requests arriving when at the limit (which can only happen after the client has reset an existing, in-flight request) will be queued until a handler exits. If the request queue grows too large, the server will terminate the connection.\n\nThis issue is also fixed in golang.org/x/net/http2 for users manually configuring HTTP/2.\n\nThe default stream concurrency limit is 250 streams (requests) per HTTP/2 connection. This value may be adjusted using the golang.org/x/net/http2 package; see the Server.MaxConcurrentStreams setting and the ConfigureServer function.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.20.10" + }, + { + "introduced": "1.21.0-0" + }, + { + "fixed": "1.21.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "ListenAndServe", + "ListenAndServeTLS", + "Serve", + "ServeTLS", + "Server.ListenAndServe", + "Server.ListenAndServeTLS", + "Server.Serve", + "Server.ServeTLS", + "http2Server.ServeConn", + "http2serverConn.processHeaders", + "http2serverConn.runHandler", + "http2serverConn.serve", + "http2serverConn.upgradeRequest" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.17.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http2", + "symbols": [ + "Server.ServeConn", + "serverConn.processHeaders", + "serverConn.runHandler", + "serverConn.serve", + "serverConn.upgradeRequest" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/63417" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/534215" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/534235" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/iNNxDTCjZvo/m/UDd7VKQuAAAJ" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-2102", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-2185", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-11-08T22:42:14Z", + "aliases": [ + "CVE-2023-45283" + ], + "summary": "Insecure parsing of Windows paths with a \\??\\ prefix in path/filepath", + "details": "The filepath package does not recognize paths with a \\??\\ prefix as special.\n\nOn Windows, a path beginning with \\??\\ is a Root Local Device path equivalent to a path beginning with \\\\?\\. Paths with a \\??\\ prefix may be used to access arbitrary locations on the system. For example, the path \\??\\c:\\x is equivalent to the more common path c:\\x.\n\nBefore fix, Clean could convert a rooted path such as \\a\\..\\??\\b into the root local device path \\??\\b. Clean will now convert this to .\\??\\b.\n\nSimilarly, Join(\\, ??, b) could convert a seemingly innocent sequence of path elements into the root local device path \\??\\b. Join will now convert this to \\.\\??\\b.\n\nIn addition, with fix, IsAbs now correctly reports paths beginning with \\??\\ as absolute, and VolumeName correctly reports the \\??\\ prefix as a volume name.\n\nUPDATE: Go 1.20.11 and Go 1.21.4 inadvertently changed the definition of the volume name in Windows paths starting with \\?, resulting in filepath.Clean(\\?\\c:) returning \\?\\c: rather than \\?\\c:\\ (among other effects). The previous behavior has been restored.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.20.11" + }, + { + "introduced": "1.21.0-0" + }, + { + "fixed": "1.21.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "path/filepath", + "goos": [ + "windows" + ], + "symbols": [ + "Abs", + "Base", + "Clean", + "Dir", + "EvalSymlinks", + "Glob", + "IsLocal", + "Join", + "Rel", + "Split", + "VolumeName", + "Walk", + "WalkDir", + "join", + "volumeNameLen" + ] + }, + { + "path": "internal/safefilepath", + "goos": [ + "windows" + ], + "symbols": [ + "FromFS", + "fromFS" + ] + } + ] + } + }, + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.20.11" + }, + { + "fixed": "1.20.12" + }, + { + "introduced": "1.21.4" + }, + { + "fixed": "1.21.5" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "path/filepath", + "goos": [ + "windows" + ], + "symbols": [ + "Abs", + "Base", + "Clean", + "Dir", + "EvalSymlinks", + "Glob", + "IsLocal", + "Join", + "Rel", + "Split", + "VolumeName", + "Walk", + "WalkDir", + "volumeNameLen" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/63713" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/540277" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/4tU8LZfBFkY" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/64028" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/541175" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-dev/c/6ypN5EjibjM/m/KmLVYH_uAgAJ" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-2185", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-2185", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-11-08T22:42:14Z", + "aliases": [ + "CVE-2023-45283" + ], + "summary": "Insecure parsing of Windows paths with a \\??\\ prefix in path/filepath", + "details": "The filepath package does not recognize paths with a \\??\\ prefix as special.\n\nOn Windows, a path beginning with \\??\\ is a Root Local Device path equivalent to a path beginning with \\\\?\\. Paths with a \\??\\ prefix may be used to access arbitrary locations on the system. For example, the path \\??\\c:\\x is equivalent to the more common path c:\\x.\n\nBefore fix, Clean could convert a rooted path such as \\a\\..\\??\\b into the root local device path \\??\\b. Clean will now convert this to .\\??\\b.\n\nSimilarly, Join(\\, ??, b) could convert a seemingly innocent sequence of path elements into the root local device path \\??\\b. Join will now convert this to \\.\\??\\b.\n\nIn addition, with fix, IsAbs now correctly reports paths beginning with \\??\\ as absolute, and VolumeName correctly reports the \\??\\ prefix as a volume name.\n\nUPDATE: Go 1.20.11 and Go 1.21.4 inadvertently changed the definition of the volume name in Windows paths starting with \\?, resulting in filepath.Clean(\\?\\c:) returning \\?\\c: rather than \\?\\c:\\ (among other effects). The previous behavior has been restored.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.20.11" + }, + { + "introduced": "1.21.0-0" + }, + { + "fixed": "1.21.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "path/filepath", + "goos": [ + "windows" + ], + "symbols": [ + "Abs", + "Base", + "Clean", + "Dir", + "EvalSymlinks", + "Glob", + "IsLocal", + "Join", + "Rel", + "Split", + "VolumeName", + "Walk", + "WalkDir", + "join", + "volumeNameLen" + ] + }, + { + "path": "internal/safefilepath", + "goos": [ + "windows" + ], + "symbols": [ + "FromFS", + "fromFS" + ] + } + ] + } + }, + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.20.11" + }, + { + "fixed": "1.20.12" + }, + { + "introduced": "1.21.4" + }, + { + "fixed": "1.21.5" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "path/filepath", + "goos": [ + "windows" + ], + "symbols": [ + "Abs", + "Base", + "Clean", + "Dir", + "EvalSymlinks", + "Glob", + "IsLocal", + "Join", + "Rel", + "Split", + "VolumeName", + "Walk", + "WalkDir", + "volumeNameLen" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/63713" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/540277" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/4tU8LZfBFkY" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/64028" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/541175" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-dev/c/6ypN5EjibjM/m/KmLVYH_uAgAJ" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-2185", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-2186", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-11-08T22:42:19Z", + "aliases": [ + "CVE-2023-45284" + ], + "summary": "Incorrect detection of reserved device names on Windows in path/filepath", + "details": "On Windows, The IsLocal function does not correctly detect reserved device names in some cases.\n\nReserved names followed by spaces, such as \"COM1 \", and reserved names \"COM\" and \"LPT\" followed by superscript 1, 2, or 3, are incorrectly reported as local.\n\nWith fix, IsLocal now correctly reports these names as non-local.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.20.11" + }, + { + "introduced": "1.21.0-0" + }, + { + "fixed": "1.21.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "path/filepath", + "symbols": [ + "IsLocal" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/63713" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/540277" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/4tU8LZfBFkY" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-2186", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-2375", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-12-05T16:16:44Z", + "aliases": [ + "CVE-2023-45287" + ], + "summary": "Before Go 1.20, the RSA based key exchange methods in crypto/tls may exhibit a timing side channel", + "details": "Before Go 1.20, the RSA based TLS key exchanges used the math/big library, which is not constant time. RSA blinding was applied to prevent timing attacks, but analysis shows this may not have been fully effective. In particular it appears as if the removal of PKCS#1 padding may leak timing information, which in turn could be used to recover session key bits.\n\nIn Go 1.20, the crypto/tls library switched to a fully constant time RSA implementation, which we do not believe exhibits any timing side channels.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.20.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/tls", + "symbols": [ + "Conn.Handshake", + "Conn.HandshakeContext", + "Conn.Read", + "Conn.Write", + "Dial", + "DialWithDialer", + "Dialer.Dial", + "Dialer.DialContext", + "rsaKeyAgreement.generateClientKeyExchange", + "rsaKeyAgreement.processClientKeyExchange" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/20654" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/326012/26" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/QMK8IQALDvA" + }, + { + "type": "ARTICLE", + "url": "https://people.redhat.com/~hkario/marvin/" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-2375", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-2382", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-12-06T16:22:36Z", + "aliases": [ + "CVE-2023-39326" + ], + "summary": "Denial of service via chunk extensions in net/http", + "details": "A malicious HTTP sender can use chunk extensions to cause a receiver reading from a request or response body to read many more bytes from the network than are in the body.\n\nA malicious HTTP client can further exploit this to cause a server to automatically read a large amount of data (up to about 1GiB) when a handler fails to read the entire body of a request.\n\nChunk extensions are a little-used HTTP feature which permit including additional metadata in a request or response body sent using the chunked encoding. The net/http chunked encoding reader discards this metadata. A sender can exploit this by inserting a large metadata segment with each byte transferred. The chunk reader now produces an error if the ratio of real body to encoded bytes grows too small.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.20.12" + }, + { + "introduced": "1.21.0-0" + }, + { + "fixed": "1.21.5" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http/internal", + "symbols": [ + "chunkedReader.Read", + "chunkedReader.beginChunk", + "readChunkLine" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/64433" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/547335" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-dev/c/6ypN5EjibjM/m/KmLVYH_uAgAJ" + } + ], + "credits": [ + { + "name": "Bartek Nowotarski" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-2382", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-2598", + "modified": "2024-05-20T16:03:47Z", + "published": "2024-03-05T22:14:58Z", + "aliases": [ + "CVE-2024-24783" + ], + "summary": "Verify panics on certificates with an unknown public key algorithm in crypto/x509", + "details": "Verifying a certificate chain which contains a certificate with an unknown public key algorithm will cause Certificate.Verify to panic.\n\nThis affects all crypto/tls clients, and servers that set Config.ClientAuth to VerifyClientCertIfGiven or RequireAndVerifyClientCert. The default behavior is for TLS servers to not verify client certificates.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.21.8" + }, + { + "introduced": "1.22.0-0" + }, + { + "fixed": "1.22.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/x509", + "symbols": [ + "Certificate.Verify", + "Certificate.buildChains" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/65390" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/569339" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/5pwGVUPoMbg" + } + ], + "credits": [ + { + "name": "John Howard (Google)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-2598", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-2599", + "modified": "2024-05-20T16:03:47Z", + "published": "2024-03-05T22:15:00Z", + "aliases": [ + "CVE-2023-45290" + ], + "summary": "Memory exhaustion in multipart form parsing in net/textproto and net/http", + "details": "When parsing a multipart form (either explicitly with Request.ParseMultipartForm or implicitly with Request.FormValue, Request.PostFormValue, or Request.FormFile), limits on the total size of the parsed form were not applied to the memory consumed while reading a single form line. This permits a maliciously crafted input containing very long lines to cause allocation of arbitrarily large amounts of memory, potentially leading to memory exhaustion.\n\nWith fix, the ParseMultipartForm function now correctly limits the maximum size of form lines.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.21.8" + }, + { + "introduced": "1.22.0-0" + }, + { + "fixed": "1.22.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/textproto", + "symbols": [ + "Reader.ReadCodeLine", + "Reader.ReadContinuedLine", + "Reader.ReadContinuedLineBytes", + "Reader.ReadDotLines", + "Reader.ReadLine", + "Reader.ReadLineBytes", + "Reader.ReadMIMEHeader", + "Reader.ReadResponse", + "Reader.readContinuedLineSlice", + "Reader.readLineSlice" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/65383" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/569341" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/5pwGVUPoMbg" + } + ], + "credits": [ + { + "name": "Bartek Nowotarski" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-2599", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-2600", + "modified": "2024-05-20T16:03:47Z", + "published": "2024-03-05T22:15:02Z", + "aliases": [ + "CVE-2023-45289" + ], + "summary": "Incorrect forwarding of sensitive headers and cookies on HTTP redirect in net/http", + "details": "When following an HTTP redirect to a domain which is not a subdomain match or exact match of the initial domain, an http.Client does not forward sensitive headers such as \"Authorization\" or \"Cookie\". For example, a redirect from foo.com to www.foo.com will forward the Authorization header, but a redirect to bar.com will not.\n\nA maliciously crafted HTTP redirect could cause sensitive headers to be unexpectedly forwarded.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.21.8" + }, + { + "introduced": "1.22.0-0" + }, + { + "fixed": "1.22.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "Client.Do", + "Client.Get", + "Client.Head", + "Client.Post", + "Client.PostForm", + "Get", + "Head", + "Post", + "PostForm", + "isDomainOrSubdomain" + ] + }, + { + "path": "net/http/cookiejar", + "symbols": [ + "Jar.Cookies", + "Jar.SetCookies", + "isIP" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/65065" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/569340" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/5pwGVUPoMbg" + } + ], + "credits": [ + { + "name": "Juho Nurminen of Mattermost" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-2600", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-2609", + "modified": "2024-05-20T16:03:47Z", + "published": "2024-03-05T22:15:04Z", + "aliases": [ + "CVE-2024-24784" + ], + "summary": "Comments in display names are incorrectly handled in net/mail", + "details": "The ParseAddressList function incorrectly handles comments (text within parentheses) within display names. Since this is a misalignment with conforming address parsers, it can result in different trust decisions being made by programs using different parsers.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.21.8" + }, + { + "introduced": "1.22.0-0" + }, + { + "fixed": "1.22.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/mail", + "symbols": [ + "Address.String", + "AddressParser.Parse", + "AddressParser.ParseList", + "Header.AddressList", + "ParseAddress", + "ParseAddressList", + "addrParser.consumeGroupList", + "addrParser.consumePhrase", + "isAtext" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/65083" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/555596" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/5pwGVUPoMbg" + } + ], + "credits": [ + { + "name": "Juho Nurminen of Mattermost" + }, + { + "name": "Slonser (https://github.com/Slonser)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-2609", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-2610", + "modified": "2024-05-20T16:03:47Z", + "published": "2024-03-05T22:15:40Z", + "aliases": [ + "CVE-2024-24785" + ], + "summary": "Errors returned from JSON marshaling may break template escaping in html/template", + "details": "If errors returned from MarshalJSON methods contain user controlled data, they may be used to break the contextual auto-escaping behavior of the html/template package, allowing for subsequent actions to inject unexpected content into templates.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.21.8" + }, + { + "introduced": "1.22.0-0" + }, + { + "fixed": "1.22.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "html/template", + "symbols": [ + "Template.Execute", + "Template.ExecuteTemplate", + "escaper.commit", + "jsValEscaper" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/65697" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/564196" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/5pwGVUPoMbg" + } + ], + "credits": [ + { + "name": "RyotaK (https://ryotak.net)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-2610", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-2687", + "modified": "2024-05-20T16:03:47Z", + "published": "2024-04-03T21:12:01Z", + "aliases": [ + "CVE-2023-45288", + "GHSA-4v7x-pqxf-cx7m" + ], + "summary": "HTTP/2 CONTINUATION flood in net/http", + "details": "An attacker may cause an HTTP/2 endpoint to read arbitrary amounts of header data by sending an excessive number of CONTINUATION frames.\n\nMaintaining HPACK state requires parsing and processing all HEADERS and CONTINUATION frames on a connection. When a request's headers exceed MaxHeaderBytes, no memory is allocated to store the excess headers, but they are still parsed.\n\nThis permits an attacker to cause an HTTP/2 endpoint to read arbitrary amounts of header data, all associated with a request which is going to be rejected. These headers can include Huffman-encoded data which is significantly more expensive for the receiver to decode than for an attacker to send.\n\nThe fix sets a limit on the amount of excess header frames we will process before closing a connection.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.21.9" + }, + { + "introduced": "1.22.0-0" + }, + { + "fixed": "1.22.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "CanonicalHeaderKey", + "Client.CloseIdleConnections", + "Client.Do", + "Client.Get", + "Client.Head", + "Client.Post", + "Client.PostForm", + "Cookie.String", + "Cookie.Valid", + "Dir.Open", + "Error", + "Get", + "HandlerFunc.ServeHTTP", + "Head", + "Header.Add", + "Header.Del", + "Header.Get", + "Header.Set", + "Header.Values", + "Header.Write", + "Header.WriteSubset", + "ListenAndServe", + "ListenAndServeTLS", + "NewRequest", + "NewRequestWithContext", + "NotFound", + "ParseTime", + "Post", + "PostForm", + "ProxyFromEnvironment", + "ReadRequest", + "ReadResponse", + "Redirect", + "Request.AddCookie", + "Request.BasicAuth", + "Request.FormFile", + "Request.FormValue", + "Request.MultipartReader", + "Request.ParseForm", + "Request.ParseMultipartForm", + "Request.PostFormValue", + "Request.Referer", + "Request.SetBasicAuth", + "Request.UserAgent", + "Request.Write", + "Request.WriteProxy", + "Response.Cookies", + "Response.Location", + "Response.Write", + "ResponseController.EnableFullDuplex", + "ResponseController.Flush", + "ResponseController.Hijack", + "ResponseController.SetReadDeadline", + "ResponseController.SetWriteDeadline", + "Serve", + "ServeContent", + "ServeFile", + "ServeMux.ServeHTTP", + "ServeTLS", + "Server.Close", + "Server.ListenAndServe", + "Server.ListenAndServeTLS", + "Server.Serve", + "Server.ServeTLS", + "Server.SetKeepAlivesEnabled", + "Server.Shutdown", + "SetCookie", + "Transport.CancelRequest", + "Transport.Clone", + "Transport.CloseIdleConnections", + "Transport.RoundTrip", + "body.Close", + "body.Read", + "bodyEOFSignal.Close", + "bodyEOFSignal.Read", + "bodyLocked.Read", + "bufioFlushWriter.Write", + "cancelTimerBody.Close", + "cancelTimerBody.Read", + "checkConnErrorWriter.Write", + "chunkWriter.Write", + "connReader.Read", + "connectMethodKey.String", + "expectContinueReader.Close", + "expectContinueReader.Read", + "extraHeader.Write", + "fileHandler.ServeHTTP", + "fileTransport.RoundTrip", + "globalOptionsHandler.ServeHTTP", + "gzipReader.Close", + "gzipReader.Read", + "http2ClientConn.Close", + "http2ClientConn.Ping", + "http2ClientConn.RoundTrip", + "http2ClientConn.Shutdown", + "http2ConnectionError.Error", + "http2ErrCode.String", + "http2FrameHeader.String", + "http2FrameType.String", + "http2FrameWriteRequest.String", + "http2Framer.ReadFrame", + "http2Framer.WriteContinuation", + "http2Framer.WriteData", + "http2Framer.WriteDataPadded", + "http2Framer.WriteGoAway", + "http2Framer.WriteHeaders", + "http2Framer.WritePing", + "http2Framer.WritePriority", + "http2Framer.WritePushPromise", + "http2Framer.WriteRSTStream", + "http2Framer.WriteRawFrame", + "http2Framer.WriteSettings", + "http2Framer.WriteSettingsAck", + "http2Framer.WriteWindowUpdate", + "http2Framer.readMetaFrame", + "http2GoAwayError.Error", + "http2Server.ServeConn", + "http2Setting.String", + "http2SettingID.String", + "http2SettingsFrame.ForeachSetting", + "http2StreamError.Error", + "http2Transport.CloseIdleConnections", + "http2Transport.NewClientConn", + "http2Transport.RoundTrip", + "http2Transport.RoundTripOpt", + "http2bufferedWriter.Flush", + "http2bufferedWriter.Write", + "http2chunkWriter.Write", + "http2clientConnPool.GetClientConn", + "http2connError.Error", + "http2dataBuffer.Read", + "http2duplicatePseudoHeaderError.Error", + "http2gzipReader.Close", + "http2gzipReader.Read", + "http2headerFieldNameError.Error", + "http2headerFieldValueError.Error", + "http2noDialClientConnPool.GetClientConn", + "http2noDialH2RoundTripper.RoundTrip", + "http2pipe.Read", + "http2priorityWriteScheduler.CloseStream", + "http2priorityWriteScheduler.OpenStream", + "http2pseudoHeaderError.Error", + "http2requestBody.Close", + "http2requestBody.Read", + "http2responseWriter.Flush", + "http2responseWriter.FlushError", + "http2responseWriter.Push", + "http2responseWriter.SetReadDeadline", + "http2responseWriter.SetWriteDeadline", + "http2responseWriter.Write", + "http2responseWriter.WriteHeader", + "http2responseWriter.WriteString", + "http2roundRobinWriteScheduler.OpenStream", + "http2serverConn.CloseConn", + "http2serverConn.Flush", + "http2stickyErrWriter.Write", + "http2transportResponseBody.Close", + "http2transportResponseBody.Read", + "http2writeData.String", + "initALPNRequest.ServeHTTP", + "loggingConn.Close", + "loggingConn.Read", + "loggingConn.Write", + "maxBytesReader.Close", + "maxBytesReader.Read", + "onceCloseListener.Close", + "persistConn.Read", + "persistConnWriter.ReadFrom", + "persistConnWriter.Write", + "populateResponse.Write", + "populateResponse.WriteHeader", + "readTrackingBody.Close", + "readTrackingBody.Read", + "readWriteCloserBody.Read", + "redirectHandler.ServeHTTP", + "response.Flush", + "response.FlushError", + "response.Hijack", + "response.ReadFrom", + "response.Write", + "response.WriteHeader", + "response.WriteString", + "serverHandler.ServeHTTP", + "socksDialer.DialWithConn", + "socksUsernamePassword.Authenticate", + "stringWriter.WriteString", + "timeoutHandler.ServeHTTP", + "timeoutWriter.Write", + "timeoutWriter.WriteHeader", + "transportReadFromServerError.Error" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.23.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http2", + "symbols": [ + "ClientConn.Close", + "ClientConn.Ping", + "ClientConn.RoundTrip", + "ClientConn.Shutdown", + "ConfigureServer", + "ConfigureTransport", + "ConfigureTransports", + "ConnectionError.Error", + "ErrCode.String", + "FrameHeader.String", + "FrameType.String", + "FrameWriteRequest.String", + "Framer.ReadFrame", + "Framer.WriteContinuation", + "Framer.WriteData", + "Framer.WriteDataPadded", + "Framer.WriteGoAway", + "Framer.WriteHeaders", + "Framer.WritePing", + "Framer.WritePriority", + "Framer.WritePushPromise", + "Framer.WriteRSTStream", + "Framer.WriteRawFrame", + "Framer.WriteSettings", + "Framer.WriteSettingsAck", + "Framer.WriteWindowUpdate", + "Framer.readMetaFrame", + "GoAwayError.Error", + "ReadFrameHeader", + "Server.ServeConn", + "Setting.String", + "SettingID.String", + "SettingsFrame.ForeachSetting", + "StreamError.Error", + "Transport.CloseIdleConnections", + "Transport.NewClientConn", + "Transport.RoundTrip", + "Transport.RoundTripOpt", + "bufferedWriter.Flush", + "bufferedWriter.Write", + "chunkWriter.Write", + "clientConnPool.GetClientConn", + "connError.Error", + "dataBuffer.Read", + "duplicatePseudoHeaderError.Error", + "gzipReader.Close", + "gzipReader.Read", + "headerFieldNameError.Error", + "headerFieldValueError.Error", + "noDialClientConnPool.GetClientConn", + "noDialH2RoundTripper.RoundTrip", + "pipe.Read", + "priorityWriteScheduler.CloseStream", + "priorityWriteScheduler.OpenStream", + "pseudoHeaderError.Error", + "requestBody.Close", + "requestBody.Read", + "responseWriter.Flush", + "responseWriter.FlushError", + "responseWriter.Push", + "responseWriter.SetReadDeadline", + "responseWriter.SetWriteDeadline", + "responseWriter.Write", + "responseWriter.WriteHeader", + "responseWriter.WriteString", + "roundRobinWriteScheduler.OpenStream", + "serverConn.CloseConn", + "serverConn.Flush", + "stickyErrWriter.Write", + "transportResponseBody.Close", + "transportResponseBody.Read", + "writeData.String" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/65051" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/576155" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/YgW0sx8mN3M" + } + ], + "credits": [ + { + "name": "Bartek Nowotarski (https://nowotarski.info/)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-2687", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-2824", + "modified": "2024-05-20T16:03:47Z", + "published": "2024-05-07T22:33:51Z", + "aliases": [ + "CVE-2024-24788" + ], + "summary": "Malformed DNS message can cause infinite loop in net", + "details": "A malformed DNS message in response to a query can cause the Lookup functions to get stuck in an infinite loop.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.22.0-0" + }, + { + "fixed": "1.22.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net", + "symbols": [ + "Dial", + "DialTimeout", + "Dialer.Dial", + "Dialer.DialContext", + "Listen", + "ListenConfig.Listen", + "ListenConfig.ListenPacket", + "ListenPacket", + "LookupAddr", + "LookupCNAME", + "LookupHost", + "LookupIP", + "LookupMX", + "LookupNS", + "LookupSRV", + "LookupTXT", + "ResolveIPAddr", + "ResolveTCPAddr", + "ResolveUDPAddr", + "Resolver.LookupAddr", + "Resolver.LookupCNAME", + "Resolver.LookupHost", + "Resolver.LookupIP", + "Resolver.LookupIPAddr", + "Resolver.LookupMX", + "Resolver.LookupNS", + "Resolver.LookupNetIP", + "Resolver.LookupSRV", + "Resolver.LookupTXT", + "extractExtendedRCode" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/66754" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/578375" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/wkkO4P9stm0" + } + ], + "credits": [ + { + "name": "@long-name-let-people-remember-you" + }, + { + "name": "Mateusz Poliwczak" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-2824", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-2887", + "modified": "2024-06-04T22:48:55Z", + "published": "2024-06-04T22:48:55Z", + "aliases": [ + "CVE-2024-24790" + ], + "summary": "Unexpected behavior from Is methods for IPv4-mapped IPv6 addresses in net/netip", + "details": "The various Is methods (IsPrivate, IsLoopback, etc) did not work as expected for IPv4-mapped IPv6 addresses, returning false for addresses which would return true in their traditional IPv4 forms.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.21.11" + }, + { + "introduced": "1.22.0-0" + }, + { + "fixed": "1.22.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/netip", + "symbols": [ + "Addr.IsGlobalUnicast", + "Addr.IsInterfaceLocalMulticast", + "Addr.IsLinkLocalMulticast", + "Addr.IsLoopback", + "Addr.IsMulticast", + "Addr.IsPrivate" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/590316" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/67680" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/XbxouI9gY7k/m/TuoGEhxIEwAJ" + } + ], + "credits": [ + { + "name": "Enze Wang of Alioth (@zer0yu)" + }, + { + "name": "Jianjun Chen of Zhongguancun Lab (@chenjj)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-2887", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-2888", + "modified": "2024-06-04T22:48:55Z", + "published": "2024-06-04T22:48:55Z", + "aliases": [ + "CVE-2024-24789" + ], + "summary": "Mishandling of corrupt central directory record in archive/zip", + "details": "The archive/zip package's handling of certain types of invalid zip files differs from the behavior of most zip implementations. This misalignment could be exploited to create an zip file with contents that vary depending on the implementation reading the file. The archive/zip package now rejects files containing these errors.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.21.11" + }, + { + "introduced": "1.22.0-0" + }, + { + "fixed": "1.22.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "archive/zip", + "symbols": [ + "NewReader", + "OpenReader", + "findSignatureInBlock" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/585397" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/66869" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/XbxouI9gY7k/m/TuoGEhxIEwAJ" + } + ], + "credits": [ + { + "name": "Yufan You (@ouuan)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-2888", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-2963", + "modified": "2024-07-02T20:11:00Z", + "published": "2024-07-02T20:11:00Z", + "aliases": [ + "CVE-2024-24791" + ], + "summary": "Denial of service due to improper 100-continue handling in net/http", + "details": "The net/http HTTP/1.1 client mishandled the case where a server responds to a request with an \"Expect: 100-continue\" header with a non-informational (200 or higher) status. This mishandling could leave a client connection in an invalid state, where the next request sent on the connection will fail.\n\nAn attacker sending a request to a net/http/httputil.ReverseProxy proxy can exploit this mishandling to cause a denial of service by sending \"Expect: 100-continue\" requests which elicit a non-informational response from the backend. Each such request leaves the proxy with an invalid connection, and causes one subsequent request using that connection to fail.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.21.12" + }, + { + "introduced": "1.22.0-0" + }, + { + "fixed": "1.22.5" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "Client.CloseIdleConnections", + "Client.Do", + "Client.Get", + "Client.Head", + "Client.Post", + "Client.PostForm", + "Get", + "Head", + "Post", + "PostForm", + "Transport.CancelRequest", + "Transport.CloseIdleConnections", + "Transport.RoundTrip", + "persistConn.readResponse" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/591255" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/67555" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-dev/c/t0rK-qHBqzY/m/6MMoAZkMAgAJ" + } + ], + "credits": [ + { + "name": "Geoff Franks" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-2963", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-3105", + "modified": "2024-09-06T19:15:23Z", + "published": "2024-09-06T19:15:23Z", + "aliases": [ + "CVE-2024-34155" + ], + "summary": "Stack exhaustion in all Parse functions in go/parser", + "details": "Calling any of the Parse functions on Go source code which contains deeply nested literals can cause a panic due to stack exhaustion.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.22.7" + }, + { + "introduced": "1.23.0-0" + }, + { + "fixed": "1.23.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go/parser", + "symbols": [ + "ParseDir", + "ParseExpr", + "ParseExprFrom", + "ParseFile", + "parser.parseLiteralValue" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/611238" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/69138" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-dev/c/S9POB9NCTdk" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-3105", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-3106", + "modified": "2024-09-06T19:15:23Z", + "published": "2024-09-06T19:15:23Z", + "aliases": [ + "CVE-2024-34156" + ], + "summary": "Stack exhaustion in Decoder.Decode in encoding/gob", + "details": "Calling Decoder.Decode on a message which contains deeply nested structures can cause a panic due to stack exhaustion. This is a follow-up to CVE-2022-30635.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.22.7" + }, + { + "introduced": "1.23.0-0" + }, + { + "fixed": "1.23.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "encoding/gob", + "symbols": [ + "Decoder.Decode", + "Decoder.DecodeValue", + "Decoder.decIgnoreOpFor" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/611239" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/69139" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-dev/c/S9POB9NCTdk" + } + ], + "credits": [ + { + "name": "Md Sakib Anwar of The Ohio State University (anwar.40@osu.edu)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-3106", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-3107", + "modified": "2024-09-06T19:15:23Z", + "published": "2024-09-06T19:15:23Z", + "aliases": [ + "CVE-2024-34158" + ], + "summary": "Stack exhaustion in Parse in go/build/constraint", + "details": "Calling Parse on a \"// +build\" build tag line with deeply nested expressions can cause a panic due to stack exhaustion.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.22.7" + }, + { + "introduced": "1.23.0-0" + }, + { + "fixed": "1.23.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go/build/constraint", + "symbols": [ + "Parse", + "exprParser.not", + "parsePlusBuildExpr" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/611240" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/69141" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-dev/c/S9POB9NCTdk" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-3107", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3373", + "modified": "2025-01-30T18:58:58Z", + "published": "2025-01-28T00:47:30Z", + "aliases": [ + "CVE-2024-45341" + ], + "summary": "Usage of IPv6 zone IDs can bypass URI name constraints in crypto/x509", + "details": "A certificate with a URI which has a IPv6 address with a zone ID may incorrectly satisfy a URI name constraint that applies to the certificate chain.\n\nCertificates containing URIs are not permitted in the web PKI, so this only affects users of private PKIs which make use of URIs.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.22.11" + }, + { + "introduced": "1.23.0-0" + }, + { + "fixed": "1.23.5" + }, + { + "introduced": "1.24.0-0" + }, + { + "fixed": "1.24.0-rc.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/x509", + "symbols": [ + "CertPool.AppendCertsFromPEM", + "Certificate.CheckCRLSignature", + "Certificate.CheckSignature", + "Certificate.CheckSignatureFrom", + "Certificate.CreateCRL", + "Certificate.Verify", + "Certificate.VerifyHostname", + "CertificateRequest.CheckSignature", + "CreateCertificate", + "CreateCertificateRequest", + "CreateRevocationList", + "DecryptPEMBlock", + "EncryptPEMBlock", + "HostnameError.Error", + "MarshalECPrivateKey", + "MarshalPKCS1PrivateKey", + "MarshalPKCS1PublicKey", + "MarshalPKCS8PrivateKey", + "MarshalPKIXPublicKey", + "ParseCRL", + "ParseCertificate", + "ParseCertificateRequest", + "ParseCertificates", + "ParseDERCRL", + "ParseECPrivateKey", + "ParsePKCS1PrivateKey", + "ParsePKCS1PublicKey", + "ParsePKCS8PrivateKey", + "ParsePKIXPublicKey", + "ParseRevocationList", + "RevocationList.CheckSignatureFrom", + "SetFallbackRoots", + "SystemCertPool", + "matchURIConstraint" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/643099" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/71156" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-dev/c/bG8cv1muIBM/m/G461hA6lCgAJ" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-dev/c/CAWXhan3Jww/m/bk9LAa-lCgAJ" + } + ], + "credits": [ + { + "name": "Juho Forsén of Mattermost" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3373", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3420", + "modified": "2025-01-30T18:58:58Z", + "published": "2025-01-28T00:47:30Z", + "aliases": [ + "CVE-2024-45336" + ], + "summary": "Sensitive headers incorrectly sent after cross-domain redirect in net/http", + "details": "The HTTP client drops sensitive headers after following a cross-domain redirect. For example, a request to a.com/ containing an Authorization header which is redirected to b.com/ will not send that header to b.com.\n\nIn the event that the client received a subsequent same-domain redirect, however, the sensitive headers would be restored. For example, a chain of redirects from a.com/, to b.com/1, and finally to b.com/2 would incorrectly send the Authorization header to b.com/2.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.22.11" + }, + { + "introduced": "1.23.0-0" + }, + { + "fixed": "1.23.5" + }, + { + "introduced": "1.24.0-0" + }, + { + "fixed": "1.24.0-rc.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "Client.Do", + "Client.Get", + "Client.Head", + "Client.Post", + "Client.PostForm", + "Client.do", + "Client.makeHeadersCopier", + "Get", + "Head", + "Post", + "PostForm", + "shouldCopyHeaderOnRedirect" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/643100" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/70530" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-dev/c/CAWXhan3Jww/m/bk9LAa-lCgAJ" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-dev/c/bG8cv1muIBM/m/G461hA6lCgAJ" + } + ], + "credits": [ + { + "name": "Kyle Seely" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3420", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3421", + "modified": "2025-01-30T18:58:58Z", + "published": "2025-01-28T00:47:30Z", + "aliases": [ + "CVE-2025-22865" + ], + "summary": "ParsePKCS1PrivateKey panic with partial keys in crypto/x509", + "details": "Using ParsePKCS1PrivateKey to parse a RSA key that is missing the CRT values would panic when verifying that the key is well formed.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.24.0-0" + }, + { + "fixed": "1.24.0-rc.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/x509", + "symbols": [ + "ParsePKCS1PrivateKey" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/643098" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/71216" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-dev/c/CAWXhan3Jww/m/bk9LAa-lCgAJ" + } + ], + "credits": [ + { + "name": "Philippe Antoine (Catena cyber)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3421", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3447", + "modified": "2025-02-06T16:38:14Z", + "published": "2025-02-06T16:38:14Z", + "aliases": [ + "CVE-2025-22866" + ], + "summary": "Timing sidechannel for P-256 on ppc64le in crypto/internal/nistec", + "details": "Due to the usage of a variable time instruction in the assembly implementation of an internal function, a small number of bits of secret scalars are leaked on the ppc64le architecture. Due to the way this function is used, we do not believe this leakage is enough to allow recovery of the private key when P-256 is used in any well known protocols.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.22.12" + }, + { + "introduced": "1.23.0-0" + }, + { + "fixed": "1.23.6" + }, + { + "introduced": "1.24.0-0" + }, + { + "fixed": "1.24.0-rc.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/internal/nistec", + "goarch": [ + "ppc64le" + ], + "symbols": [ + "P256Point.ScalarBaseMult", + "P256Point.ScalarMult", + "P256Point.SetBytes", + "p256NegCond" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/643735" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/71383" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/xU1ZCHUZw3k" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3447", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3503", + "modified": "2026-04-16T20:42:32Z", + "published": "2025-03-12T18:17:07Z", + "aliases": [ + "CVE-2025-22870", + "GHSA-qxp5-gwg8-xv66" + ], + "summary": "HTTP Proxy bypass using IPv6 Zone IDs in golang.org/x/net", + "details": "Matching of hosts against proxy patterns can improperly treat an IPv6 zone ID as a hostname component. For example, when the NO_PROXY environment variable is set to \"*.example.com\", a request to \"[::1%25.example.com]:80` will incorrectly match and not be proxied.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.23.7" + }, + { + "introduced": "1.24.0-0" + }, + { + "fixed": "1.24.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "ProxyFromEnvironment", + "envProxyFunc" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.36.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http/httpproxy", + "symbols": [ + "config.useProxy", + "domainMatch.match" + ] + }, + { + "path": "golang.org/x/net/proxy", + "symbols": [ + "Dial", + "FromEnvironment", + "FromEnvironmentUsing", + "PerHost.AddFromString", + "PerHost.Dial", + "PerHost.DialContext", + "PerHost.dialerForRequest" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/654697" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/71984" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/4t3lzH3I0eI/m/b42ImqrBAQAJ" + } + ], + "credits": [ + { + "name": "Juho Forsén of Mattermost" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3503", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3563", + "modified": "2025-04-08T19:46:23Z", + "published": "2025-04-08T19:46:23Z", + "aliases": [ + "CVE-2025-22871" + ], + "summary": "Request smuggling due to acceptance of invalid chunked data in net/http", + "details": "The net/http package improperly accepts a bare LF as a line terminator in chunked data chunk-size lines. This can permit request smuggling if a net/http server is used in conjunction with a server that incorrectly accepts a bare LF as part of a chunk-ext.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.23.8" + }, + { + "introduced": "1.24.0-0" + }, + { + "fixed": "1.24.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http/internal", + "symbols": [ + "chunkedReader.Read", + "readChunkLine" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/652998" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/71988" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/Y2uBTVKjBQk" + } + ], + "credits": [ + { + "name": "Jeppe Bonde Weikop" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3563", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3749", + "modified": "2025-06-16T20:08:41Z", + "published": "2025-06-11T16:23:50Z", + "aliases": [ + "CVE-2025-22874" + ], + "summary": "Usage of ExtKeyUsageAny disables policy validation in crypto/x509", + "details": "Calling Verify with a VerifyOptions.KeyUsages that contains ExtKeyUsageAny unintentionally disabledpolicy validation. This only affected certificate chains which contain policy graphs, which are rather uncommon.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.24.0-0" + }, + { + "fixed": "1.24.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/x509", + "symbols": [ + "Certificate.Verify" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/670375" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/73612" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/ufZ8WpEsA3A" + } + ], + "credits": [ + { + "name": "Krzysztof Skrzętnicki (@Tener) of Teleport" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3749", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3750", + "modified": "2025-06-11T16:59:06Z", + "published": "2025-06-11T16:59:06Z", + "aliases": [ + "CVE-2025-0913" + ], + "summary": "Inconsistent handling of O_CREATE|O_EXCL on Unix and Windows in os in syscall", + "details": "os.OpenFile(path, os.O_CREATE|O_EXCL) behaved differently on Unix and Windows systems when the target path was a dangling symlink. On Unix systems, OpenFile with O_CREATE and O_EXCL flags never follows symlinks. On Windows, when the target path was a symlink to a nonexistent location, OpenFile would create a file in that location. OpenFile now always returns an error when the O_CREATE and O_EXCL flags are both set and the target path is a symlink.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.23.10" + }, + { + "introduced": "1.24.0-0" + }, + { + "fixed": "1.24.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "syscall", + "goos": [ + "windows" + ], + "symbols": [ + "Open" + ] + }, + { + "path": "os", + "goos": [ + "windows" + ], + "symbols": [ + "Chdir", + "Chmod", + "Chown", + "CopyFS", + "Create", + "CreateTemp", + "File.ReadDir", + "File.Readdir", + "File.Readdirnames", + "Getwd", + "Lchown", + "Link", + "Lstat", + "Mkdir", + "MkdirAll", + "MkdirTemp", + "NewFile", + "Open", + "OpenFile", + "OpenInRoot", + "OpenRoot", + "Pipe", + "ReadDir", + "ReadFile", + "Remove", + "RemoveAll", + "Rename", + "Root.Create", + "Root.Lstat", + "Root.Mkdir", + "Root.Open", + "Root.OpenFile", + "Root.OpenRoot", + "Root.Remove", + "Root.Stat", + "StartProcess", + "Stat", + "Symlink", + "Truncate", + "WriteFile", + "dirFS.Open", + "dirFS.ReadDir", + "dirFS.ReadFile", + "dirFS.Stat", + "rootFS.Open", + "rootFS.ReadDir", + "rootFS.ReadFile", + "rootFS.Stat", + "unixDirent.Info" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/672396" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/73702" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/ufZ8WpEsA3A" + } + ], + "credits": [ + { + "name": "Junyoung Park and Dong-uk Kim of KAIST Hacking Lab" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3750", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3751", + "modified": "2025-06-11T16:23:58Z", + "published": "2025-06-11T16:23:58Z", + "aliases": [ + "CVE-2025-4673" + ], + "summary": "Sensitive headers not cleared on cross-origin redirect in net/http", + "details": "Proxy-Authorization and Proxy-Authenticate headers persisted on cross-origin redirects potentially leaking sensitive information.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.23.10" + }, + { + "introduced": "1.24.0-0" + }, + { + "fixed": "1.24.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "Client.Do", + "Client.Get", + "Client.Head", + "Client.Post", + "Client.PostForm", + "Client.makeHeadersCopier", + "Get", + "Head", + "Post", + "PostForm" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/679257" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/73816" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/ufZ8WpEsA3A" + } + ], + "credits": [ + { + "name": "Takeshi Kaneko (GMO Cybersecurity by Ierae, Inc.)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3751", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3849", + "modified": "2025-08-07T15:07:27Z", + "published": "2025-08-07T15:07:27Z", + "aliases": [ + "CVE-2025-47907" + ], + "summary": "Incorrect results returned from Rows.Scan in database/sql", + "details": "Cancelling a query (e.g. by cancelling the context passed to one of the query methods) during a call to the Scan method of the returned Rows can result in unexpected results if other queries are being made in parallel. This can result in a race condition that may overwrite the expected results with those of another query, causing the call to Scan to return either unexpected results from the other query or an error.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.23.12" + }, + { + "introduced": "1.24.0" + }, + { + "fixed": "1.24.6" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "database/sql", + "symbols": [ + "Row.Scan", + "Rows.Scan" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/693735" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/74831" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/x5MKroML2yM" + } + ], + "credits": [ + { + "name": "Spike Curtis from Coder" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3849", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3955", + "modified": "2025-09-22T20:48:35Z", + "published": "2025-09-22T20:48:35Z", + "aliases": [ + "CVE-2025-47910", + "CVE-2025-47910" + ], + "summary": "CrossOriginProtection insecure bypass patterns not limited to exact matches in net/http", + "details": "When using http.CrossOriginProtection, the AddInsecureBypassPattern method can unexpectedly bypass more requests than intended. CrossOriginProtection then skips validation, but forwards the original request path, which may be served by a different handler without the intended security protections.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.25.0" + }, + { + "fixed": "1.25.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "CrossOriginProtection.AddInsecureBypassPattern" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/699275" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/75054" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/PtW9VW21NPs/m/DJhMQ-m5AQAJ" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3955", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3956", + "modified": "2025-09-18T18:21:44Z", + "published": "2025-09-18T18:21:44Z", + "aliases": [ + "CVE-2025-47906", + "CVE-2025-47906" + ], + "summary": "Unexpected paths returned from LookPath in os/exec", + "details": "If the PATH environment variable contains paths which are executables (rather than just directories), passing certain strings to LookPath (\"\", \".\", and \"..\"), can result in the binaries listed in the PATH being unexpectedly returned.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.23.12" + }, + { + "introduced": "1.24.0" + }, + { + "fixed": "1.24.6" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "os/exec", + "symbols": [ + "LookPath" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/691775" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/74466" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/x5MKroML2yM" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3956", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-4006", + "modified": "2025-12-09T17:20:47Z", + "published": "2025-10-29T21:48:35Z", + "aliases": [ + "CVE-2025-61725", + "CVE-2025-61725" + ], + "summary": "Excessive CPU consumption in ParseAddress in net/mail", + "details": "The ParseAddress function constructs domain-literal address components through repeated string concatenation. When parsing large domain-literal components, this can cause excessive CPU consumption.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.24.8" + }, + { + "introduced": "1.25.0" + }, + { + "fixed": "1.25.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/mail", + "symbols": [ + "AddressParser.Parse", + "AddressParser.ParseList", + "Header.AddressList", + "ParseAddress", + "ParseAddressList", + "addrParser.consumeDomainLiteral" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/709860" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/75680" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/4Emdl2iQ_bI" + } + ], + "credits": [ + { + "name": "Philippe Antoine (Catena cyber)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-4006", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-4007", + "modified": "2025-11-20T22:03:19Z", + "published": "2025-10-29T21:49:50Z", + "aliases": [ + "CVE-2025-58187", + "CVE-2025-58187" + ], + "summary": "Quadratic complexity when checking name constraints in crypto/x509", + "details": "Due to the design of the name constraint checking algorithm, the processing time of some inputs scale non-linearly with respect to the size of the certificate.\n\nThis affects programs which validate arbitrary certificate chains.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.24.9" + }, + { + "introduced": "1.25.0" + }, + { + "fixed": "1.25.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/x509", + "symbols": [ + "CertPool.AppendCertsFromPEM", + "Certificate.CheckCRLSignature", + "Certificate.CheckSignature", + "Certificate.CheckSignatureFrom", + "Certificate.CreateCRL", + "Certificate.Verify", + "CertificateRequest.CheckSignature", + "CreateCertificate", + "CreateCertificateRequest", + "CreateRevocationList", + "DecryptPEMBlock", + "EncryptPEMBlock", + "MarshalECPrivateKey", + "MarshalPKCS1PrivateKey", + "MarshalPKCS1PublicKey", + "MarshalPKCS8PrivateKey", + "MarshalPKIXPublicKey", + "ParseCRL", + "ParseCertificate", + "ParseCertificateRequest", + "ParseCertificates", + "ParseDERCRL", + "ParseECPrivateKey", + "ParsePKCS1PrivateKey", + "ParsePKCS1PublicKey", + "ParsePKCS8PrivateKey", + "ParsePKIXPublicKey", + "ParseRevocationList", + "RevocationList.CheckSignatureFrom", + "SetFallbackRoots", + "SystemCertPool", + "domainToReverseLabels", + "parseSANExtension" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/75681" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/709854" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/4Emdl2iQ_bI" + } + ], + "credits": [ + { + "name": "Jakub Ciolek" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-4007", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-4008", + "modified": "2025-10-29T21:49:53Z", + "published": "2025-10-29T21:49:53Z", + "aliases": [ + "CVE-2025-58189", + "CVE-2025-58189" + ], + "summary": "ALPN negotiation error contains attacker controlled information in crypto/tls", + "details": "When Conn.Handshake fails during ALPN negotiation the error contains attacker controlled information (the ALPN protocols sent by the client) which is not escaped.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.24.8" + }, + { + "introduced": "1.25.0" + }, + { + "fixed": "1.25.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/tls", + "symbols": [ + "Conn.Handshake", + "Conn.HandshakeContext", + "Conn.Read", + "Conn.Write", + "Dial", + "DialWithDialer", + "Dialer.Dial", + "Dialer.DialContext", + "QUICConn.Start", + "negotiateALPN" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/707776" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/75652" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/4Emdl2iQ_bI" + } + ], + "credits": [ + { + "name": "National Cyber Security Centre Finland" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-4008", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-4009", + "modified": "2025-10-29T21:49:55Z", + "published": "2025-10-29T21:49:55Z", + "aliases": [ + "CVE-2025-61723", + "CVE-2025-61723" + ], + "summary": "Quadratic complexity when parsing some invalid inputs in encoding/pem", + "details": "The processing time for parsing some invalid inputs scales non-linearly with respect to the size of the input.\n\nThis affects programs which parse untrusted PEM inputs.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.24.8" + }, + { + "introduced": "1.25.0" + }, + { + "fixed": "1.25.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "encoding/pem", + "symbols": [ + "Decode", + "getLine" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/75676" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/709858" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/4Emdl2iQ_bI" + } + ], + "credits": [ + { + "name": "Jakub Ciolek" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-4009", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-4010", + "modified": "2025-10-29T21:49:58Z", + "published": "2025-10-29T21:49:58Z", + "aliases": [ + "CVE-2025-47912", + "CVE-2025-47912" + ], + "summary": "Insufficient validation of bracketed IPv6 hostnames in net/url", + "details": "The Parse function permits values other than IPv6 addresses to be included in square brackets within the host component of a URL. RFC 3986 permits IPv6 addresses to be included within the host component, enclosed within square brackets. For example: \"http://[::1]/\". IPv4 addresses and hostnames must not appear within square brackets. Parse did not enforce this requirement.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.24.8" + }, + { + "introduced": "1.25.0" + }, + { + "fixed": "1.25.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/url", + "symbols": [ + "JoinPath", + "Parse", + "ParseRequestURI", + "URL.Parse", + "URL.UnmarshalBinary", + "parseHost" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/75678" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/709857" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/4Emdl2iQ_bI" + } + ], + "credits": [ + { + "name": "Enze Wang, Jingcheng Yang and Zehui Miao of Tsinghua University" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-4010", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-4011", + "modified": "2025-10-29T21:50:00Z", + "published": "2025-10-29T21:50:00Z", + "aliases": [ + "CVE-2025-58185", + "CVE-2025-58185" + ], + "summary": "Parsing DER payload can cause memory exhaustion in encoding/asn1", + "details": "Parsing a maliciously crafted DER payload could allocate large amounts of memory, causing memory exhaustion.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.24.8" + }, + { + "introduced": "1.25.0" + }, + { + "fixed": "1.25.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "encoding/asn1", + "symbols": [ + "Unmarshal", + "UnmarshalWithParams", + "parseSequenceOf" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/75671" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/709856" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/4Emdl2iQ_bI" + } + ], + "credits": [ + { + "name": "Jakub Ciolek" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-4011", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-4012", + "modified": "2025-10-29T21:50:05Z", + "published": "2025-10-29T21:50:05Z", + "aliases": [ + "CVE-2025-58186", + "CVE-2025-58186" + ], + "summary": "Lack of limit when parsing cookies can cause memory exhaustion in net/http", + "details": "Despite HTTP headers having a default limit of 1MB, the number of cookies that can be parsed does not have a limit. By sending a lot of very small cookies such as \"a=;\", an attacker can make an HTTP server allocate a large amount of structs, causing large memory consumption.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.24.8" + }, + { + "introduced": "1.25.0" + }, + { + "fixed": "1.25.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "Client.Do", + "Client.Get", + "Client.Head", + "Client.Post", + "Client.PostForm", + "Get", + "Head", + "ParseCookie", + "Post", + "PostForm", + "Request.Cookie", + "Request.Cookies", + "Request.CookiesNamed", + "Response.Cookies", + "readCookies", + "readSetCookies" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/75672" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/709855" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/4Emdl2iQ_bI" + } + ], + "credits": [ + { + "name": "jub0bs" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-4012", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-4013", + "modified": "2025-10-29T21:50:08Z", + "published": "2025-10-29T21:50:08Z", + "aliases": [ + "CVE-2025-58188", + "CVE-2025-58188" + ], + "summary": "Panic when validating certificates with DSA public keys in crypto/x509", + "details": "Validating certificate chains which contain DSA public keys can cause programs to panic, due to a interface cast that assumes they implement the Equal method.\n\nThis affects programs which validate arbitrary certificate chains.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.24.8" + }, + { + "introduced": "1.25.0" + }, + { + "fixed": "1.25.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/x509", + "symbols": [ + "Certificate.Verify", + "alreadyInChain" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/709853" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/75675" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/4Emdl2iQ_bI" + } + ], + "credits": [ + { + "name": "Jakub Ciolek" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-4013", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-4014", + "modified": "2025-10-29T21:51:04Z", + "published": "2025-10-29T21:51:04Z", + "aliases": [ + "CVE-2025-58183", + "CVE-2025-58183" + ], + "summary": "Unbounded allocation when parsing GNU sparse map in archive/tar", + "details": "tar.Reader does not set a maximum size on the number of sparse region data blocks in GNU tar pax 1.0 sparse files. A maliciously-crafted archive containing a large number of sparse regions can cause a Reader to read an unbounded amount of data from the archive into memory. When reading from a compressed source, a small compressed input can result in large allocations.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.24.8" + }, + { + "introduced": "1.25.0" + }, + { + "fixed": "1.25.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "archive/tar", + "symbols": [ + "Reader.Next", + "readGNUSparseMap1x0" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/709861" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/75677" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/4Emdl2iQ_bI" + } + ], + "credits": [ + { + "name": "Harshit Gupta (Mr HAX)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-4014", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-4015", + "modified": "2025-10-29T21:51:07Z", + "published": "2025-10-29T21:51:07Z", + "aliases": [ + "CVE-2025-61724", + "CVE-2025-61724" + ], + "summary": "Excessive CPU consumption in Reader.ReadResponse in net/textproto", + "details": "The Reader.ReadResponse function constructs a response string through repeated string concatenation of lines. When the number of lines in a response is large, this can cause excessive CPU consumption.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.24.8" + }, + { + "introduced": "1.25.0" + }, + { + "fixed": "1.25.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/textproto", + "symbols": [ + "Reader.ReadResponse" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/709859" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/75716" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/4Emdl2iQ_bI" + } + ], + "credits": [ + { + "name": "Jakub Ciolek" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-4015", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-4155", + "modified": "2025-12-03T17:43:24Z", + "published": "2025-12-02T18:30:24Z", + "aliases": [ + "CVE-2025-61729", + "CVE-2025-61729" + ], + "summary": "Excessive resource consumption when printing error string for host certificate validation in crypto/x509", + "details": "Within HostnameError.Error(), when constructing an error string, there is no limit to the number of hosts that will be printed out. Furthermore, the error string is constructed by repeated string concatenation, leading to quadratic runtime. Therefore, a certificate provided by a malicious actor can result in excessive resource consumption.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.24.11" + }, + { + "introduced": "1.25.0" + }, + { + "fixed": "1.25.5" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/x509", + "symbols": [ + "Certificate.Verify", + "Certificate.VerifyHostname" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/725920" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/76445" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/8FJoBkPddm4" + } + ], + "credits": [ + { + "name": "Philippe Antoine (Catena cyber)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-4155", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-4175", + "modified": "2025-12-02T20:55:55Z", + "published": "2025-12-02T20:55:55Z", + "aliases": [ + "CVE-2025-61727" + ], + "summary": "Improper application of excluded DNS name constraints when verifying wildcard names in crypto/x509", + "details": "An excluded subdomain constraint in a certificate chain does not restrict the usage of wildcard SANs in the leaf certificate. For example a constraint that excludes the subdomain test.example.com does not prevent a leaf certificate from claiming the SAN *.example.com.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.24.11" + }, + { + "introduced": "1.25.0" + }, + { + "fixed": "1.25.5" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/x509", + "symbols": [ + "Certificate.Verify" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/723900" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/76442" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/8FJoBkPddm4" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-4175", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4337", + "modified": "2026-02-05T17:23:09Z", + "published": "2026-02-05T17:23:09Z", + "aliases": [ + "CVE-2025-68121" + ], + "summary": "Unexpected session resumption in crypto/tls", + "details": "During session resumption in crypto/tls, if the underlying Config has its ClientCAs or RootCAs fields mutated between the initial handshake and the resumed handshake, the resumed handshake may succeed when it should have failed. This may happen when a user calls Config.Clone and mutates the returned Config, or uses Config.GetConfigForClient. This can cause a client to resume a session with a server that it would not have resumed with during the initial handshake, or cause a server to resume a session with a client that it would not have resumed with during the initial handshake.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.24.13" + }, + { + "introduced": "1.25.0-0" + }, + { + "fixed": "1.25.7" + }, + { + "introduced": "1.26.0-rc.1" + }, + { + "fixed": "1.26.0-rc.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/tls", + "symbols": [ + "Conn.Handshake", + "Conn.HandshakeContext", + "Conn.Read", + "Conn.Write", + "Conn.handshakeContext", + "Dial", + "DialWithDialer", + "Dialer.Dial", + "Dialer.DialContext", + "QUICConn.Start" + ] + } + ] + } + } + ], + "references": [ + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/K09ubi9FQFk" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/737700" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/77217" + } + ], + "credits": [ + { + "name": "Coia Prant (github.com/rbqvq)" + }, + { + "name": "Go Security Team" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4337", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4340", + "modified": "2026-01-28T19:08:09Z", + "published": "2026-01-28T19:08:09Z", + "aliases": [ + "CVE-2025-61730", + "CVE-2025-61730" + ], + "summary": "Handshake messages may be processed at the incorrect encryption level in crypto/tls", + "details": "During the TLS 1.3 handshake if multiple messages are sent in records that span encryption level boundaries (for instance the Client Hello and Encrypted Extensions messages), the subsequent messages may be processed before the encryption level changes. This can cause some minor information disclosure if a network-local attacker can inject messages during the handshake.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.24.12" + }, + { + "introduced": "1.25.0" + }, + { + "fixed": "1.25.6" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/tls", + "symbols": [ + "Conn.Handshake", + "Conn.HandshakeContext", + "Conn.Read", + "Conn.Write", + "Conn.handleKeyUpdate", + "Conn.handshakeContext", + "Conn.quicSetReadSecret", + "Dial", + "DialWithDialer", + "Dialer.Dial", + "Dialer.DialContext", + "QUICConn.HandleData", + "QUICConn.Start", + "clientHandshakeStateTLS13.establishHandshakeKeys", + "clientHandshakeStateTLS13.readServerFinished", + "clientHandshakeStateTLS13.sendClientFinished", + "serverHandshakeStateTLS13.checkForResumption", + "serverHandshakeStateTLS13.doHelloRetryRequest", + "serverHandshakeStateTLS13.readClientFinished", + "serverHandshakeStateTLS13.sendServerFinished", + "serverHandshakeStateTLS13.sendServerParameters" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/724120" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/76443" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/Vd2tYVM8eUc" + } + ], + "credits": [ + { + "name": "Coia Prant (github.com/rbqvq)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4340", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4341", + "modified": "2026-01-28T19:08:18Z", + "published": "2026-01-28T19:08:18Z", + "aliases": [ + "CVE-2025-61726", + "CVE-2025-61726" + ], + "summary": "Memory exhaustion in query parameter parsing in net/url", + "details": "The net/url package does not set a limit on the number of query parameters in a query.\n\nWhile the maximum size of query parameters in URLs is generally limited by the maximum request header size, the net/http.Request.ParseForm method can parse large URL-encoded forms. Parsing a large form containing many unique query parameters can cause excessive memory consumption.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.24.12" + }, + { + "introduced": "1.25.0" + }, + { + "fixed": "1.25.6" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/url", + "symbols": [ + "ParseQuery", + "URL.Query", + "parseQuery" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/736712" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/77101" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/Vd2tYVM8eUc" + } + ], + "credits": [ + { + "name": "jub0bs" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4341", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4342", + "modified": "2026-01-28T19:08:28Z", + "published": "2026-01-28T19:08:28Z", + "aliases": [ + "CVE-2025-61728", + "CVE-2025-61728" + ], + "summary": "Excessive CPU consumption when building archive index in archive/zip", + "details": "archive/zip uses a super-linear file name indexing algorithm that is invoked the first time a file in an archive is opened. This can lead to a denial of service when consuming a maliciously constructed ZIP archive.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.24.12" + }, + { + "introduced": "1.25.0" + }, + { + "fixed": "1.25.6" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "archive/zip", + "symbols": [ + "Reader.Open", + "Reader.initFileList" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/736713" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/77102" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/Vd2tYVM8eUc" + } + ], + "credits": [ + { + "name": "Jakub Ciolek" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4342", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4403", + "modified": "2026-02-04T22:42:26Z", + "published": "2026-02-04T22:42:26Z", + "aliases": [ + "CVE-2025-22873" + ], + "summary": "Improper access to parent directory of root in os", + "details": "It was possible to improperly access the parent directory of an os.Root by opening a filename ending in \"../\". For example, Root.Open(\"../\") would open the parent directory of the Root. This escape only permits opening the parent directory itself, not ancestors of the parent or files contained within the parent.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.23.9" + }, + { + "introduced": "1.24.0-0" + }, + { + "fixed": "1.24.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "os", + "symbols": [ + "checkPathEscapesInternal", + "doInRoot", + "splitPathInRoot" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/670036" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/73555" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/UZoIkUT367A/m/5WDxKizJAQAJ" + } + ], + "credits": [ + { + "name": "Dan Sebastian Thrane of SDU eScience Center" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4403", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4599", + "modified": "2026-03-07T17:04:32Z", + "published": "2026-03-06T21:03:42Z", + "aliases": [ + "CVE-2026-27137" + ], + "summary": "Incorrect enforcement of email constraints in crypto/x509", + "details": "When verifying a certificate chain which contains a certificate containing multiple email address constraints which share common local portions but different domain portions, these constraints will not be properly applied, and only the last constraint will be considered.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/x509", + "symbols": [ + "Certificate.Verify", + "checkChainConstraints", + "checkConstraints", + "emailConstraints.query", + "newEmailConstraints", + "parseMailboxes" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/752182" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/77952" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/EdhZqrQ98hk" + } + ], + "credits": [ + { + "name": "Jakub Ciolek" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4599", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4600", + "modified": "2026-03-07T17:04:32Z", + "published": "2026-03-06T21:03:42Z", + "aliases": [ + "CVE-2026-27138" + ], + "summary": "Panic in name constraint checking for malformed certificates in crypto/x509", + "details": "Certificate verification can panic when a certificate in the chain has an empty DNS name and another certificate in the chain has excluded name constraints. This can crash programs that are either directly verifying X.509 certificate chains, or those that use TLS.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/x509", + "symbols": [ + "Certificate.Verify", + "dnsConstraints.query" + ] + } + ] + } + } + ], + "references": [ + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/EdhZqrQ98hk" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/77953" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/752183" + } + ], + "credits": [ + { + "name": "Jakub Ciolek" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4600", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4601", + "modified": "2026-03-06T21:03:42Z", + "published": "2026-03-06T21:03:42Z", + "aliases": [ + "CVE-2026-25679" + ], + "summary": "Incorrect parsing of IPv6 host literals in net/url", + "details": "url.Parse insufficiently validated the host/authority component and accepted some invalid URLs.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.8" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/url", + "symbols": [ + "JoinPath", + "Parse", + "ParseRequestURI", + "URL.Parse", + "URL.UnmarshalBinary", + "parseHost" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/752180" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/77578" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/EdhZqrQ98hk" + } + ], + "credits": [ + { + "name": "Masaki Hara (https://github.com/qnighy) of Wantedly" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4601", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4602", + "modified": "2026-03-06T21:03:42Z", + "published": "2026-03-06T21:03:42Z", + "aliases": [ + "CVE-2026-27139" + ], + "summary": "FileInfo can escape from a Root in os", + "details": "On Unix platforms, when listing the contents of a directory using File.ReadDir or File.Readdir the returned FileInfo could reference a file outside of the Root in which the File was opened.\n\nThe impact of this escape is limited to reading metadata provided by lstat from arbitrary locations on the filesystem without permitting reading or writing files outside the root.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.8" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "os", + "symbols": [ + "File.ReadDir", + "File.Readdir", + "ReadDir", + "dirFS.ReadDir", + "rootFS.ReadDir" + ] + } + ] + } + } + ], + "references": [ + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/EdhZqrQ98hk" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/77827" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/749480" + } + ], + "credits": [ + { + "name": "Miloslav Trmač of Red Hat" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4602", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4603", + "modified": "2026-03-06T21:03:42Z", + "published": "2026-03-06T21:03:42Z", + "aliases": [ + "CVE-2026-27142" + ], + "summary": "URLs in meta content attribute actions are not escaped in html/template", + "details": "Actions which insert URLs into the content attribute of HTML meta tags are not escaped. This can allow XSS if the meta tag also has an http-equiv attribute with the value \"refresh\".\n\nA new GODEBUG setting has been added, htmlmetacontenturlescape, which can be used to disable escaping URLs in actions in the meta content attribute which follow \"url=\" by setting htmlmetacontenturlescape=0.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.8" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "html/template", + "symbols": [ + "Template.Execute", + "Template.ExecuteTemplate", + "escaper.escapeAction", + "tTag" + ] + } + ] + } + } + ], + "references": [ + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/EdhZqrQ98hk" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/77954" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/752081" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4603", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4864", + "modified": "2026-04-07T22:53:49Z", + "published": "2026-04-07T22:53:49Z", + "aliases": [ + "CVE-2026-32282" + ], + "summary": "TOCTOU permits root escape on Linux via Root.Chmod in os in internal/syscall/unix", + "details": "On Linux, if the target of Root.Chmod is replaced with a symlink while the chmod operation is in progress, Chmod can operate on the target of the symlink, even when the target lies outside the root.\n\nThe Linux fchmodat syscall silently ignores the AT_SYMLINK_NOFOLLOW flag, which Root.Chmod uses to avoid symlink traversal. Root.Chmod checks its target before acting and returns an error if the target is a symlink lying outside the root, so the impact is limited to cases where the target is replaced with a symlink between the check and operation.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.9" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "internal/syscall/unix", + "goos": [ + "linux" + ], + "symbols": [ + "Fchmodat" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/763761" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/78293" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/0uYbvbPZRWU" + } + ], + "credits": [ + { + "name": "Uuganbayar Lkhamsuren (https://github.com/uug4na)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4864", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4865", + "modified": "2026-04-07T22:53:49Z", + "published": "2026-04-07T22:53:49Z", + "aliases": [ + "CVE-2026-32289" + ], + "summary": "JsBraceDepth Context Tracking Bugs (XSS) in html/template", + "details": "Context was not properly tracked across template branches for JS template literals, leading to possibly incorrect escaping of content when branches were used. Additionally template actions within JS template literals did not properly track the brace depth, leading to incorrect escaping being applied.\n\nThese issues could cause actions within JS template literals to be incorrectly or improperly escaped, leading to XSS vulnerabilities.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.9" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "html/template", + "symbols": [ + "Error.Error", + "HTMLEscaper", + "JSEscape", + "JSEscapeString", + "JSEscaper", + "ParseFS", + "ParseFiles", + "ParseGlob", + "Template.AddParseTree", + "Template.Clone", + "Template.DefinedTemplates", + "Template.Execute", + "Template.ExecuteTemplate", + "Template.Funcs", + "Template.Parse", + "Template.ParseFS", + "Template.ParseFiles", + "Template.ParseGlob", + "URLQueryEscaper", + "context.String", + "context.mangle", + "escaper.escapeBranch" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/763762" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/78331" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/0uYbvbPZRWU" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4865", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4866", + "modified": "2026-04-07T22:53:49Z", + "published": "2026-04-07T22:53:49Z", + "aliases": [ + "CVE-2026-33810" + ], + "summary": "Case-sensitive excludedSubtrees name constraints cause Auth Bypass in crypto/x509", + "details": "When verifying a certificate chain containing excluded DNS constraints, these constraints are not correctly applied to wildcard DNS SANs which use a different case than the constraint.\n\nThis only affects validation of otherwise trusted certificate chains, issued by a root CA in the VerifyOptions.Roots CertPool, or in the system certificate pool.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/x509", + "symbols": [ + "Certificate.Verify", + "dnsConstraints.query", + "newDNSConstraints" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/763763" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/78332" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/0uYbvbPZRWU" + } + ], + "credits": [ + { + "name": "Riyas from Saintgits College of Engineering" + }, + { + "name": "k1rnt" + }, + { + "name": "@1seal" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4866", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4869", + "modified": "2026-04-07T22:53:49Z", + "published": "2026-04-07T22:53:49Z", + "aliases": [ + "CVE-2026-32288" + ], + "summary": "Unbounded allocation for old GNU sparse in archive/tar", + "details": "tar.Reader can allocate an unbounded amount of memory when reading a maliciously-crafted archive containing a large number of sparse regions encoded in the \"old GNU sparse map\" format.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.9" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "archive/tar", + "symbols": [ + "Reader.Next", + "Reader.readOldGNUSparseMap", + "readGNUSparseMap1x0" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/763766" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/78301" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/0uYbvbPZRWU" + } + ], + "credits": [ + { + "name": "Colin Walters (walters@verbum.org)" + }, + { + "name": "Uuganbayar Lkhamsuren (https://github.com/uug4na)" + }, + { + "name": "Jakub Ciolek" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4869", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4870", + "modified": "2026-04-07T22:53:49Z", + "published": "2026-04-07T22:53:49Z", + "aliases": [ + "CVE-2026-32283" + ], + "summary": "Unauthenticated TLS 1.3 KeyUpdate record can cause persistent connection retention and DoS in crypto/tls", + "details": "If one side of the TLS connection sends multiple key update messages post-handshake in a single record, the connection can deadlock, causing uncontrolled consumption of resources. This can lead to a denial of service.\n\nThis only affects TLS 1.3.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.9" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/tls", + "symbols": [ + "Conn.Handshake", + "Conn.HandshakeContext", + "Conn.Read", + "Conn.Write", + "Conn.handleKeyUpdate", + "Dial", + "DialWithDialer", + "Dialer.Dial", + "Dialer.DialContext", + "QUICConn.HandleData", + "QUICConn.Start", + "clientHandshakeStateTLS13.establishHandshakeKeys", + "clientHandshakeStateTLS13.readServerFinished", + "serverHandshakeStateTLS13.readClientFinished", + "serverHandshakeStateTLS13.sendServerParameters" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/763767" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/78334" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/0uYbvbPZRWU" + } + ], + "credits": [ + { + "name": "Jakub Ciolek - https://ciolek.dev/" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4870", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4918", + "modified": "2026-05-07T19:21:40Z", + "published": "2026-05-07T19:21:40Z", + "aliases": [ + "CVE-2026-33814" + ], + "summary": "Infinite loop in HTTP/2 transport when given bad SETTINGS_MAX_FRAME_SIZE in net/http/internal/http2 in golang.org/x/net", + "details": "When processing HTTP/2 SETTINGS frames, transport will enter an infinite loop of writing CONTINUATION frames if it receives a SETTINGS_MAX_FRAME_SIZE with a value of 0.", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.53.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http2", + "symbols": [ + "Transport.NewClientConn", + "Transport.RoundTrip", + "Transport.RoundTripOpt", + "clientConnPool.GetClientConn", + "clientConnReadLoop.processSettingsNoWrite", + "noDialClientConnPool.GetClientConn", + "noDialH2RoundTripper.NewClientConn", + "noDialH2RoundTripper.RoundTrip", + "unencryptedTransport.RoundTrip" + ] + } + ] + } + }, + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.10" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "Client.CloseIdleConnections", + "Client.Do", + "Client.Get", + "Client.Head", + "Client.Post", + "Client.PostForm", + "ClientConn.Close", + "ClientConn.RoundTrip", + "Get", + "Head", + "Post", + "PostForm", + "Transport.CloseIdleConnections", + "Transport.NewClientConn", + "Transport.RoundTrip", + "http1ClientConn.Close", + "http1ClientConn.RoundTrip", + "http2Transport.NewClientConn", + "http2Transport.RoundTrip", + "http2Transport.RoundTripOpt", + "http2clientConnPool.GetClientConn", + "http2clientConnReadLoop.processSettingsNoWrite", + "http2noDialClientConnPool.GetClientConn", + "http2noDialH2RoundTripper.NewClientConn", + "http2noDialH2RoundTripper.RoundTrip", + "http2unencryptedTransport.RoundTrip" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/761581" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/761640" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/78476" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/qcCIEXso47M" + } + ], + "credits": [ + { + "name": "Marwan Atia (marwansamir688@gmail.com)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4918", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4946", + "modified": "2026-04-07T22:53:49Z", + "published": "2026-04-07T22:53:49Z", + "aliases": [ + "CVE-2026-32281" + ], + "summary": "Inefficient policy validation in crypto/x509", + "details": "Validating certificate chains which use policies is unexpectedly inefficient when certificates in the chain contain a very large number of policy mappings, possibly causing denial of service.\n\nThis only affects validation of otherwise trusted certificate chains, issued by a root CA in the VerifyOptions.Roots CertPool, or in the system certificate pool.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.9" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/x509", + "symbols": [ + "Certificate.Verify", + "policiesValid" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/758061" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/78281" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/0uYbvbPZRWU" + } + ], + "credits": [ + { + "name": "Jakub Ciolek - https://ciolek.dev" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4946", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4947", + "modified": "2026-04-07T22:53:49Z", + "published": "2026-04-07T22:53:49Z", + "aliases": [ + "CVE-2026-32280" + ], + "summary": "Unexpected work during chain building in crypto/x509", + "details": "During chain building, the amount of work that is done is not correctly limited when a large number of intermediate certificates are passed in VerifyOptions.Intermediates, which can lead to a denial of service. This affects both direct users of crypto/x509 and users of crypto/tls.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.9" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/x509", + "symbols": [ + "Certificate.Verify", + "Certificate.buildChains" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/758320" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/78282" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/0uYbvbPZRWU" + } + ], + "credits": [ + { + "name": "Jakub Ciolek - https://ciolek.dev" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4947", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4970", + "modified": "2026-07-07T21:34:47Z", + "published": "2026-07-07T21:34:47Z", + "aliases": [ + "CVE-2026-39822" + ], + "summary": "Root escape via symlink plus trailing slash in os", + "details": "On Unix systems, opening a file in an os.Root improperly follows symlinks to locations outside of the Root when the final path component of the a path is a symbolic link and the path ends in /.\n\nFor example, 'root.Open(\"symlink/\")' will open \"symlink\" even when \"symlink\" is a symbolic link pointing outside of the root.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.12" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.5" + }, + { + "introduced": "1.27.0-0" + }, + { + "fixed": "1.27.0-rc.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "os", + "symbols": [ + "OpenInRoot", + "Root.Create", + "Root.Open", + "Root.OpenFile", + "Root.OpenRoot", + "Root.ReadFile", + "Root.WriteFile", + "openRootInRoot", + "rootFS.Open", + "rootFS.ReadDir", + "rootFS.ReadFile", + "rootOpenFileNolog" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/79005" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/OrmQE_Yp5Sc" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/797880" + } + ], + "credits": [ + { + "name": "Mundur (https://github.com/M0nd0R)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4970", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4971", + "modified": "2026-05-07T19:21:40Z", + "published": "2026-05-07T19:21:40Z", + "aliases": [ + "CVE-2026-39836" + ], + "summary": "Panic in Dial and LookupPort when handling NUL byte on Windows in net", + "details": "The Dial and LookupPort functions panic on Windows when provided with an input containing a NUL (0).", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.10" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net", + "symbols": [ + "Dial", + "DialTimeout", + "Dialer.Dial", + "Dialer.DialContext", + "Listen", + "ListenConfig.Listen", + "ListenConfig.ListenPacket", + "ListenPacket", + "LookupAddr", + "LookupCNAME", + "LookupHost", + "LookupIP", + "LookupMX", + "LookupNS", + "LookupPort", + "LookupSRV", + "LookupTXT", + "ResolveIPAddr", + "ResolveTCPAddr", + "ResolveUDPAddr", + "Resolver.LookupAddr", + "Resolver.LookupCNAME", + "Resolver.LookupHost", + "Resolver.LookupIP", + "Resolver.LookupIPAddr", + "Resolver.LookupMX", + "Resolver.LookupNS", + "Resolver.LookupNetIP", + "Resolver.LookupPort", + "Resolver.LookupSRV", + "Resolver.LookupTXT", + "Resolver.lookupAddr", + "Resolver.lookupMX", + "Resolver.lookupNS", + "Resolver.lookupPort", + "Resolver.lookupSRV", + "Resolver.lookupTXT" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/79006" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/qcCIEXso47M" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/775320" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4971", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4976", + "modified": "2026-05-07T19:21:40Z", + "published": "2026-05-07T19:21:40Z", + "aliases": [ + "CVE-2026-39825" + ], + "summary": "ReverseProxy forwards queries with more than urlmaxqueryparams parameters in net/http/httputil", + "details": "ReverseProxy can forward queries containing parameters not visible to Rewrite functions.\n\nWhen used with a Rewrite function, or a Director function which parses query parameters, ReverseProxy sanitizes the forwarded request to remove query parameters which are not parsed by url.ParseQuery. ReverseProxy does not take ParseQuery's limit on the total number of query parameters (controlled by GODEBUG=urlmaxqueryparams=N) into account. This can permit ReverseProxy to forward a request containing a query parameter that is not visible to the Rewrite function.\n\nFor example, the query \"a1=x\u0026a2=x\u0026...\u0026a10000=x\u0026hidden=y\" can forward the parameter \"hidden=y\" while hiding it from the proxy's Rewrite function.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.10" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http/httputil", + "symbols": [ + "ReverseProxy.ServeHTTP", + "cleanQueryParams" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/770541" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/78948" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/qcCIEXso47M" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4976", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4977", + "modified": "2026-05-07T19:21:40Z", + "published": "2026-05-07T19:21:40Z", + "aliases": [ + "CVE-2026-42499" + ], + "summary": "Quadratic string concatenation in consumePhrase in net/mail", + "details": "Pathological inputs could cause DoS through consumePhrase when parsing an email address according to RFC 5322.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.10" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/mail", + "symbols": [ + "AddressParser.Parse", + "AddressParser.ParseList", + "Header.AddressList", + "ParseAddress", + "ParseAddressList", + "addrParser.consumePhrase" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/78987" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/771520" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/qcCIEXso47M" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4977", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4980", + "modified": "2026-05-07T19:21:40Z", + "published": "2026-05-07T19:21:40Z", + "aliases": [ + "CVE-2026-39826" + ], + "summary": "Escaper bypass leads to XSS in html/template", + "details": "If a trusted template author were to write a \u003cscript\u003e tag containing an empty 'type' attribute or a 'type' attribute with an ASCII whitespace, the execution of the template would incorrectly escape any data passed into the \u003cscript\u003e block.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.10" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "html/template", + "symbols": [ + "Template.Execute", + "Template.ExecuteTemplate", + "isJSType" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/78981" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/771180" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/qcCIEXso47M" + } + ], + "credits": [ + { + "name": "Mundur (https://github.com/M0nd0R)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4980", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4981", + "modified": "2026-05-07T19:21:40Z", + "published": "2026-05-07T19:21:40Z", + "aliases": [ + "CVE-2026-33811" + ], + "summary": "Crash when handling long CNAME response in net", + "details": "When using LookupCNAME with the cgo DNS resolver, a very long CNAME response can trigger a double-free of C memory and a crash.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.10" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net", + "symbols": [ + "LookupCNAME", + "Resolver.LookupCNAME", + "cgoResSearch" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/78803" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/767860" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/qcCIEXso47M" + } + ], + "credits": [ + { + "name": "hamayanhamayan" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4981", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4982", + "modified": "2026-05-07T19:21:40Z", + "published": "2026-05-07T19:21:40Z", + "aliases": [ + "CVE-2026-39823" + ], + "summary": "Bypass of meta content URL escaping causes XSS in html/template", + "details": "CVE-2026-27142 fixed a vulnerability in which URLs were not correctly escaped inside of a \u003cmeta\u003e tag's \u003ccontent\u003e attribute. If the URL content were to insert ASCII whitespaces around the '=' rune inside of the \u003ccontent\u003e attribute, the escaper would fail to similarly escape it, leading to XSS.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.10" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "html/template", + "symbols": [ + "Template.Execute", + "Template.ExecuteTemplate", + "tMetaContent" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/78913" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/769920" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/qcCIEXso47M" + } + ], + "credits": [ + { + "name": "Samy Ghannad" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4982", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4986", + "modified": "2026-05-07T19:21:40Z", + "published": "2026-05-07T19:21:40Z", + "aliases": [ + "CVE-2026-39820" + ], + "summary": "Quadratic string concatentation in consumeComment in net/mail", + "details": "Well-crafted inputs reaching ParseAddress, ParseAddressList, and ParseDate were able to trigger excessive CPU exhaustion and memory allocations.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.10" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/mail", + "symbols": [ + "AddressParser.Parse", + "AddressParser.ParseList", + "Header.AddressList", + "Header.Date", + "ParseAddress", + "ParseAddressList", + "ParseDate", + "addrParser.consumeComment" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/78566" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/759940" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/qcCIEXso47M" + } + ], + "credits": [ + { + "name": "thatnealpatel" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4986", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5026", + "modified": "2026-08-21T20:38:00Z", + "published": "2026-05-22T02:46:43Z", + "aliases": [ + "CVE-2026-39821" + ], + "summary": "Invoking failure to reject ASCII-only Punycode-encoded labels in golang.org/x/net/idna", + "details": "The ToASCII and ToUnicode functions incorrectly accept Punycode-encoded labels that decode to an ASCII-only label. For example, ToUnicode(\"xn--example-.com\") incorrectly returns the name \"example.com\" rather than an error.\n\nThis behavior can lead to privilege escalation in programs using the idna package. For example, a program which performs privilege checks on the ASCII hostname may reject \"example.com\" but permit \"xn--example-.com\". If that program subsequently converts the ASCII hostname to Unicode, it will inadvertently permits access to the Unicode name \"example.com\".", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.13" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.6" + }, + { + "introduced": "1.27.0-0" + }, + { + "fixed": "1.27.0-rc.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "Client.CloseIdleConnections", + "Client.Do", + "Client.Get", + "Client.Head", + "Client.Post", + "Client.PostForm", + "ClientConn.Close", + "ClientConn.RoundTrip", + "Get", + "Head", + "Post", + "PostForm", + "Transport.CloseIdleConnections", + "Transport.NewClientConn", + "Transport.RoundTrip", + "http1ClientConn.Close", + "http1ClientConn.RoundTrip", + "http2ClientConn.RoundTrip", + "http2RoundTripper.RoundTrip", + "idnaASCII" + ] + }, + { + "path": "net/http/internal/http2", + "symbols": [ + "Transport.AddConn", + "Transport.RoundTrip", + "Transport.RoundTripOpt", + "authorityAddr" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.55.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/idna", + "symbols": [ + "Profile.ToASCII", + "Profile.ToUnicode", + "Profile.process", + "ToASCII", + "ToUnicode" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/767220" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/78760" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/iI-mYSI0lu8" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/94pEornpRlI" + } + ], + "credits": [ + { + "name": "KC1zs4 (https://github.com/KC1zs4)" + }, + { + "name": "Longwei Wang (https://github.com/gn00295120)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5026", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5037", + "modified": "2026-06-02T21:39:47Z", + "published": "2026-06-02T21:39:47Z", + "aliases": [ + "CVE-2026-27145" + ], + "summary": "Inefficient candidate hostname parsing in crypto/x509", + "details": "(*x509.Certificate).VerifyHostname previously called matchHostnames in a loop over all DNS Subject Alternative Name (SAN) entries. This caused strings.Split(host, \".\") to execute repeatedly on the same input hostname.\n\nWith a large DNS SAN list, verification costs scaled quadratically based on the number of SAN entries multiplied by the hostname's label count. Because x509.Verify validates hostnames before building the certificate chain, this overhead occurred even for untrusted certificates.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.11" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/x509", + "symbols": [ + "Certificate.Verify", + "Certificate.VerifyHostname", + "HostnameError.Error", + "matchHostnames" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/783621" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/79694" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/tKs3rmcBcKw" + } + ], + "credits": [ + { + "name": "Jakub Ciolek - https://ciolek.dev/" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5037", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5038", + "modified": "2026-06-02T21:39:47Z", + "published": "2026-06-02T21:39:47Z", + "aliases": [ + "CVE-2026-42504" + ], + "summary": "Quadratic complexity in WordDecoder.DecodeHeader in mime", + "details": "Decoding a maliciously-crafted MIME header containing many invalid encoded-words can consume excessive CPU.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.11" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "mime", + "symbols": [ + "WordDecoder.DecodeHeader" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/79217" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/774481" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/tKs3rmcBcKw" + } + ], + "credits": [ + { + "name": "p4p3r (https://hackerone.com/p4p3r_hak) " + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5038", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5039", + "modified": "2026-06-02T21:39:47Z", + "published": "2026-06-02T21:39:47Z", + "aliases": [ + "CVE-2026-42507" + ], + "summary": "Arbitrary inputs are included in errors without any escaping in net/textproto", + "details": "When returning errors, functions in the net/textproto package would include its input as part of the error. This might allow an attacker to inject misleading content to errors that are printed or logged.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.11" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/textproto", + "symbols": [ + "Error.Error", + "Reader.ReadCodeLine", + "Reader.ReadMIMEHeader", + "Reader.ReadResponse", + "parseCodeLine", + "readMIMEHeader" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/79346" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/777060" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/tKs3rmcBcKw" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5039", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5856", + "modified": "2026-07-07T21:34:47Z", + "published": "2026-07-07T21:34:47Z", + "aliases": [ + "CVE-2026-42505" + ], + "summary": "Invoking Encrypted Client Hello privacy leak in crypto/tls", + "details": "Handshakes which used Encrypted Client Hello could be de-anonymized by a passive network observer due to a disclosure of pre-shared key identities in the unencrypted client hello.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.12" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.5" + }, + { + "introduced": "1.27.0-0" + }, + { + "fixed": "1.27.0-rc.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/tls", + "symbols": [ + "Conn.Handshake", + "Conn.HandshakeContext", + "Conn.Read", + "Conn.Write", + "Dial", + "DialWithDialer", + "Dialer.Dial", + "Dialer.DialContext", + "QUICConn.HandleData", + "QUICConn.SendSessionTicket", + "QUICConn.Start", + "clientHelloMsg.marshalMsg" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/775960" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/79282" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/OrmQE_Yp5Sc" + } + ], + "credits": [ + { + "name": "Coia Prant (github.com/rbqvq)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5856", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5942", + "modified": "2026-08-14T15:30:22Z", + "published": "2026-07-14T17:29:16Z", + "aliases": [ + "CVE-2026-46600" + ], + "summary": "Parsing an invalid SVCB or HTTPS RR can panic in golang.org/x/net/dns/dnsmessage", + "details": "Parsing an invalid SVCB or HTTPS RR can panic when the size of a parameter value overflows the message buffer.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.6" + }, + { + "introduced": "1.27.0-0" + }, + { + "fixed": "1.27.0-rc.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net", + "symbols": [ + "LookupCNAME", + "Resolver.LookupCNAME", + "cgoResSearch" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.56.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/dns/dnsmessage", + "symbols": [ + "Message.Unpack", + "Parser.Additional", + "Parser.AllAdditionals", + "Parser.AllAnswers", + "Parser.AllAuthorities", + "Parser.Answer", + "Parser.Authority", + "Parser.HTTPSResource", + "Parser.SVCBResource", + "unpackSVCBResource" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/786345" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/79795" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/94pEornpRlI" + } + ], + "credits": [ + { + "name": "Mundur (https://github.com/M0nd0R)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5942", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5972", + "modified": "2026-08-13T21:43:54Z", + "published": "2026-08-13T21:43:54Z", + "aliases": [ + "CVE-2026-33818" + ], + "summary": "Enforce maximum recursion depth in encoding/asn1", + "details": "Enforce a recursion limit in Unmarshal to prevent stack exhaustion when parsing deeply-nested, recursive structures.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.13" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.6" + }, + { + "introduced": "1.27.0-0" + }, + { + "fixed": "1.27.0-rc.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "encoding/asn1", + "symbols": [ + "Unmarshal", + "UnmarshalWithParams", + "parseField", + "parseSequenceOf" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/80405" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/94pEornpRlI" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/814980" + } + ], + "credits": [ + { + "name": "Marwan Atia (marwansamir688@gmail.com) " + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5972", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-6088", + "modified": "2026-08-13T21:43:54Z", + "published": "2026-08-13T21:43:54Z", + "aliases": [ + "CVE-2026-56859" + ], + "summary": "Add recursion depth guard during decode in encoding/xml", + "details": "Previously, DecodeElement would reset the depth counter causing it to never fire; this could lead to stack exhaustion.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.13" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.6" + }, + { + "introduced": "1.27.0-0" + }, + { + "fixed": "1.27.0-rc.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "encoding/xml", + "symbols": [ + "Decoder.Decode", + "Decoder.DecodeElement", + "Decoder.RawToken", + "Decoder.Skip", + "Decoder.Token", + "Decoder.pop", + "Decoder.push", + "Decoder.unmarshal", + "Decoder.unmarshalPath", + "Unmarshal" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/80481" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/803320" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/94pEornpRlI" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-6088", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-6089", + "modified": "2026-08-13T21:43:54Z", + "published": "2026-08-13T21:43:54Z", + "aliases": [ + "CVE-2026-56853" + ], + "summary": "Apply ReadHeaderTimeout when doing unencrypted HTTP/2 check in net/http", + "details": "When a server is configured to support unencrypted HTTP/2, it reads a few bytes from each new connection to see if they contain the HTTP/2 client preface. ReadHeaderTimeout is unexpectedly not being applied when doing this.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.13" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.6" + }, + { + "introduced": "1.27.0-0" + }, + { + "fixed": "1.27.0-rc.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "ListenAndServe", + "ListenAndServeTLS", + "Serve", + "ServeTLS", + "Server.ListenAndServe", + "Server.ListenAndServeTLS", + "Server.Serve", + "Server.ServeTLS", + "conn.readRequest", + "conn.serve" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/80205" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/795540" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/94pEornpRlI" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-6089", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-6090", + "modified": "2026-08-13T21:43:54Z", + "published": "2026-08-13T21:43:54Z", + "aliases": [ + "CVE-2026-56862" + ], + "summary": "Limit handshake messages we are willing to accept post-handshake in crypto/tls", + "details": "Handshake messages, such as KeyUpdate, are always considered as state-advancing, regardless of whether a handshake has been completed or not. As a result, a malicious client can keep sending KeyUpdate messages to force the server to keep performing key derivation operations indefinitely.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.13" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.6" + }, + { + "introduced": "1.27.0-0" + }, + { + "fixed": "1.27.0-rc.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/tls", + "symbols": [ + "Conn.Handshake", + "Conn.HandshakeContext", + "Conn.Read", + "Conn.Write", + "Conn.readRecordOrCCS", + "Dial", + "DialWithDialer", + "Dialer.Dial", + "Dialer.DialContext", + "QUICConn.HandleData", + "QUICConn.Start" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/80528" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/804261" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/94pEornpRlI" + } + ], + "credits": [ + { + "name": "Qi Deng of Aurascape.ai" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-6090", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-6091", + "modified": "2026-08-13T21:43:54Z", + "published": "2026-08-13T21:43:54Z", + "aliases": [ + "CVE-2026-56858" + ], + "summary": "Fix Javascript regexp context tracking in html/template", + "details": "Previously, pathological inputs could close an unescaped '/' early, allowing for attack-controlled data to inject arbitrary content, potentially leading to XSS.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.13" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.6" + }, + { + "introduced": "1.27.0-0" + }, + { + "fixed": "1.27.0-rc.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "html/template", + "symbols": [ + "Template.Execute", + "Template.ExecuteTemplate", + "tJS" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/80435" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/807100" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/94pEornpRlI" + } + ], + "credits": [ + { + "name": "Ali Sherif" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-6091", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-6218", + "modified": "2026-08-13T21:43:54Z", + "published": "2026-08-13T21:43:54Z", + "aliases": [ + "CVE-2026-56860" + ], + "summary": "Avoid quadratic complexity in resolvePath in net/url", + "details": "Previously, resolving relative paths containing parent directory ('..') segments performed string conversions and buffer rewrites on each step, resulting in quadratic time complexity and high memory allocation overhead.\n\nNow, path resolution operates on a byte buffer using index-based backtracking for '..' segments, eliminating the quadratic time complexity and significantly reducing memory allocations.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.13" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.6" + }, + { + "introduced": "1.27.0-0" + }, + { + "fixed": "1.27.0-rc.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/url", + "symbols": [ + "URL.Parse", + "URL.ResolveReference", + "resolvePath" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/803681" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/80494" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/94pEornpRlI" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-6218", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-2153", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-11-01T22:39:27Z", + "aliases": [ + "GHSA-m425-mq94-257g" + ], + "summary": "Denial of service from HTTP/2 Rapid Reset in google.golang.org/grpc", + "details": "An attacker can send HTTP/2 requests, cancel them, and send subsequent requests. This is valid by the HTTP/2 protocol, but would cause the gRPC-Go server to launch more concurrent method handlers than the configured maximum stream limit, grpc.MaxConcurrentStreams. This results in a denial of service due to resource consumption.", + "affected": [ + { + "package": { + "name": "google.golang.org/grpc", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.56.3" + }, + { + "introduced": "1.57.0" + }, + { + "fixed": "1.57.1" + }, + { + "introduced": "1.58.0" + }, + { + "fixed": "1.58.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "google.golang.org/grpc/internal/transport", + "symbols": [ + "NewServerTransport" + ] + }, + { + "path": "google.golang.org/grpc", + "symbols": [ + "NewServer", + "Server.Serve", + "Server.initServerWorkers" + ] + } + ] + } + } + ], + "references": [ + { + "type": "WEB", + "url": "https://github.com/grpc/grpc-go/pull/6703" + }, + { + "type": "FIX", + "url": "https://github.com/grpc/grpc-go/commit/f2180b4d5403d2210b30b93098eb7da31c05c721" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-2153", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-2978", + "modified": "2024-07-09T16:55:06Z", + "published": "2024-07-09T16:55:06Z", + "aliases": [ + "GHSA-xr7q-jx4m-x55m" + ], + "summary": "Private tokens could appear in logs if context containing gRPC metadata is logged in google.golang.org/grpc", + "details": "If applications print or log a context containing gRPC metadata, the output will contain all the metadata, which may include private information. This represents a potential PII concern.", + "affected": [ + { + "package": { + "name": "google.golang.org/grpc", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.64.0" + }, + { + "fixed": "1.64.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "google.golang.org/grpc/metadata", + "symbols": [ + "MD.String" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/grpc/grpc-go/security/advisories/GHSA-xr7q-jx4m-x55m" + }, + { + "type": "FIX", + "url": "https://github.com/grpc/grpc-go/commit/ab292411ddc0f3b7a7786754d1fe05264c3021eb" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-2978", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4762", + "modified": "2026-03-27T18:39:44Z", + "published": "2026-03-27T18:39:44Z", + "aliases": [ + "CVE-2026-33186", + "GHSA-p77j-4mvh-x3m3" + ], + "summary": "Authorization bypass in gRPC-Go via missing leading slash in :path in google.golang.org/grpc", + "details": "Authorization bypass in gRPC-Go via missing leading slash in :path in google.golang.org/grpc", + "affected": [ + { + "package": { + "name": "google.golang.org/grpc", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.79.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "google.golang.org/grpc", + "symbols": [ + "Server.Serve", + "Server.ServeHTTP", + "Server.handleStream" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/grpc/grpc-go/security/advisories/GHSA-p77j-4mvh-x3m3" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4762", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-6061", + "modified": "2026-07-27T15:30:50Z", + "published": "2026-07-27T15:30:50Z", + "aliases": [ + "GHSA-hrxh-6v49-42gf" + ], + "summary": "Vulnerabilities in the xDS RBAC authorization engine and the HTTP/2 transport server implementation in google.golang.org/grpc", + "details": "Vulnerabilities in the xDS RBAC authorization engine and the HTTP/2 transport server implementation in google.golang.org/grpc", + "affected": [ + { + "package": { + "name": "google.golang.org/grpc", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.82.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "google.golang.org/grpc/internal/transport", + "symbols": [ + "ClientStream.Close", + "ClientStream.Header", + "ClientStream.Read", + "ClientStream.RecvCompress", + "ClientStream.TrailersOnly", + "ClientStream.Write", + "NewHTTP2Client", + "NewServerTransport", + "ServerStream.Read", + "ServerStream.SendHeader", + "ServerStream.Write", + "ServerStream.WriteStatus", + "Stream.ReadMessageHeader", + "controlBuffer.executeAndPut", + "controlBuffer.getOnceLocked", + "http2Client.Close", + "http2Client.GracefulClose", + "http2Client.NewStream", + "http2Server.Drain", + "http2Server.HandleStreams", + "recvBufferReader.Read", + "recvBufferReader.ReadMessageHeader", + "transportReader.Read", + "transportReader.ReadMessageHeader" + ] + }, + { + "path": "google.golang.org/grpc/internal/xds/rbac", + "symbols": [ + "ChainEngine.IsAuthorized", + "NewChainEngine", + "matchersFromPermissions", + "matchersFromPrincipals" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/grpc/grpc-go/security/advisories/GHSA-hrxh-6v49-42gf" + }, + { + "type": "FIX", + "url": "https://github.com/grpc/grpc-go/commit/4ea465d4ab98013f72a142fe0fc89c19770b2935" + }, + { + "type": "WEB", + "url": "https://github.com/grpc/grpc-go/pull/9236" + }, + { + "type": "WEB", + "url": "https://github.com/grpc/grpc-go/releases/tag/v1.82.1" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-6061", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-6179", + "modified": "2026-08-13T21:43:54Z", + "published": "2026-08-13T21:43:54Z", + "aliases": [ + "CVE-2026-56865" + ], + "summary": "Fix transparency log tile verification bypass in golang.org/x/mod/sumdb/tlog", + "details": "A malicious GOPROXY was previously capable of forging up to two sumdb tiles that allow for a requested module to bypass the GOSUMDB check and persist attacker-controlled module content to a local Go module cache.\n\nThis attack allows for a malicious GOPROXY to serve malicious module content that cannot be detected by evaluating the transparency log.\n\nAll tiles are now correctly verified against their parents.\n\nIn order to determine if you have been affected:\n\nrm -r go.sum go.work.sum vendor/ \u0026\u0026 go mod tidy", + "affected": [ + { + "package": { + "name": "toolchain", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.13" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.6" + }, + { + "introduced": "1.27.0-0" + }, + { + "fixed": "1.27.0-rc.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "cmd/go" + } + ] + } + }, + { + "package": { + "name": "golang.org/x/mod", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.40.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/mod/sumdb/tlog", + "symbols": [ + "tileHashReader.ReadHashes" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/80744" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/94pEornpRlI" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/814960" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/815020" + } + ], + "credits": [ + { + "name": "Filippo Valsorda (Geomys)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-6179", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-6180", + "modified": "2026-08-13T21:43:54Z", + "published": "2026-08-13T21:43:54Z", + "aliases": [ + "CVE-2026-56864" + ], + "summary": "Ignore unrelated, unauthenticated hashes in Lookup in golang.org/x/mod/sumdb", + "details": "A malicious GOSUMDB was capable of serving arbitrary module content not contained within the transparency log.\n\nThis attack allows for a coordinating GOPROXY and GOSUMDB to serve a client malicious module content that cannot be detected by evaluating the transparency log.\n\nIn order to determine if you have been affected:\n\nrm -r go.sum go.work.sum vendor/ \u0026\u0026 go mod tidy", + "affected": [ + { + "package": { + "name": "toolchain", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.13" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.6" + }, + { + "introduced": "1.27.0-0" + }, + { + "fixed": "1.27.0-rc.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "cmd/go" + } + ] + } + }, + { + "package": { + "name": "golang.org/x/mod", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.40.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/mod/sumdb", + "symbols": [ + "Client.Lookup" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/80745" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/94pEornpRlI" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/815000" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/815020" + } + ], + "credits": [ + { + "name": "mundur" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-6180", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0054", + "modified": "2024-05-20T16:03:47Z", + "published": "2021-04-14T20:04:52Z", + "aliases": [ + "CVE-2020-36067", + "GHSA-p64j-r5f4-pwwx" + ], + "summary": "Panic due to improper input validation in ForEach in github.com/tidwall/gjson", + "details": "Due to improper bounds checking, maliciously crafted JSON objects can cause an out-of-bounds panic. If parsing user input, this may be used as a denial of service vector.", + "affected": [ + { + "package": { + "name": "github.com/tidwall/gjson", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.6.6" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/tidwall/gjson", + "symbols": [ + "Get", + "GetBytes", + "GetMany", + "GetManyBytes", + "Result.ForEach", + "Result.Get", + "unwrap" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://github.com/tidwall/gjson/commit/bf4efcb3c18d1825b2988603dea5909140a5302b" + }, + { + "type": "WEB", + "url": "https://github.com/tidwall/gjson/issues/196" + } + ], + "credits": [ + { + "name": "@toptotu" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0054", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0059", + "modified": "2024-05-20T16:03:47Z", + "published": "2021-04-14T20:04:52Z", + "aliases": [ + "CVE-2020-35380", + "GHSA-w942-gw6m-p62c" + ], + "summary": "Panic due to improper input validation in Get in github.com/tidwall/gjson", + "details": "Due to improper bounds checking, maliciously crafted JSON objects can cause an out-of-bounds panic. If parsing user input, this may be used as a denial of service vector.", + "affected": [ + { + "package": { + "name": "github.com/tidwall/gjson", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.6.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/tidwall/gjson", + "symbols": [ + "Get", + "GetBytes", + "GetMany", + "GetManyBytes", + "Result.Array", + "Result.Get", + "Result.Map", + "Result.Value", + "squash" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://github.com/tidwall/gjson/commit/f0ee9ebde4b619767ae4ac03e8e42addb530f6bc" + }, + { + "type": "WEB", + "url": "https://github.com/tidwall/gjson/issues/192" + } + ], + "credits": [ + { + "name": "@toptotu" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0059", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0265", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-08-15T18:06:07Z", + "aliases": [ + "CVE-2021-42248", + "CVE-2021-42836", + "GHSA-c9gm-7rfj-8w5h", + "GHSA-ppj4-34rq-v8j9" + ], + "summary": "Denial of service via maliciously crafted path in github.com/tidwall/gjson", + "details": "A maliciously crafted path can cause Get and other query functions to consume excessive amounts of CPU and time.", + "affected": [ + { + "package": { + "name": "github.com/tidwall/gjson", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.9.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/tidwall/gjson", + "symbols": [ + "Get", + "GetBytes", + "GetMany", + "GetManyBytes", + "Result.Get", + "parseObject", + "queryMatches" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://github.com/tidwall/gjson/commit/77a57fda87dca6d0d7d4627d512a630f89a91c96" + }, + { + "type": "WEB", + "url": "https://github.com/tidwall/gjson/issues/237" + }, + { + "type": "WEB", + "url": "https://github.com/tidwall/gjson/issues/236" + }, + { + "type": "WEB", + "url": "https://github.com/tidwall/gjson/commit/590010fdac311cc8990ef5c97448d4fec8f29944" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0265", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0957", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-08-25T06:28:20Z", + "aliases": [ + "CVE-2020-36066", + "GHSA-wjm3-fq3r-5x46" + ], + "summary": "Denial of service via maliciously crafted JSON in github.com/tidwall/gjson", + "details": "A maliciously crafted JSON input can cause a denial of service attack.", + "affected": [ + { + "package": { + "name": "github.com/tidwall/gjson", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.6.5" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/tidwall/gjson", + "symbols": [ + "Get", + "GetBytes", + "GetMany", + "GetManyBytes", + "Result.Get", + "parseObject", + "queryMatches" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://github.com/tidwall/match/commit/c2f534168b739a7ec1821a33839fb2f029f26bbc" + }, + { + "type": "WEB", + "url": "https://github.com/tidwall/gjson/commit/9f58baa7a613f89dfdc764c39e47fd3a15606153" + }, + { + "type": "WEB", + "url": "https://github.com/tidwall/gjson/issues/195" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0957", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0603", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-08-22T18:00:47Z", + "aliases": [ + "CVE-2022-28948", + "GHSA-hp87-p4gw-j4gq" + ], + "summary": "Panic in gopkg.in/yaml.v3", + "details": "An issue in the Unmarshal function can cause a program to panic when attempting to deserialize invalid input.", + "affected": [ + { + "package": { + "name": "gopkg.in/yaml.v3", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "3.0.0-20220521103104-8f96da9f5d5e" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "gopkg.in/yaml.v3", + "symbols": [ + "Unmarshal" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://github.com/go-yaml/yaml/commit/8f96da9f5d5eff988554c1aae1784627c4bf6754" + }, + { + "type": "WEB", + "url": "https://github.com/go-yaml/yaml/issues/666" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0603", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-1546", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-04-05T18:02:21Z", + "aliases": [ + "CVE-2023-25151", + "GHSA-5r5m-65gx-7vrh" + ], + "summary": "Denial of service in go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp", + "details": "The otelhttp package of opentelemetry-go-contrib is vulnerable to a denial-of-service attack.\n\nThe otelhttp package uses the httpconv.ServerRequest function to annotate metric measurements for the http.server.request_content_length, http.server.response_content_length, and http.server.duration instruments. The ServerRequest function sets the http.target attribute value to be the whole request URI (including the query string). The metric instruments do not \"forget\" previous measurement attributes when \"cumulative\" temporality is used, meaning that the cardinality of the measurements allocated is directly correlated with the unique URIs handled. If the query string is constantly random, this will result in a constant increase in memory allocation that can be used in a denial-of-service attack.", + "affected": [ + { + "package": { + "name": "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0.38.0" + }, + { + "fixed": "0.39.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp", + "symbols": [ + "Handler.ServeHTTP" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/open-telemetry/opentelemetry-go-contrib/security/advisories/GHSA-5r5m-65gx-7vrh" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-1546", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-2113", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-10-16T19:30:55Z", + "aliases": [ + "CVE-2023-45142", + "GHSA-rcjv-mgp8-qvmr" + ], + "summary": "Memory exhaustion in go.opentelemetry.io/contrib/instrumentation", + "details": "Memory exhaustion in go.opentelemetry.io/contrib/instrumentation", + "affected": [ + { + "package": { + "name": "go.opentelemetry.io/contrib/instrumentation/github.com/emicklei/go-restful/otelrestful", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.44.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go.opentelemetry.io/contrib/instrumentation/github.com/emicklei/go-restful/otelrestful/internal/semconvutil", + "symbols": [ + "HTTPClientRequest", + "HTTPServerRequest", + "httpConv.ClientRequest", + "httpConv.ServerRequest", + "httpConv.proto" + ] + } + ] + } + }, + { + "package": { + "name": "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.44.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin/internal/semconvutil", + "symbols": [ + "HTTPClientRequest", + "HTTPServerRequest", + "httpConv.ClientRequest", + "httpConv.ServerRequest", + "httpConv.proto" + ] + } + ] + } + }, + { + "package": { + "name": "go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.44.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux/internal/semconvutil", + "symbols": [ + "HTTPClientRequest", + "HTTPServerRequest", + "httpConv.ClientRequest", + "httpConv.ServerRequest", + "httpConv.proto" + ] + } + ] + } + }, + { + "package": { + "name": "go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.44.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho/internal/semconvutil", + "symbols": [ + "HTTPClientRequest", + "HTTPServerRequest", + "httpConv.ClientRequest", + "httpConv.ServerRequest", + "httpConv.proto" + ] + } + ] + } + }, + { + "package": { + "name": "go.opentelemetry.io/contrib/instrumentation/gopkg.in/macaron.v1/otelmacaron", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.44.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go.opentelemetry.io/contrib/instrumentation/gopkg.in/macaron.v1/otelmacaron/internal/semconvutil", + "symbols": [ + "HTTPClientRequest", + "HTTPServerRequest", + "httpConv.ClientRequest", + "httpConv.ServerRequest", + "httpConv.proto" + ] + } + ] + } + }, + { + "package": { + "name": "go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.44.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace/internal/semconvutil", + "symbols": [ + "HTTPClientRequest", + "HTTPServerRequest", + "httpConv.ClientRequest", + "httpConv.ServerRequest", + "httpConv.proto" + ] + } + ] + } + }, + { + "package": { + "name": "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.44.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp", + "symbols": [ + "middleware.serveHTTP" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/open-telemetry/opentelemetry-go-contrib/security/advisories/GHSA-rcjv-mgp8-qvmr" + }, + { + "type": "FIX", + "url": "https://github.com/open-telemetry/opentelemetry-go-contrib/pull/4277" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-2113", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5764", + "modified": "2026-07-07T16:52:19Z", + "published": "2026-07-07T16:52:19Z", + "aliases": [ + "GHSA-xmrv-pmrh-hhx2" + ], + "summary": "DoS due to Panic in AWS SDK for Go v2 SDK EventStream Decoder in github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream", + "details": "DoS due to Panic in AWS SDK for Go v2 SDK EventStream Decoder in github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream", + "affected": [ + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.7.8" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/bedrockagentcore", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.15.2" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/bedrockagentruntime", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.51.8" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/bedrockruntime", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.50.4" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.65.0" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/iotsitewise", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.52.19" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/kinesis", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.43.5" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/lambda", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.88.5" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/lexruntimev2", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.35.15" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/s3", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.97.3" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/sagemakerruntime", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.39.6" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/transcribestreaming", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.34.5" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/aws/aws-sdk-go-v2/security/advisories/GHSA-xmrv-pmrh-hhx2" + }, + { + "type": "WEB", + "url": "https://github.com/aws/aws-sdk-go-v2/releases/tag/release-2026-03-23" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5764", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5764", + "modified": "2026-07-07T16:52:19Z", + "published": "2026-07-07T16:52:19Z", + "aliases": [ + "GHSA-xmrv-pmrh-hhx2" + ], + "summary": "DoS due to Panic in AWS SDK for Go v2 SDK EventStream Decoder in github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream", + "details": "DoS due to Panic in AWS SDK for Go v2 SDK EventStream Decoder in github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream", + "affected": [ + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.7.8" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/bedrockagentcore", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.15.2" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/bedrockagentruntime", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.51.8" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/bedrockruntime", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.50.4" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.65.0" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/iotsitewise", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.52.19" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/kinesis", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.43.5" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/lambda", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.88.5" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/lexruntimev2", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.35.15" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/s3", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.97.3" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/sagemakerruntime", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.39.6" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/aws/aws-sdk-go-v2/service/transcribestreaming", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.34.5" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/aws/aws-sdk-go-v2/security/advisories/GHSA-xmrv-pmrh-hhx2" + }, + { + "type": "WEB", + "url": "https://github.com/aws/aws-sdk-go-v2/releases/tag/release-2026-03-23" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5764", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4985", + "modified": "2026-05-26T22:48:49Z", + "published": "2026-05-26T22:48:49Z", + "aliases": [ + "CVE-2026-39882", + "GHSA-w8rr-5gcm-pp58" + ], + "summary": "Oversized OTLP HTTP response bodies can cause memory exhaustion in go.opentelemetry.io/otel/exporters/otlp", + "details": "The OTLP HTTP exporters (traces, metrics, and logs) do not limit the size of the HTTP response body read from the collector. A malicious or misconfigured collector can send a large response body, leading to excessive memory consumption and potential process termination (OOM).", + "affected": [ + { + "package": { + "name": "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.19.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp", + "symbols": [ + "Exporter.Export", + "client.UploadLogs" + ] + } + ] + } + }, + { + "package": { + "name": "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.43.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp", + "symbols": [ + "Exporter.Export", + "client.UploadMetrics" + ] + } + ] + } + }, + { + "package": { + "name": "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.43.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp", + "symbols": [ + "New", + "NewUnstarted", + "client.UploadTraces" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://github.com/open-telemetry/opentelemetry-go/pull/8108" + }, + { + "type": "ADVISORY", + "url": "https://github.com/open-telemetry/opentelemetry-go/security/advisories/GHSA-w8rr-5gcm-pp58" + }, + { + "type": "WEB", + "url": "http://github.com/open-telemetry/opentelemetry-go/releases/tag/v1.43.0" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4985", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2020-0019", + "modified": "2024-05-20T16:03:47Z", + "published": "2021-04-14T20:04:52Z", + "aliases": [ + "CVE-2020-27813", + "GHSA-3xh2-74w9-5vxm", + "GHSA-jf24-p9p9-4rjh" + ], + "summary": "Integer overflow in github.com/gorilla/websocket", + "details": "An attacker can craft malicious WebSocket frames that cause an integer overflow in a variable which tracks the number of bytes remaining. This may cause the server or client to get stuck attempting to read frames in a loop, which can be used as a denial of service vector.", + "affected": [ + { + "package": { + "name": "github.com/gorilla/websocket", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.4.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/gorilla/websocket", + "symbols": [ + "Conn.Close", + "Conn.NextReader", + "Conn.NextWriter", + "Conn.ReadJSON", + "Conn.ReadMessage", + "Conn.WriteControl", + "Conn.WriteJSON", + "Conn.WriteMessage", + "Conn.WritePreparedMessage", + "Conn.advanceFrame", + "Dialer.Dial", + "Dialer.DialContext", + "NewClient", + "NewPreparedMessage", + "ReadJSON", + "Subprotocols", + "Upgrade", + "Upgrader.Upgrade", + "WriteJSON", + "flateReadWrapper.Read", + "flateWriteWrapper.Close", + "flateWriteWrapper.Write", + "httpProxyDialer.Dial", + "messageReader.Read", + "messageWriter.Close", + "messageWriter.ReadFrom", + "messageWriter.Write", + "messageWriter.WriteString", + "netDialerFunc.Dial", + "proxy_direct.Dial", + "proxy_envOnce.Get", + "proxy_socks5.Dial", + "truncWriter.Write" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://github.com/gorilla/websocket/pull/537" + }, + { + "type": "FIX", + "url": "https://github.com/gorilla/websocket/commit/5b740c29263eb386f33f265561c8262522f19d37" + } + ], + "credits": [ + { + "name": "Max Justicz" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2020-0019", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-6278", + "modified": "2026-08-25T15:00:08Z", + "published": "2026-08-25T15:00:08Z", + "aliases": [ + "GHSA-w67g-5rqw-f597" + ], + "summary": "Gorilla WebSocket Uses Cryptographically Weak PRNG for WebSocket Mask Key in github.com/gorilla/websocket", + "details": "Gorilla WebSocket Uses Cryptographically Weak PRNG for WebSocket Mask Key in github.com/gorilla/websocket", + "affected": [ + { + "package": { + "name": "github.com/gorilla/websocket", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.5.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/gorilla/websocket", + "symbols": [ + "Conn.Close", + "Conn.NextReader", + "Conn.NextWriter", + "Conn.ReadJSON", + "Conn.ReadMessage", + "Conn.WriteControl", + "Conn.WriteJSON", + "Conn.WriteMessage", + "Conn.WritePreparedMessage", + "Dialer.Dial", + "Dialer.DialContext", + "NewClient", + "NewPreparedMessage", + "ReadJSON", + "Subprotocols", + "Upgrade", + "Upgrader.Upgrade", + "WriteJSON", + "flateReadWrapper.Read", + "flateWriteWrapper.Close", + "flateWriteWrapper.Write", + "httpProxyDialer.Dial", + "joinReader.Read", + "messageReader.Read", + "messageWriter.Close", + "messageWriter.ReadFrom", + "messageWriter.Write", + "messageWriter.WriteString", + "netDialerFunc.Dial", + "newMaskKey", + "proxy_direct.Dial", + "proxy_envOnce.Get", + "proxy_socks5.Dial", + "truncWriter.Write" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/canolgun-commits/websocket/security/advisories/GHSA-w67g-5rqw-f597" + }, + { + "type": "FIX", + "url": "https://github.com/gorilla/websocket/commit/d67f41855da42d7bccd9ef050c49f7e54e783b95" + }, + { + "type": "WEB", + "url": "https://github.com/gorilla/websocket/releases/tag/v1.5.3" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-6278", + "review_status": "UNREVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4958", + "modified": "2026-05-26T22:49:05Z", + "published": "2026-05-26T22:49:05Z", + "aliases": [ + "CVE-2026-35469", + "GHSA-pc3f-x583-g7j2" + ], + "summary": "Uncontrolled resource consumption when parsing SPDY frames in github.com/moby/spdystream", + "details": "The SPDY/3 frame parser in spdystream does not validate attacker-controlled counts and lengths before allocating memory. A remote peer that can send SPDY frames to a service using spdystream can cause the process to allocate gigabytes of memory with a small number of malformed control frames, leading to an out-of-memory crash.\n\nThree allocation paths in the receive side are affected:\n1. SETTINGS entry count: The SETTINGS frame reader reads a 32-bit numSettings from the payload and allocates a slice of that size without checking it against the declared frame length.\n2. Header count: parseHeaderValueBlock reads a 32-bit numHeaders from the decompressed header block and allocates an http.Header map of that size with no upper bound.\n3. Header field size: Individual header name and value lengths are read as 32-bit integers and used directly as allocation sizes with no validation.\n\nBecause SPDY header blocks are zlib-compressed, a small on-the-wire payload can decompress into attacker-controlled bytes that the parser interprets as 32-bit counts and lengths. A single crafted frame is enough to exhaust process memory.", + "affected": [ + { + "package": { + "name": "github.com/moby/spdystream", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.5.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/moby/spdystream", + "symbols": [ + "Connection.Serve", + "NewConnection", + "idleAwareFramer.ReadFrame" + ] + }, + { + "path": "github.com/moby/spdystream/spdy", + "symbols": [ + "Framer.ReadFrame", + "NewFramer" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/moby/spdystream/security/advisories/GHSA-pc3f-x583-g7j2" + }, + { + "type": "FIX", + "url": "https://github.com/moby/spdystream/commit/ef6121f62c730110bf5ae604a865a8613bfb787f" + }, + { + "type": "WEB", + "url": "https://github.com/moby/spdystream/releases/tag/v0.5.1" + } + ], + "credits": [ + { + "name": "Samuel Karp" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4958", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2020-0012", + "modified": "2024-05-20T16:03:47Z", + "published": "2021-04-14T20:04:52Z", + "aliases": [ + "CVE-2020-9283", + "GHSA-ffhg-7mh4-33c4" + ], + "summary": "Panic due to improper verification of cryptographic signatures in golang.org/x/crypto/ssh", + "details": "An attacker can craft an ssh-ed25519 or sk-ssh-ed25519@openssh.com public key, such that the library will panic when trying to verify a signature with it. If verifying signatures using user supplied public keys, this may be used as a denial of service vector.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20200220183623-bac4c82f6975" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh", + "symbols": [ + "CertChecker.Authenticate", + "CertChecker.CheckCert", + "CertChecker.CheckHostKey", + "Certificate.Verify", + "Dial", + "NewClientConn", + "NewPublicKey", + "NewServerConn", + "NewSignerFromKey", + "NewSignerFromSigner", + "ParseAuthorizedKey", + "ParseKnownHosts", + "ParsePrivateKey", + "ParsePrivateKeyWithPassphrase", + "ParsePublicKey", + "ParseRawPrivateKey", + "ParseRawPrivateKeyWithPassphrase", + "ed25519PublicKey.Verify", + "parseED25519", + "parseSKEd25519", + "skEd25519PublicKey.Verify" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/220357" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/crypto/+/bac4c82f69751a6dd76e702d54b3ceb88adab236" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/3L45YRc91SY" + } + ], + "credits": [ + { + "name": "Alex Gaynor, Fish in a Barrel" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2020-0012", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2020-0013", + "modified": "2024-05-20T16:03:47Z", + "published": "2021-04-14T20:04:52Z", + "aliases": [ + "CVE-2017-3204", + "GHSA-xhjq-w7xm-p8qj" + ], + "summary": "Man-in-the-middle attack in golang.org/x/crypto/ssh", + "details": "By default host key verification is disabled which allows for man-in-the-middle attacks against SSH clients if ClientConfig.HostKeyCallback is not set.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20170330155735-e4e2799dd7aa" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh", + "symbols": [ + "Dial", + "NewClientConn" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/38701" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/crypto/+/e4e2799dd7aab89f583e1d898300d96367750991" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/19767" + }, + { + "type": "WEB", + "url": "https://bridge.grumpy-troll.org/2017/04/golang-ssh-security/" + } + ], + "credits": [ + { + "name": "Phil Pennock" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2020-0013", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0227", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-02-17T17:35:32Z", + "aliases": [ + "CVE-2020-29652", + "GHSA-3vm4-22fp-5rfm" + ], + "summary": "Panic on crafted authentication request message in golang.org/x/crypto/ssh", + "details": "Clients can cause a panic in SSH servers. An attacker can craft an authentication request message for the “gssapi-with-mic” method which will cause NewServerConn to panic via a nil pointer dereference if ServerConfig.GSSAPIWithMICConfig is nil.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20201216223049-8b5274cf687f" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh", + "symbols": [ + "NewServerConn", + "connection.serverAuthenticate" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/278852" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/crypto/+/8b5274cf687fd9316b4108863654cc57385531e8" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/ouZIlBimOsE?pli=1" + } + ], + "credits": [ + { + "name": "Joern Schneewesiz (GitLab Security Research Team)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0227", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0356", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-04-25T20:38:40Z", + "aliases": [ + "CVE-2022-27191", + "GHSA-8c26-wmh5-6g9v" + ], + "summary": "Denial of service via crafted Signer in golang.org/x/crypto/ssh", + "details": "Attackers can cause a crash in SSH servers when the server has been configured by passing a Signer to ServerConfig.AddHostKey such that\n1) the Signer passed to AddHostKey does not implement AlgorithmSigner, and\n2) the Signer passed to AddHostKey returns a key of type “ssh-rsa” from its PublicKey method.\n\nServers that only use Signer implementations provided by the ssh package are unaffected.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20220314234659-1baeb1ce4c0b" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh", + "symbols": [ + "ServerConfig.AddHostKey" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/392355" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/crypto/+/1baeb1ce4c0b006eff0f294c47cb7617598dfb3d" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/-cp44ypCT5s" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0356", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0209", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-01T20:15:25Z", + "aliases": [ + "CVE-2019-11840", + "GHSA-r5c5-pr8j-pfp7" + ], + "summary": "Insufficiently random values in golang.org/x/crypto/salsa20", + "details": "XORKeyStream generates incorrect and insecure output for very large inputs.\n\nIf more than 256 GiB of keystream is generated, or if the counter otherwise grows greater than 32 bits, the amd64 implementation will first generate incorrect output, and then cycle back to previously generated keystream. Repeated keystream bytes can lead to loss of confidentiality in encryption applications, or to predictability in CSPRNG applications.\n\nThe issue might affect uses of golang.org/x/crypto/nacl with extremely large messages.\n\nArchitectures other than amd64 and uses that generate less than 256 GiB of keystream for a single salsa20.XORKeyStream invocation are unaffected.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20190320223903-b7391e95e576" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/salsa20/salsa", + "goarch": [ + "amd64" + ], + "symbols": [ + "XORKeyStream" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/168406" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/crypto/+/b7391e95e576cacdcdd422573063bc057239113d" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/30965" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/tjyNcJxb2vQ/m/n0NRBziSCAAJ" + } + ], + "credits": [ + { + "name": "Michael McLoughlin" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0209", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0229", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-06T18:23:48Z", + "aliases": [ + "CVE-2020-7919", + "GHSA-cjjc-xp8v-855w" + ], + "summary": "Panic in certificate parsing in crypto/x509 and golang.org/x/crypto/cryptobyte", + "details": "On 32-bit architectures, a malformed input to crypto/x509 or the ASN.1 parsing functions of golang.org/x/crypto/cryptobyte can lead to a panic.\n\nThe malformed certificate can be delivered via a crypto/tls connection to a client, or to a server that accepts client certificates. net/http clients can be made to crash by an HTTPS server, while net/http servers that accept client certificates will recover the panic and are unaffected.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.12.16" + }, + { + "introduced": "1.13.0-0" + }, + { + "fixed": "1.13.7" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "crypto/x509" + } + ] + } + }, + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20200124225646-8b5121be2f68" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/cryptobyte" + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/216680" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/b13ce14c4a6aa59b7b041ad2b6eed2d23e15b574" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/216677" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/36837" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/Hsw4mHYc470" + } + ], + "credits": [ + { + "name": "Project Wycheproof" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0229", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0968", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-09-13T03:32:38Z", + "aliases": [ + "CVE-2021-43565", + "GHSA-gwc9-m7rh-j2ww" + ], + "summary": "Panic on malformed packets in golang.org/x/crypto/ssh", + "details": "Unauthenticated clients can cause a panic in SSH servers.\n\nWhen using AES-GCM or ChaCha20Poly1305, consuming a malformed packet which contains an empty plaintext causes a panic.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20211202192323-5770296d904e" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh", + "symbols": [ + "Dial", + "NewClientConn", + "NewServerConn", + "chacha20Poly1305Cipher.readCipherPacket", + "curve25519sha256.Client", + "curve25519sha256.Server", + "dhGEXSHA.Client", + "dhGEXSHA.Server", + "dhGroup.Client", + "dhGroup.Server", + "ecdh.Client", + "ecdh.Server", + "gcmCipher.readCipherPacket" + ] + } + ] + } + } + ], + "references": [ + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/2AR1sKiM-Qs" + }, + { + "type": "REPORT", + "url": "https://go.dev/issues/49932" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/368814/" + } + ], + "credits": [ + { + "name": "Rod Hynes (Psiphon Inc)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0968", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-1992", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-08-23T14:38:42Z", + "aliases": [ + "CVE-2019-11841", + "GHSA-x3jr-pf6g-c48f" + ], + "summary": "Misleading message verification in golang.org/x/crypto/openpgp/clearsign", + "details": "The clearsign package accepts some malformed messages, making it possible for an attacker to trick a human user (but not a Go program) into thinking unverified text is part of the message.\n\nWith fix, messages with malformed headers in the SIGNED MESSAGE section are rejected.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20190424203555-c05e17bb3b2d" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/openpgp/clearsign", + "symbols": [ + "Decode" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go-review.git.corp.google.com/c/crypto/+/173778" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/crypto/+/c05e17bb3b2dca130fc919668a96b4bec9eb9442" + }, + { + "type": "WEB", + "url": "https://groups.google.com/d/msg/golang-openpgp/6vdgZoTgbIY/K6bBY9z3DAAJ" + } + ], + "credits": [ + { + "name": "Aida Mynzhasova (SEC Consult Vulnerability Lab)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-1992", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-2402", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-12-18T21:18:26Z", + "aliases": [ + "CVE-2023-48795", + "GHSA-45x7-px36-x8w8" + ], + "summary": "Man-in-the-middle attacker can compromise integrity of secure channel in golang.org/x/crypto", + "details": "A protocol weakness allows a MITM attacker to compromise the integrity of the secure channel before it is established, allowing the attacker to prevent transmission of a number of messages immediately after the secure channel is established without either side being aware.\n\nThe impact of this attack is relatively limited, as it does not compromise confidentiality of the channel. Notably this attack would allow an attacker to prevent the transmission of the SSH2_MSG_EXT_INFO message, disabling a handful of newer security features.\n\nThis protocol weakness was also fixed in OpenSSH 9.6.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.17.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh", + "symbols": [ + "Client.Dial", + "Client.DialContext", + "Client.DialTCP", + "Client.Listen", + "Client.ListenTCP", + "Client.ListenUnix", + "Client.NewSession", + "Dial", + "DiscardRequests", + "NewClient", + "NewClientConn", + "NewServerConn", + "Request.Reply", + "Session.Close", + "Session.CombinedOutput", + "Session.Output", + "Session.RequestPty", + "Session.RequestSubsystem", + "Session.Run", + "Session.SendRequest", + "Session.Setenv", + "Session.Shell", + "Session.Signal", + "Session.Start", + "Session.WindowChange", + "channel.Accept", + "channel.Close", + "channel.CloseWrite", + "channel.Read", + "channel.ReadExtended", + "channel.Reject", + "channel.SendRequest", + "channel.Write", + "channel.WriteExtended", + "connectionState.readPacket", + "connectionState.writePacket", + "curve25519sha256.Client", + "curve25519sha256.Server", + "dhGEXSHA.Client", + "dhGEXSHA.Server", + "dhGroup.Client", + "dhGroup.Server", + "ecdh.Client", + "ecdh.Server", + "extChannel.Read", + "extChannel.Write", + "handshakeTransport.enterKeyExchange", + "handshakeTransport.readLoop", + "handshakeTransport.sendKexInit", + "mux.OpenChannel", + "mux.SendRequest", + "sessionStdin.Close", + "sshClientKeyboardInteractive.Challenge", + "tcpListener.Accept", + "tcpListener.Close", + "transport.readPacket", + "transport.writePacket", + "unixListener.Accept", + "unixListener.Close" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/64784" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/550715" + }, + { + "type": "FIX", + "url": "https://github.com/golang/crypto/commit/9d2ee975ef9fe627bf0a6f01c1f69e8ef1d4f05d" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/qA3XtxvMUyg" + }, + { + "type": "WEB", + "url": "https://www.openssh.com/txt/release-9.6" + } + ], + "credits": [ + { + "name": "Fabian Bäumer (Ruhr University Bochum)" + }, + { + "name": "Marcus Brinkmann (Ruhr University Bochum)" + }, + { + "name": "Jörg Schwenk (Ruhr University Bochum)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-2402", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-2961", + "modified": "2024-07-02T19:27:52Z", + "published": "2024-07-02T19:27:52Z", + "aliases": [ + "CVE-2022-30636" + ], + "summary": "Limited directory traversal vulnerability on Windows in golang.org/x/crypto", + "details": "httpTokenCacheKey uses path.Base to extract the expected HTTP-01 token value to lookup in the DirCache implementation. On Windows, path.Base acts differently to filepath.Base, since Windows uses a different path separator (\\ vs. /), allowing a user to provide a relative path, i.e. .well-known/acme-challenge/..\\..\\asd becomes ..\\..\\asd. The extracted path is then suffixed with +http-01, joined with the cache directory, and opened.\n\nSince the controlled path is suffixed with +http-01 before opening, the impact of this is significantly limited, since it only allows reading arbitrary files on the system if and only if they have this suffix.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20220525230936-793ad666bf5e" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/acme/autocert", + "goos": [ + "windows" + ], + "symbols": [ + "DirCache.Delete", + "DirCache.Get", + "DirCache.Put", + "HostWhitelist", + "Manager.GetCertificate", + "Manager.Listener", + "NewListener", + "listener.Accept", + "listener.Close" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/408694" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/53082" + } + ], + "credits": [ + { + "name": "Juho Nurminen of Mattermost" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-2961", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-3321", + "modified": "2025-02-18T20:32:01Z", + "published": "2024-12-11T18:40:19Z", + "aliases": [ + "CVE-2024-45337", + "GHSA-v778-237x-gjrc" + ], + "summary": "Misuse of connection.serverAuthenticate may cause authorization bypass in golang.org/x/crypto", + "details": "Applications and libraries which misuse connection.serverAuthenticate (via callback field ServerConfig.PublicKeyCallback) may be susceptible to an authorization bypass.\n\nThe documentation for ServerConfig.PublicKeyCallback says that \"A call to this function does not guarantee that the key offered is in fact used to authenticate.\" Specifically, the SSH protocol allows clients to inquire about whether a public key is acceptable before proving control of the corresponding private key. PublicKeyCallback may be called with multiple keys, and the order in which the keys were provided cannot be used to infer which key the client successfully authenticated with, if any. Some applications, which store the key(s) passed to PublicKeyCallback (or derived information) and make security relevant determinations based on it once the connection is established, may make incorrect assumptions.\n\nFor example, an attacker may send public keys A and B, and then authenticate with A. PublicKeyCallback would be called only twice, first with A and then with B. A vulnerable application may then make authorization decisions based on key B for which the attacker does not actually control the private key.\n\nSince this API is widely misused, as a partial mitigation golang.org/x/cry...@v0.31.0 enforces the property that, when successfully authenticating via public key, the last key passed to ServerConfig.PublicKeyCallback will be the key used to authenticate the connection. PublicKeyCallback will now be called multiple times with the same key, if necessary. Note that the client may still not control the last key passed to PublicKeyCallback if the connection is then authenticated with a different method, such as PasswordCallback, KeyboardInteractiveCallback, or NoClientAuth.\n\nUsers should be using the Extensions field of the Permissions return value from the various authentication callbacks to record data associated with the authentication attempt instead of referencing external state. Once the connection is established the state corresponding to the successful authentication attempt can be retrieved via the ServerConn.Permissions field. Note that some third-party libraries misuse the Permissions type by sharing it across authentication attempts; users of third-party libraries should refer to the relevant projects for guidance.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.31.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh", + "symbols": [ + "NewServerConn", + "ServerConfig.PublicKeyCallback", + "connection.serverAuthenticate" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://github.com/golang/crypto/commit/b4f1988a35dee11ec3e05d6bf3e90b695fbd8909" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/635315" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/70779" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/-nPEi39gI4Q/m/cGVPJCqdAQAJ" + } + ], + "credits": [ + { + "name": "Damien Tournoud (Platform.sh / Upsun)" + }, + { + "name": "Patrick Dawkins (Platform.sh / Upsun)" + }, + { + "name": "Vince Parker (Platform.sh / Upsun)" + }, + { + "name": "Jules Duvivier (Platform.sh / Upsun)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-3321", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3487", + "modified": "2025-02-26T02:51:51Z", + "published": "2025-02-26T02:51:51Z", + "aliases": [ + "CVE-2025-22869" + ], + "summary": "Potential denial of service in golang.org/x/crypto", + "details": "SSH servers which implement file transfer protocols are vulnerable to a denial of service attack from clients which complete the key exchange slowly, or not at all, causing pending content to be read into memory, but never transmitted.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.35.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh", + "symbols": [ + "Client.Dial", + "Client.DialContext", + "Client.DialTCP", + "Client.Listen", + "Client.ListenTCP", + "Client.ListenUnix", + "Client.NewSession", + "Dial", + "DiscardRequests", + "NewClient", + "NewClientConn", + "NewServerConn", + "Request.Reply", + "Session.Close", + "Session.CombinedOutput", + "Session.Output", + "Session.RequestPty", + "Session.RequestSubsystem", + "Session.Run", + "Session.SendRequest", + "Session.Setenv", + "Session.Shell", + "Session.Signal", + "Session.Start", + "Session.WindowChange", + "channel.Accept", + "channel.Close", + "channel.CloseWrite", + "channel.Read", + "channel.ReadExtended", + "channel.Reject", + "channel.SendRequest", + "channel.Write", + "channel.WriteExtended", + "connection.SendAuthBanner", + "curve25519sha256.Client", + "curve25519sha256.Server", + "dhGEXSHA.Client", + "dhGEXSHA.Server", + "dhGroup.Client", + "dhGroup.Server", + "ecdh.Client", + "ecdh.Server", + "extChannel.Read", + "extChannel.Write", + "handshakeTransport.kexLoop", + "handshakeTransport.recordWriteError", + "handshakeTransport.writePacket", + "mux.OpenChannel", + "mux.SendRequest", + "newHandshakeTransport", + "sessionStdin.Close", + "sshClientKeyboardInteractive.Challenge", + "tcpListener.Accept", + "tcpListener.Close", + "unixListener.Accept", + "unixListener.Close" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/652135" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/71931" + } + ], + "credits": [ + { + "name": "Yuichi Watanabe" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3487", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-4116", + "modified": "2025-12-16T16:23:22Z", + "published": "2025-11-13T21:12:03Z", + "aliases": [ + "CVE-2025-47913" + ], + "summary": "Potential denial of service in golang.org/x/crypto/ssh/agent", + "details": "SSH clients receiving SSH_AGENT_SUCCESS when expecting a typed response will panic and cause early termination of the client process.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.43.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh/agent", + "symbols": [ + "agentKeyringSigner.Sign", + "agentKeyringSigner.SignWithAlgorithm", + "client.List", + "client.Sign", + "client.SignWithFlags", + "client.Signers" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/700295" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/75178" + }, + { + "type": "WEB", + "url": "https://github.com/advisories/GHSA-56w8-48fp-6mgv" + } + ], + "credits": [ + { + "name": "Jakub Ciolek" + }, + { + "name": "Nicola Murino" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-4116", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-4134", + "modified": "2025-11-20T16:52:31Z", + "published": "2025-11-19T20:11:57Z", + "aliases": [ + "CVE-2025-58181", + "GHSA-j5w8-q4qc-rx2x" + ], + "summary": "Unbounded memory consumption in golang.org/x/crypto/ssh", + "details": "SSH servers parsing GSSAPI authentication requests do not validate the number of mechanisms specified in the request, allowing an attacker to cause unbounded memory consumption.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.45.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh", + "symbols": [ + "NewServerConn", + "parseGSSAPIPayload" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://groups.google.com/g/golang-announce/c/w-oX3UxNcZA" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/721961" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/76363" + } + ], + "credits": [ + { + "name": "Jakub Ciolek" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-4134", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-4135", + "modified": "2025-11-20T16:52:31Z", + "published": "2025-11-19T20:11:57Z", + "aliases": [ + "CVE-2025-47914", + "GHSA-f6x5-jh6r-wrfv" + ], + "summary": "Malformed constraint may cause denial of service in golang.org/x/crypto/ssh/agent", + "details": "SSH Agent servers do not validate the size of messages when processing new identity requests, which may cause the program to panic if the message is malformed due to an out of bounds read.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.45.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh/agent", + "symbols": [ + "ForwardToAgent", + "ServeAgent", + "parseConstraints" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://groups.google.com/g/golang-announce/c/w-oX3UxNcZA" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/721960" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/76364" + } + ], + "credits": [ + { + "name": "Jakub Ciolek" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-4135", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5005", + "modified": "2026-08-11T21:06:47Z", + "published": "2026-05-22T02:08:34Z", + "aliases": [ + "CVE-2026-39833", + "GHSA-jppx-rxg9-jmrx" + ], + "summary": "Invoking key constraints not enforced in golang.org/x/crypto/ssh/agent", + "details": "The in-memory keyring returned by NewKeyring() silently accepted keys with the ConfirmBeforeUse constraint but never enforced it. The key would sign without any confirmation prompt, with no indication to the caller that the constraint was not in effect. NewKeyring() now returns an error when unsupported constraints are requested.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.52.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh/agent", + "symbols": [ + "keyring.Add" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/79436" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/778642" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/a082jnz-LvI" + } + ], + "credits": [ + { + "name": "NCC Group Cryptography Services, sponsored by Teleport" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5005", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5006", + "modified": "2026-08-11T21:06:47Z", + "published": "2026-05-22T02:08:34Z", + "aliases": [ + "CVE-2026-39832", + "GHSA-f5wc-c3c7-36mc" + ], + "summary": "Invoking agent constraints dropped when forwarding keys in golang.org/x/crypto/ssh/agent", + "details": "When adding a key to a remote agent constraint extensions such as restrict-destination-v00@openssh.com were not serialized in the request. Destination restrictions were silently stripped when forwarding keys, allowing unrestricted use of the key on the remote host. The client now serializes all constraint extensions. Additionally, the in-memory keyring returned by NewKeyring() now rejects keys with unsupported constraint extensions instead of silently ignoring them.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.52.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh/agent", + "symbols": [ + "client.Add", + "keyring.Add" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/79435" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/778640" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/778641" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/a082jnz-LvI" + } + ], + "credits": [ + { + "name": "NCC Group Cryptography Services, sponsored by Teleport" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5006", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5013", + "modified": "2026-05-22T02:08:34Z", + "published": "2026-05-22T02:08:34Z", + "aliases": [ + "CVE-2026-46597" + ], + "summary": "Invoking byte arithmetic causes underflow and panic in golang.org/x/crypto/ssh", + "details": "An incorrectly placed cast from bytes to int allowed for server-side panic in the AES-GCM packet decoder for well-crafted inputs.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.52.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh", + "symbols": [ + "Dial", + "NewClientConn", + "NewServerConn", + "curve25519sha256.Client", + "curve25519sha256.Server", + "dhGEXSHA.Client", + "dhGEXSHA.Server", + "dhGroup.Client", + "dhGroup.Server", + "ecdh.Client", + "ecdh.Server", + "gcmCipher.readCipherPacket", + "mlkem768WithCurve25519sha256.Client", + "mlkem768WithCurve25519sha256.Server" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/79561" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/a082jnz-LvI" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/781620" + } + ], + "credits": [ + { + "name": "Maciej Kawka" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5013", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5014", + "modified": "2026-05-22T02:08:34Z", + "published": "2026-05-22T02:08:34Z", + "aliases": [ + "CVE-2026-39828" + ], + "summary": "Invoking bypass of certificate restrictions in golang.org/x/crypto/ssh", + "details": "When an SSH server authentication callback returned PartialSuccessError with non-nil Permissions, those permissions were silently discarded, potentially dropping certificate restrictions such as force-command after a second factor succeeded. Returning non-nil Permissions with PartialSuccessError now results in a connection error.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.52.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh", + "symbols": [ + "NewServerConn", + "connection.serverAuthenticate" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/79562" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/a082jnz-LvI" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/781621" + } + ], + "credits": [ + { + "name": "NCC Group Cryptography Services, sponsored by Teleport" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5014", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5015", + "modified": "2026-05-22T02:08:34Z", + "published": "2026-05-22T02:08:34Z", + "aliases": [ + "CVE-2026-39835" + ], + "summary": "Invoking server panic during CheckHostKey/Authenticate in golang.org/x/crypto/ssh", + "details": "SSH servers which use CertChecker as a public key callback without setting IsUserAuthority or IsHostAuthority could be caused to panic by a client presenting a certificate. CertChecker now returns an error instead of panicking when these callbacks are nil.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.52.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh", + "symbols": [ + "CertChecker.Authenticate", + "CertChecker.CheckHostKey" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/79563" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/a082jnz-LvI" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/781660" + } + ], + "credits": [ + { + "name": "NCC Group Cryptography Services, sponsored by Teleport" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5015", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5016", + "modified": "2026-05-22T02:08:34Z", + "published": "2026-05-22T02:08:34Z", + "aliases": [ + "CVE-2026-39827" + ], + "summary": "Invoking memory leak when rejecting channels can lead to DoS in golang.org/x/crypto/ssh", + "details": "An authenticated SSH client that repeatedly opened channels which were rejected by the server caused unbounded memory growth, eventually crashing the server process and affecting all connected users. Rejected channels are now properly removed from the connection's internal state and released for garbage collection.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.52.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh", + "symbols": [ + "channel.Reject" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/35127" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/781320" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/a082jnz-LvI" + } + ], + "credits": [ + { + "name": "Ziyan Zhou" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5016", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5017", + "modified": "2026-05-22T02:08:34Z", + "published": "2026-05-22T02:08:34Z", + "aliases": [ + "CVE-2026-39830" + ], + "summary": "Invoking client can cause server deadlock on unexpected responses in golang.org/x/crypto/ssh", + "details": "A malicious SSH peer could send unsolicited global request responses to fill an internal buffer, blocking the connection's read loop. The blocked goroutine could not be released by calling Close(), resulting in a resource leak per connection. Unsolicited global responses are now discarded.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.52.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh", + "symbols": [ + "Client.Listen", + "Client.ListenTCP", + "Client.ListenUnix", + "Dial", + "NewClientConn", + "NewServerConn", + "Session.CombinedOutput", + "Session.Output", + "Session.RequestPty", + "Session.RequestSubsystem", + "Session.Run", + "Session.SendRequest", + "Session.Setenv", + "Session.Shell", + "Session.Signal", + "Session.Start", + "Session.WindowChange", + "channel.SendRequest", + "channel.handlePacket", + "mux.SendRequest", + "mux.handleGlobalPacket", + "tcpListener.Close", + "unixListener.Close" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/79564" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/a082jnz-LvI" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/781640" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/781664" + } + ], + "credits": [ + { + "name": "NCC Group Cryptography Services, sponsored by Teleport" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5017", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5018", + "modified": "2026-05-22T02:08:34Z", + "published": "2026-05-22T02:08:34Z", + "aliases": [ + "CVE-2026-39829" + ], + "summary": "Invoking pathological RSA/DSA parameters may cause DoS in golang.org/x/crypto/ssh", + "details": "The RSA and DSA public key parsers did not enforce size limits on key parameters. A crafted public key with an excessively large modulus or DSA parameter could cause several minutes of CPU consumption during signature verification. This could be triggered by unauthenticated clients during public key authentication. RSA moduli are now limited to 8192 bits, and DSA parameters are validated per FIPS 186-2.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.52.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh", + "symbols": [ + "Dial", + "NewClientConn", + "NewServerConn", + "NewSignerFromKey", + "ParseAuthorizedKey", + "ParseKnownHosts", + "ParsePrivateKey", + "ParsePrivateKeyWithPassphrase", + "ParsePublicKey", + "ParseRawPrivateKey", + "ParseRawPrivateKeyWithPassphrase", + "checkDSAParams", + "parseDSA", + "parseRSA" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/79565" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/a082jnz-LvI" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/781641" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/781661" + } + ], + "credits": [ + { + "name": "NCC Group Cryptography Services, sponsored by Teleport" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5018", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5019", + "modified": "2026-05-22T02:08:34Z", + "published": "2026-05-22T02:08:34Z", + "aliases": [ + "CVE-2026-39831" + ], + "summary": "Invoking bypass of FIDO/U2F security keys physical interaction in golang.org/x/crypto/ssh", + "details": "The Verify() method for FIDO/U2F security key types (sk-ecdsa-sha2-nistp256@openssh.com, sk-ssh-ed25519@openssh.com) did not check the User Presence flag. Signatures generated without physical touch were accepted, allowing unattended use of a hardware security key. To restore the previous behavior, return a \"no-touch-required\" extension in Permissions.Extensions from PublicKeyCallback.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.52.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh", + "symbols": [ + "CertChecker.Authenticate", + "CertChecker.CheckCert", + "CertChecker.CheckHostKey", + "Certificate.Verify", + "Dial", + "NewClientConn", + "NewServerConn", + "connection.serverAuthenticate", + "skECDSAPublicKey.Verify", + "skEd25519PublicKey.Verify" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/79566" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/a082jnz-LvI" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/781662" + } + ], + "credits": [ + { + "name": "NCC Group Cryptography Services, sponsored by Teleport" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5019", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5020", + "modified": "2026-05-22T02:08:34Z", + "published": "2026-05-22T02:08:34Z", + "aliases": [ + "CVE-2026-39834" + ], + "summary": "Invoking infinite loop on large channel writes in golang.org/x/crypto/ssh", + "details": "When writing data larger than 4GB in a single Write call on an SSH channel, an integer overflow in the internal payload size calculation caused the write loop to spin indefinitely, sending empty packets without making progress. The size comparison now uses int64 to prevent truncation.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.52.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh", + "symbols": [ + "Dial", + "NewClientConn", + "NewServerConn", + "Session.CombinedOutput", + "Session.Output", + "Session.Run", + "Session.Shell", + "Session.Start", + "channel.Write", + "channel.WriteExtended", + "curve25519sha256.Client", + "curve25519sha256.Server", + "dhGEXSHA.Client", + "dhGEXSHA.Server", + "dhGroup.Client", + "dhGroup.Server", + "ecdh.Client", + "ecdh.Server", + "extChannel.Write", + "mlkem768WithCurve25519sha256.Client", + "mlkem768WithCurve25519sha256.Server" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/79567" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/a082jnz-LvI" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/781663" + } + ], + "credits": [ + { + "name": "NCC Group Cryptography Services, sponsored by Teleport" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5020", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5021", + "modified": "2026-08-14T16:22:54Z", + "published": "2026-05-22T02:08:34Z", + "aliases": [ + "CVE-2026-42508", + "GHSA-5cgq-3rg8-m6cv" + ], + "summary": "Invoking auth bypass via unenforced @revoked status in golang.org/x/crypto/ssh/knownhosts", + "details": "Previously, a revoked 'SignatureKey' belonging to a CA was not correctly checked for revocation. Now, both the 'key' and 'key.SignatureKey' are checked for @revoked.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.52.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh/knownhosts", + "symbols": [ + "hostKeyDB.IsRevoked" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/79568" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/781220" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/a082jnz-LvI" + } + ], + "credits": [ + { + "name": "Omar Khaleel Ibrahim - mr3okk" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5021", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5023", + "modified": "2026-05-22T02:08:34Z", + "published": "2026-05-22T02:08:34Z", + "aliases": [ + "CVE-2026-46595" + ], + "summary": "Invoking VerifiedPublicKeyCallback permissions skip enforcement in golang.org/x/crypto/ssh", + "details": "Previously, CVE-2024-45337 fixed an authorization bypass for misused ssh server configurations; if any other type of callback is passed other than public key, then the source-address validation would be skipped.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.52.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh", + "symbols": [ + "NewServerConn", + "connection.serverAuthenticate" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/79570" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/a082jnz-LvI" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/781642" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5023", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5033", + "modified": "2026-05-22T02:08:34Z", + "published": "2026-05-22T02:08:34Z", + "aliases": [ + "CVE-2026-46598" + ], + "summary": "Invoking pathological inputs can lead to client panic in golang.org/x/crypto/ssh/agent", + "details": "For certain crafted inputs, a 'ed25519.PrivateKey' was created by casting malformed wire bytes, leading to a panic when used.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.52.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh/agent", + "symbols": [ + "ForwardToAgent", + "ServeAgent", + "parseEd25519Cert", + "parseEd25519Key" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/79596" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/781360" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/a082jnz-LvI" + } + ], + "credits": [ + { + "name": "NCC Group Cryptography Services, sponsored by Teleport" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5033", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5932", + "modified": "2026-07-07T22:15:29Z", + "published": "2026-07-07T22:15:29Z", + "summary": "The golang.org/x/crypto/openpgp package is unmaintained, unsafe by design, and has known security issues", + "details": "The golang.org/x/crypto/openpgp package is unsafe by design, has numerous known security issues, is not maintained, and should not be used.\n\nIf you are required to interoperate with OpenPGP systems and need a maintained package, consider github.com/ProtonMail/go-crypto/openpgp which is a maintained fork that aims to be a drop-in replacement for this package.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/openpgp" + }, + { + "path": "golang.org/x/crypto/openpgp/packet" + }, + { + "path": "golang.org/x/crypto/openpgp/armor" + }, + { + "path": "golang.org/x/crypto/openpgp/clearsign" + }, + { + "path": "golang.org/x/crypto/openpgp/errors" + }, + { + "path": "golang.org/x/crypto/openpgp/elgamal" + }, + { + "path": "golang.org/x/crypto/openpgp/s2k" + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/44226" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5932", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-6303", + "modified": "2026-08-28T14:47:45Z", + "published": "2026-08-28T14:47:45Z", + "aliases": [ + "CVE-2026-56854" + ], + "summary": "Source-address critical option not enforced for non-public-key auth callbacks in golang.org/x/crypto/ssh", + "details": "The source-address critical option in the Permissions returned by an authentication callback was only enforced for the PublicKeyCallback and VerifiedPublicKeyCallback paths, extending the fix for CVE-2026-46595. Permissions returned by the PasswordCallback, KeyboardInteractiveCallback, NoClientAuthCallback, and GSSAPIWithMICConfig.AllowLogin callbacks were not validated against the client's remote address, so a source-address restriction set by those callbacks was silently ignored. The check is now applied to the Permissions returned by any authentication callback.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.55.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh", + "symbols": [ + "NewServerConn", + "connection.serverAuthenticate" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/80213" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/797040" + } + ], + "credits": [ + { + "name": "Longwei Wang (@gn00295120)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-6303", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-6354", + "modified": "2026-09-02T19:12:04Z", + "published": "2026-09-02T19:12:04Z", + "aliases": [ + "CVE-2026-78662" + ], + "summary": "Prevent DoS on deadlocked undecided channel in golang.org/x/crypto/ssh", + "details": "Previously, a channel registered in the mux's chanList is not usable until it is established. A malicious peer was able flood the channel's incomingRequests, deadlocking the entire connection.\n\nNow, we add an atomic established state, set when a channel becomes usable. Until such a time, handlePacket drops every packet other than the open confirmation/failure, without blocking and without tearing down the connection.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.56.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh", + "symbols": [ + "Dial", + "NewClientConn", + "NewControlClientConn", + "NewServerConn", + "channel.Accept", + "channel.handlePacket", + "channel.responseMessageReceived" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/81316" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/826504" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/1y3fb2np35U" + } + ], + "credits": [ + { + "name": "Will Mortensen" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-6354", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-6355", + "modified": "2026-09-02T19:12:04Z", + "published": "2026-09-02T19:12:04Z", + "aliases": [ + "CVE-2026-56855" + ], + "summary": "Prevent DoS on deadlocked established channel in golang.org/x/crypto/ssh", + "details": "Previously, after a channel has been established, a malicious peer could send crafted messages that would deadlock the entire connection.\n\nNow, we handle all RFC 4254 channel messages; global requests are handled explicitly. Then, treat all other messages as a protocol error and tear the connection down instead of buffering and blocking.", + "affected": [ + { + "package": { + "name": "golang.org/x/crypto", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.56.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/crypto/ssh", + "symbols": [ + "Dial", + "NewClientConn", + "NewControlClientConn", + "NewServerConn", + "channel.handlePacket" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/81317" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/826524" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/1y3fb2np35U" + } + ], + "credits": [ + { + "name": "Will Mortensen" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-6355", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5841", + "modified": "2026-07-27T16:16:22Z", + "published": "2026-07-27T16:16:22Z", + "aliases": [ + "GHSA-259r-337f-4rfw" + ], + "summary": "OOB read in github.com/klauspost/compress/s2", + "details": "Providing a specially crafted dictionary to s2.NewDict and using it to encode data can make the encoder read out of bounds.", + "affected": [ + { + "package": { + "name": "github.com/klauspost/compress", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.16.0" + }, + { + "fixed": "1.18.7" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/klauspost/compress/s2", + "symbols": [ + "NewDict" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/klauspost/compress/security/advisories/GHSA-259r-337f-4rfw" + }, + { + "type": "FIX", + "url": "https://github.com/klauspost/compress/commit/8668e357e776d5152ed62f33c17f21b8690664fa" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5841", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-2912", + "modified": "2024-07-19T16:04:58Z", + "published": "2024-07-01T21:50:42Z", + "aliases": [ + "CVE-2021-41092", + "GHSA-99pg-grm5-qq3v" + ], + "summary": "Docker CLI leaks private registry credentials to registry-1.docker.io in github.com/docker/cli", + "details": "Docker CLI leaks private registry credentials to registry-1.docker.io in github.com/docker/cli", + "affected": [ + { + "package": { + "name": "github.com/docker/cli", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "20.10.9+incompatible" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/docker/cli/cli/command", + "symbols": [ + "GetDefaultAuthConfig", + "RegistryAuthenticationPrivilegedFunc" + ] + }, + { + "path": "github.com/docker/cli/cli/command/registry", + "symbols": [ + "runLogin" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/docker/cli/security/advisories/GHSA-99pg-grm5-qq3v" + }, + { + "type": "FIX", + "url": "https://github.com/docker/cli/commit/893e52cf4ba4b048d72e99748e0f86b2767c6c6b" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-2912", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4610", + "modified": "2026-03-10T18:28:25Z", + "published": "2026-03-10T18:28:25Z", + "aliases": [ + "CVE-2025-15558", + "GHSA-p436-gjf2-799p" + ], + "summary": "Docker CLI Plugins: Uncontrolled Search Path Element Leads to Local Privilege Escalation on Windows in github.com/docker/cli", + "details": "Docker CLI Plugins: Uncontrolled Search Path Element Leads to Local Privilege Escalation on Windows in github.com/docker/cli", + "affected": [ + { + "package": { + "name": "github.com/docker/cli", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "29.2.0+incompatible" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/docker/cli/cli-plugins/manager", + "symbols": [ + "defaultSystemPluginDirs" + ] + } + ] + } + }, + { + "package": { + "name": "github.com/docker/compose", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/docker/compose/v2", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "2.31.0" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/docker/compose/v5", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "5.1.0" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/docker/cli/security/advisories/GHSA-p436-gjf2-799p" + }, + { + "type": "FIX", + "url": "https://github.com/docker/cli/commit/13759330b1f7e7cb0d67047ea42c5482548ba7fa" + }, + { + "type": "FIX", + "url": "https://github.com/docker/cli/pull/6713" + }, + { + "type": "FIX", + "url": "https://github.com/docker/compose/pull/12300" + }, + { + "type": "WEB", + "url": "https://docs.docker.com/desktop/release-notes" + }, + { + "type": "WEB", + "url": "https://www.zerodayinitiative.com/advisories/ZDI-CAN-28304" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4610", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5289", + "modified": "2026-06-25T18:43:19Z", + "published": "2026-06-25T18:43:19Z", + "aliases": [ + "CVE-2026-39350", + "GHSA-9gcg-w975-3rjh" + ], + "summary": "Istio: AuthorizationPolicy serviceAccounts regex injection via unescaped dots in istio.io/istio", + "details": "Istio: AuthorizationPolicy serviceAccounts regex injection via unescaped dots in istio.io/istio", + "affected": [ + { + "package": { + "name": "istio.io/istio", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0.0.0-20241024090207-0bf27d49ba4b" + }, + { + "fixed": "0.0.0-20260403004500-692e460c342d" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/istio/istio/security/advisories/GHSA-9gcg-w975-3rjh" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-39350" + }, + { + "type": "WEB", + "url": "https://github.com/istio/istio/commit/692e460c342d8f308a35b6ecbdace47807da8ade" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5289", + "review_status": "UNREVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5363", + "modified": "2026-06-25T18:43:19Z", + "published": "2026-06-25T18:43:19Z", + "aliases": [ + "CVE-2026-41413", + "GHSA-fgw5-hp8f-xfhc" + ], + "summary": "Istio: SSRF via RequestAuthentication jwksUri in istio.io/istio", + "details": "Istio: SSRF via RequestAuthentication jwksUri in istio.io/istio", + "affected": [ + { + "package": { + "name": "istio.io/istio", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20260410004459-189832a289c1" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/istio/istio/security/advisories/GHSA-fgw5-hp8f-xfhc" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-41413" + }, + { + "type": "WEB", + "url": "https://github.com/istio/istio/releases/tag/1.28.6" + }, + { + "type": "WEB", + "url": "https://github.com/istio/istio/releases/tag/1.29.2" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5363", + "review_status": "UNREVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0322", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-15T23:29:02Z", + "aliases": [ + "CVE-2022-21698", + "GHSA-cg3q-j54f-5p7p" + ], + "summary": "Uncontrolled resource consumption in github.com/prometheus/client_golang", + "details": "The Prometheus client_golang HTTP server is vulnerable to a denial of service attack when handling requests with non-standard HTTP methods.\n\nIn order to be affected, an instrumented software must use any of the promhttp.InstrumentHandler* middleware except RequestsInFlight; not filter any specific methods (e.g GET) before middleware; pass a metric with a \"method\" label name to a middleware; and not have any firewall/LB/proxy that filters away requests with unknown \"method\".", + "affected": [ + { + "package": { + "name": "github.com/prometheus/client_golang", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.11.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/prometheus/client_golang/prometheus/promhttp", + "symbols": [ + "Handler", + "HandlerFor", + "InstrumentHandlerCounter", + "InstrumentHandlerDuration", + "InstrumentHandlerRequestSize", + "InstrumentHandlerResponseSize", + "InstrumentHandlerTimeToWriteHeader", + "InstrumentMetricHandler", + "InstrumentRoundTripperCounter", + "InstrumentRoundTripperDuration", + "flusherDelegator.Flush", + "readerFromDelegator.ReadFrom", + "responseWriterDelegator.Write", + "responseWriterDelegator.WriteHeader", + "sanitizeMethod" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://github.com/prometheus/client_golang/pull/962" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0322", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0411", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-01T20:08:24Z", + "aliases": [ + "CVE-2021-4238", + "GHSA-3839-6r69-m497", + "GHSA-xg2h-wx96-xgxr" + ], + "summary": "Insufficient randomness in github.com/Masterminds/goutils", + "details": "Randomly-generated alphanumeric strings contain significantly less entropy than expected.\n\nThe RandomAlphaNumeric and CryptoRandomAlphaNumeric functions always return strings containing at least one digit from 0 to 9. This significantly reduces the amount of entropy in short strings generated by these functions.", + "affected": [ + { + "package": { + "name": "github.com/Masterminds/goutils", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.1.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/Masterminds/goutils", + "symbols": [ + "CryptoRandomAlphaNumeric", + "RandomAlphaNumeric" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://github.com/Masterminds/goutils/commit/869801f20f9f1e7ecdbdb6422049d8241270d5e1" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0411", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-1792", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-06-08T18:30:06Z", + "aliases": [ + "CVE-2018-20744", + "GHSA-927h-x4qj-r242" + ], + "summary": "Insecure wildcard CORS policy in github.com/rs/cors", + "details": "The CORS handler actively converts a wildcard CORS policy into reflecting an arbitrary Origin header value, which is incompatible with the CORS security design, and could lead to CORS misconfiguration security problems.", + "affected": [ + { + "package": { + "name": "github.com/rs/cors", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.5.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/rs/cors", + "symbols": [ + "AllowAll", + "Cors.HandlerFunc", + "Cors.ServeHTTP", + "Cors.handlePreflight", + "Default", + "New" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://github.com/rs/cors/pull/57" + }, + { + "type": "REPORT", + "url": "https://github.com/rs/cors/issues/55" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-1792", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-2883", + "modified": "2025-08-06T17:36:32Z", + "published": "2024-07-02T19:20:36Z", + "aliases": [ + "CVE-2025-47908", + "GHSA-mh55-gqvf-xfwm" + ], + "summary": "Denial of service via malicious preflight requests in github.com/rs/cors", + "details": "Middleware causes a prohibitive amount of heap allocations when processing malicious preflight requests that include a Access-Control-Request-Headers (ACRH) header whose value contains many commas. This behavior can be abused by attackers to produce undue load on the middleware/server as an attempt to cause a denial of service.", + "affected": [ + { + "package": { + "name": "github.com/rs/cors", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.9.0" + }, + { + "fixed": "1.11.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/rs/cors", + "symbols": [ + "AllowAll", + "Cors.HandlerFunc", + "Cors.ServeHTTP", + "Cors.areHeadersAllowed", + "Cors.handlePreflight", + "Default", + "New", + "splitHeaderValues" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://github.com/rs/cors/pull/171" + }, + { + "type": "REPORT", + "url": "https://github.com/rs/cors/issues/170" + } + ], + "credits": [ + { + "name": "@jub0bs" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-2883", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-4188", + "modified": "2026-01-20T16:59:56Z", + "published": "2025-12-15T20:37:41Z", + "aliases": [ + "CVE-2025-65637", + "GHSA-4f99-4q7p-p3gh" + ], + "summary": "Logrus is vulnerable to DoS when using Entry.writerScanner in github.com/sirupsen/logrus", + "details": "Logrus is vulnerable to DoS when using Entry.writerScanner in github.com/sirupsen/logrus", + "affected": [ + { + "package": { + "name": "github.com/sirupsen/logrus", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.8.3" + }, + { + "introduced": "1.9.0" + }, + { + "fixed": "1.9.1" + }, + { + "introduced": "1.9.2" + }, + { + "fixed": "1.9.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/sirupsen/logrus", + "symbols": [ + "Entry.Writer", + "Entry.WriterLevel", + "Entry.writerScanner", + "Logger.Writer", + "Logger.WriterLevel" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/advisories/GHSA-4f99-4q7p-p3gh" + }, + { + "type": "FIX", + "url": "https://github.com/sirupsen/logrus/commit/6acd903758687c4a3db3c11701e6c414fcf1c1f7" + }, + { + "type": "FIX", + "url": "https://github.com/sirupsen/logrus/pull/1376" + }, + { + "type": "REPORT", + "url": "https://github.com/sirupsen/logrus/issues/1370" + }, + { + "type": "WEB", + "url": "https://github.com/mjuanxd/logrus-dos-poc" + }, + { + "type": "WEB", + "url": "https://github.com/mjuanxd/logrus-dos-poc/blob/main/README.md" + }, + { + "type": "WEB", + "url": "https://github.com/sirupsen/logrus/releases/tag/v1.8.3" + }, + { + "type": "WEB", + "url": "https://github.com/sirupsen/logrus/releases/tag/v1.9.1" + }, + { + "type": "WEB", + "url": "https://github.com/sirupsen/logrus/releases/tag/v1.9.3" + }, + { + "type": "WEB", + "url": "https://security.snyk.io/vuln/SNYK-GOLANG-GITHUBCOMSIRUPSENLOGRUS-5564391" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-4188", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3553", + "modified": "2025-04-08T21:04:08Z", + "published": "2025-03-26T17:24:24Z", + "aliases": [ + "CVE-2025-30204", + "GHSA-mh63-6h87-95cp" + ], + "summary": "Excessive memory allocation during header parsing in github.com/golang-jwt/jwt", + "details": "Excessive memory allocation during header parsing in github.com/golang-jwt/jwt", + "affected": [ + { + "package": { + "name": "github.com/golang-jwt/jwt", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + } + ] + } + ], + "ecosystem_specific": {} + }, + { + "package": { + "name": "github.com/golang-jwt/jwt/v4", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "4.5.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/golang-jwt/jwt/v4", + "symbols": [ + "Parser.ParseUnverified" + ] + } + ] + } + }, + { + "package": { + "name": "github.com/golang-jwt/jwt/v5", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "5.0.0-rc.1" + }, + { + "fixed": "5.2.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/golang-jwt/jwt/v5", + "symbols": [ + "Parser.ParseUnverified" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/golang-jwt/jwt/security/advisories/GHSA-mh63-6h87-95cp" + }, + { + "type": "FIX", + "url": "https://github.com/golang-jwt/jwt/commit/0951d184286dece21f73c85673fd308786ffe9c3" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3553", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-2918", + "modified": "2024-07-01T21:50:42Z", + "published": "2024-07-01T21:50:42Z", + "aliases": [ + "CVE-2024-35255", + "GHSA-m5vv-6r4h-3vj9" + ], + "summary": "Azure Identity Libraries Elevation of Privilege Vulnerability in github.com/Azure/azure-sdk-for-go/sdk/azidentity", + "details": "Azure Identity Libraries Elevation of Privilege Vulnerability in github.com/Azure/azure-sdk-for-go/sdk/azidentity", + "affected": [ + { + "package": { + "name": "github.com/Azure/azure-sdk-for-go/sdk/azidentity", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.6.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/Azure/azure-sdk-for-go/sdk/azidentity", + "symbols": [ + "AzurePipelinesCredential.GetToken", + "ChainedTokenCredential.GetToken", + "ClientAssertionCredential.GetToken", + "ClientCertificateCredential.GetToken", + "ClientSecretCredential.GetToken", + "DefaultAzureCredential.GetToken", + "EnvironmentCredential.GetToken", + "ManagedIdentityCredential.GetToken", + "NewDefaultAzureCredential", + "NewManagedIdentityCredential", + "OnBehalfOfCredential.GetToken", + "WorkloadIdentityCredential.GetToken", + "confidentialClient.GetToken", + "managedIdentityClient.authenticate", + "managedIdentityClient.createAccessToken", + "managedIdentityClient.createAppServiceAuthRequest", + "managedIdentityClient.createAzureArcAuthRequest", + "managedIdentityClient.createAzureMLAuthRequest", + "managedIdentityClient.createCloudShellAuthRequest", + "managedIdentityClient.createIMDSAuthRequest", + "managedIdentityClient.createServiceFabricAuthRequest", + "managedIdentityClient.getAzureArcSecretKey", + "newManagedIdentityClient" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/advisories/GHSA-m5vv-6r4h-3vj9" + }, + { + "type": "FIX", + "url": "https://github.com/Azure/azure-sdk-for-go/commit/50774cd9709905523136fb05e8c85a50e8984499" + }, + { + "type": "WEB", + "url": "https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/issues/4806#issuecomment-2178960340" + }, + { + "type": "WEB", + "url": "https://msrc.microsoft.com/update-guide/vulnerability/CVE-2024-35255" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-2918", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-2901", + "modified": "2024-12-12T20:32:54Z", + "published": "2024-06-14T13:41:08Z", + "aliases": [ + "CVE-2024-37032", + "GHSA-8hqg-whrw-pv92" + ], + "summary": "Ollama does not validate the format of the digest (sha256 with 64 hex digits) in github.com/ollama/ollama", + "details": "Ollama does not validate the format of the digest (sha256 with 64 hex digits) in github.com/ollama/ollama", + "affected": [ + { + "package": { + "name": "github.com/ollama/ollama", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.1.34" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/ollama/ollama/server", + "symbols": [ + "GetBlobsPath" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/advisories/GHSA-8hqg-whrw-pv92" + }, + { + "type": "FIX", + "url": "https://github.com/ollama/ollama/commit/2a21363bb756a7341d3d577f098583865bd7603f" + }, + { + "type": "FIX", + "url": "https://github.com/ollama/ollama/pull/4175" + }, + { + "type": "WEB", + "url": "https://github.com/ollama/ollama/blob/adeb40eaf29039b8964425f69a9315f9f1694ba8/server/modelpath_test.go#L41-L58" + }, + { + "type": "WEB", + "url": "https://github.com/ollama/ollama/compare/v0.1.33...v0.1.34" + }, + { + "type": "WEB", + "url": "https://www.vicarius.io/vsociety/posts/probllama-in-ollama-a-tale-of-a-yet-another-rce-vulnerability-cve-2024-37032" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-2901", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-3104", + "modified": "2024-12-12T22:00:24Z", + "published": "2024-08-30T17:18:07Z", + "aliases": [ + "CVE-2024-45436", + "GHSA-846m-99qv-67mg" + ], + "summary": "Ollama can extract members of a ZIP archive outside of the parent directory in github.com/ollama/ollama", + "details": "Ollama can extract members of a ZIP archive outside of the parent directory in github.com/ollama/ollama", + "affected": [ + { + "package": { + "name": "github.com/ollama/ollama", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.1.47" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/ollama/ollama/cmd", + "symbols": [ + "tempZipFiles" + ] + }, + { + "path": "github.com/ollama/ollama/server", + "symbols": [ + "parseFromZipFile" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/advisories/GHSA-846m-99qv-67mg" + }, + { + "type": "FIX", + "url": "https://github.com/ollama/ollama/commit/123a722a6f541e300bc8e34297ac378ebe23f527" + }, + { + "type": "FIX", + "url": "https://github.com/ollama/ollama/pull/5314" + }, + { + "type": "WEB", + "url": "https://github.com/ollama/ollama/compare/v0.1.46...v0.1.47" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-3104", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-3245", + "modified": "2024-12-12T15:17:04Z", + "published": "2024-11-01T21:55:12Z", + "aliases": [ + "CVE-2024-39720", + "GHSA-95j2-w8x7-hm88" + ], + "summary": "Ollama Out-of-bounds Read in github.com/ollama/ollama", + "details": "Ollama Out-of-bounds Read in github.com/ollama/ollama", + "affected": [ + { + "package": { + "name": "github.com/ollama/ollama", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.1.46" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/advisories/GHSA-95j2-w8x7-hm88" + }, + { + "type": "WEB", + "url": "https://github.com/ollama/ollama/compare/v0.1.45...v0.1.46#diff-782c2737eecfa83b7cb46a77c8bdaf40023e7067baccd4f806ac5517b4563131L417" + }, + { + "type": "WEB", + "url": "https://oligo.security/blog/more-models-more-probllms" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-3245", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3548", + "modified": "2026-05-21T17:44:50Z", + "published": "2025-03-31T17:03:53Z", + "aliases": [ + "CVE-2024-12886", + "GHSA-v464-r2r9-www7" + ], + "summary": "Ollama Vulnerable to Denial of Service (DoS) via Crafted GZIP in github.com/ollama/ollama", + "details": "Ollama Vulnerable to Denial of Service (DoS) via Crafted GZIP in github.com/ollama/ollama", + "affected": [ + { + "package": { + "name": "github.com/ollama/ollama", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.4.0-ci1" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/advisories/GHSA-v464-r2r9-www7" + }, + { + "type": "WEB", + "url": "https://huntr.com/bounties/f115fe52-58af-4844-ad29-b1c25f7245df" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3548", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3557", + "modified": "2025-03-31T17:03:53Z", + "published": "2025-03-31T17:03:53Z", + "aliases": [ + "CVE-2025-0315", + "GHSA-fccc-8m69-8r78" + ], + "summary": "Ollama Allocation of Resources Without Limits or Throttling vulnerability in github.com/ollama/ollama", + "details": "Ollama Allocation of Resources Without Limits or Throttling vulnerability in github.com/ollama/ollama", + "affected": [ + { + "package": { + "name": "github.com/ollama/ollama", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/advisories/GHSA-fccc-8m69-8r78" + }, + { + "type": "WEB", + "url": "https://huntr.com/bounties/da414d29-b55a-496f-b135-17e0fcec67bc" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3557", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3558", + "modified": "2025-03-31T17:03:53Z", + "published": "2025-03-31T17:03:53Z", + "aliases": [ + "CVE-2024-12055", + "GHSA-89qx-m49c-8crf" + ], + "summary": "Ollama Allows Out-of-Bounds Read in github.com/ollama/ollama", + "details": "Ollama Allows Out-of-Bounds Read in github.com/ollama/ollama", + "affected": [ + { + "package": { + "name": "github.com/ollama/ollama", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/advisories/GHSA-89qx-m49c-8crf" + }, + { + "type": "WEB", + "url": "https://huntr.com/bounties/7b111d55-8215-4727-8807-c5ed4cf1bfbe" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3558", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3559", + "modified": "2025-03-31T17:03:53Z", + "published": "2025-03-31T17:03:53Z", + "aliases": [ + "CVE-2025-0317", + "GHSA-9gcr-28rp-cc24" + ], + "summary": "Ollama Divide By Zero vulnerability in github.com/ollama/ollama", + "details": "Ollama Divide By Zero vulnerability in github.com/ollama/ollama", + "affected": [ + { + "package": { + "name": "github.com/ollama/ollama", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/advisories/GHSA-9gcr-28rp-cc24" + }, + { + "type": "WEB", + "url": "https://huntr.com/bounties/a9951bca-9bd8-49b2-b143-4cd4219f9fa0" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3559", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3582", + "modified": "2025-04-01T21:39:54Z", + "published": "2025-04-01T21:39:54Z", + "aliases": [ + "CVE-2025-0312", + "GHSA-p2wh-w96x-w232" + ], + "summary": "Ollama Denial of Service (DoS) via Null Pointer Dereference in github.com/ollama/ollama", + "details": "Ollama Denial of Service (DoS) via Null Pointer Dereference in github.com/ollama/ollama", + "affected": [ + { + "package": { + "name": "github.com/ollama/ollama", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/advisories/GHSA-p2wh-w96x-w232" + }, + { + "type": "WEB", + "url": "https://huntr.com/bounties/522c87b6-a7ac-41b2-84f3-62fd58921f21" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3582", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3689", + "modified": "2025-05-15T19:45:07Z", + "published": "2025-05-15T19:45:07Z", + "aliases": [ + "CVE-2024-8063", + "GHSA-2xf2-gjm6-g2c6" + ], + "summary": "Ollama Divide by Zero Vulnerability in github.com/ollama/ollama", + "details": "Ollama Divide by Zero Vulnerability in github.com/ollama/ollama", + "affected": [ + { + "package": { + "name": "github.com/ollama/ollama", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/advisories/GHSA-2xf2-gjm6-g2c6" + }, + { + "type": "REPORT", + "url": "https://github.com/ollama/ollama/issues/8020" + }, + { + "type": "WEB", + "url": "https://huntr.com/bounties/fd8e1ed6-21d2-4c9e-8395-2098f11b7db9" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3689", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3695", + "modified": "2025-05-22T16:50:31Z", + "published": "2025-05-22T16:50:31Z", + "aliases": [ + "CVE-2025-1975", + "GHSA-wrh5-cmwx-q2qr" + ], + "summary": "Ollama Server Vulnerable to Denial of Service (DoS) Attack in github.com/ollama/ollama", + "details": "Ollama Server Vulnerable to Denial of Service (DoS) Attack in github.com/ollama/ollama", + "affected": [ + { + "package": { + "name": "github.com/ollama/ollama", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/advisories/GHSA-wrh5-cmwx-q2qr" + }, + { + "type": "WEB", + "url": "https://huntr.com/bounties/921ba5d4-f1d0-4c66-9764-4f72dffe7acd" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3695", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3824", + "modified": "2025-07-30T21:37:48Z", + "published": "2025-07-30T21:37:48Z", + "aliases": [ + "CVE-2025-51471", + "GHSA-x9hg-5q6g-q3jr" + ], + "summary": "Ollama vulnerable to Cross-Domain Token Exposure in github.com/ollama/ollama", + "details": "Ollama vulnerable to Cross-Domain Token Exposure in github.com/ollama/ollama", + "affected": [ + { + "package": { + "name": "github.com/ollama/ollama", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/advisories/GHSA-x9hg-5q6g-q3jr" + }, + { + "type": "FIX", + "url": "https://github.com/ollama/ollama/pull/10750" + }, + { + "type": "WEB", + "url": "https://www.gecko.security/blog/cve-2025-51471" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3824", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3851", + "modified": "2025-08-11T19:29:33Z", + "published": "2025-08-11T19:29:33Z", + "aliases": [ + "CVE-2025-44779", + "GHSA-93jv-pvg8-hf3v" + ], + "summary": "Ollama allows deletion of arbitrary files in github.com/ollama/ollama", + "details": "Ollama allows deletion of arbitrary files in github.com/ollama/ollama", + "affected": [ + { + "package": { + "name": "github.com/ollama/ollama", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.1.34" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/advisories/GHSA-93jv-pvg8-hf3v" + }, + { + "type": "WEB", + "url": "https://a1batr0ss.top/2025/08/06/CVE-2025-44779-Ollama-arbitrary-file-deletion" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3851", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-4251", + "modified": "2026-01-14T19:15:43Z", + "published": "2026-01-14T19:15:43Z", + "aliases": [ + "CVE-2025-63389", + "GHSA-f6mr-38g8-39rg" + ], + "summary": "Ollama has missing authentication enabling attackers to perform model management operations in github.com/ollama/ollama", + "details": "Ollama has missing authentication enabling attackers to perform model management operations in github.com/ollama/ollama", + "affected": [ + { + "package": { + "name": "github.com/ollama/ollama", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/advisories/GHSA-f6mr-38g8-39rg" + }, + { + "type": "WEB", + "url": "https://gist.github.com/Cristliu/48dae561696374744d9fced07a544ecd" + }, + { + "type": "WEB", + "url": "https://github.com/ollama/ollama/issues" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-4251", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5748", + "modified": "2026-07-27T15:30:50Z", + "published": "2026-07-27T15:30:50Z", + "aliases": [ + "CVE-2026-7482", + "GHSA-x8qc-fggm-mpqg" + ], + "summary": "Ollama contains a heap out-of-bounds read vulnerability in the GGUF model loader in github.com/ollama/ollama", + "details": "Ollama is vulnerable to a heap out-of-bounds read in its GGUF model loader. A specially crafted GGUF file can cause the loader to read beyond the allocated heap buffer, potentially leading to a denial of service (crash).", + "affected": [ + { + "package": { + "name": "github.com/ollama/ollama", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.17.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/ollama/ollama/server", + "symbols": [ + "WriteTo", + "quantizer.WriteTo" + ] + }, + { + "path": "github.com/ollama/ollama/fs/ggml", + "symbols": [ + "Decode", + "gguf.Decode" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/advisories/GHSA-x8qc-fggm-mpqg" + }, + { + "type": "FIX", + "url": "https://github.com/ollama/ollama/commit/88d57d0483cca907e0b23a968c83627a20b21047" + }, + { + "type": "FIX", + "url": "https://github.com/ollama/ollama/pull/14406" + }, + { + "type": "WEB", + "url": "https://github.com/ollama/ollama/releases/tag/v0.17.1" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5748", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5750", + "modified": "2026-07-27T16:28:49Z", + "published": "2026-07-27T16:28:49Z", + "aliases": [ + "CVE-2026-7020", + "GHSA-x99g-8v8j-25j2" + ], + "summary": "Ollama is Vulnerable to Path Traversal in github.com/ollama/ollama", + "details": "Ollama is Vulnerable to Path Traversal in github.com/ollama/ollama", + "affected": [ + { + "package": { + "name": "github.com/ollama/ollama", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/ollama/ollama/x/imagegen/transfer" + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/advisories/GHSA-x99g-8v8j-25j2" + }, + { + "type": "WEB", + "url": "https://vuldb.com/submit/797576" + }, + { + "type": "WEB", + "url": "https://vuldb.com/vuln/359599" + }, + { + "type": "WEB", + "url": "https://vuldb.com/vuln/359599/cti" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5750", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-6253", + "modified": "2026-08-25T19:43:10Z", + "published": "2026-08-25T19:43:10Z", + "aliases": [ + "CVE-2026-17106", + "GHSA-hfg8-hc9c-6c3h" + ], + "summary": "moby/go-archive: Crafted tar archive can write outside the extraction directory in github.com/moby/go-archive", + "details": "moby/go-archive: Crafted tar archive can write outside the extraction directory in github.com/moby/go-archive", + "affected": [ + { + "package": { + "name": "github.com/moby/go-archive", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.3.0" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/moby/go-archive/security/advisories/GHSA-hfg8-hc9c-6c3h" + }, + { + "type": "WEB", + "url": "https://docs.docker.com/desktop/release-notes/#4860" + }, + { + "type": "WEB", + "url": "https://github.com/bikini/exploitarium/tree/main/docker-cp-copyout-destination-escape" + }, + { + "type": "WEB", + "url": "https://github.com/docker/cli/releases/tag/v29.7.0" + }, + { + "type": "WEB", + "url": "https://github.com/moby/moby/issues/52948" + }, + { + "type": "WEB", + "url": "https://github.com/moby/moby/releases/tag/docker-v29.7.0" + }, + { + "type": "WEB", + "url": "https://www.imperva.com/blog/copyescape-taking-over-docker-hosts-with-docker-cp" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-6253", + "review_status": "UNREVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-1859", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-06-22T16:36:11Z", + "aliases": [ + "GHSA-rm8v-mxj3-5rmq" + ], + "summary": "Padding oracle vulnerability in github.com/lestrrat-go/jwx", + "details": "AES-CBC decryption is vulnerable to a timing attack which may permit an attacker to recover the plaintext of JWE data.", + "affected": [ + { + "package": { + "name": "github.com/lestrrat-go/jwx", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.2.26" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/lestrrat-go/jwx/jwe/internal/aescbc", + "symbols": [ + "Hmac.Open", + "unpad" + ] + } + ] + } + }, + { + "package": { + "name": "github.com/lestrrat-go/jwx/v2", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "2.0.11-0.20230614080639-c8b6bec919a1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/lestrrat-go/jwx/v2/jwe/internal/aescbc", + "symbols": [ + "Hmac.Open", + "unpad" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://github.com/lestrrat-go/jwx/commit/6c41e3822485fc7e11dd70b4b0524b075d66b103" + }, + { + "type": "FIX", + "url": "https://github.com/lestrrat-go/jwx/commit/d9ddbc8e5009cfdd8c28413390b67afa7f576dd6" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-1859", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-2379", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-12-11T15:08:59Z", + "aliases": [ + "CVE-2023-49290", + "GHSA-7f9x-gw85-8grf" + ], + "summary": "Denial of service due to malicious parameters in github.com/lestrrat-go/jwx", + "details": "The JWE key management algorithms based on PBKDF2 require a JOSE Header Parameter called p2c (PBES2 Count). This parameter dictates the number of PBKDF2 iterations needed to derive a CEK wrapping key. Its purpose is to intentionally slow down the key derivation function, making password brute-force and dictionary attacks more resource-intensive. However, if an attacker sets the p2c parameter in JWE to a very large number, it can cause excessive computational consumption.", + "affected": [ + { + "package": { + "name": "github.com/lestrrat-go/jwx", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.2.27" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/lestrrat-go/jwx/jwe", + "symbols": [ + "Decrypt" + ] + } + ] + } + }, + { + "package": { + "name": "github.com/lestrrat-go/jwx/v2", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "2.0.18" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/lestrrat-go/jwx/v2/jwe", + "symbols": [ + "Decrypt", + "decryptCtx.decryptContent" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/lestrrat-go/jwx/security/advisories/GHSA-7f9x-gw85-8grf" + }, + { + "type": "FIX", + "url": "https://github.com/lestrrat-go/jwx/commit/64f2a229b8e18605f47361d292b526bdc4aee01c" + } + ], + "credits": [ + { + "name": "@P3ngu1nW" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-2379", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-2454", + "modified": "2024-05-20T16:03:47Z", + "published": "2024-01-23T18:01:33Z", + "aliases": [ + "CVE-2024-21664", + "GHSA-pvcr-v8j8-j5q3" + ], + "summary": "Panic due to nil pointer dereference in github.com/lestrrat-go/jwx/v2", + "details": "Panic due to nil pointer dereference in github.com/lestrrat-go/jwx/v2", + "affected": [ + { + "package": { + "name": "github.com/lestrrat-go/jwx", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.0.8" + }, + { + "fixed": "1.2.28" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/lestrrat-go/jwx/jws", + "symbols": [ + "Message.UnmarshalJSON" + ] + } + ] + } + }, + { + "package": { + "name": "github.com/lestrrat-go/jwx/v2", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "2.0.19" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/lestrrat-go/jwx/v2/jws", + "symbols": [ + "Message.UnmarshalJSON" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2024-21664" + }, + { + "type": "FIX", + "url": "https://github.com/lestrrat-go/jwx/commit/0e8802ce6842625845d651456493e7c87625601f" + }, + { + "type": "FIX", + "url": "https://github.com/lestrrat-go/jwx/commit/d69a721931a5c48b9850a42404f18e143704adcd" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-2454", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-2632", + "modified": "2024-05-20T19:46:23Z", + "published": "2024-05-20T19:46:23Z", + "aliases": [ + "CVE-2024-28122", + "GHSA-hj3v-m684-v259" + ], + "summary": "JWX vulnerable to a denial of service attack using compressed JWE message in github.com/lestrrat-go/jwx", + "details": "An attacker with a trusted public key may cause a Denial-of-Service (DoS) condition by crafting a malicious JSON Web Encryption (JWE) token with an exceptionally high compression ratio. When this token is processed by the recipient, it results in significant memory allocation and processing time during decompression.", + "affected": [ + { + "package": { + "name": "github.com/lestrrat-go/jwx", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.2.29" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/lestrrat-go/jwx/jwe", + "symbols": [ + "Decrypt", + "Message.Decrypt", + "uncompress" + ] + } + ] + } + }, + { + "package": { + "name": "github.com/lestrrat-go/jwx/v2", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "2.0.21" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/lestrrat-go/jwx/v2/jwe", + "symbols": [ + "Decrypt", + "Settings", + "decryptCtx.decryptContent", + "uncompress" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/lestrrat-go/jwx/security/advisories/GHSA-hj3v-m684-v259" + }, + { + "type": "FIX", + "url": "https://github.com/lestrrat-go/jwx/commit/d01027d74c7376d66037a10f4f64af9af26a7e34" + }, + { + "type": "FIX", + "url": "https://github.com/lestrrat-go/jwx/commit/d43f2ceb7f0c13714dfe8854d6439766e86faa76" + }, + { + "type": "WEB", + "url": "https://github.com/lestrrat-go/jwx/releases/tag/v1.2.29" + }, + { + "type": "WEB", + "url": "https://github.com/lestrrat-go/jwx/releases/tag/v2.0.21" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-2632", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4394", + "modified": "2026-02-25T20:15:11Z", + "published": "2026-02-19T17:28:55Z", + "aliases": [ + "CVE-2026-24051", + "GHSA-9h8m-3fm2-qjrq" + ], + "summary": "OpenTelemetry Go SDK Vulnerable to Arbitrary Code Execution via PATH Hijacking in go.opentelemetry.io/otel/sdk", + "details": "OpenTelemetry Go SDK Vulnerable to Arbitrary Code Execution via PATH Hijacking in go.opentelemetry.io/otel/sdk", + "affected": [ + { + "package": { + "name": "go.opentelemetry.io/otel/sdk", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.21.0" + }, + { + "fixed": "1.40.0" + } + ] + } + ], + "ecosystem_specific": {} + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/open-telemetry/opentelemetry-go/security/advisories/GHSA-9h8m-3fm2-qjrq" + }, + { + "type": "WEB", + "url": "https://github.com/open-telemetry/opentelemetry-go/commit/d45961bcda453fcbdb6469c22d6e88a1f9970a53" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4394", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5426", + "modified": "2026-07-24T18:35:55Z", + "published": "2026-07-24T18:35:55Z", + "aliases": [ + "CVE-2026-39883", + "GHSA-hfvc-g4fc-pqhx" + ], + "summary": "Opentelemetry-go: BSD kenv command not using absolute path enables PATH hijacking in go.opentelemetry.io/otel/sdk", + "details": "Opentelemetry-go: BSD kenv command not using absolute path enables PATH hijacking in go.opentelemetry.io/otel/sdk", + "affected": [ + { + "package": { + "name": "go.opentelemetry.io/otel/sdk", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.15.0" + }, + { + "fixed": "1.43.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "go.opentelemetry.io/otel/sdk/resource", + "symbols": [ + "New" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://github.com/open-telemetry/opentelemetry-go/security/advisories/GHSA-hfvc-g4fc-pqhx" + }, + { + "type": "WEB", + "url": "http://github.com/open-telemetry/opentelemetry-go/releases/tag/v1.43.0" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5426", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2020-0014", + "modified": "2024-05-20T16:03:47Z", + "published": "2021-04-14T20:04:52Z", + "aliases": [ + "CVE-2018-17846", + "GHSA-vfw5-hrgq-h5wf" + ], + "summary": "Infinite loop due to improper handling of \"select\" tags in golang.org/x/net/html", + "details": "html.Parse does not properly handle \"select\" tags, which can lead to an infinite loop. If parsing user supplied input, this may be used as a denial of service vector.", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20190125091013-d26f9f9a57f3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/html", + "symbols": [ + "Parse", + "ParseFragment", + "inSelectIM", + "inSelectInTableIM" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go-review.googlesource.com/c/137275" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/net/+/d26f9f9a57f3fab6a695bec0d84433c2c50f8bbf" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/27842" + } + ], + "credits": [ + { + "name": "@tr3ee" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2020-0014", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0078", + "modified": "2024-05-20T16:03:47Z", + "published": "2021-04-14T20:04:52Z", + "aliases": [ + "CVE-2018-17075", + "GHSA-5p4h-3377-7w67" + ], + "summary": "Panic when parsing malformed HTML in golang.org/x/net/html", + "details": "The HTML parser does not properly handle \"in frameset\" insertion mode, and can be made to panic when operating on malformed HTML that contains \u003ctemplate\u003e tags. If operating on user input, this may be a vector for a denial of service attack.", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20180816102801-aaf60122140d" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/html", + "symbols": [ + "Parse", + "ParseFragment", + "inBodyIM", + "inFramesetIM" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/123776" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/net/+/aaf60122140d3fcf75376d319f0554393160eb50" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/27016" + }, + { + "type": "WEB", + "url": "https://bugs.chromium.org/p/chromium/issues/detail?id=829668" + }, + { + "type": "WEB", + "url": "https://go-review.googlesource.com/c/net/+/94838/9/html/parse.go#1906" + } + ], + "credits": [ + { + "name": "Kunpei Sakai" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0078", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0238", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-02-17T17:33:43Z", + "aliases": [ + "CVE-2021-33194", + "GHSA-83g2-8m93-v3w7" + ], + "summary": "Infinite loop when parsing inputs in golang.org/x/net/html", + "details": "An attacker can craft an input to ParseFragment that causes it to enter an infinite loop and never return.", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20210520170846-37e1c6afe023" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/html", + "symbols": [ + "Parse", + "ParseFragment", + "ParseFragmentWithOptions", + "ParseWithOptions", + "inHeadIM" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/311090" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/net/+/37e1c6afe02340126705deced573a85ab75209d7" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/46288" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/wPunbCPkWUg" + } + ], + "credits": [ + { + "name": "OSS-Fuzz (discovery)" + }, + { + "name": "Andrew Thornton (reporter)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0238", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0192", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-01T20:11:34Z", + "aliases": [ + "CVE-2018-17142", + "GHSA-2wp2-chmh-r934" + ], + "summary": "Incorrect parsing of nested templates in golang.org/x/net/html", + "details": "The Parse function can panic on some invalid inputs.\n\nFor example, the Parse function panics on the input \"\u003cmath\u003e\u003ctemplate\u003e\u003cmo\u003e\u003ctemplate\u003e\".", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20180925071336-cf3bd585ca2a" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/html", + "symbols": [ + "Parse", + "ParseFragment", + "parser.resetInsertionMode" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/136875" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/net/+/cf3bd585ca2a5a21b057abd8be7eea2204af89d0" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/27702" + } + ], + "credits": [ + { + "name": "@tr3ee" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0192", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0193", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-06T18:14:54Z", + "aliases": [ + "CVE-2018-17143", + "GHSA-fcf9-6fv2-fc5v" + ], + "summary": "Panic on unconsidered isindex and template combination in golang.org/x/net/html", + "details": "The Parse function can panic on some invalid inputs.\n\nFor example, the Parse function panics on the input \"\u003ctemplate\u003e\u003ctBody\u003e\u003cisindex/action=0\u003e\".", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20180921000356-2f5d2388922f" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/html", + "symbols": [ + "Parse", + "ParseFragment", + "inBodyIM" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go-review.googlesource.com/c/net/+/136575" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/net/+/2f5d2388922f370f4355f327fcf4cfe9f5583908" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/27704" + } + ], + "credits": [ + { + "name": "@tr3ee" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0193", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0197", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-01T20:15:19Z", + "aliases": [ + "CVE-2018-17847", + "CVE-2018-17848", + "GHSA-4r78-hx75-jjj2", + "GHSA-mv93-wvcp-7m7r" + ], + "summary": "Panic when parsing certain inputs in golang.org/x/net/html", + "details": "The Parse function can panic on some invalid inputs.\n\nFor example, the Parse function panics on the input \"\u003csvg\u003e\u003ctemplate\u003e\u003cdesc\u003e\u003ct\u003e\u003csvg\u003e\u003c/template\u003e\".", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20190125002852-4b62a64f59f7" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/html", + "symbols": [ + "Parse", + "ParseFragment", + "nodeStack.contains" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/159397" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/net/+/4b62a64f59f73840b9ab79204c94fee61cd1ba2c" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/27846" + } + ], + "credits": [ + { + "name": "@tr3ee" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0197", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0236", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-15T23:04:18Z", + "aliases": [ + "CVE-2021-31525", + "GHSA-h86h-8ppg-mxmh" + ], + "summary": "Panic due to large headers in net/http and golang.org/x/net/http/httpguts", + "details": "A malicious HTTP server or client can cause the net/http client or server to panic.\n\nReadRequest and ReadResponse can hit an unrecoverable panic when reading a very large header (over 7MB on 64-bit architectures, or over 4MB on 32-bit ones). Transport and Client are vulnerable and the program can be made to crash by a malicious server. Server is not vulnerable by default, but can be if the default max header of 1MB is overridden by setting Server.MaxHeaderBytes to a higher value, in which case the program can be made to crash by a malicious client.\n\nThis also affects golang.org/x/net/http2/h2c and HeaderValuesContainsToken in golang.org/x/net/http/httpguts.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.15.12" + }, + { + "introduced": "1.16.0-0" + }, + { + "fixed": "1.16.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "http2clientStream.writeRequest", + "http2isConnectionCloseRequest", + "isProtocolSwitchHeader", + "shouldClose" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20210428140749-89ef3d95e781" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http/httpguts", + "symbols": [ + "HeaderValuesContainsToken", + "headerValueContainsToken" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/313069" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/net/+/89ef3d95e781148a0951956029c92a211477f7f9" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/45710" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/cu9SP4eSXMc" + } + ], + "credits": [ + { + "name": "Guido Vranken" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0236", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0288", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-07-15T23:08:33Z", + "aliases": [ + "CVE-2021-44716", + "GHSA-vc3p-29h2-gpcp" + ], + "summary": "Unbounded memory growth in net/http and golang.org/x/net/http2", + "details": "An attacker can cause unbounded memory growth in servers accepting HTTP/2 requests.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.16.12" + }, + { + "introduced": "1.17.0-0" + }, + { + "fixed": "1.17.5" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "http2serverConn.canonicalHeader" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20211209124913-491a49abca63" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http2", + "symbols": [ + "Server.ServeConn", + "serverConn.canonicalHeader" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/369794" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/50058" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/hcmEScgc00k" + } + ], + "credits": [ + { + "name": "murakmii" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0288", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0536", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-08-01T22:20:53Z", + "aliases": [ + "CVE-2019-9512", + "CVE-2019-9514", + "GHSA-39qc-96h7-956f", + "GHSA-hgr8-6h9x-f7q9" + ], + "summary": "Reset flood in net/http and golang.org/x/net/http", + "details": "Some HTTP/2 implementations are vulnerable to a reset flood, potentially leading to a denial of service.\n\nServers that accept direct connections from untrusted clients could be remotely made to allocate an unlimited amount of memory, until the program crashes. The attacker opens a number of streams and sends an invalid request over each stream that should solicit a stream of RST_STREAM frames from the peer. Depending on how the peer queues the RST_STREAM frames, this can consume excess memory, CPU, or both.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.11.13" + }, + { + "introduced": "1.12.0-0" + }, + { + "fixed": "1.12.8" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "http2serverConn.scheduleFrameWrite", + "http2serverConn.serve", + "http2serverConn.writeFrame" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20190813141303-74dc4d7220e7" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http2", + "symbols": [ + "Server.ServeConn", + "serverConn.scheduleFrameWrite", + "serverConn.serve", + "serverConn.writeFrame" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/190137" + }, + { + "type": "FIX", + "url": "https://go.googlesource.com/go/+/145e193131eb486077b66009beb051aba07c52a5" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/33606" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/65QixT3tcmg/m/DrFiG6vvCwAJ" + } + ], + "credits": [ + { + "name": "Jonathan Looney of Netflix" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0536", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-0969", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-09-12T20:23:06Z", + "aliases": [ + "CVE-2022-27664", + "GHSA-69cg-p879-7622" + ], + "summary": "Denial of service in net/http and golang.org/x/net/http2", + "details": "HTTP/2 server connections can hang forever waiting for a clean shutdown that was preempted by a fatal error. This condition can be exploited by a malicious client to cause a denial of service.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.18.6" + }, + { + "introduced": "1.19.0-0" + }, + { + "fixed": "1.19.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "ListenAndServe", + "ListenAndServeTLS", + "Serve", + "ServeTLS", + "Server.ListenAndServe", + "Server.ListenAndServeTLS", + "Server.Serve", + "Server.ServeTLS", + "http2Server.ServeConn", + "http2serverConn.goAway" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.0.0-20220906165146-f3363e06e74c" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http2", + "symbols": [ + "Server.ServeConn", + "serverConn.goAway" + ] + } + ] + } + } + ], + "references": [ + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/x49AQzIVX-s" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/54658" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/428735" + } + ], + "credits": [ + { + "name": "Bahruz Jabiyev" + }, + { + "name": "Tommaso Innocenti" + }, + { + "name": "Anthony Gavazzi" + }, + { + "name": "Steven Sprecher" + }, + { + "name": "Kaan Onarlioglu" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-0969", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2022-1144", + "modified": "2024-05-20T16:03:47Z", + "published": "2022-12-08T19:01:21Z", + "aliases": [ + "CVE-2022-41717", + "GHSA-xrjj-mj9h-534m" + ], + "summary": "Excessive memory growth in net/http and golang.org/x/net/http2", + "details": "An attacker can cause excessive memory growth in a Go server accepting HTTP/2 requests.\n\nHTTP/2 server connections contain a cache of HTTP header keys sent by the client. While the total number of entries in this cache is capped, an attacker sending very large keys can cause the server to allocate approximately 64 MiB per open connection.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.18.9" + }, + { + "introduced": "1.19.0-0" + }, + { + "fixed": "1.19.4" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "ListenAndServe", + "ListenAndServeTLS", + "Serve", + "ServeTLS", + "Server.ListenAndServe", + "Server.ListenAndServeTLS", + "Server.Serve", + "Server.ServeTLS", + "http2Server.ServeConn", + "http2serverConn.canonicalHeader" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.4.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http2", + "symbols": [ + "Server.ServeConn", + "serverConn.canonicalHeader" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/56350" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/455717" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/455635" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/L_3rmdT0BMU/m/yZDrXjIiBQAJ" + } + ], + "credits": [ + { + "name": "Josselin Costanzi" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2022-1144", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-1495", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-01-13T22:39:40Z", + "aliases": [ + "CVE-2022-41721", + "GHSA-fxg5-wq6x-vr4w" + ], + "summary": "Request smuggling due to improper request handling in golang.org/x/net/http2/h2c", + "details": "A request smuggling attack is possible when using MaxBytesHandler.\n\nWhen using MaxBytesHandler, the body of an HTTP request is not fully consumed. When the server attempts to read HTTP2 frames from the connection, it will instead be reading the body of the HTTP request, which could be attacker-manipulated to represent arbitrary HTTP2 requests.", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0.0.0-20220524220425-1d687d428aca" + }, + { + "fixed": "0.1.1-0.20221104162952-702349b0e862" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http2/h2c", + "symbols": [ + "h2cHandler.ServeHTTP", + "h2cUpgrade" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/56352" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/447396" + } + ], + "credits": [ + { + "name": "John Howard (Google)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-1495", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-1571", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-02-16T22:31:36Z", + "aliases": [ + "CVE-2022-41723", + "GHSA-vvpx-j8f3-3w6h" + ], + "summary": "Denial of service via crafted HTTP/2 stream in net/http and golang.org/x/net", + "details": "A maliciously crafted HTTP/2 stream could cause excessive CPU consumption in the HPACK decoder, sufficient to cause a denial of service from a small number of small requests.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.19.6" + }, + { + "introduced": "1.20.0-0" + }, + { + "fixed": "1.20.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "Client.Do", + "Client.Get", + "Client.Head", + "Client.Post", + "Client.PostForm", + "Get", + "Head", + "ListenAndServe", + "ListenAndServeTLS", + "Post", + "PostForm", + "Serve", + "ServeTLS", + "Server.ListenAndServe", + "Server.ListenAndServeTLS", + "Server.Serve", + "Server.ServeTLS", + "Transport.RoundTrip" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.7.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http2", + "symbols": [ + "ClientConn.Close", + "ClientConn.Ping", + "ClientConn.RoundTrip", + "ClientConn.Shutdown", + "ConfigureServer", + "ConfigureTransport", + "ConfigureTransports", + "ConnectionError.Error", + "ErrCode.String", + "FrameHeader.String", + "FrameType.String", + "FrameWriteRequest.String", + "Framer.ReadFrame", + "Framer.WriteContinuation", + "Framer.WriteData", + "Framer.WriteDataPadded", + "Framer.WriteGoAway", + "Framer.WriteHeaders", + "Framer.WritePing", + "Framer.WritePriority", + "Framer.WritePushPromise", + "Framer.WriteRSTStream", + "Framer.WriteRawFrame", + "Framer.WriteSettings", + "Framer.WriteSettingsAck", + "Framer.WriteWindowUpdate", + "GoAwayError.Error", + "ReadFrameHeader", + "Server.ServeConn", + "Setting.String", + "SettingID.String", + "SettingsFrame.ForeachSetting", + "StreamError.Error", + "Transport.CloseIdleConnections", + "Transport.NewClientConn", + "Transport.RoundTrip", + "Transport.RoundTripOpt", + "bufferedWriter.Flush", + "bufferedWriter.Write", + "chunkWriter.Write", + "clientConnPool.GetClientConn", + "connError.Error", + "dataBuffer.Read", + "duplicatePseudoHeaderError.Error", + "gzipReader.Close", + "gzipReader.Read", + "headerFieldNameError.Error", + "headerFieldValueError.Error", + "noDialClientConnPool.GetClientConn", + "noDialH2RoundTripper.RoundTrip", + "pipe.Read", + "priorityWriteScheduler.CloseStream", + "priorityWriteScheduler.OpenStream", + "pseudoHeaderError.Error", + "requestBody.Close", + "requestBody.Read", + "responseWriter.Flush", + "responseWriter.FlushError", + "responseWriter.Push", + "responseWriter.SetReadDeadline", + "responseWriter.SetWriteDeadline", + "responseWriter.Write", + "responseWriter.WriteHeader", + "responseWriter.WriteString", + "serverConn.CloseConn", + "serverConn.Flush", + "stickyErrWriter.Write", + "transportResponseBody.Close", + "transportResponseBody.Read", + "writeData.String" + ] + }, + { + "path": "golang.org/x/net/http2/hpack", + "symbols": [ + "Decoder.DecodeFull", + "Decoder.Write", + "Decoder.parseFieldLiteral", + "Decoder.readString" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/57855" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/468135" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/468295" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/V0aBFqaFs_E" + } + ], + "credits": [ + { + "name": "Philippe Antoine (Catena cyber)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-1571", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-1988", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-08-02T15:06:27Z", + "aliases": [ + "CVE-2023-3978", + "GHSA-2wrh-6pvc-2jm9" + ], + "summary": "Improper rendering of text nodes in golang.org/x/net/html", + "details": "Text nodes not in the HTML namespace are incorrectly literally rendered, causing text which should be escaped to not be. This could lead to an XSS attack.", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.13.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/html", + "symbols": [ + "Render", + "render1" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/61615" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/514896" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-1988", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2023-2102", + "modified": "2024-05-20T16:03:47Z", + "published": "2023-10-11T16:49:53Z", + "aliases": [ + "CVE-2023-39325", + "GHSA-4374-p667-p6c8" + ], + "summary": "HTTP/2 rapid reset can cause excessive work in net/http", + "details": "A malicious HTTP/2 client which rapidly creates requests and immediately resets them can cause excessive server resource consumption. While the total number of requests is bounded by the http2.Server.MaxConcurrentStreams setting, resetting an in-progress request allows the attacker to create a new request while the existing one is still executing.\n\nWith the fix applied, HTTP/2 servers now bound the number of simultaneously executing handler goroutines to the stream concurrency limit (MaxConcurrentStreams). New requests arriving when at the limit (which can only happen after the client has reset an existing, in-flight request) will be queued until a handler exits. If the request queue grows too large, the server will terminate the connection.\n\nThis issue is also fixed in golang.org/x/net/http2 for users manually configuring HTTP/2.\n\nThe default stream concurrency limit is 250 streams (requests) per HTTP/2 connection. This value may be adjusted using the golang.org/x/net/http2 package; see the Server.MaxConcurrentStreams setting and the ConfigureServer function.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.20.10" + }, + { + "introduced": "1.21.0-0" + }, + { + "fixed": "1.21.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "ListenAndServe", + "ListenAndServeTLS", + "Serve", + "ServeTLS", + "Server.ListenAndServe", + "Server.ListenAndServeTLS", + "Server.Serve", + "Server.ServeTLS", + "http2Server.ServeConn", + "http2serverConn.processHeaders", + "http2serverConn.runHandler", + "http2serverConn.serve", + "http2serverConn.upgradeRequest" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.17.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http2", + "symbols": [ + "Server.ServeConn", + "serverConn.processHeaders", + "serverConn.runHandler", + "serverConn.serve", + "serverConn.upgradeRequest" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/63417" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/534215" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/534235" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/iNNxDTCjZvo/m/UDd7VKQuAAAJ" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2023-2102", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-2687", + "modified": "2024-05-20T16:03:47Z", + "published": "2024-04-03T21:12:01Z", + "aliases": [ + "CVE-2023-45288", + "GHSA-4v7x-pqxf-cx7m" + ], + "summary": "HTTP/2 CONTINUATION flood in net/http", + "details": "An attacker may cause an HTTP/2 endpoint to read arbitrary amounts of header data by sending an excessive number of CONTINUATION frames.\n\nMaintaining HPACK state requires parsing and processing all HEADERS and CONTINUATION frames on a connection. When a request's headers exceed MaxHeaderBytes, no memory is allocated to store the excess headers, but they are still parsed.\n\nThis permits an attacker to cause an HTTP/2 endpoint to read arbitrary amounts of header data, all associated with a request which is going to be rejected. These headers can include Huffman-encoded data which is significantly more expensive for the receiver to decode than for an attacker to send.\n\nThe fix sets a limit on the amount of excess header frames we will process before closing a connection.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.21.9" + }, + { + "introduced": "1.22.0-0" + }, + { + "fixed": "1.22.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "CanonicalHeaderKey", + "Client.CloseIdleConnections", + "Client.Do", + "Client.Get", + "Client.Head", + "Client.Post", + "Client.PostForm", + "Cookie.String", + "Cookie.Valid", + "Dir.Open", + "Error", + "Get", + "HandlerFunc.ServeHTTP", + "Head", + "Header.Add", + "Header.Del", + "Header.Get", + "Header.Set", + "Header.Values", + "Header.Write", + "Header.WriteSubset", + "ListenAndServe", + "ListenAndServeTLS", + "NewRequest", + "NewRequestWithContext", + "NotFound", + "ParseTime", + "Post", + "PostForm", + "ProxyFromEnvironment", + "ReadRequest", + "ReadResponse", + "Redirect", + "Request.AddCookie", + "Request.BasicAuth", + "Request.FormFile", + "Request.FormValue", + "Request.MultipartReader", + "Request.ParseForm", + "Request.ParseMultipartForm", + "Request.PostFormValue", + "Request.Referer", + "Request.SetBasicAuth", + "Request.UserAgent", + "Request.Write", + "Request.WriteProxy", + "Response.Cookies", + "Response.Location", + "Response.Write", + "ResponseController.EnableFullDuplex", + "ResponseController.Flush", + "ResponseController.Hijack", + "ResponseController.SetReadDeadline", + "ResponseController.SetWriteDeadline", + "Serve", + "ServeContent", + "ServeFile", + "ServeMux.ServeHTTP", + "ServeTLS", + "Server.Close", + "Server.ListenAndServe", + "Server.ListenAndServeTLS", + "Server.Serve", + "Server.ServeTLS", + "Server.SetKeepAlivesEnabled", + "Server.Shutdown", + "SetCookie", + "Transport.CancelRequest", + "Transport.Clone", + "Transport.CloseIdleConnections", + "Transport.RoundTrip", + "body.Close", + "body.Read", + "bodyEOFSignal.Close", + "bodyEOFSignal.Read", + "bodyLocked.Read", + "bufioFlushWriter.Write", + "cancelTimerBody.Close", + "cancelTimerBody.Read", + "checkConnErrorWriter.Write", + "chunkWriter.Write", + "connReader.Read", + "connectMethodKey.String", + "expectContinueReader.Close", + "expectContinueReader.Read", + "extraHeader.Write", + "fileHandler.ServeHTTP", + "fileTransport.RoundTrip", + "globalOptionsHandler.ServeHTTP", + "gzipReader.Close", + "gzipReader.Read", + "http2ClientConn.Close", + "http2ClientConn.Ping", + "http2ClientConn.RoundTrip", + "http2ClientConn.Shutdown", + "http2ConnectionError.Error", + "http2ErrCode.String", + "http2FrameHeader.String", + "http2FrameType.String", + "http2FrameWriteRequest.String", + "http2Framer.ReadFrame", + "http2Framer.WriteContinuation", + "http2Framer.WriteData", + "http2Framer.WriteDataPadded", + "http2Framer.WriteGoAway", + "http2Framer.WriteHeaders", + "http2Framer.WritePing", + "http2Framer.WritePriority", + "http2Framer.WritePushPromise", + "http2Framer.WriteRSTStream", + "http2Framer.WriteRawFrame", + "http2Framer.WriteSettings", + "http2Framer.WriteSettingsAck", + "http2Framer.WriteWindowUpdate", + "http2Framer.readMetaFrame", + "http2GoAwayError.Error", + "http2Server.ServeConn", + "http2Setting.String", + "http2SettingID.String", + "http2SettingsFrame.ForeachSetting", + "http2StreamError.Error", + "http2Transport.CloseIdleConnections", + "http2Transport.NewClientConn", + "http2Transport.RoundTrip", + "http2Transport.RoundTripOpt", + "http2bufferedWriter.Flush", + "http2bufferedWriter.Write", + "http2chunkWriter.Write", + "http2clientConnPool.GetClientConn", + "http2connError.Error", + "http2dataBuffer.Read", + "http2duplicatePseudoHeaderError.Error", + "http2gzipReader.Close", + "http2gzipReader.Read", + "http2headerFieldNameError.Error", + "http2headerFieldValueError.Error", + "http2noDialClientConnPool.GetClientConn", + "http2noDialH2RoundTripper.RoundTrip", + "http2pipe.Read", + "http2priorityWriteScheduler.CloseStream", + "http2priorityWriteScheduler.OpenStream", + "http2pseudoHeaderError.Error", + "http2requestBody.Close", + "http2requestBody.Read", + "http2responseWriter.Flush", + "http2responseWriter.FlushError", + "http2responseWriter.Push", + "http2responseWriter.SetReadDeadline", + "http2responseWriter.SetWriteDeadline", + "http2responseWriter.Write", + "http2responseWriter.WriteHeader", + "http2responseWriter.WriteString", + "http2roundRobinWriteScheduler.OpenStream", + "http2serverConn.CloseConn", + "http2serverConn.Flush", + "http2stickyErrWriter.Write", + "http2transportResponseBody.Close", + "http2transportResponseBody.Read", + "http2writeData.String", + "initALPNRequest.ServeHTTP", + "loggingConn.Close", + "loggingConn.Read", + "loggingConn.Write", + "maxBytesReader.Close", + "maxBytesReader.Read", + "onceCloseListener.Close", + "persistConn.Read", + "persistConnWriter.ReadFrom", + "persistConnWriter.Write", + "populateResponse.Write", + "populateResponse.WriteHeader", + "readTrackingBody.Close", + "readTrackingBody.Read", + "readWriteCloserBody.Read", + "redirectHandler.ServeHTTP", + "response.Flush", + "response.FlushError", + "response.Hijack", + "response.ReadFrom", + "response.Write", + "response.WriteHeader", + "response.WriteString", + "serverHandler.ServeHTTP", + "socksDialer.DialWithConn", + "socksUsernamePassword.Authenticate", + "stringWriter.WriteString", + "timeoutHandler.ServeHTTP", + "timeoutWriter.Write", + "timeoutWriter.WriteHeader", + "transportReadFromServerError.Error" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.23.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http2", + "symbols": [ + "ClientConn.Close", + "ClientConn.Ping", + "ClientConn.RoundTrip", + "ClientConn.Shutdown", + "ConfigureServer", + "ConfigureTransport", + "ConfigureTransports", + "ConnectionError.Error", + "ErrCode.String", + "FrameHeader.String", + "FrameType.String", + "FrameWriteRequest.String", + "Framer.ReadFrame", + "Framer.WriteContinuation", + "Framer.WriteData", + "Framer.WriteDataPadded", + "Framer.WriteGoAway", + "Framer.WriteHeaders", + "Framer.WritePing", + "Framer.WritePriority", + "Framer.WritePushPromise", + "Framer.WriteRSTStream", + "Framer.WriteRawFrame", + "Framer.WriteSettings", + "Framer.WriteSettingsAck", + "Framer.WriteWindowUpdate", + "Framer.readMetaFrame", + "GoAwayError.Error", + "ReadFrameHeader", + "Server.ServeConn", + "Setting.String", + "SettingID.String", + "SettingsFrame.ForeachSetting", + "StreamError.Error", + "Transport.CloseIdleConnections", + "Transport.NewClientConn", + "Transport.RoundTrip", + "Transport.RoundTripOpt", + "bufferedWriter.Flush", + "bufferedWriter.Write", + "chunkWriter.Write", + "clientConnPool.GetClientConn", + "connError.Error", + "dataBuffer.Read", + "duplicatePseudoHeaderError.Error", + "gzipReader.Close", + "gzipReader.Read", + "headerFieldNameError.Error", + "headerFieldValueError.Error", + "noDialClientConnPool.GetClientConn", + "noDialH2RoundTripper.RoundTrip", + "pipe.Read", + "priorityWriteScheduler.CloseStream", + "priorityWriteScheduler.OpenStream", + "pseudoHeaderError.Error", + "requestBody.Close", + "requestBody.Read", + "responseWriter.Flush", + "responseWriter.FlushError", + "responseWriter.Push", + "responseWriter.SetReadDeadline", + "responseWriter.SetWriteDeadline", + "responseWriter.Write", + "responseWriter.WriteHeader", + "responseWriter.WriteString", + "roundRobinWriteScheduler.OpenStream", + "serverConn.CloseConn", + "serverConn.Flush", + "stickyErrWriter.Write", + "transportResponseBody.Close", + "transportResponseBody.Read", + "writeData.String" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/65051" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/576155" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/YgW0sx8mN3M" + } + ], + "credits": [ + { + "name": "Bartek Nowotarski (https://nowotarski.info/)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-2687", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2024-3333", + "modified": "2024-12-20T20:37:27Z", + "published": "2024-12-18T20:22:06Z", + "aliases": [ + "CVE-2024-45338", + "GHSA-w32m-9786-jp63" + ], + "summary": "Non-linear parsing of case-insensitive content in golang.org/x/net/html", + "details": "An attacker can craft an input to the Parse functions that would be processed non-linearly with respect to its length, resulting in extremely slow parsing. This could cause a denial of service.", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.33.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/html", + "symbols": [ + "Parse", + "ParseFragment", + "ParseFragmentWithOptions", + "ParseWithOptions", + "htmlIntegrationPoint", + "inBodyIM", + "inTableIM", + "parseDoctype" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/637536" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/70906" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/wSCRmFnNmPA/m/Lvcd0mRMAwAJ" + } + ], + "credits": [ + { + "name": "Guido Vranken" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2024-3333", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3503", + "modified": "2026-04-16T20:42:32Z", + "published": "2025-03-12T18:17:07Z", + "aliases": [ + "CVE-2025-22870", + "GHSA-qxp5-gwg8-xv66" + ], + "summary": "HTTP Proxy bypass using IPv6 Zone IDs in golang.org/x/net", + "details": "Matching of hosts against proxy patterns can improperly treat an IPv6 zone ID as a hostname component. For example, when the NO_PROXY environment variable is set to \"*.example.com\", a request to \"[::1%25.example.com]:80` will incorrectly match and not be proxied.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.23.7" + }, + { + "introduced": "1.24.0-0" + }, + { + "fixed": "1.24.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "ProxyFromEnvironment", + "envProxyFunc" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.36.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http/httpproxy", + "symbols": [ + "config.useProxy", + "domainMatch.match" + ] + }, + { + "path": "golang.org/x/net/proxy", + "symbols": [ + "Dial", + "FromEnvironment", + "FromEnvironmentUsing", + "PerHost.AddFromString", + "PerHost.Dial", + "PerHost.DialContext", + "PerHost.dialerForRequest" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/654697" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/71984" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/4t3lzH3I0eI/m/b42ImqrBAQAJ" + } + ], + "credits": [ + { + "name": "Juho Forsén of Mattermost" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3503", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2025-3595", + "modified": "2025-04-16T16:54:55Z", + "published": "2025-04-16T16:54:55Z", + "aliases": [ + "CVE-2025-22872" + ], + "summary": "Incorrect Neutralization of Input During Web Page Generation in x/net in golang.org/x/net", + "details": "The tokenizer incorrectly interprets tags with unquoted attribute values that end with a solidus character (/) as self-closing. When directly using Tokenizer, this can result in such tags incorrectly being marked as self-closing, and when using the Parse functions, this can result in content following such tags as being placed in the wrong scope during DOM construction, but only when tags are in foreign content (e.g. \u003cmath\u003e, \u003csvg\u003e, etc contexts).", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.38.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/html", + "symbols": [ + "Parse", + "ParseFragment", + "ParseFragmentWithOptions", + "ParseWithOptions", + "Tokenizer.Next", + "Tokenizer.readStartTag" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/662715" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/73070" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/ezSKR9vqbqA" + } + ], + "credits": [ + { + "name": "Sean Ng (https://ensy.zip)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2025-3595", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4440", + "modified": "2026-02-05T17:23:14Z", + "published": "2026-02-05T17:23:14Z", + "aliases": [ + "CVE-2025-47911" + ], + "summary": "Quadratic parsing complexity in golang.org/x/net/html", + "details": "The html.Parse function in golang.org/x/net/html has quadratic parsing complexity when processing certain inputs, which can lead to denial of service (DoS) if an attacker provides specially crafted HTML content.", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.45.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/html", + "symbols": [ + "Parse", + "ParseFragment", + "ParseFragmentWithOptions", + "ParseWithOptions", + "parser.parse" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/709876" + }, + { + "type": "REPORT", + "url": "https://github.com/golang/vulndb/issues/4440" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/jnQcOYpiR2c" + } + ], + "credits": [ + { + "name": "Guido Vranken" + }, + { + "name": "Jakub Ciolek" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4440", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4441", + "modified": "2026-02-05T17:23:17Z", + "published": "2026-02-05T17:23:17Z", + "aliases": [ + "CVE-2025-58190" + ], + "summary": "Infinite parsing loop in golang.org/x/net", + "details": "The html.Parse function in golang.org/x/net/html has an infinite parsing loop when processing certain inputs, which can lead to denial of service (DoS) if an attacker provides specially crafted HTML content.", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.45.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/html", + "symbols": [ + "Parse", + "ParseFragment", + "ParseFragmentWithOptions", + "ParseWithOptions", + "inRowIM" + ] + } + ] + } + } + ], + "references": [ + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/jnQcOYpiR2c" + }, + { + "type": "REPORT", + "url": "https://github.com/golang/vulndb/issues/4441" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/709875" + } + ], + "credits": [ + { + "name": "Guido Vranken" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4441", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4559", + "modified": "2026-02-26T18:24:17Z", + "published": "2026-02-26T18:24:17Z", + "aliases": [ + "CVE-2026-27141" + ], + "summary": "Sending certain HTTP/2 frames can cause a server to panic in golang.org/x/net", + "details": "Due to missing nil check, sending 0x0a-0x0f HTTP/2 frames will cause a running server to panic", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0.50.0" + }, + { + "fixed": "0.51.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http2", + "symbols": [ + "ClientConn.Close", + "ClientConn.Ping", + "ClientConn.RoundTrip", + "ClientConn.Shutdown", + "ConfigureServer", + "ConfigureTransport", + "ConfigureTransports", + "ConnectionError.Error", + "ErrCode.String", + "FrameHeader.String", + "FrameType.String", + "FrameWriteRequest.String", + "Framer.ReadFrame", + "Framer.ReadFrameForHeader", + "Framer.ReadFrameHeader", + "Framer.WriteContinuation", + "Framer.WriteData", + "Framer.WriteDataPadded", + "Framer.WriteGoAway", + "Framer.WriteHeaders", + "Framer.WritePing", + "Framer.WritePriority", + "Framer.WritePriorityUpdate", + "Framer.WritePushPromise", + "Framer.WriteRSTStream", + "Framer.WriteRawFrame", + "Framer.WriteSettings", + "Framer.WriteSettingsAck", + "Framer.WriteWindowUpdate", + "GoAwayError.Error", + "ReadFrameHeader", + "Server.ServeConn", + "Setting.String", + "SettingID.String", + "SettingsFrame.ForeachSetting", + "StreamError.Error", + "Transport.CloseIdleConnections", + "Transport.NewClientConn", + "Transport.RoundTrip", + "Transport.RoundTripOpt", + "bufferedWriter.Flush", + "bufferedWriter.Write", + "bufferedWriterTimeoutWriter.Write", + "chunkWriter.Write", + "clientConnPool.GetClientConn", + "connError.Error", + "dataBuffer.Read", + "duplicatePseudoHeaderError.Error", + "gzipReader.Close", + "gzipReader.Read", + "headerFieldNameError.Error", + "headerFieldValueError.Error", + "netHTTPClientConn.Close", + "netHTTPClientConn.RoundTrip", + "noDialClientConnPool.GetClientConn", + "noDialH2RoundTripper.NewClientConn", + "noDialH2RoundTripper.RoundTrip", + "pipe.Read", + "priorityWriteSchedulerRFC7540.CloseStream", + "priorityWriteSchedulerRFC7540.OpenStream", + "priorityWriteSchedulerRFC9218.OpenStream", + "pseudoHeaderError.Error", + "requestBody.Close", + "requestBody.Read", + "responseWriter.Flush", + "responseWriter.FlushError", + "responseWriter.Push", + "responseWriter.SetReadDeadline", + "responseWriter.SetWriteDeadline", + "responseWriter.Write", + "responseWriter.WriteHeader", + "responseWriter.WriteString", + "roundRobinWriteScheduler.OpenStream", + "serverConn.CloseConn", + "serverConn.Flush", + "stickyErrWriter.Write", + "transportResponseBody.Close", + "transportResponseBody.Read", + "typeFrameParser", + "unencryptedTransport.RoundTrip", + "writeData.String" + ] + } + ] + } + } + ], + "references": [ + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27141" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/746180" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/77652" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4559", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4918", + "modified": "2026-05-07T19:21:40Z", + "published": "2026-05-07T19:21:40Z", + "aliases": [ + "CVE-2026-33814" + ], + "summary": "Infinite loop in HTTP/2 transport when given bad SETTINGS_MAX_FRAME_SIZE in net/http/internal/http2 in golang.org/x/net", + "details": "When processing HTTP/2 SETTINGS frames, transport will enter an infinite loop of writing CONTINUATION frames if it receives a SETTINGS_MAX_FRAME_SIZE with a value of 0.", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.53.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/http2", + "symbols": [ + "Transport.NewClientConn", + "Transport.RoundTrip", + "Transport.RoundTripOpt", + "clientConnPool.GetClientConn", + "clientConnReadLoop.processSettingsNoWrite", + "noDialClientConnPool.GetClientConn", + "noDialH2RoundTripper.NewClientConn", + "noDialH2RoundTripper.RoundTrip", + "unencryptedTransport.RoundTrip" + ] + } + ] + } + }, + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.10" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "Client.CloseIdleConnections", + "Client.Do", + "Client.Get", + "Client.Head", + "Client.Post", + "Client.PostForm", + "ClientConn.Close", + "ClientConn.RoundTrip", + "Get", + "Head", + "Post", + "PostForm", + "Transport.CloseIdleConnections", + "Transport.NewClientConn", + "Transport.RoundTrip", + "http1ClientConn.Close", + "http1ClientConn.RoundTrip", + "http2Transport.NewClientConn", + "http2Transport.RoundTrip", + "http2Transport.RoundTripOpt", + "http2clientConnPool.GetClientConn", + "http2clientConnReadLoop.processSettingsNoWrite", + "http2noDialClientConnPool.GetClientConn", + "http2noDialH2RoundTripper.NewClientConn", + "http2noDialH2RoundTripper.RoundTrip", + "http2unencryptedTransport.RoundTrip" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/761581" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/761640" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/78476" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/qcCIEXso47M" + } + ], + "credits": [ + { + "name": "Marwan Atia (marwansamir688@gmail.com)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4918", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5025", + "modified": "2026-05-22T02:46:43Z", + "published": "2026-05-22T02:46:43Z", + "aliases": [ + "CVE-2026-42506" + ], + "summary": "Invoking incorrect handling of namespaced elements in foreign content in golang.org/x/net/html", + "details": "Parsing arbitrary HTML which is then rendered using Render can result in an unexpected HTML tree. This can be leveraged to execute XSS attacks in applications that attempt to sanitize input HTML before rendering.", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.55.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/html", + "symbols": [ + "Parse", + "ParseFragment", + "ParseFragmentWithOptions", + "ParseWithOptions", + "parser.parse" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/79571" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/iI-mYSI0lu8" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/781700" + } + ], + "credits": [ + { + "name": "ensy" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5025", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5026", + "modified": "2026-08-21T20:38:00Z", + "published": "2026-05-22T02:46:43Z", + "aliases": [ + "CVE-2026-39821" + ], + "summary": "Invoking failure to reject ASCII-only Punycode-encoded labels in golang.org/x/net/idna", + "details": "The ToASCII and ToUnicode functions incorrectly accept Punycode-encoded labels that decode to an ASCII-only label. For example, ToUnicode(\"xn--example-.com\") incorrectly returns the name \"example.com\" rather than an error.\n\nThis behavior can lead to privilege escalation in programs using the idna package. For example, a program which performs privilege checks on the ASCII hostname may reject \"example.com\" but permit \"xn--example-.com\". If that program subsequently converts the ASCII hostname to Unicode, it will inadvertently permits access to the Unicode name \"example.com\".", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.13" + }, + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.6" + }, + { + "introduced": "1.27.0-0" + }, + { + "fixed": "1.27.0-rc.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net/http", + "symbols": [ + "Client.CloseIdleConnections", + "Client.Do", + "Client.Get", + "Client.Head", + "Client.Post", + "Client.PostForm", + "ClientConn.Close", + "ClientConn.RoundTrip", + "Get", + "Head", + "Post", + "PostForm", + "Transport.CloseIdleConnections", + "Transport.NewClientConn", + "Transport.RoundTrip", + "http1ClientConn.Close", + "http1ClientConn.RoundTrip", + "http2ClientConn.RoundTrip", + "http2RoundTripper.RoundTrip", + "idnaASCII" + ] + }, + { + "path": "net/http/internal/http2", + "symbols": [ + "Transport.AddConn", + "Transport.RoundTrip", + "Transport.RoundTripOpt", + "authorityAddr" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.55.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/idna", + "symbols": [ + "Profile.ToASCII", + "Profile.ToUnicode", + "Profile.process", + "ToASCII", + "ToUnicode" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/767220" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/78760" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/iI-mYSI0lu8" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/94pEornpRlI" + } + ], + "credits": [ + { + "name": "KC1zs4 (https://github.com/KC1zs4)" + }, + { + "name": "Longwei Wang (https://github.com/gn00295120)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5026", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5027", + "modified": "2026-05-22T02:46:43Z", + "published": "2026-05-22T02:46:43Z", + "aliases": [ + "CVE-2026-42502" + ], + "summary": "Invoking incorrect handling of HTML elements in foreign content in golang.org/x/net/html", + "details": "Parsing arbitrary HTML which is then rendered using Render can result in an unexpected HTML tree. This can be leveraged to execute XSS attacks in applications that attempt to sanitize input HTML before rendering.", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.55.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/html", + "symbols": [ + "Parse", + "ParseFragment", + "ParseFragmentWithOptions", + "ParseWithOptions", + "parser.parse" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/79572" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/iI-mYSI0lu8" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/781701" + } + ], + "credits": [ + { + "name": "Tristan Madani" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5027", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5028", + "modified": "2026-05-22T02:46:43Z", + "published": "2026-05-22T02:46:43Z", + "aliases": [ + "CVE-2026-25680" + ], + "summary": "Invoking denial of service when parsing arbitrary HTML in golang.org/x/net/html", + "details": "Parsing arbitrary HTML can consume excessive CPU time, possibly leading to denial of service.", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.55.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/html", + "symbols": [ + "Parse", + "ParseFragment", + "ParseFragmentWithOptions", + "ParseWithOptions", + "parser.parse" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/781702" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/79573" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/iI-mYSI0lu8" + } + ], + "credits": [ + { + "name": "IPC Labs" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5028", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5029", + "modified": "2026-05-22T02:46:43Z", + "published": "2026-05-22T02:46:43Z", + "aliases": [ + "CVE-2026-25681" + ], + "summary": "Invoking incorrect handling of character references in DOCTYPE nodes in golang.org/x/net/html", + "details": "Parsing arbitrary HTML which is then rendered using Render can result in an unexpected HTML tree. This can be leveraged to execute XSS attacks in applications that attempt to sanitize input HTML before rendering.", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.55.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/html", + "symbols": [ + "Parse", + "ParseFragment", + "ParseFragmentWithOptions", + "ParseWithOptions", + "parser.parse" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/79574" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/iI-mYSI0lu8" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/781703" + } + ], + "credits": [ + { + "name": "ensy" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5029", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5030", + "modified": "2026-05-22T02:46:43Z", + "published": "2026-05-22T02:46:43Z", + "aliases": [ + "CVE-2026-27136" + ], + "summary": "Invoking duplicate attributes can cause XSS in golang.org/x/net/html", + "details": "Parsing arbitrary HTML which is then rendered using Render can result in an unexpected HTML tree. This can be leveraged to execute XSS attacks in applications that attempt to sanitize input HTML before rendering.", + "affected": [ + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.55.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/html", + "symbols": [ + "Parse", + "ParseFragment", + "ParseFragmentWithOptions", + "ParseWithOptions", + "parser.parse" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://go.dev/issue/79575" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/iI-mYSI0lu8" + }, + { + "type": "FIX", + "url": "https://go.dev/cl/781685" + } + ], + "credits": [ + { + "name": "ensy" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5030", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-5942", + "modified": "2026-08-14T15:30:22Z", + "published": "2026-07-14T17:29:16Z", + "aliases": [ + "CVE-2026-46600" + ], + "summary": "Parsing an invalid SVCB or HTTPS RR can panic in golang.org/x/net/dns/dnsmessage", + "details": "Parsing an invalid SVCB or HTTPS RR can panic when the size of a parameter value overflows the message buffer.", + "affected": [ + { + "package": { + "name": "stdlib", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "1.26.0-0" + }, + { + "fixed": "1.26.6" + }, + { + "introduced": "1.27.0-0" + }, + { + "fixed": "1.27.0-rc.3" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "net", + "symbols": [ + "LookupCNAME", + "Resolver.LookupCNAME", + "cgoResSearch" + ] + } + ] + } + }, + { + "package": { + "name": "golang.org/x/net", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "0.56.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "golang.org/x/net/dns/dnsmessage", + "symbols": [ + "Message.Unpack", + "Parser.Additional", + "Parser.AllAdditionals", + "Parser.AllAnswers", + "Parser.AllAuthorities", + "Parser.Answer", + "Parser.Authority", + "Parser.HTTPSResource", + "Parser.SVCBResource", + "unpackSVCBResource" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://go.dev/cl/786345" + }, + { + "type": "REPORT", + "url": "https://go.dev/issue/79795" + }, + { + "type": "WEB", + "url": "https://groups.google.com/g/golang-announce/c/94pEornpRlI" + } + ], + "credits": [ + { + "name": "Mundur (https://github.com/M0nd0R)" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-5942", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0057", + "modified": "2024-05-20T16:03:47Z", + "published": "2021-04-14T20:04:52Z", + "aliases": [ + "CVE-2020-35381", + "GHSA-8vrw-m3j9-j27c" + ], + "summary": "Panic due to improper input validation in github.com/buger/jsonparser", + "details": "Due to improper bounds checking, maliciously crafted JSON objects can cause an out-of-bounds panic. If parsing user input, this may be used as a denial of service vector.", + "affected": [ + { + "package": { + "name": "github.com/buger/jsonparser", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.1.1" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/buger/jsonparser", + "symbols": [ + "ArrayEach", + "Delete", + "EachKey", + "FuzzDelete", + "FuzzEachKey", + "FuzzGetBoolean", + "FuzzGetFloat", + "FuzzGetInt", + "FuzzGetString", + "FuzzGetUnsafeString", + "FuzzObjectEach", + "FuzzSet", + "Get", + "GetBoolean", + "GetFloat", + "GetInt", + "GetString", + "GetUnsafeString", + "ObjectEach", + "Set", + "searchKeys" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://github.com/buger/jsonparser/pull/221" + }, + { + "type": "FIX", + "url": "https://github.com/buger/jsonparser/commit/df3ea76ece10095374fd1c9a22a4fb85a44efc42" + }, + { + "type": "WEB", + "url": "https://github.com/buger/jsonparser/issues/219" + } + ], + "credits": [ + { + "name": "@toptotu" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0057", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2021-0089", + "modified": "2024-05-20T16:03:47Z", + "published": "2021-04-14T20:04:52Z", + "aliases": [ + "CVE-2020-10675", + "GHSA-rmh2-65xw-9m6q" + ], + "summary": "Infinite loop in github.com/buger/jsonparser", + "details": "Parsing malformed JSON which contain opening brackets, but not closing brackets, leads to an infinite loop. If operating on untrusted user input this can be used as a denial of service vector.", + "affected": [ + { + "package": { + "name": "github.com/buger/jsonparser", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.0.0" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/buger/jsonparser", + "symbols": [ + "Delete", + "findKeyStart" + ] + } + ] + } + } + ], + "references": [ + { + "type": "FIX", + "url": "https://github.com/buger/jsonparser/pull/192" + }, + { + "type": "FIX", + "url": "https://github.com/buger/jsonparser/commit/91ac96899e492584984ded0c8f9a08f10b473717" + }, + { + "type": "WEB", + "url": "https://github.com/buger/jsonparser/issues/188" + } + ], + "credits": [ + { + "name": "Cong Wang" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2021-0089", + "review_status": "REVIEWED" + } + } +} +{ + "osv": { + "schema_version": "1.3.1", + "id": "GO-2026-4514", + "modified": "2026-04-20T18:42:23Z", + "published": "2026-03-16T20:27:18Z", + "aliases": [ + "CVE-2026-32285", + "GHSA-6g7g-w4f8-9c9x" + ], + "summary": "Denial of service in github.com/buger/jsonparser", + "details": "The Delete function fails to properly validate offsets when processing malformed JSON input. This can lead to a negative slice index and a runtime panic, allowing a denial of service attack.", + "affected": [ + { + "package": { + "name": "github.com/buger/jsonparser", + "ecosystem": "Go" + }, + "ranges": [ + { + "type": "SEMVER", + "events": [ + { + "introduced": "0" + }, + { + "fixed": "1.1.2" + } + ] + } + ], + "ecosystem_specific": { + "imports": [ + { + "path": "github.com/buger/jsonparser", + "symbols": [ + "Delete", + "FuzzDelete" + ] + } + ] + } + } + ], + "references": [ + { + "type": "REPORT", + "url": "https://github.com/buger/jsonparser/issues/275" + }, + { + "type": "REPORT", + "url": "https://github.com/golang/vulndb/issues/4514" + } + ], + "database_specific": { + "url": "https://pkg.go.dev/vuln/GO-2026-4514", + "review_status": "REVIEWED" + } + } +} +{ + "progress": { + "message": "Checking the code against the vulnerabilities..." + } +} +{ + "finding": { + "osv": "GO-2026-5932", + "trace": [ + { + "module": "golang.org/x/crypto", + "version": "v0.56.0" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15" + } + ] + } +} +{ + "finding": { + "osv": "GO-2026-5750", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/types/model" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/types/model" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/types/model" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/types/model" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/types/model" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/types/model" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/types/model" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/types/model" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/format" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/format" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/format" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/format" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/format" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/format" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/format" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/format" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/version" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/version" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/version" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/version" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/version" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/version" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/version" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/version" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth" + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth", + "function": "init", + "position": { + "filename": "auth/auth.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 740, + "line": 30, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/version", + "function": "init", + "position": { + "filename": "version/version.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 846, + "line": 33, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "New[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 450, + "line": 18, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8056, + "line": 239, + "column": 66 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7817, + "line": 281, + "column": 50 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "init", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 161, + "line": 3, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/types.go", + "offset": 188, + "line": 18, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "Set", + "receiver": "*Map[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 918, + "line": 36, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8459, + "line": 258, + "column": 10 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7940, + "line": 286, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig", + "function": "init", + "position": { + "filename": "envconfig/config.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 773, + "line": 31, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "Set", + "receiver": "*Map[string, github.com/ollama/ollama/api.ToolProperty]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 918, + "line": 36, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 11405, + "line": 379, + "column": 10 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 10669, + "line": 384, + "column": 30 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8334, + "line": 251, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7940, + "line": 286, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Error", + "receiver": "StatusError", + "position": { + "filename": "api/types.go", + "offset": 468, + "line": 29, + "column": 22 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/core/internal/service/model", + "function": "GetProviderModels", + "receiver": "*Service", + "position": { + "filename": "core/internal/service/model/discovery.go", + "offset": 16286, + "line": 364, + "column": 71 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig", + "function": "init", + "position": { + "filename": "envconfig/config.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 773, + "line": 31, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig", + "function": "init", + "position": { + "filename": "envconfig/config.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 773, + "line": 31, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "Set", + "receiver": "*Map[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 918, + "line": 36, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8459, + "line": 258, + "column": 10 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7940, + "line": 286, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "Set", + "receiver": "*Map[string, github.com/ollama/ollama/api.ToolProperty]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 918, + "line": 36, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 11405, + "line": 379, + "column": 10 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 10669, + "line": 384, + "column": 30 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 11262, + "line": 372, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 10669, + "line": 384, + "column": 30 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewClient", + "position": { + "filename": "api/client.go", + "offset": 2100, + "line": 81, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "NewOllamaModel", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 3418, + "line": 135, + "column": 25 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Chat", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 7871, + "line": 297, + "column": 18 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 2317, + "line": 79, + "column": 22 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1511, + "line": 56, + "column": 23 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/format", + "function": "init", + "position": { + "filename": "format/bytes.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 811, + "line": 32, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 594, + "line": 14, + "column": 1 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 594, + "line": 14, + "column": 1 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "Set", + "receiver": "*Map[string, github.com/ollama/ollama/api.ToolProperty]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 918, + "line": 36, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 11405, + "line": 379, + "column": 10 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 10669, + "line": 384, + "column": 30 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "ToMap", + "receiver": "*Map[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 1617, + "line": 70, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "ToMap", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 9049, + "line": 282, + "column": 19 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateNonStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 5661, + "line": 203, + "column": 38 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1597, + "line": 58, + "column": 26 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "ToMap", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8960, + "line": 278, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateNonStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 5661, + "line": 203, + "column": 38 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1597, + "line": 58, + "column": 26 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "ToMap", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8960, + "line": 278, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateNonStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 5661, + "line": 203, + "column": 38 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1597, + "line": 58, + "column": 26 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12415, + "line": 430, + "column": 18 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "init", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 161, + "line": 3, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/types.go", + "offset": 188, + "line": 18, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "Set", + "receiver": "*Map[string, github.com/ollama/ollama/api.ToolProperty]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 918, + "line": 36, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 11405, + "line": 379, + "column": 10 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 10669, + "line": 384, + "column": 30 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig", + "function": "Bool$1", + "position": { + "filename": "envconfig/config.go", + "offset": 4723, + "line": 194, + "column": 9 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "do", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 2875, + "line": 119, + "column": 22 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12526, + "line": 432, + "column": 16 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 7932, + "line": 238, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7817, + "line": 281, + "column": 50 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Error", + "receiver": "AuthorizationError", + "position": { + "filename": "api/types.go", + "offset": 951, + "line": 49, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/core/internal/service/model", + "function": "GetProviderModels", + "receiver": "*Service", + "position": { + "filename": "core/internal/service/model/discovery.go", + "offset": 16286, + "line": 364, + "column": 71 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 594, + "line": 14, + "column": 1 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 7932, + "line": 238, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7817, + "line": 281, + "column": 50 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 11262, + "line": 372, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 10669, + "line": 384, + "column": 30 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "Set", + "receiver": "*Map[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 918, + "line": 36, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8459, + "line": 258, + "column": 10 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7940, + "line": 286, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "ToMap", + "receiver": "*Map[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 1617, + "line": 70, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "ToMap", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 9049, + "line": 282, + "column": 19 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateNonStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 5661, + "line": 203, + "column": 38 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1597, + "line": 58, + "column": 26 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "ToMap", + "receiver": "*Map[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 1617, + "line": 70, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "ToMap", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 9049, + "line": 282, + "column": 19 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateNonStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 5661, + "line": 203, + "column": 38 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1597, + "line": 58, + "column": 26 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig", + "function": "init", + "position": { + "filename": "envconfig/config.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 773, + "line": 31, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig", + "function": "Bool$1", + "position": { + "filename": "envconfig/config.go", + "offset": 4723, + "line": 194, + "column": 9 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "do", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 2875, + "line": 119, + "column": 22 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12526, + "line": 432, + "column": 16 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "ToMap", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8960, + "line": 278, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateNonStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 5661, + "line": 203, + "column": 38 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1597, + "line": 58, + "column": 26 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 11262, + "line": 372, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 10669, + "line": 384, + "column": 30 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/types/model", + "function": "init", + "position": { + "filename": "types/model/capability.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/types.go", + "offset": 236, + "line": 19, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewClient", + "position": { + "filename": "api/client.go", + "offset": 2100, + "line": 81, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "NewOllamaModel", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 3418, + "line": 135, + "column": 25 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 594, + "line": 14, + "column": 1 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8334, + "line": 251, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7940, + "line": 286, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "New[string, github.com/ollama/ollama/api.ToolProperty]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 450, + "line": 18, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 10982, + "line": 360, + "column": 68 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 9700, + "line": 363, + "column": 41 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "Set", + "receiver": "*Map[string, github.com/ollama/ollama/api.ToolProperty]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 918, + "line": 36, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 11405, + "line": 379, + "column": 10 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 10669, + "line": 384, + "column": 30 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth", + "function": "Sign", + "position": { + "filename": "auth/auth.go", + "offset": 968, + "line": 53, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "getAuthorizationToken", + "position": { + "filename": "api/client.go", + "offset": 2313, + "line": 89, + "column": 25 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "do", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 3059, + "line": 122, + "column": 37 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12526, + "line": 432, + "column": 16 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/version", + "function": "init", + "position": { + "filename": "version/version.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 846, + "line": 33, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/format", + "function": "init", + "position": { + "filename": "format/bytes.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 811, + "line": 32, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Chat", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 7871, + "line": 297, + "column": 18 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 2317, + "line": 79, + "column": 22 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1511, + "line": 56, + "column": 23 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth", + "function": "Sign", + "position": { + "filename": "auth/auth.go", + "offset": 968, + "line": 53, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "getAuthorizationToken", + "position": { + "filename": "api/client.go", + "offset": 2313, + "line": 89, + "column": 25 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "do", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 3059, + "line": 122, + "column": 37 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12526, + "line": 432, + "column": 16 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12415, + "line": 430, + "column": 18 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "New[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 450, + "line": 18, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8056, + "line": 239, + "column": 66 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7817, + "line": 281, + "column": 50 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth", + "function": "Sign", + "position": { + "filename": "auth/auth.go", + "offset": 968, + "line": 53, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "getAuthorizationToken", + "position": { + "filename": "api/client.go", + "offset": 2313, + "line": 89, + "column": 25 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "do", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 3059, + "line": 122, + "column": 37 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12526, + "line": 432, + "column": 16 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/format", + "function": "init", + "position": { + "filename": "format/bytes.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 811, + "line": 32, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Chat", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 7871, + "line": 297, + "column": 18 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 2317, + "line": 79, + "column": 22 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1511, + "line": 56, + "column": 23 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewClient", + "position": { + "filename": "api/client.go", + "offset": 2100, + "line": 81, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "NewOllamaModel", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 3418, + "line": 135, + "column": 25 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Error", + "receiver": "StatusError", + "position": { + "filename": "api/types.go", + "offset": 468, + "line": 29, + "column": 22 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/core/internal/service/model", + "function": "GetProviderModels", + "receiver": "*Service", + "position": { + "filename": "core/internal/service/model/discovery.go", + "offset": 16286, + "line": 364, + "column": 71 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "New[string, github.com/ollama/ollama/api.ToolProperty]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 450, + "line": 18, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 10982, + "line": 360, + "column": 68 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 9700, + "line": 363, + "column": 41 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth", + "function": "init", + "position": { + "filename": "auth/auth.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 740, + "line": 30, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 10871, + "line": 359, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 9700, + "line": 363, + "column": 41 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 11262, + "line": 372, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 10669, + "line": 384, + "column": 30 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12415, + "line": 430, + "column": 18 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Error", + "receiver": "StatusError", + "position": { + "filename": "api/types.go", + "offset": 468, + "line": 29, + "column": 22 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/core/internal/service/model", + "function": "GetProviderModels", + "receiver": "*Service", + "position": { + "filename": "core/internal/service/model/discovery.go", + "offset": 16286, + "line": 364, + "column": 71 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/version", + "function": "init", + "position": { + "filename": "version/version.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 846, + "line": 33, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/version", + "function": "init", + "position": { + "filename": "version/version.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 846, + "line": 33, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth", + "function": "init", + "position": { + "filename": "auth/auth.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 740, + "line": 30, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "Set", + "receiver": "*Map[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 918, + "line": 36, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8459, + "line": 258, + "column": 10 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7940, + "line": 286, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig", + "function": "Bool$1", + "position": { + "filename": "envconfig/config.go", + "offset": 4723, + "line": 194, + "column": 9 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "do", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 2875, + "line": 119, + "column": 22 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12526, + "line": 432, + "column": 16 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth", + "function": "init", + "position": { + "filename": "auth/auth.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 740, + "line": 30, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "init", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 161, + "line": 3, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/types.go", + "offset": 188, + "line": 18, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/version", + "function": "init", + "position": { + "filename": "version/version.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 846, + "line": 33, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth", + "function": "Sign", + "position": { + "filename": "auth/auth.go", + "offset": 968, + "line": 53, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "getAuthorizationToken", + "position": { + "filename": "api/client.go", + "offset": 2313, + "line": 89, + "column": 25 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "do", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 3059, + "line": 122, + "column": 37 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12526, + "line": 432, + "column": 16 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8334, + "line": 251, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7940, + "line": 286, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig", + "function": "Bool$1", + "position": { + "filename": "envconfig/config.go", + "offset": 4723, + "line": 194, + "column": 9 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "do", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 2875, + "line": 119, + "column": 22 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12526, + "line": 432, + "column": 16 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig", + "function": "Bool$1", + "position": { + "filename": "envconfig/config.go", + "offset": 4723, + "line": 194, + "column": 9 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "do", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 2875, + "line": 119, + "column": 22 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12526, + "line": 432, + "column": 16 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 10871, + "line": 359, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 9700, + "line": 363, + "column": 41 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewClient", + "position": { + "filename": "api/client.go", + "offset": 2100, + "line": 81, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "NewOllamaModel", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 3418, + "line": 135, + "column": 25 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8334, + "line": 251, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7940, + "line": 286, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "Set", + "receiver": "*Map[string, github.com/ollama/ollama/api.ToolProperty]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 918, + "line": 36, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 11405, + "line": 379, + "column": 10 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 10669, + "line": 384, + "column": 30 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "init", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 161, + "line": 3, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/types.go", + "offset": 188, + "line": 18, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/types/model", + "function": "init", + "position": { + "filename": "types/model/capability.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/types.go", + "offset": 236, + "line": 19, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth", + "function": "Sign", + "position": { + "filename": "auth/auth.go", + "offset": 968, + "line": 53, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "getAuthorizationToken", + "position": { + "filename": "api/client.go", + "offset": 2313, + "line": 89, + "column": 25 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "do", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 3059, + "line": 122, + "column": 37 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12526, + "line": 432, + "column": 16 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig", + "function": "Bool$1", + "position": { + "filename": "envconfig/config.go", + "offset": 4723, + "line": 194, + "column": 9 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "do", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 2875, + "line": 119, + "column": 22 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12526, + "line": 432, + "column": 16 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 10871, + "line": 359, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 9700, + "line": 363, + "column": 41 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewClient", + "position": { + "filename": "api/client.go", + "offset": 2100, + "line": 81, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "NewOllamaModel", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 3418, + "line": 135, + "column": 25 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "New[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 450, + "line": 18, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8056, + "line": 239, + "column": 66 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7817, + "line": 281, + "column": 50 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth", + "function": "Sign", + "position": { + "filename": "auth/auth.go", + "offset": 968, + "line": 53, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "getAuthorizationToken", + "position": { + "filename": "api/client.go", + "offset": 2313, + "line": 89, + "column": 25 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "do", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 3059, + "line": 122, + "column": 37 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12526, + "line": 432, + "column": 16 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 11262, + "line": 372, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 10669, + "line": 384, + "column": 30 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/format", + "function": "init", + "position": { + "filename": "format/bytes.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 811, + "line": 32, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/types/model", + "function": "init", + "position": { + "filename": "types/model/capability.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/types.go", + "offset": 236, + "line": 19, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "New[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 450, + "line": 18, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8056, + "line": 239, + "column": 66 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7817, + "line": 281, + "column": 50 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/format", + "function": "init", + "position": { + "filename": "format/bytes.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 811, + "line": 32, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "Set", + "receiver": "*Map[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 918, + "line": 36, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8459, + "line": 258, + "column": 10 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7940, + "line": 286, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 594, + "line": 14, + "column": 1 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "ToMap", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8960, + "line": 278, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateNonStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 5661, + "line": 203, + "column": 38 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1597, + "line": 58, + "column": 26 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Error", + "receiver": "AuthorizationError", + "position": { + "filename": "api/types.go", + "offset": 951, + "line": 49, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/core/internal/service/model", + "function": "GetProviderModels", + "receiver": "*Service", + "position": { + "filename": "core/internal/service/model/discovery.go", + "offset": 16286, + "line": 364, + "column": 71 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Chat", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 7871, + "line": 297, + "column": 18 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 2317, + "line": 79, + "column": 22 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1511, + "line": 56, + "column": 23 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/version", + "function": "init", + "position": { + "filename": "version/version.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 846, + "line": 33, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "New[string, github.com/ollama/ollama/api.ToolProperty]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 450, + "line": 18, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 10982, + "line": 360, + "column": 68 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 9700, + "line": 363, + "column": 41 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "ToMap", + "receiver": "*Map[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 1617, + "line": 70, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "ToMap", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 9049, + "line": 282, + "column": 19 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateNonStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 5661, + "line": 203, + "column": 38 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1597, + "line": 58, + "column": 26 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 10871, + "line": 359, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 9700, + "line": 363, + "column": 41 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "ToMap", + "receiver": "*Map[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 1617, + "line": 70, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "ToMap", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 9049, + "line": 282, + "column": 19 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateNonStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 5661, + "line": 203, + "column": 38 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1597, + "line": 58, + "column": 26 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Error", + "receiver": "AuthorizationError", + "position": { + "filename": "api/types.go", + "offset": 951, + "line": 49, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/core/internal/service/model", + "function": "GetProviderModels", + "receiver": "*Service", + "position": { + "filename": "core/internal/service/model/discovery.go", + "offset": 16286, + "line": 364, + "column": 71 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth", + "function": "init", + "position": { + "filename": "auth/auth.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 740, + "line": 30, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Error", + "receiver": "AuthorizationError", + "position": { + "filename": "api/types.go", + "offset": 951, + "line": 49, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/core/internal/service/model", + "function": "GetProviderModels", + "receiver": "*Service", + "position": { + "filename": "core/internal/service/model/discovery.go", + "offset": 16286, + "line": 364, + "column": 71 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 7932, + "line": 238, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7817, + "line": 281, + "column": 50 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewClient", + "position": { + "filename": "api/client.go", + "offset": 2100, + "line": 81, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "NewOllamaModel", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 3418, + "line": 135, + "column": 25 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "New[string, github.com/ollama/ollama/api.ToolProperty]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 450, + "line": 18, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 10982, + "line": 360, + "column": 68 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 9700, + "line": 363, + "column": 41 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 11262, + "line": 372, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 10669, + "line": 384, + "column": 30 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/format", + "function": "init", + "position": { + "filename": "format/bytes.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 811, + "line": 32, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 10871, + "line": 359, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 9700, + "line": 363, + "column": 41 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 594, + "line": 14, + "column": 1 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 594, + "line": 14, + "column": 1 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/types/model", + "function": "init", + "position": { + "filename": "types/model/capability.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/types.go", + "offset": 236, + "line": 19, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "ToMap", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8960, + "line": 278, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateNonStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 5661, + "line": 203, + "column": 38 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1597, + "line": 58, + "column": 26 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 7932, + "line": 238, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7817, + "line": 281, + "column": 50 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "Set", + "receiver": "*Map[string, github.com/ollama/ollama/api.ToolProperty]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 918, + "line": 36, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 11405, + "line": 379, + "column": 10 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 10669, + "line": 384, + "column": 30 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig", + "function": "Bool$1", + "position": { + "filename": "envconfig/config.go", + "offset": 4723, + "line": 194, + "column": 9 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "do", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 2875, + "line": 119, + "column": 22 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12526, + "line": 432, + "column": 16 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Chat", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 7871, + "line": 297, + "column": 18 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 2317, + "line": 79, + "column": 22 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1511, + "line": 56, + "column": 23 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth", + "function": "Sign", + "position": { + "filename": "auth/auth.go", + "offset": 968, + "line": 53, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "getAuthorizationToken", + "position": { + "filename": "api/client.go", + "offset": 2313, + "line": 89, + "column": 25 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "do", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 3059, + "line": 122, + "column": 37 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12526, + "line": 432, + "column": 16 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth", + "function": "init", + "position": { + "filename": "auth/auth.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 740, + "line": 30, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "ToMap", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8960, + "line": 278, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateNonStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 5661, + "line": 203, + "column": 38 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1597, + "line": 58, + "column": 26 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 7932, + "line": 238, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7817, + "line": 281, + "column": 50 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12415, + "line": 430, + "column": 18 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/types/model", + "function": "init", + "position": { + "filename": "types/model/capability.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/types.go", + "offset": 236, + "line": 19, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12415, + "line": 430, + "column": 18 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "New[string, github.com/ollama/ollama/api.ToolProperty]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 450, + "line": 18, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 10982, + "line": 360, + "column": 68 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 9700, + "line": 363, + "column": 41 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Chat", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 7871, + "line": 297, + "column": 18 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 2317, + "line": 79, + "column": 22 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1511, + "line": 56, + "column": 23 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 10871, + "line": 359, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 9700, + "line": 363, + "column": 41 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Error", + "receiver": "StatusError", + "position": { + "filename": "api/types.go", + "offset": 468, + "line": 29, + "column": 22 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/core/internal/service/model", + "function": "GetProviderModels", + "receiver": "*Service", + "position": { + "filename": "core/internal/service/model/discovery.go", + "offset": 16286, + "line": 364, + "column": 71 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12415, + "line": 430, + "column": 18 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "init", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 161, + "line": 3, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/types.go", + "offset": 188, + "line": 18, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Error", + "receiver": "AuthorizationError", + "position": { + "filename": "api/types.go", + "offset": 951, + "line": 49, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/core/internal/service/model", + "function": "GetProviderModels", + "receiver": "*Service", + "position": { + "filename": "core/internal/service/model/discovery.go", + "offset": 16286, + "line": 364, + "column": 71 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewClient", + "position": { + "filename": "api/client.go", + "offset": 2100, + "line": 81, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "NewOllamaModel", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 3418, + "line": 135, + "column": 25 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Error", + "receiver": "StatusError", + "position": { + "filename": "api/types.go", + "offset": 468, + "line": 29, + "column": 22 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/core/internal/service/model", + "function": "GetProviderModels", + "receiver": "*Service", + "position": { + "filename": "core/internal/service/model/discovery.go", + "offset": 16286, + "line": 364, + "column": 71 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "New[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 450, + "line": 18, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8056, + "line": 239, + "column": 66 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7817, + "line": 281, + "column": 50 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/version", + "function": "init", + "position": { + "filename": "version/version.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 846, + "line": 33, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "init", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 161, + "line": 3, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/types.go", + "offset": 188, + "line": 18, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8334, + "line": 251, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7940, + "line": 286, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Error", + "receiver": "AuthorizationError", + "position": { + "filename": "api/types.go", + "offset": 951, + "line": 49, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/core/internal/service/model", + "function": "GetProviderModels", + "receiver": "*Service", + "position": { + "filename": "core/internal/service/model/discovery.go", + "offset": 16286, + "line": 364, + "column": 71 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig", + "function": "init", + "position": { + "filename": "envconfig/config.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 773, + "line": 31, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/types/model", + "function": "init", + "position": { + "filename": "types/model/capability.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/types.go", + "offset": 236, + "line": 19, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 11262, + "line": 372, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 10669, + "line": 384, + "column": 30 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig", + "function": "init", + "position": { + "filename": "envconfig/config.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 773, + "line": 31, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "ToMap", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8960, + "line": 278, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateNonStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 5661, + "line": 203, + "column": 38 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1597, + "line": 58, + "column": 26 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8334, + "line": 251, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7940, + "line": 286, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "Set", + "receiver": "*Map[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 918, + "line": 36, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8459, + "line": 258, + "column": 10 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7940, + "line": 286, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12415, + "line": 430, + "column": 18 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "init", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 161, + "line": 3, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/types.go", + "offset": 188, + "line": 18, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/format", + "function": "init", + "position": { + "filename": "format/bytes.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 811, + "line": 32, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "Set", + "receiver": "*Map[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 918, + "line": 36, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8459, + "line": 258, + "column": 10 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7940, + "line": 286, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "Set", + "receiver": "*Map[string, github.com/ollama/ollama/api.ToolProperty]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 918, + "line": 36, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 11405, + "line": 379, + "column": 10 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 10669, + "line": 384, + "column": 30 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Error", + "receiver": "StatusError", + "position": { + "filename": "api/types.go", + "offset": 468, + "line": 29, + "column": 22 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/core/internal/service/model", + "function": "GetProviderModels", + "receiver": "*Service", + "position": { + "filename": "core/internal/service/model/discovery.go", + "offset": 16286, + "line": 364, + "column": 71 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "New[string, github.com/ollama/ollama/api.ToolProperty]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 450, + "line": 18, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 10982, + "line": 360, + "column": 68 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 9700, + "line": 363, + "column": 41 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "ToMap", + "receiver": "*Map[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 1617, + "line": 70, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "ToMap", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 9049, + "line": 282, + "column": 19 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateNonStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 5661, + "line": 203, + "column": 38 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1597, + "line": 58, + "column": 26 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 7932, + "line": 238, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7817, + "line": 281, + "column": 50 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Error", + "receiver": "AuthorizationError", + "position": { + "filename": "api/types.go", + "offset": 951, + "line": 49, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/core/internal/service/model", + "function": "GetProviderModels", + "receiver": "*Service", + "position": { + "filename": "core/internal/service/model/discovery.go", + "offset": 16286, + "line": 364, + "column": 71 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "ToMap", + "receiver": "*Map[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 1617, + "line": 70, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "ToMap", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 9049, + "line": 282, + "column": 19 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateNonStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 5661, + "line": 203, + "column": 38 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1597, + "line": 58, + "column": 26 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "New[string, github.com/ollama/ollama/api.ToolProperty]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 450, + "line": 18, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 10982, + "line": 360, + "column": 68 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 9700, + "line": 363, + "column": 41 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 11262, + "line": 372, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 10669, + "line": 384, + "column": 30 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8334, + "line": 251, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7940, + "line": 286, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8334, + "line": 251, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7940, + "line": 286, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 7932, + "line": 238, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7817, + "line": 281, + "column": 50 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 10871, + "line": 359, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 9700, + "line": 363, + "column": 41 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 7932, + "line": 238, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7817, + "line": 281, + "column": 50 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth", + "function": "Sign", + "position": { + "filename": "auth/auth.go", + "offset": 968, + "line": 53, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "getAuthorizationToken", + "position": { + "filename": "api/client.go", + "offset": 2313, + "line": 89, + "column": 25 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "do", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 3059, + "line": 122, + "column": 37 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12526, + "line": 432, + "column": 16 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/types/model", + "function": "init", + "position": { + "filename": "types/model/capability.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/types.go", + "offset": 236, + "line": 19, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-4251", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth", + "function": "init", + "position": { + "filename": "auth/auth.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 740, + "line": 30, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "init", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 161, + "line": 3, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/types.go", + "offset": 188, + "line": 18, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "New[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 450, + "line": 18, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8056, + "line": 239, + "column": 66 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7817, + "line": 281, + "column": 50 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "New[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 450, + "line": 18, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8056, + "line": 239, + "column": 66 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7817, + "line": 281, + "column": 50 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/version", + "function": "init", + "position": { + "filename": "version/version.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 846, + "line": 33, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Error", + "receiver": "StatusError", + "position": { + "filename": "api/types.go", + "offset": 468, + "line": 29, + "column": 22 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/core/internal/service/model", + "function": "GetProviderModels", + "receiver": "*Service", + "position": { + "filename": "core/internal/service/model/discovery.go", + "offset": 16286, + "line": 364, + "column": 71 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Error", + "receiver": "StatusError", + "position": { + "filename": "api/types.go", + "offset": 468, + "line": 29, + "column": 22 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/core/internal/service/model", + "function": "GetProviderModels", + "receiver": "*Service", + "position": { + "filename": "core/internal/service/model/discovery.go", + "offset": 16286, + "line": 364, + "column": 71 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "New[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 450, + "line": 18, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8056, + "line": 239, + "column": 66 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7817, + "line": 281, + "column": 50 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig", + "function": "Bool$1", + "position": { + "filename": "envconfig/config.go", + "offset": 4723, + "line": 194, + "column": 9 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "do", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 2875, + "line": 119, + "column": 22 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12526, + "line": 432, + "column": 16 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "New[string, github.com/ollama/ollama/api.ToolProperty]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 450, + "line": 18, + "column": 6 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 10982, + "line": 360, + "column": 68 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 9700, + "line": 363, + "column": 41 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "Set", + "receiver": "*Map[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 918, + "line": 36, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Set", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8459, + "line": 258, + "column": 10 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiContentsToOllamaMessages", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 7940, + "line": 286, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 962, + "line": 35, + "column": 70 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig", + "function": "init", + "position": { + "filename": "envconfig/config.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 773, + "line": 31, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/format", + "function": "init", + "position": { + "filename": "format/bytes.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 811, + "line": 32, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3559", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Error", + "receiver": "AuthorizationError", + "position": { + "filename": "api/types.go", + "offset": 951, + "line": 49, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/core/internal/service/model", + "function": "GetProviderModels", + "receiver": "*Service", + "position": { + "filename": "core/internal/service/model/discovery.go", + "offset": 16286, + "line": 364, + "column": 71 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Chat", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 7871, + "line": 297, + "column": 18 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 2317, + "line": 79, + "column": 22 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1511, + "line": 56, + "column": 23 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3695", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewToolPropertiesMap", + "position": { + "filename": "api/types.go", + "offset": 10871, + "line": 359, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "convertGenaiToolsToOllama", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 9700, + "line": 363, + "column": 41 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1363, + "line": 49, + "column": 37 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3824", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 594, + "line": 14, + "column": 1 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3557", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/internal/orderedmap", + "function": "ToMap", + "receiver": "*Map[string, any]", + "position": { + "filename": "internal/orderedmap/orderedmap.go", + "offset": 1617, + "line": 70, + "column": 21 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "ToMap", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 9049, + "line": 282, + "column": 19 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateNonStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 5661, + "line": 203, + "column": 38 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1597, + "line": 58, + "column": 26 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/types/model", + "function": "init", + "position": { + "filename": "types/model/capability.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/types.go", + "offset": 236, + "line": 19, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "ToMap", + "receiver": "*ToolCallFunctionArguments", + "position": { + "filename": "api/types.go", + "offset": 8960, + "line": 278, + "column": 37 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateNonStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 5661, + "line": 203, + "column": 38 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1597, + "line": 58, + "column": 26 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "NewClient", + "position": { + "filename": "api/client.go", + "offset": 2100, + "line": 81, + "column": 6 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "NewOllamaModel", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 3418, + "line": 135, + "column": 25 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3558", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/auth", + "function": "init", + "position": { + "filename": "auth/auth.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 740, + "line": 30, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3582", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/envconfig", + "function": "init", + "position": { + "filename": "envconfig/config.go", + "offset": 0, + "line": 1, + "column": 1 + } + }, + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "init", + "position": { + "filename": "api/client.go", + "offset": 773, + "line": 31, + "column": 2 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "init", + "position": { + "filename": "adk/pkg/models/ollama.go", + "offset": 132, + "line": 12, + "column": 2 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Embed", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 12415, + "line": 430, + "column": 18 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "generate", + "receiver": "*ollamaProvider", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 7446, + "line": 262, + "column": 29 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/embedding", + "function": "Generate", + "receiver": "*Client", + "position": { + "filename": "adk/pkg/embedding/embedding.go", + "offset": 2510, + "line": 93, + "column": 21 + } + } + ] + } +} +{ + "finding": { + "osv": "GO-2025-3689", + "trace": [ + { + "module": "github.com/ollama/ollama", + "version": "v0.32.15", + "package": "github.com/ollama/ollama/api", + "function": "Chat", + "receiver": "*Client", + "position": { + "filename": "api/client.go", + "offset": 7871, + "line": 297, + "column": 18 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "generateStreaming", + "receiver": "*OllamaModel", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 2317, + "line": 79, + "column": 22 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/pkg/models", + "function": "GenerateContent$1", + "position": { + "filename": "adk/pkg/models/ollama_adk.go", + "offset": 1511, + "line": 56, + "column": 23 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "generateContent$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "callLLM$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "runOneStep$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/internal/llminternal", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent/llmagent", + "function": "run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/agent", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "google.golang.org/adk/v2", + "version": "v2.2.0", + "package": "google.golang.org/adk/v2/runner", + "function": "Run$1", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + }, + { + "module": "github.com/kagent-dev/kagent/go", + "package": "github.com/kagent-dev/kagent/go/adk/examples/oneshot", + "function": "main", + "position": { + "offset": 0, + "line": 0, + "column": 0 + } + } + ] + } +} diff --git a/go/harness/claude/Dockerfile b/go/harness/claude/Dockerfile index 78371425d9..ce45d8e5cb 100644 --- a/go/harness/claude/Dockerfile +++ b/go/harness/claude/Dockerfile @@ -44,8 +44,11 @@ RUN apk upgrade --no-cache && apk add --no-cache bash ca-certificates git libgcc COPY --from=builder /kagent-claude /usr/local/bin/kagent-claude -USER 65532:65532 -WORKDIR /data/workspace +# The harness runs as root and starts Claude Code as the kagent user, so that +# Claude and its tools cannot read the harness process or change the policy it +# writes. The harness sets Claude's working directory itself. +USER 0:0 +WORKDIR / EXPOSE 80 8081 LABEL org.opencontainers.image.source="https://github.com/kagent-dev/kagent" diff --git a/go/harness/claude/README.md b/go/harness/claude/README.md index 0a422cab62..f8501f2215 100644 --- a/go/harness/claude/README.md +++ b/go/harness/claude/README.md @@ -15,7 +15,7 @@ harness. | [`../../core/internal/translator/claude`](../../core/internal/translator/claude) | Translating `Harness`, `AgentTemplate`, model, MCP, plugin, and Secret inputs into a runtime revision and warnings | | [`config/config.go`](config/config.go) | The versioned JSON contract shared by the compiler and runtime, including defaults and reserved environment variables | | [`cmd/main.go`](cmd/main.go) | Actor startup, environment inputs, Claude version validation, continuation-store wiring, and private A2A startup | -| [`internal/adapter/adapter.go`](internal/adapter/adapter.go) | Materializing Claude home, skills, MCP config, and ephemeral provider credentials | +| [`internal/adapter/adapter.go`](internal/adapter/adapter.go) | Materializing Claude home, skills, permission rules, MCP config and provider credentials, and handing Claude's trees to the unprivileged user | | [`internal/driver`](internal/driver) | Claude CLI arguments, stream-JSON parsing, runtime-event translation, cancellation, and process supervision | ## Working @@ -34,6 +34,79 @@ Credentials use [Substrate gateway injection](../../../docs/architecture/credent AWS IAM keys and Vertex service-account keys require local signing and are rejected by the compiler. Harness environment entries accept only literal values. +With `KAGENT_PROPAGATE_TOKEN=true` in the Harness environment, the caller's `authorization` +of each A2A turn is forwarded on MCP calls, as the Go ADK does. The adapter points every +compiled streamable HTTP MCP server at a private, authenticated loopback endpoint of the +harness process; that forwarder adds the server's compiled headers and the turn's credential, +unless the server configures its own `Authorization` (the egress gateway's placeholder, which it +keeps), and drops the credential before the turn's outcome is returned, ending every forwarded +request still in flight with it, so a parked or suspended Actor holds none and nothing acts as the +caller after the turn. The credential +never enters Claude's environment or `mcp.json`. SSE servers are not fronted: their +message endpoint is announced by the upstream host. A request whose path holds a `.` or `..` +segment, decoded or not, an encoded slash or backslash, an encoded `%`, a `;` or a control +byte is refused with 400 before it is matched to a server or a caller route, since the forwarded +path is joined onto the upstream URL. Of Claude's request headers, only those the streamable HTTP transport +and trace propagation use reach the server (`Accept`, `Content-Type`, `Mcp-Session-Id`, +`Mcp-Protocol-Version`, `Last-Event-ID`, `User-Agent`, `traceparent`, `tracestate`, `baggage`). + +`KAGENT_CLAUDE_CALLER_ROUTES` (it requires `KAGENT_PROPAGATE_TOKEN=true`) extends the same +forwarder to clients other than Claude's MCP runtime. It maps a host to an upstream that acts on +the caller's credential for that host, typically a gateway that exchanges it for the person's own +token there: + +```json +{"github.com": "http://agentgateway.agent-platform.svc.cluster.local:8080/route/github.com/"} +``` + +Each host is served on the forwarder's loopback origin under `/route//`, and only while a +turn runs; between turns a route answers 401 and nothing leaves the process. The adapter points +git at the routes through `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n`: `https:///` +is rewritten to the host's route, and git sends the forwarder's loopback token, the same one +`mcp.json` carries. The Harness environment must not set `GIT_CONFIG_COUNT` itself. Route bodies +are streamed without the MCP body limit, so a push of any size passes where the upstream admits +pushes. The caller's credential +never reaches git, its configuration or the environment; the person's token at the host exists +only in the upstream. Of git's request headers, only those its smart HTTP protocol and trace +propagation use reach the upstream (`Accept`, `Accept-Encoding`, `Content-Type`, +`Content-Encoding`, `Git-Protocol`, `User-Agent`, `traceparent`, `tracestate`, `baggage`). + +## Users + +The image runs the harness as root. On every start the harness hands the workspace and +`/data/claude` to the image's `kagent` user (uid 65532) and starts Claude Code as that +user with no supplementary groups, so Claude and every process it starts can read +neither the harness process, which holds the turn's credential, nor the harness's own +state. The claude translator asks Substrate for `CHOWN`, `SETGID` and `SETUID` on top of +its defaults; no `DAC_*` capability is needed. + +A start trusts nothing Claude could have written before it. The harness first takes +`/data` and everything in it other than Claude's two trees back, removing every link +and clearing group and other write, which also covers a volume an earlier image ran as +uid 65532. It then rebuilds `/data/generated` (the skill and plugin packages and the +skills Claude loads) as root and leaves it readable by the `kagent` group only. Of +Claude's trees it touches only the two root directories and never walks their +contents, so links, setuid bits or depth Claude leaves there neither reach the harness +nor fail a start, and Claude's files stay as Claude left them. + +What the harness writes for Claude to obey is root-owned and readable by the `kagent` +group only (`0640` in a `0750` directory), so Claude can read it but neither edit, +replace nor add to it: the permission rules in Claude Code's managed settings +(`/etc/claude-code/managed-settings.json`, which the harness owns: an operator file +mounted there is replaced), and `mcp.json` and the Google credentials in +`/run/kagent-claude`. + +This does not make approval a boundary against Claude. `mcp.json` must be readable for +Claude to reach its servers, so a process Claude starts can call the forwarder or the +approval broker with the same loopback token, and a hook in Claude's own settings can +answer a permission prompt. Approval of a protected tool is enforced by the MCP server. + +`KAGENT_PROPAGATE_TOKEN=true` with MCP servers or caller routes requires the root harness: a harness +that runs as another user would start Claude Code as itself, and it refuses to start. +A harness without the forwarder may run as any user; Claude Code then runs as the same +user, and the settings, `mcp.json` and Google credentials are owner-only files in +`/tmp/kagent-claude`. + ## Telemetry The driver passes each prompt to Claude Code on stdin as stream-JSON. When @@ -58,7 +131,11 @@ absent. ## Human-in-the-loop approval flow Claude runs in print mode with `permissions.ask` rules for MCP servers -that require approval. Its native `--permission-prompt-tool` calls a private, +that require approval. A root harness renders them as managed settings with +`allowManagedPermissionRulesOnly`, on every start, so no user, project or workspace +rule applies; otherwise the driver passes them with `--settings`, and user and project +`allow` rules do not skip an `ask`. Skills under `--add-dir` and `CLAUDE.md` load either +way. Its native `--permission-prompt-tool` calls a private, authenticated loopback MCP tool before executing a protected call. ```mermaid diff --git a/go/harness/claude/config/config.go b/go/harness/claude/config/config.go index dd20f52fb7..2eeca34548 100644 --- a/go/harness/claude/config/config.go +++ b/go/harness/claude/config/config.go @@ -6,7 +6,10 @@ import ( "encoding/json" "fmt" "io" + "maps" "regexp" + "slices" + "strconv" "strings" "time" @@ -34,10 +37,25 @@ const ( AWSBedrockTokenEnvName = "AWS_BEARER_TOKEN_BEDROCK" AnthropicAPIKeyEnvName = "ANTHROPIC_API_KEY" AnthropicBaseURLEnvName = "ANTHROPIC_BASE_URL" + AnthropicCustomHeadersEnvName = "ANTHROPIC_CUSTOM_HEADERS" + MaxOutputTokensEnvName = "CLAUDE_CODE_MAX_OUTPUT_TOKENS" VertexProjectEnvName = "ANTHROPIC_VERTEX_PROJECT_ID" VertexRegionEnvName = "CLOUD_ML_REGION" SandboxEnvName = "IS_SANDBOX" + PropagateTokenEnvName = "KAGENT_PROPAGATE_TOKEN" + HomeEnvName = "HOME" MCPCredentialEnvPrefix = "KAGENT_CLAUDE_MCP_CREDENTIAL_" + // CallerRoutesEnvName holds a JSON object from host to the URL of an + // upstream that acts on the turn caller's credential for that host, e.g. + // {"github.com":"http://agentgateway.agent-platform.svc:8080/route/github.com/"}. + CallerRoutesEnvName = "KAGENT_CLAUDE_CALLER_ROUTES" +) + +// UnprivilegedUID and UnprivilegedGID are the image's kagent user. A harness +// that runs as root starts Claude Code as this user. +const ( + UnprivilegedUID = 65532 + UnprivilegedGID = 65532 ) // OwnsEnvironment reports whether the compiler or adapter reserves name for @@ -50,8 +68,8 @@ func OwnsEnvironment(name string) bool { case ClaudeConfigDirEnvName, DisableUpdatesEnvName, GoogleApplicationCredentialsEnvName, GoogleCredentialsJSONEnvName, UseBedrockEnvName, UseVertexEnvName, AWSRegionEnvName, AWSAccessKeyEnvName, AWSSecretKeyEnvName, AWSSessionTokenEnvName, AWSBedrockTokenEnvName, - AnthropicAPIKeyEnvName, AnthropicBaseURLEnvName, VertexProjectEnvName, VertexRegionEnvName, - SandboxEnvName, "CLAUDE_CODE_ENABLE_TELEMETRY", + AnthropicAPIKeyEnvName, AnthropicBaseURLEnvName, AnthropicCustomHeadersEnvName, VertexProjectEnvName, VertexRegionEnvName, + MaxOutputTokensEnvName, SandboxEnvName, "CLAUDE_CODE_ENABLE_TELEMETRY", "CLAUDE_CODE_ENHANCED_TELEMETRY_BETA", "OTEL_TRACES_EXPORTER", "OTEL_METRICS_EXPORTER", "OTEL_LOGS_EXPORTER", "OTEL_LOG_USER_PROMPTS", "OTEL_LOG_TOOL_DETAILS", "OTEL_LOG_TOOL_CONTENT", "OTEL_LOG_ASSISTANT_RESPONSES", "OTEL_LOG_RAW_API_BODIES", "TRACEPARENT", "TRACESTATE", @@ -63,6 +81,18 @@ func OwnsEnvironment(name string) bool { } } +// CustomHeaders renders headers as the value of ANTHROPIC_CUSTOM_HEADERS: one +// "Name: Value" pair per line, which Claude adds to every model call. The lines +// are sorted by name so the same headers render the same value. +func CustomHeaders(headers map[string]string) string { + names := slices.Sorted(maps.Keys(headers)) + lines := make([]string, 0, len(names)) + for _, name := range names { + lines = append(lines, name+": "+headers[name]) + } + return strings.Join(lines, "\n") +} + type Config struct { Version int `json:"version"` ClaudeExecutable string `json:"claude_executable"` @@ -73,6 +103,10 @@ type Config struct { Agents map[string]Agent `json:"agents,omitempty"` SkillResources *agentplugin.Resources `json:"skill_resources,omitempty"` MCPServers map[string]MCPServer `json:"mcp_servers,omitempty"` + // MaxBudgetUSD and MaxTurns bound one turn (--max-budget-usd, --max-turns); + // zero values leave the bound unset. + MaxBudgetUSD string `json:"max_budget_usd,omitempty"` + MaxTurns int `json:"max_turns,omitempty"` // RuntimeTelemetry carries the compiler-owned span identity and content // capture policy. It is absent for standalone runs, which fall back to the // environment for service identity and leave capture disabled. @@ -111,6 +145,8 @@ func Production(model, instruction string) Config { } } +var budgetPattern = regexp.MustCompile(`^(0|[1-9][0-9]{0,5})(\.[0-9]{1,4})?$`) + func Parse(b []byte) (Config, error) { var cfg Config dec := json.NewDecoder(strings.NewReader(string(b))) @@ -143,6 +179,15 @@ func (c Config) Validate() error { if err := c.RuntimeTelemetry.Validate(); err != nil { return err } + if c.MaxBudgetUSD != "" { + budget, err := strconv.ParseFloat(c.MaxBudgetUSD, 64) + if err != nil || !budgetPattern.MatchString(c.MaxBudgetUSD) || budget <= 0 { + return fmt.Errorf("max_budget_usd %q must be a positive decimal amount", c.MaxBudgetUSD) + } + } + if c.MaxTurns < 0 { + return fmt.Errorf("max_turns must not be negative") + } if runtime := c.RuntimeTelemetry.Runtime; runtime != "" && runtime != tracing.RuntimeClaude { return fmt.Errorf("claude runtime telemetry names runtime %q", runtime) } diff --git a/go/harness/claude/config/config_test.go b/go/harness/claude/config/config_test.go index fc9b7ddd44..03d2a8e3c7 100644 --- a/go/harness/claude/config/config_test.go +++ b/go/harness/claude/config/config_test.go @@ -12,6 +12,7 @@ import ( func TestOwnsEnvironment(t *testing.T) { for _, name := range []string{ AnthropicAPIKeyEnvName, + AnthropicCustomHeadersEnvName, ClaudeConfigDirEnvName, "CLAUDE_CODE_ENHANCED_TELEMETRY_BETA", "OTEL_TRACES_EXPORTER", @@ -33,6 +34,18 @@ func TestOwnsEnvironment(t *testing.T) { } } +// Claude reads ANTHROPIC_CUSTOM_HEADERS as "Name: Value" lines; the same +// headers render the same value whatever the map's order. +func TestCustomHeaders(t *testing.T) { + got := CustomHeaders(map[string]string{"x-kagent-agent-namespace": "team", "x-kagent-agent": "assistant"}) + if want := "x-kagent-agent: assistant\nx-kagent-agent-namespace: team"; got != want { + t.Fatalf("CustomHeaders() = %q, want %q", got, want) + } + if CustomHeaders(nil) != "" { + t.Fatal("CustomHeaders(nil) is not empty") + } +} + func TestProductionRoundTrip(t *testing.T) { cfg := Production("claude-test", "help") if err := cfg.Validate(); err != nil { @@ -176,3 +189,29 @@ func TestParseAcceptsConfigWithoutRuntimeTelemetry(t *testing.T) { t.Fatal("capture is enabled without a telemetry section") } } + +func TestValidateTurnLimits(t *testing.T) { + for name, tt := range map[string]struct { + budget string + turns int + wantErr bool + }{ + "none": {}, + "budget": {budget: "2.50"}, + "turns": {turns: 40}, + "both": {budget: "0.05", turns: 1}, + "zero budget": {budget: "0", wantErr: true}, + "negative budget": {budget: "-1", wantErr: true}, + "words": {budget: "two", wantErr: true}, + "too precise": {budget: "1.00001", wantErr: true}, + "negative turns": {turns: -1, wantErr: true}, + } { + t.Run(name, func(t *testing.T) { + cfg := Production("claude-test", "help") + cfg.MaxBudgetUSD, cfg.MaxTurns = tt.budget, tt.turns + if err := cfg.Validate(); (err != nil) != tt.wantErr { + t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/go/harness/claude/executor/executor.go b/go/harness/claude/executor/executor.go index c93ec2d688..b82c6ec2b8 100644 --- a/go/harness/claude/executor/executor.go +++ b/go/harness/claude/executor/executor.go @@ -32,8 +32,10 @@ func New(ctx context.Context, cfg Config) (a2asrv.AgentExecutor, io.Closer, erro runner, err := adapter.New(ctx, adapter.Input{ ConfigJSON: cfg.ConfigJSON, Workspace: cfg.DataDir + "/workspace", DurableDir: cfg.DataDir, - EphemeralDir: "/tmp/kagent-claude", - Environment: cfg.Environment, + EphemeralDir: "/tmp/kagent-claude", + Environment: cfg.Environment, + PolicyDir: "/run/kagent-claude", + ManagedSettingsPath: "/etc/claude-code/managed-settings.json", }) if err != nil { return nil, nil, fmt.Errorf("configure Claude Harness: %w", err) diff --git a/go/harness/claude/internal/adapter/adapter.go b/go/harness/claude/internal/adapter/adapter.go index d332007030..6584bbd2ad 100644 --- a/go/harness/claude/internal/adapter/adapter.go +++ b/go/harness/claude/internal/adapter/adapter.go @@ -5,10 +5,15 @@ package adapter import ( "context" "encoding/json" + "errors" "fmt" + "io/fs" "maps" + "os" "path/filepath" + "regexp" "slices" + "strconv" "strings" "github.com/kagent-dev/kagent/go/core/pkg/agentplugins" @@ -21,6 +26,20 @@ import ( const approvalMCPServerName = "kagent_hitl" +// claudeSettings is the permission rules the adapter renders. A harness that +// runs as root renders them as Claude Code's managed settings, where only +// managed permission rules apply. +type claudeSettings struct { + Permissions claudePermissions `json:"permissions"` + AllowManagedPermissionRulesOnly bool `json:"allowManagedPermissionRulesOnly,omitempty"` +} + +type claudePermissions struct { + Ask []string `json:"ask,omitempty"` +} + +var callerRouteHostPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:[0-9]{1,5})?$`) + // Input contains compiler output and Actor-owned locations used to construct // the Claude driver. type Input struct { @@ -29,6 +48,12 @@ type Input struct { DurableDir string EphemeralDir string Environment []string + // PolicyDir holds what a root harness writes for Claude Code to read but + // not change: the MCP configuration and the Google credentials. + PolicyDir string + // ManagedSettingsPath is Claude Code's managed settings file, where a root + // harness renders the permission rules. + ManagedSettingsPath string } // New validates and materializes Claude-owned state, then constructs its driver. @@ -45,9 +70,57 @@ func New(ctx context.Context, input Input) (*driver.ProcessDriver, error) { return nil, fmt.Errorf("workspace, durable, and ephemeral directories must be absolute paths") } claudeDir := filepath.Join(input.DurableDir, "claude") + generatedDir := filepath.Join(input.DurableDir, "generated") var skillRoot string if cfg.SkillResources != nil { - skillRoot = filepath.Join(input.DurableDir, "generated", "claude") + skillRoot = filepath.Join(generatedDir, "claude") + } + routes, err := callerRoutes(input.Environment) + if err != nil { + return nil, err + } + propagate := propagateCallerToken(input.Environment) + if len(routes) != 0 && !propagate { + return nil, fmt.Errorf("%s requires %s=true: a caller route acts as the turn's caller", config.CallerRoutesEnvName, config.PropagateTokenEnvName) + } + holdsCallerCredential := propagate && (len(cfg.MCPServers) != 0 || len(routes) != 0) + if holdsCallerCredential { + if err := validateForwardedServers(cfg.MCPServers); err != nil { + return nil, err + } + } + runsAsRoot := os.Geteuid() == 0 + if runsAsRoot && (!filepath.IsAbs(input.PolicyDir) || !filepath.IsAbs(input.ManagedSettingsPath)) { + return nil, fmt.Errorf("a harness that runs as root needs absolute policy and managed settings paths") + } + if holdsCallerCredential { + if !runsAsRoot { + // Claude Code would run as the harness's own user and could read the + // caller's credential out of the harness process. + return nil, fmt.Errorf("%s requires the harness to run as root, so that Claude Code runs as another user", config.PropagateTokenEnvName) + } + } + files := harnessFiles{dir: input.EphemeralDir} + claudeTrees := []string{input.Workspace, claudeDir} + if runsAsRoot { + files = harnessFiles{dir: input.PolicyDir, groupReadable: true} + } else { + claudeTrees = append(claudeTrees, input.EphemeralDir) + } + if runsAsRoot { + // Claude may have left links or its own files anywhere it could write + // before this start; none of it is trusted. + if err := secureDurableDir(input.DurableDir, claudeTrees); err != nil { + return nil, err + } + if err := os.RemoveAll(generatedDir); err != nil { + return nil, fmt.Errorf("remove the generated Claude tree: %w", err) + } + for _, tree := range claudeTrees { + if err := reclaimTreeRoot(tree); err != nil { + return nil, err + } + } } for _, directory := range []struct{ name, path string }{ {name: "workspace", path: input.Workspace}, @@ -64,13 +137,18 @@ func New(ctx context.Context, input Input) (*driver.ProcessDriver, error) { return nil, fmt.Errorf("prepare generated Claude skills directory: %w", err) } materialized, err := agentplugins.Materialize(ctx, *cfg.SkillResources, agentplugins.Paths{ - Packages: filepath.Join(claudeDir, "packages"), + Packages: filepath.Join(generatedDir, "packages"), Skills: skillsDir, }) if err != nil { return nil, fmt.Errorf("materialize Claude skills: %w", err) } pluginDirs = materialized.ClaudeFormatPluginRoots() + if runsAsRoot { + if err := utils.ShareTreeReadOnly(generatedDir, config.UnprivilegedGID); err != nil { + return nil, fmt.Errorf("share the generated Claude tree read-only: %w", err) + } + } } environment := setEnvironment(input.Environment, config.ClaudeConfigDirEnvName, claudeDir) // The native runtime inherits the compiled identity through the standard @@ -79,35 +157,44 @@ func New(ctx context.Context, input Input) (*driver.ProcessDriver, error) { // The image and compiler pin an exact Claude version. Prevent both automatic // and manual update paths from changing that runtime after validation. environment = setEnvironment(environment, config.DisableUpdatesEnvName, "1") - environment, err = materializeGoogleCredentials(environment, input.EphemeralDir) + environment, err = materializeGoogleCredentials(environment, files) if err != nil { return nil, err } - protectedServers := approvalServerNames(cfg.MCPServers) + if _, exists := cfg.MCPServers[approvalMCPServerName]; exists { + return nil, fmt.Errorf("Claude MCP server name %q is reserved for human approval", approvalMCPServerName) + } + var forwarder *driver.CredentialForwarder var approvalBroker *driver.ApprovalBroker - var settingsPath string - var permissionPromptTool string - if len(protectedServers) != 0 { - if _, exists := cfg.MCPServers[approvalMCPServerName]; exists { - return nil, fmt.Errorf("Claude MCP server name %q is reserved for human approval", approvalMCPServerName) + closeListeners := func() { + if forwarder != nil { + _ = forwarder.Close() } - if err := utils.EnsurePrivateDir(input.EphemeralDir); err != nil { - return nil, fmt.Errorf("prepare ephemeral Claude settings directory: %w", err) + if approvalBroker != nil { + _ = approvalBroker.Close() } - approvalBroker, err = driver.NewApprovalBroker(protectedServers, cfg.MaxEventBytes) + } + if holdsCallerCredential { + forwarder, err = frontMCPServers(&cfg, input.Environment, routes) if err != nil { - return nil, fmt.Errorf("start Claude approval broker: %w", err) + return nil, err } - settingsJSON, settingsErr := approvalBroker.SettingsJSON() - if settingsErr != nil { - _ = approvalBroker.Close() - return nil, settingsErr + environment, err = gitRouteEnvironment(environment, forwarder, slices.Sorted(maps.Keys(routes))) + if err != nil { + closeListeners() + return nil, err } - settingsPath = filepath.Join(input.EphemeralDir, "settings.json") - if err := utils.ReplacePrivateFile(settingsPath, settingsJSON); err != nil { - _ = approvalBroker.Close() - return nil, fmt.Errorf("materialize Claude approval settings: %w", err) + } + protectedServers := approvalServerNames(cfg.MCPServers) + settings := claudeSettings{AllowManagedPermissionRulesOnly: runsAsRoot} + var permissionPromptTool string + if len(protectedServers) != 0 { + approvalBroker, err = driver.NewApprovalBroker(protectedServers, cfg.MaxEventBytes) + if err != nil { + closeListeners() + return nil, fmt.Errorf("start Claude approval broker: %w", err) } + settings.Permissions.Ask = approvalBroker.AskRules() permissionPromptTool = "mcp__" + approvalMCPServerName + "__" + driver.ApprovalToolName mcpServers := make(map[string]config.MCPServer, len(cfg.MCPServers)+1) maps.Copy(mcpServers, cfg.MCPServers) @@ -116,38 +203,193 @@ func New(ctx context.Context, input Input) (*driver.ProcessDriver, error) { } cfg.MCPServers = mcpServers } + var settingsPath string + if runsAsRoot || len(settings.Permissions.Ask) != 0 { + settingsJSON, err := json.Marshal(settings) + if err != nil { + closeListeners() + return nil, fmt.Errorf("encode Claude settings: %w", err) + } + if runsAsRoot { + err = utils.ReplaceGroupReadableFile(input.ManagedSettingsPath, settingsJSON, config.UnprivilegedGID) + } else { + settingsPath, err = files.write("settings.json", settingsJSON) + } + if err != nil { + closeListeners() + return nil, fmt.Errorf("materialize Claude settings: %w", err) + } + } mcpJSON, err := cfg.MCPConfigJSON() if err != nil { - if approvalBroker != nil { - _ = approvalBroker.Close() - } + closeListeners() return nil, err } var mcpConfigPath string if len(mcpJSON) != 0 { - if err := utils.EnsurePrivateDir(input.EphemeralDir); err != nil { - if approvalBroker != nil { - _ = approvalBroker.Close() - } - return nil, fmt.Errorf("prepare ephemeral MCP directory: %w", err) - } - mcpConfigPath = filepath.Join(input.EphemeralDir, "mcp.json") - if err := utils.ReplacePrivateFile(mcpConfigPath, mcpJSON); err != nil { - if approvalBroker != nil { - _ = approvalBroker.Close() - } + if mcpConfigPath, err = files.write("mcp.json", mcpJSON); err != nil { + closeListeners() return nil, fmt.Errorf("materialize Claude MCP configuration: %w", err) } } - return driver.NewProcessDriver(driver.ProcessConfig{ + var runAs *driver.Identity + if runsAsRoot { + if err := handOver(input.DurableDir, claudeTrees); err != nil { + closeListeners() + return nil, err + } + runAs = &driver.Identity{UID: config.UnprivilegedUID, GID: config.UnprivilegedGID} + environment = setEnvironment(environment, config.HomeEnvName, claudeDir) + } + processConfig := driver.ProcessConfig{ Executable: cfg.ClaudeExecutable, ExpectedVersion: cfg.ExpectedClaudeVersion, StrictVersion: cfg.StrictVersion, Workspace: input.Workspace, Model: cfg.Model, AppendSystemPrompt: cfg.AppendSystemPrompt, AgentsJSON: agentsJSON, MCPConfigPath: mcpConfigPath, SettingsPath: settingsPath, PermissionPromptTool: permissionPromptTool, ApprovalBroker: approvalBroker, SkillRoot: skillRoot, PluginDirs: pluginDirs, Environment: environment, MaxEventBytes: cfg.MaxEventBytes, MaxStderrBytes: cfg.MaxStderrBytes, - InterruptGrace: cfg.InterruptGrace(), AwaitTelemetry: awaitTelemetry, - }), nil + InterruptGrace: cfg.InterruptGrace(), AwaitTelemetry: awaitTelemetry, RunAs: runAs, + MaxBudgetUSD: cfg.MaxBudgetUSD, MaxTurns: cfg.MaxTurns, + } + if forwarder != nil { + processConfig.CallerCredentials = forwarder + } + return driver.NewProcessDriver(processConfig), nil +} + +// propagateCallerToken reports whether the Actor environment asks for the +// caller's credential on MCP calls, the same switch the Go ADK reads. +func propagateCallerToken(environment []string) bool { + return strings.EqualFold(strings.TrimSpace(environmentValue(environment, config.PropagateTokenEnvName)), "true") +} + +// callerRoutes reads the caller routes of the Harness environment. +func callerRoutes(environment []string) (map[string]driver.CallerRoute, error) { + raw := strings.TrimSpace(environmentValue(environment, config.CallerRoutesEnvName)) + if raw == "" { + return nil, nil + } + var urls map[string]string + if err := json.Unmarshal([]byte(raw), &urls); err != nil { + return nil, fmt.Errorf("%s must be a JSON object from host to URL: %w", config.CallerRoutesEnvName, err) + } + routes := make(map[string]driver.CallerRoute, len(urls)) + for host, url := range urls { + if !callerRouteHostPattern.MatchString(host) { + return nil, fmt.Errorf("%s host %q must be a lowercase host name with an optional port", config.CallerRoutesEnvName, host) + } + routes[host] = driver.CallerRoute{URL: url} + } + return routes, nil +} + +// gitRouteEnvironment points git at the caller routes through git's +// environment configuration: https:/// is rewritten to the host's +// loopback route, and git authenticates to the forwarder with its loopback +// token. That token already reaches Claude through mcp.json; it acts only +// while a turn has bound its caller's credential. +func gitRouteEnvironment(environment []string, forwarder *driver.CredentialForwarder, hosts []string) ([]string, error) { + if len(hosts) == 0 { + return environment, nil + } + if environmentValue(environment, "GIT_CONFIG_COUNT") != "" { + return nil, fmt.Errorf("%s configures git through GIT_CONFIG_COUNT, which the Harness environment already sets", config.CallerRoutesEnvName) + } + entries := [][2]string{{"http." + forwarder.BaseURL() + ".extraHeader", "Authorization: " + forwarder.Headers()["Authorization"]}} + for _, host := range hosts { + entries = append(entries, [2]string{"url." + forwarder.RouteURL(host) + ".insteadOf", "https://" + host + "/"}) + } + environment = setEnvironment(environment, "GIT_CONFIG_COUNT", strconv.Itoa(len(entries))) + for i, entry := range entries { + environment = setEnvironment(environment, "GIT_CONFIG_KEY_"+strconv.Itoa(i), entry[0]) + environment = setEnvironment(environment, "GIT_CONFIG_VALUE_"+strconv.Itoa(i), entry[1]) + } + return environment, nil +} + +// frontMCPServers starts the credential forwarder for the compiled MCP servers +// and the caller routes and rewrites the servers to their loopback endpoints, +// so the written configuration carries no upstream URL, no static header and +// no credential. +func frontMCPServers(cfg *config.Config, environment []string, routes map[string]driver.CallerRoute) (*driver.CredentialForwarder, error) { + upstream := make(map[string]driver.UpstreamMCPServer, len(cfg.MCPServers)) + for name, server := range cfg.MCPServers { + headers, err := resolveCredentialHeaders(server.Headers, environment) + if err != nil { + return nil, fmt.Errorf("MCP server %q: %w", name, err) + } + upstream[name] = driver.UpstreamMCPServer{URL: server.URL, Headers: headers} + } + forwarder, err := driver.NewCredentialForwarder(upstream, routes, cfg.MaxEventBytes) + if err != nil { + return nil, fmt.Errorf("start Claude credential forwarder: %w", err) + } + fronted := make(map[string]config.MCPServer, len(cfg.MCPServers)) + for name, server := range cfg.MCPServers { + fronted[name] = config.MCPServer{ + Type: "http", URL: forwarder.URL(name), Headers: forwarder.Headers(), + RequireApproval: server.RequireApproval, + } + } + cfg.MCPServers = fronted + return forwarder, nil +} + +// resolveCredentialHeaders replaces the compiler's "${KAGENT_CLAUDE_MCP_CREDENTIAL_*}" +// header values with the Actor environment's value, which Claude would +// otherwise expand itself: the gateway placeholder of a Secret-backed header. +func resolveCredentialHeaders(headers map[string]string, environment []string) (map[string]string, error) { + resolved := make(map[string]string, len(headers)) + for header, value := range headers { + name, opens := strings.CutPrefix(value, "${") + name, closes := strings.CutSuffix(name, "}") + if !opens || !closes || !strings.HasPrefix(name, config.MCPCredentialEnvPrefix) { + resolved[header] = value + continue + } + credential := environmentValue(environment, name) + if credential == "" { + return nil, fmt.Errorf("header %q references %s, which the environment does not set", header, name) + } + resolved[header] = credential + } + return resolved, nil +} + +// validateForwardedServers refuses the servers the credential forwarder cannot +// front. +func validateForwardedServers(servers map[string]config.MCPServer) error { + for name, server := range servers { + if server.Type != "http" { + return fmt.Errorf("caller credential forwarding requires streamable HTTP MCP servers; %q uses %q", name, server.Type) + } + } + return nil +} + +// harnessFiles is where the adapter writes the files Claude Code reads. A root +// harness writes them group-readable for the unprivileged user, which can read +// but neither edit nor replace them; otherwise they are owner-only. +type harnessFiles struct { + dir string + groupReadable bool +} + +func (f harnessFiles) write(name string, contents []byte) (string, error) { + path := filepath.Join(f.dir, name) + if f.groupReadable { + return path, utils.ReplaceGroupReadableFile(path, contents, config.UnprivilegedGID) + } + return path, utils.ReplacePrivateFile(path, contents) +} + +func environmentValue(environment []string, name string) string { + for _, entry := range environment { + if value, ok := strings.CutPrefix(entry, name+"="); ok { + return value + } + } + return "" } func approvalServerNames(servers map[string]config.MCPServer) (protected []string) { @@ -159,10 +401,10 @@ func approvalServerNames(servers map[string]config.MCPServer) (protected []strin return protected } -func materializeGoogleCredentials(environment []string, directory string) ([]string, error) { +func materializeGoogleCredentials(environment []string, files harnessFiles) ([]string, error) { // The compiler injects the Secret value as JSON, while Google ADC expects a - // file path. Keep the credential in ephemeral Actor storage rather than the - // well-known path under /data, which is durable and may be snapshotted. + // file path. Keep the credential out of the well-known path under /data, + // which is durable and may be snapshotted. prefix := config.GoogleCredentialsJSONEnvName + "=" var credentials string filtered := make([]string, 0, len(environment)) @@ -182,16 +424,103 @@ func materializeGoogleCredentials(environment []string, directory string) ([]str if !json.Valid([]byte(credentials)) { return nil, fmt.Errorf("%s must contain valid JSON", config.GoogleCredentialsJSONEnvName) } - if err := utils.EnsurePrivateDir(directory); err != nil { - return nil, fmt.Errorf("prepare ephemeral credentials directory: %w", err) - } - path := filepath.Join(directory, "google-credentials.json") - if err := utils.ReplacePrivateFile(path, []byte(credentials)); err != nil { + path, err := files.write("google-credentials.json", []byte(credentials)) + if err != nil { return nil, fmt.Errorf("materialize Google credentials: %w", err) } return setEnvironment(filtered, config.GoogleApplicationCredentialsEnvName, path), nil } +// reclaimTreeRoot takes the root directory of one of Claude's trees back, so +// that the harness can prepare it whatever mode Claude left it in. Nothing +// under it is touched: its contents are Claude's, and only a walk as root +// would let Claude's links, setuid bits or depth reach the harness. +func reclaimTreeRoot(tree string) error { + if err := os.Lchown(tree, 0, 0); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("reclaim %s from the unprivileged user: %w", tree, err) + } + return nil +} + +// handOver gives the roots of Claude's trees to the image's unprivileged +// user, which Claude Code runs as when the harness runs as root, so the +// harness process and the turn's credential in its memory are out of reach of +// Claude and its tools. Everything under a root already is that user's: the +// harness writes nothing there. The directories above a tree, up to the +// durable directory, stay the harness's and only gain search permission: +// Claude can reach its trees but cannot replace the harness's own state beside +// them. +func handOver(durableDir string, trees []string) error { + for _, tree := range trees { + if err := os.Lchown(tree, config.UnprivilegedUID, config.UnprivilegedGID); err != nil { + return fmt.Errorf("hand %s to the unprivileged user: %w", tree, err) + } + for dir := filepath.Dir(tree); dir == durableDir || strings.HasPrefix(dir, durableDir+string(filepath.Separator)); dir = filepath.Dir(dir) { + if err := searchableByAll(dir); err != nil { + return fmt.Errorf("let the unprivileged user traverse %s: %w", dir, err) + } + if dir == durableDir { + break + } + } + } + return nil +} + +// secureDurableDir makes the durable directory and everything in it other than +// Claude's trees the harness's alone before the harness reads or writes any of +// it: a volume an earlier image ran as the unprivileged user, or a tree Claude +// wrote into, may hold links and files Claude planted. +func secureDurableDir(durableDir string, claudeTrees []string) error { + if err := os.MkdirAll(durableDir, 0o700); err != nil { + return fmt.Errorf("create the durable directory: %w", err) + } + info, err := os.Lstat(durableDir) + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("the durable directory %s is not a directory", durableDir) + } + if err := os.Lchown(durableDir, 0, 0); err != nil { + return err + } + if err := os.Chmod(durableDir, info.Mode().Perm()&^0o022|0o700); err != nil { + return err + } + entries, err := os.ReadDir(durableDir) + if err != nil { + return err + } + for _, entry := range entries { + path := filepath.Join(durableDir, entry.Name()) + if slices.Contains(claudeTrees, path) && entry.IsDir() { + continue + } + if err := utils.SecureTree(path); err != nil { + return fmt.Errorf("secure %s: %w", path, err) + } + } + return nil +} + +func searchableByAll(dir string) error { + if err := os.Lchown(dir, 0, 0); err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + info, err := os.Lstat(dir) + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("%s is not a directory", dir) + } + return os.Chmod(dir, info.Mode().Perm()|0o111) +} + // nativeTelemetryEnvironment turns on Claude Code telemetry for the signals the // controller exports, and its content flags when capture is on. It reports // whether telemetry is on. diff --git a/go/harness/claude/internal/adapter/adapter_test.go b/go/harness/claude/internal/adapter/adapter_test.go index 1a6059c9e1..07b5905a49 100644 --- a/go/harness/claude/internal/adapter/adapter_test.go +++ b/go/harness/claude/internal/adapter/adapter_test.go @@ -13,9 +13,11 @@ import ( "github.com/kagent-dev/kagent/go/harness/claude/config" "github.com/kagent-dev/kagent/go/harness/runtime" "github.com/kagent-dev/kagent/go/pkg/tracing" + "github.com/stretchr/testify/require" ) func TestNewMaterializesDurableDirectories(t *testing.T) { + skipAsRoot(t) durableDir := filepath.Join(t.TempDir(), "data") ephemeralDir := filepath.Join(t.TempDir(), "credentials") workspace := filepath.Join(durableDir, "workspace") @@ -43,10 +45,10 @@ func TestNewMaterializesDurableDirectories(t *testing.T) { } func TestNewMaterializesSkillsAndMCPConfig(t *testing.T) { + skipAsRoot(t) durableDir := filepath.Join(t.TempDir(), "data") - claudeDir := filepath.Join(durableDir, "claude") skillRoot := filepath.Join(durableDir, "generated", "claude") - packageRoot := filepath.Join(claudeDir, "packages", "standalone-0") + packageRoot := filepath.Join(durableDir, "generated", "packages", "standalone-0") if err := os.MkdirAll(packageRoot, 0o755); err != nil { t.Fatal(err) } @@ -86,6 +88,7 @@ func TestNewMaterializesSkillsAndMCPConfig(t *testing.T) { } func TestNewMaterializesApprovalSettings(t *testing.T) { + skipAsRoot(t) durableDir := filepath.Join(t.TempDir(), "data") ephemeralDir := filepath.Join(t.TempDir(), "generated") cfg := config.Production("claude-test", "help") @@ -137,7 +140,6 @@ func TestNewMaterializesApprovalSettings(t *testing.T) { } args := strings.Join(runner.Args(runtime.Turn{Prompt: "test"}), "\n") for _, required := range []string{ - "--setting-sources\n\n", "--settings\n" + filepath.Join(ephemeralDir, "settings.json"), "--dangerously-skip-permissions", "--permission-prompt-tool\nmcp__" + approvalMCPServerName + "__approve", @@ -146,7 +148,7 @@ func TestNewMaterializesApprovalSettings(t *testing.T) { t.Fatalf("approval arguments do not contain %q: %s", required, args) } } - for _, forbidden := range []string{"--bare", "--permission-mode", "dontAsk"} { + for _, forbidden := range []string{"--bare", "--permission-mode", "dontAsk", "--setting-sources"} { if strings.Contains(args, forbidden) { t.Fatalf("approval arguments contain %q: %s", forbidden, args) } @@ -163,7 +165,7 @@ func TestNewRejectsInvalidInput(t *testing.T) { func TestMaterializeGoogleCredentials(t *testing.T) { dir := t.TempDir() raw := `{"type":"service_account","project_id":"test"}` - environment, err := materializeGoogleCredentials([]string{"A=1", config.GoogleCredentialsJSONEnvName + "=" + raw}, dir) + environment, err := materializeGoogleCredentials([]string{"A=1", config.GoogleCredentialsJSONEnvName + "=" + raw}, harnessFiles{dir: dir}) if err != nil { t.Fatal(err) } @@ -239,3 +241,82 @@ func TestNativeTelemetryEnvironmentFollowsExportedSignals(t *testing.T) { }) } } + +func TestNewRefusesTheCallerTokenUnlessClaudeRunsAsAnotherUser(t *testing.T) { + skipAsRoot(t) + durableDir := filepath.Join(t.TempDir(), "data") + cfg := config.Production("claude-test", "help") + cfg.StrictVersion = false + cfg.MCPServers = map[string]config.MCPServer{"tools": {Type: "http", URL: "https://tools.example.com/mcp"}} + raw, err := json.Marshal(cfg) + require.NoError(t, err) + _, err = New(t.Context(), Input{ + ConfigJSON: raw, Workspace: filepath.Join(durableDir, "workspace"), DurableDir: durableDir, + EphemeralDir: filepath.Join(t.TempDir(), "generated"), Environment: []string{"PATH=/bin", "KAGENT_PROPAGATE_TOKEN=true"}, + }) + require.ErrorContains(t, err, "KAGENT_PROPAGATE_TOKEN requires the harness to run as root") +} + +func TestNewRefusesToFrontSSEServers(t *testing.T) { + durableDir := filepath.Join(t.TempDir(), "data") + cfg := config.Production("claude-test", "help") + cfg.StrictVersion = false + cfg.MCPServers = map[string]config.MCPServer{"legacy": {Type: "sse", URL: "https://mcp.example.com/sse"}} + raw, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + _, err = New(context.Background(), Input{ + ConfigJSON: raw, Workspace: filepath.Join(durableDir, "workspace"), DurableDir: durableDir, + EphemeralDir: filepath.Join(t.TempDir(), "generated"), Environment: []string{"KAGENT_PROPAGATE_TOKEN=true"}, + }) + if err == nil || !strings.Contains(err.Error(), "streamable HTTP") { + t.Fatalf("New() error = %v, want the SSE refusal", err) + } +} + +func TestNewRendersNoSettingsWithoutApprovals(t *testing.T) { + skipAsRoot(t) + durableDir := filepath.Join(t.TempDir(), "data") + ephemeralDir := filepath.Join(t.TempDir(), "generated") + cfg := config.Production("claude-test", "help") + cfg.StrictVersion = false + raw, err := json.Marshal(cfg) + require.NoError(t, err) + runner, err := New(t.Context(), Input{ + ConfigJSON: raw, Workspace: filepath.Join(durableDir, "workspace"), DurableDir: durableDir, + EphemeralDir: ephemeralDir, Environment: []string{"PATH=/bin"}, + }) + require.NoError(t, err) + require.NoFileExists(t, filepath.Join(ephemeralDir, "settings.json")) + args := runner.Args(runtime.Turn{Prompt: "test"}) + require.NotContains(t, args, "--settings") + require.NotContains(t, args, "--permission-prompt-tool") +} + +// skipAsRoot skips a test of the harness that runs as the same user as Claude +// Code; adapter_unix_test.go covers the root harness. +func skipAsRoot(t *testing.T) { + t.Helper() + if os.Geteuid() == 0 { + t.Skip("a root harness writes its files for the unprivileged user") + } +} + +func TestResolveCredentialHeaders(t *testing.T) { + credential := config.MCPCredentialEnvPrefix + "0123456789ABCDEF" + resolved, err := resolveCredentialHeaders(map[string]string{ + "Authorization": "${" + credential + "}", + "X-Tenant": "${HOME}", + "X-Literal": "${" + config.MCPCredentialEnvPrefix, + }, []string{"HOME=/home/kagent", credential + "=kagent-credential-injected"}) + require.NoError(t, err) + require.Equal(t, map[string]string{ + "Authorization": "kagent-credential-injected", + "X-Tenant": "${HOME}", + "X-Literal": "${" + config.MCPCredentialEnvPrefix, + }, resolved) + + _, err = resolveCredentialHeaders(map[string]string{"Authorization": "${" + credential + "}"}, []string{"HOME=/home/kagent"}) + require.ErrorContains(t, err, credential+", which the environment does not set") +} diff --git a/go/harness/claude/internal/adapter/adapter_unix_test.go b/go/harness/claude/internal/adapter/adapter_unix_test.go new file mode 100644 index 0000000000..d258be9615 --- /dev/null +++ b/go/harness/claude/internal/adapter/adapter_unix_test.go @@ -0,0 +1,349 @@ +//go:build unix + +package adapter + +import ( + "encoding/json" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/kagent-dev/kagent/go/api/agentplugin" + "github.com/kagent-dev/kagent/go/harness/claude/config" + "github.com/kagent-dev/kagent/go/harness/internal/utils" + "github.com/kagent-dev/kagent/go/harness/runtime" +) + +// Run as root with only CHOWN, SETGID and SETUID to prove the capability set +// the claude translator asks for. +func TestNewHandsClaudesTreesToTheUnprivilegedUserOnEveryStart(t *testing.T) { + input := rootInput(t, []byte(`{"version":5,"claude_executable":"claude","expected_claude_version":"2.1.260","strict_version":true,"max_event_bytes":100,"max_stderr_bytes":100,"interrupt_grace_millis":100}`)) + durableDir := input.DurableDir + start := func() { + t.Helper() + if _, err := New(t.Context(), input); err != nil { + t.Fatalf("New() = %v", err) + } + for _, tree := range []string{input.Workspace, filepath.Join(durableDir, "claude")} { + if uid := owner(t, tree); uid != config.UnprivilegedUID { + t.Errorf("%s belongs to %d, want the unprivileged user", tree, uid) + } + } + info, err := os.Lstat(durableDir) + if err != nil { + t.Fatal(err) + } + if owner(t, durableDir) != 0 || info.Mode().Perm()&0o011 != 0o011 { + t.Errorf("the durable directory is the harness's and searchable, got uid %d mode %o", owner(t, durableDir), info.Mode().Perm()) + } + } + start() + // Without approvals the managed settings still hold: no user, project or + // workspace permission rule applies. + managed, err := os.ReadFile(input.ManagedSettingsPath) + if err != nil { + t.Fatal(err) + } + if string(managed) != `{"permissions":{},"allowManagedPermissionRulesOnly":true}` { + t.Errorf("managed settings = %s", managed) + } + harnessState := filepath.Join(durableDir, "adapter") + if err := utils.EnsurePrivateDir(harnessState); err != nil { + t.Fatal(err) + } + + output := asClaude(t, input.Workspace, `set -e +id -G +mkdir -p repo/.git && echo ref > repo/.git/HEAD && chmod 600 repo/.git/HEAD && chmod 000 repo +if mv "$1" "$1.moved" 2>/dev/null; then echo "renamed the harness state"; exit 1; fi +chmod 000 .`, harnessState) + if groups := strings.Fields(output); len(groups) != 1 || groups[0] != "65532" { + t.Fatalf("Claude runs with groups %v, want only its own", groups) + } + + start() + asClaude(t, input.Workspace, `set -e +test -r . -a -w . -a -x . || { echo "the workspace Claude locked is not reopened"; exit 1; } +chmod 700 repo +test -O repo/.git/HEAD || { echo "a file Claude wrote is no longer Claude's"; exit 1; }`) +} + +// A start touches only the roots of Claude's trees, so nothing Claude makes +// under them, however it links, marks or nests it, changes hands or stops the +// next start. +func TestNewLeavesWhatClaudeMadeOfItsTreesAlone(t *testing.T) { + input := rootInput(t, []byte(`{"version":5,"claude_executable":"claude","expected_claude_version":"2.1.260","strict_version":true,"max_event_bytes":100,"max_stderr_bytes":100,"interrupt_grace_millis":100}`)) + _, err := New(t.Context(), input) + require.NoError(t, err) + claudeDir := filepath.Join(input.DurableDir, "claude") + for _, tree := range []string{input.Workspace, claudeDir} { + asClaude(t, tree, `set -e +echo a > linked && chmod 600 linked && ln linked link +cp /bin/true setuid && chmod 4755 setuid && ln setuid pinned +touch setgid && chmod 2644 setgid +mkdir shared && chmod 2775 shared +perl -e 'for (1..600) { mkdir "deep" or die $!; chdir "deep" or die $! }'`) + } + + _, err = New(t.Context(), input) + require.NoError(t, err) + for _, tree := range []string{input.Workspace, claudeDir} { + asClaude(t, tree, `set -e +for name in linked setuid setgid shared; do test -O "$name" || { echo "$name is no longer Claude's"; exit 1; }; done +cat link > /dev/null && : >> link +test -u setuid -a -g setgid -a -g shared`) + } +} + +func TestNewFrontsMCPServersWhenTheCallerTokenPropagates(t *testing.T) { + cfg := config.Production("claude-test", "help") + cfg.StrictVersion = false + cfg.MCPServers = map[string]config.MCPServer{ + "muster": {Type: "http", URL: "https://muster.example.com/mcp", Headers: map[string]string{"X-Muster-Toolset": "preset:read-only"}, RequireApproval: true}, + "knowledge": {Type: "http", URL: "https://mcp.example.com/read", Headers: map[string]string{"Authorization": "Bearer ${KAGENT_CLAUDE_MCP_CREDENTIAL_ABC}"}}, + } + raw, err := json.Marshal(cfg) + require.NoError(t, err) + input := rootInput(t, raw) + input.Environment = []string{"PATH=/bin", "KAGENT_PROPAGATE_TOKEN=true"} + runner, err := New(t.Context(), input) + require.NoError(t, err) + t.Cleanup(func() { _ = runner.Close() }) + contents, err := os.ReadFile(filepath.Join(input.PolicyDir, "mcp.json")) + require.NoError(t, err) + var written struct { + Servers map[string]struct { + Type string `json:"type"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + } `json:"mcpServers"` + } + require.NoError(t, json.Unmarshal(contents, &written)) + for _, name := range []string{"muster", "knowledge", "kagent_hitl"} { + server, ok := written.Servers[name] + require.True(t, ok, "mcp.json lacks %q: %s", name, contents) + require.Equal(t, "http", server.Type, name) + require.True(t, strings.HasPrefix(server.URL, "http://127.0.0.1:"), "%s URL = %q, want a loopback endpoint", name, server.URL) + require.Len(t, server.Headers, 1, "%s headers = %v, want only the loopback token", name, server.Headers) + require.True(t, strings.HasPrefix(server.Headers["Authorization"], "Bearer "), name) + } + for _, leaked := range []string{"muster.example.com", "mcp.example.com", "X-Muster-Toolset", "KAGENT_CLAUDE_MCP_CREDENTIAL_ABC"} { + require.NotContains(t, string(contents), leaked, "mcp.json carries upstream detail") + } + require.Contains(t, strings.Join(runner.Args(runtime.Turn{Prompt: "test"}), "\n"), "--permission-prompt-tool\nmcp__kagent_hitl__approve\n", + "the approval bridge is lost behind the forwarder") +} + +func TestNewFrontsCallerRoutesWithoutMCPServers(t *testing.T) { + cfg := config.Production("claude-test", "help") + cfg.StrictVersion = false + raw, err := json.Marshal(cfg) + require.NoError(t, err) + input := rootInput(t, raw) + input.Environment = []string{ + "PATH=/bin", config.PropagateTokenEnvName + "=true", + config.CallerRoutesEnvName + `={"github.com":"http://gw:8080/git/github.com/"}`, + } + runner, err := New(t.Context(), input) + require.NoError(t, err, "routes without MCP servers still need the forwarder") + t.Cleanup(func() { _ = runner.Close() }) +} + +// The rules and configuration Claude Code obeys are the harness's: Claude can +// read them and can neither edit, replace nor add to them. +func TestNewKeepsClaudesPolicyOutOfItsReach(t *testing.T) { + cfg := config.Production("claude-test", "help") + cfg.StrictVersion = false + cfg.MCPServers = map[string]config.MCPServer{ + "muster": {Type: "http", URL: "https://muster.example.com/mcp", RequireApproval: true}, + } + raw, err := json.Marshal(cfg) + require.NoError(t, err) + input := rootInput(t, raw) + input.Environment = append(input.Environment, config.GoogleCredentialsJSONEnvName+`={"type":"service_account"}`) + runner, err := New(t.Context(), input) + require.NoError(t, err) + t.Cleanup(func() { _ = runner.Close() }) + + contents, err := os.ReadFile(input.ManagedSettingsPath) + require.NoError(t, err) + require.JSONEq(t, `{"permissions":{"ask":["mcp__muster__*"]},"allowManagedPermissionRulesOnly":true}`, string(contents)) + args := runner.Args(runtime.Turn{Prompt: "test"}) + require.NotContains(t, args, "--settings", "the managed settings carry every permission rule") + mcpConfig := filepath.Join(input.PolicyDir, "mcp.json") + require.Subset(t, args, []string{"--mcp-config", mcpConfig}) + + policy := []string{input.ManagedSettingsPath, mcpConfig, filepath.Join(input.PolicyDir, "google-credentials.json")} + for _, path := range policy { + info, err := os.Lstat(path) + require.NoError(t, err) + stat := info.Sys().(*syscall.Stat_t) + require.Equal(t, [3]uint32{0, config.UnprivilegedGID, 0o640}, [3]uint32{stat.Uid, stat.Gid, uint32(info.Mode().Perm())}, path) + } + asClaude(t, input.Workspace, `set -e +for f in "$@"; do + cat "$f" > /dev/null + if (: >> "$f") 2>/dev/null; then echo "wrote $f"; exit 1; fi + if mv "$f" "$f.moved" 2>/dev/null; then echo "replaced $f"; exit 1; fi + if touch "$(dirname "$f")/planted" 2>/dev/null; then echo "added beside $f"; exit 1; fi +done`, policy...) +} + +// A start trusts nothing Claude could write before it: a link or a package it +// planted in the generated tree, or one an earlier image let it plant there, +// neither redirects root's writes nor feeds the skills Claude gets. +func TestNewRebuildsTheGeneratedTreeClaudeCouldWrite(t *testing.T) { + repo, commit := skillRepository(t, "# Review") + cfg := config.Production("claude-test", "help") + cfg.StrictVersion = false + cfg.SkillResources = &agentplugin.Resources{Skills: []agentplugin.Skill{{ + Name: "review", Source: agentplugin.Source{Git: &agentplugin.GitSource{URL: "file://" + repo, Commit: commit}}, + }}} + raw, err := json.Marshal(cfg) + require.NoError(t, err) + input := rootInput(t, raw) + generated := filepath.Join(input.DurableDir, "generated") + for _, dir := range []string{input.Workspace, filepath.Join(input.DurableDir, "claude", "packages", "standalone-0"), filepath.Join(generated, "claude")} { + require.NoError(t, os.MkdirAll(dir, 0o755)) + } + for _, tree := range []string{input.Workspace, filepath.Join(input.DurableDir, "claude"), generated} { + chownTree(t, tree) + } + require.NoError(t, os.Chmod(input.DurableDir, 0o755)) + outside := filepath.Join(filepath.Dir(input.DurableDir), "outside") + require.NoError(t, os.MkdirAll(filepath.Join(outside, "standalone-0"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(outside, "standalone-0", "SKILL.md"), []byte("root only"), 0o600)) + asClaude(t, input.Workspace, `set -e +echo planted > "$1/claude/packages/standalone-0/SKILL.md" +ln -s "$2" "$1/generated/claude/.claude" +ln -s "$2" "$1/generated/packages"`, input.DurableDir, outside) + + runner, err := New(t.Context(), input) + require.NoError(t, err) + t.Cleanup(func() { _ = runner.Close() }) + + entries, err := os.ReadDir(outside) + require.NoError(t, err) + require.Len(t, entries, 1, "root wrote outside the generated tree") + skill := filepath.Join(generated, "claude", ".claude", "skills", "review", "SKILL.md") + contents, err := os.ReadFile(skill) + require.NoError(t, err) + require.Equal(t, "# Review", string(contents)) + info, err := os.Lstat(skill) + require.NoError(t, err) + stat := info.Sys().(*syscall.Stat_t) + require.Equal(t, [3]uint32{0, config.UnprivilegedGID, 0o640}, [3]uint32{stat.Uid, stat.Gid, uint32(info.Mode().Perm())}) + asClaude(t, input.Workspace, `set -e +cat "$1" > /dev/null +if (: >> "$1") 2>/dev/null; then echo "wrote a skill"; exit 1; fi +if touch "$(dirname "$1")/planted" 2>/dev/null; then echo "added a skill file"; exit 1; fi`, skill) +} + +// A volume an earlier image ran as the unprivileged user leaves the harness's +// own state Claude's; the first root start takes it back and drops the links. +func TestNewSecuresTheHarnessStateOfAnUpgradedVolume(t *testing.T) { + input := rootInput(t, []byte(`{"version":5,"claude_executable":"claude","expected_claude_version":"2.1.260","strict_version":true,"max_event_bytes":100,"max_stderr_bytes":100,"interrupt_grace_millis":100}`)) + state := filepath.Join(input.DurableDir, "adapter") + require.NoError(t, os.MkdirAll(state, 0o777)) + require.NoError(t, os.WriteFile(filepath.Join(state, "state.json"), []byte("{}"), 0o666)) + require.NoError(t, os.Symlink("/etc/passwd", filepath.Join(state, "link"))) + require.NoError(t, os.Chmod(state, 0o777)) + chownTree(t, input.DurableDir) + + _, err := New(t.Context(), input) + require.NoError(t, err) + + require.NoFileExists(t, filepath.Join(state, "link")) + require.Zero(t, owner(t, state)) + require.Zero(t, owner(t, filepath.Join(state, "state.json"))) + asClaude(t, input.Workspace, `if (: >> "$1/state.json") 2>/dev/null; then echo "wrote the harness state"; exit 1; fi +if touch "$1/planted" 2>/dev/null; then echo "added to the harness state"; exit 1; fi`, state) +} + +// skillRepository is a local git repository holding one skill, fetchable by +// commit. +func skillRepository(t *testing.T, skill string) (string, string) { + t.Helper() + repo := t.TempDir() + run := func(args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", repo, "-c", "user.name=test", "-c", "user.email=test@example.com"}, args...)...) + output, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, output) + return strings.TrimSpace(string(output)) + } + run("init", "-q") + run("config", "uploadpack.allowAnySHA1InWant", "true") + require.NoError(t, os.WriteFile(filepath.Join(repo, "SKILL.md"), []byte(skill), 0o644)) + run("add", "SKILL.md") + run("commit", "-q", "-m", "skill") + return repo, run("rev-parse", "HEAD") +} + +// rootInput is the adapter input of a root harness under a fresh directory the +// unprivileged user can traverse. It skips the test unless it runs as root. +func rootInput(t *testing.T, configJSON []byte) Input { + t.Helper() + if os.Geteuid() != 0 { + t.Skip("the harness splits Claude off only when it runs as root") + } + base := t.TempDir() + durableDir := filepath.Join(base, "data") + // Root cannot read what Claude locked; Claude empties its own trees. + t.Cleanup(func() { + claude := exec.Command("/bin/sh", "-c", `chmod -R u+rwx "$@"; find "$@" -mindepth 1 -delete`, "sh", + filepath.Join(durableDir, "workspace"), filepath.Join(durableDir, "claude")) + utils.RunAs(claude, config.UnprivilegedUID, config.UnprivilegedGID) + _ = claude.Run() + }) + for _, dir := range []string{filepath.Dir(base), base} { + if err := os.Chmod(dir, 0o711); err != nil { + t.Fatal(err) + } + } + return Input{ + ConfigJSON: configJSON, Workspace: filepath.Join(durableDir, "workspace"), DurableDir: durableDir, + EphemeralDir: filepath.Join(base, "ephemeral"), Environment: []string{"PATH=/bin:/usr/bin"}, + PolicyDir: filepath.Join(base, "policy"), + ManagedSettingsPath: filepath.Join(base, "etc", "claude-code", "managed-settings.json"), + } +} + +func asClaude(t *testing.T, dir, script string, args ...string) string { + t.Helper() + claude := exec.Command("/bin/sh", append([]string{"-c", script, "sh"}, args...)...) + claude.Dir = dir + utils.RunAs(claude, config.UnprivilegedUID, config.UnprivilegedGID) + output, err := claude.CombinedOutput() + if err != nil { + t.Fatalf("Claude's turn: %v: %s", err, output) + } + return string(output) +} + +// chownTree hands a tree the test built as root to the unprivileged user, as +// an earlier image running as that user would have left it. +func chownTree(t *testing.T, root string) { + t.Helper() + require.NoError(t, filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error { + if err != nil { + return err + } + return os.Lchown(path, config.UnprivilegedUID, config.UnprivilegedGID) + })) +} + +func owner(t *testing.T, path string) int { + t.Helper() + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + return int(info.Sys().(*syscall.Stat_t).Uid) +} diff --git a/go/harness/claude/internal/adapter/callerroutes_test.go b/go/harness/claude/internal/adapter/callerroutes_test.go new file mode 100644 index 0000000000..32f3d28fdd --- /dev/null +++ b/go/harness/claude/internal/adapter/callerroutes_test.go @@ -0,0 +1,173 @@ +package adapter + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/kagent-dev/kagent/go/harness/claude/config" + "github.com/kagent-dev/kagent/go/harness/claude/internal/driver" +) + +func TestCallerRoutesParsesTheHarnessEnvironment(t *testing.T) { + routes, err := callerRoutes([]string{"PATH=/bin"}) + require.NoError(t, err) + require.Empty(t, routes) + + routes, err = callerRoutes([]string{config.CallerRoutesEnvName + `={"github.com":"http://gw:8080/git/github.com/","gitlab.example.com:8443":"http://gw:8080/git/gitlab/"}`}) + require.NoError(t, err) + require.Equal(t, map[string]driver.CallerRoute{ + "github.com": {URL: "http://gw:8080/git/github.com/"}, + "gitlab.example.com:8443": {URL: "http://gw:8080/git/gitlab/"}, + }, routes) + + for name, value := range map[string]string{ + "not JSON": `github.com=http://gw`, + "scheme in host": `{"https://github.com":"http://gw"}`, + "path in host": `{"github.com/org":"http://gw"}`, + "uppercase host": `{"GitHub.com":"http://gw"}`, + "URL not a string": `{"github.com":1}`, + } { + _, err := callerRoutes([]string{config.CallerRoutesEnvName + "=" + value}) + require.Error(t, err, name) + } +} + +func TestNewRequiresCallerTokenPropagationForRoutes(t *testing.T) { + durableDir := filepath.Join(t.TempDir(), "data") + cfg := config.Production("claude-test", "help") + cfg.StrictVersion = false + raw, err := json.Marshal(cfg) + require.NoError(t, err) + _, err = New(t.Context(), Input{ + ConfigJSON: raw, Workspace: filepath.Join(durableDir, "workspace"), DurableDir: durableDir, + EphemeralDir: filepath.Join(t.TempDir(), "generated"), + Environment: []string{config.CallerRoutesEnvName + `={"github.com":"http://gw:8080/git/github.com/"}`}, + }) + require.ErrorContains(t, err, config.PropagateTokenEnvName+"=true: a caller route acts as the turn's caller") +} + +func TestNewRefusesCallerRoutesUnlessClaudeRunsAsAnotherUser(t *testing.T) { + skipAsRoot(t) + durableDir := filepath.Join(t.TempDir(), "data") + cfg := config.Production("claude-test", "help") + cfg.StrictVersion = false + raw, err := json.Marshal(cfg) + require.NoError(t, err) + _, err = New(t.Context(), Input{ + ConfigJSON: raw, Workspace: filepath.Join(durableDir, "workspace"), DurableDir: durableDir, + EphemeralDir: filepath.Join(t.TempDir(), "generated"), + Environment: []string{ + config.PropagateTokenEnvName + "=true", + config.CallerRoutesEnvName + `={"github.com":"http://gw:8080/git/github.com/"}`, + }, + }) + require.ErrorContains(t, err, "KAGENT_PROPAGATE_TOKEN requires the harness to run as root", + "routes without MCP servers still hold the caller's credential") +} + +func TestGitRouteEnvironmentRefusesAnExistingGitConfigCount(t *testing.T) { + forwarder, err := driver.NewCredentialForwarder(nil, map[string]driver.CallerRoute{"github.com": {URL: "http://gw:8080/"}}, 1<<20) + require.NoError(t, err) + t.Cleanup(func() { _ = forwarder.Close() }) + _, err = gitRouteEnvironment([]string{"GIT_CONFIG_COUNT=1"}, forwarder, []string{"github.com"}) + require.ErrorContains(t, err, "GIT_CONFIG_COUNT") +} + +// TestGitReachesTheRouteAsTheCaller runs the real git client with the +// environment the adapter hands Claude: https://github.com/ must reach the +// route's upstream with the turn caller's credential and nothing else. +func TestGitReachesTheRouteAsTheCaller(t *testing.T) { + gitPath, err := exec.LookPath("git") + if err != nil { + t.Skip("git is not installed") + } + var mu sync.Mutex + var paths, authorizations []string + upstream := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + mu.Lock() + paths = append(paths, request.URL.Path+"?"+request.URL.RawQuery) + authorizations = append(authorizations, request.Header.Get("Authorization")) + mu.Unlock() + http.Error(response, "not a git server", http.StatusNotFound) + })) + t.Cleanup(upstream.Close) + forwarder, err := driver.NewCredentialForwarder(nil, map[string]driver.CallerRoute{ + "github.com": {URL: upstream.URL + "/git/github.com/"}, + }, 1<<20) + require.NoError(t, err) + t.Cleanup(func() { _ = forwarder.Close() }) + + environment, err := gitRouteEnvironment([]string{"HOME=" + t.TempDir(), "GIT_CONFIG_NOSYSTEM=1", "GIT_TERMINAL_PROMPT=0"}, forwarder, []string{"github.com"}) + require.NoError(t, err) + environment = append(environment, "GIT_CONFIG_GLOBAL=/dev/null") + + forwarder.Bind("Bearer person-token") + command := exec.CommandContext(t.Context(), gitPath, "ls-remote", "https://github.com/owner/repo") + command.Env = environment + command.Dir = t.TempDir() + output, err := command.CombinedOutput() + require.Error(t, err, "the fake upstream is not a git server: %s", output) + + mu.Lock() + defer mu.Unlock() + require.NotEmpty(t, paths, "git never reached the route: %s", output) + require.Equal(t, "/git/github.com/owner/repo/info/refs?service=git-upload-pack", paths[0]) + for _, authorization := range authorizations { + require.Equal(t, "Bearer person-token", authorization) + } + require.NotContains(t, strings.Join(environment, "\n"), "person-token", "the caller's credential entered Claude's environment") +} + +// TestGitRewritesOnlyTheRoutedHosts asks the real git client how it resolves +// remote URLs under the adapter's environment: only https:/// of a +// routed host reaches the forwarder, and the loopback token is sent nowhere +// else. +func TestGitRewritesOnlyTheRoutedHosts(t *testing.T) { + gitPath, err := exec.LookPath("git") + if err != nil { + t.Skip("git is not installed") + } + forwarder, err := driver.NewCredentialForwarder(nil, map[string]driver.CallerRoute{ + "github.com": {URL: "http://gw:8080/route/github.com/"}, + "gitlab.example.com:8443": {URL: "http://gw:8080/route/gitlab.example.com:8443/"}, + }, 1<<20) + require.NoError(t, err) + t.Cleanup(func() { _ = forwarder.Close() }) + environment, err := gitRouteEnvironment([]string{"HOME=" + t.TempDir(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null"}, forwarder, []string{"github.com", "gitlab.example.com:8443"}) + require.NoError(t, err) + // Outside any repository: a checkout's own config (CI writes an + // extraheader for https://github.com/ there) would answer for the remotes. + outside := t.TempDir() + git := func(args ...string) string { + command := exec.CommandContext(t.Context(), gitPath, args...) + command.Env = environment + command.Dir = outside + output, _ := command.Output() + return strings.TrimSpace(string(output)) + } + + require.Equal(t, forwarder.RouteURL("github.com")+"owner/repo", git("ls-remote", "--get-url", "https://github.com/owner/repo")) + require.Equal(t, forwarder.RouteURL("gitlab.example.com:8443")+"group/repo", git("ls-remote", "--get-url", "https://gitlab.example.com:8443/group/repo")) + for _, remote := range []string{ + "https://github.com.evil.example/owner/repo", + "https://api.github.com/owner/repo", + "https://gitlab.com/owner/repo", + "https://gitlab.example.com/group/repo", + "https://GitHub.com/owner/repo", + "https://github.com:443/owner/repo", + "http://github.com/owner/repo", + "git@github.com:owner/repo", + } { + require.Equal(t, remote, git("ls-remote", "--get-url", remote), "git rewrote a remote outside the routes") + require.Empty(t, git("config", "--get-urlmatch", "http.extraHeader", remote), "the loopback token would reach %s", remote) + } + require.Equal(t, "Authorization: "+forwarder.Headers()["Authorization"], git("config", "--get-urlmatch", "http.extraHeader", forwarder.RouteURL("github.com"))) +} diff --git a/go/harness/claude/internal/driver/approval.go b/go/harness/claude/internal/driver/approval.go index ff2badb78f..424b693703 100644 --- a/go/harness/claude/internal/driver/approval.go +++ b/go/harness/claude/internal/driver/approval.go @@ -112,26 +112,16 @@ func (b *ApprovalBroker) Headers() map[string]string { return map[string]string{"Authorization": "Bearer " + b.token} } -// SettingsJSON asks only for tools belonging to protected MCP servers. Claude +// AskRules ask only for tools belonging to protected MCP servers. Claude // resolves those asks through the permission-prompt tool; bypass mode handles // every other tool without a static allowlist. -func (b *ApprovalBroker) SettingsJSON() ([]byte, error) { +func (b *ApprovalBroker) AskRules() []string { ask := make([]string, 0, len(b.protected)) for name := range b.protected { ask = append(ask, "mcp__"+name+"__*") } slices.Sort(ask) - settings := struct { - Permissions struct { - Ask []string `json:"ask"` - } `json:"permissions"` - }{} - settings.Permissions.Ask = ask - raw, err := json.Marshal(settings) - if err != nil { - return nil, fmt.Errorf("encode Claude approval settings: %w", err) - } - return raw, nil + return ask } // Close stops the private listener. Pending process ownership is retained by diff --git a/go/harness/claude/internal/driver/approval_test.go b/go/harness/claude/internal/driver/approval_test.go index 06ce17c472..e41d195f59 100644 --- a/go/harness/claude/internal/driver/approval_test.go +++ b/go/harness/claude/internal/driver/approval_test.go @@ -5,6 +5,7 @@ import ( "net/http" "os" "path/filepath" + "slices" "testing" "time" @@ -19,12 +20,8 @@ func TestApprovalBrokerAllowsAndDeniesProtectedCalls(t *testing.T) { } t.Cleanup(func() { _ = broker.Close() }) - settings, err := broker.SettingsJSON() - if err != nil { - t.Fatal(err) - } - if got, want := string(settings), `{"permissions":{"ask":["mcp__production_db__*"]}}`; got != want { - t.Fatalf("settings = %s, want %s", got, want) + if got, want := broker.AskRules(), []string{"mcp__production_db__*"}; !slices.Equal(got, want) { + t.Fatalf("ask rules = %v, want %v", got, want) } client := mcp.NewClient(&mcp.Implementation{Name: "approval-test", Version: "1"}, nil) diff --git a/go/harness/claude/internal/driver/event.go b/go/harness/claude/internal/driver/event.go index e069413898..f9cf7e04dd 100644 --- a/go/harness/claude/internal/driver/event.go +++ b/go/harness/claude/internal/driver/event.go @@ -1,5 +1,7 @@ package driver +import "github.com/kagent-dev/kagent/go/harness/runtime" + type EventKind string const ( @@ -26,4 +28,5 @@ type Event struct { Category string SafeMessage string Result string + Usage *runtime.Usage } diff --git a/go/harness/claude/internal/driver/forwarder.go b/go/harness/claude/internal/driver/forwarder.go new file mode 100644 index 0000000000..13f79f07e5 --- /dev/null +++ b/go/harness/claude/internal/driver/forwarder.go @@ -0,0 +1,413 @@ +package driver + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "fmt" + "net" + "net/http" + "net/http/httputil" + "net/url" + "slices" + "strings" + "sync" + "time" + + "github.com/a2aproject/a2a-go/v2/a2asrv" +) + +const ( + forwarderPathPrefix = "/mcp/" + routePathPrefix = "/route/" + callerCredentialParameter = "authorization" +) + +// UpstreamMCPServer is one compiled MCP server the forwarder fronts: its real +// URL and the static headers the compiler assigned to it. +type UpstreamMCPServer struct { + URL string + Headers map[string]string +} + +// CallerRoute is one upstream HTTP endpoint the forwarder serves on behalf of +// the turn's caller, for clients other than Claude's MCP runtime (git, for +// one). The upstream is expected to act on the credential, for example a +// gateway that exchanges it for the caller's token at a downstream service. +type CallerRoute struct { + URL string +} + +// CallerCredentialBinder receives the caller's credential for the duration of +// one turn. A binder holds nothing between Clear and the next Bind. +type CallerCredentialBinder interface { + Bind(credential string) + Clear() +} + +// CredentialForwarder fronts the compiled MCP servers and the caller routes on +// loopback and adds the caller's credential of the current turn to every +// request it forwards to a caller route or to a server that configures no +// Authorization of its own. Claude receives the loopback address and a +// per-process token only; the caller's credential never enters its +// environment, its configuration files or the Actor's filesystem, and it is +// held in this process for one turn at a time. +type CredentialForwarder struct { + token string + maxBody int64 + listener net.Listener + server *http.Server + targets map[string]*forwardTarget + routes map[string]*forwardTarget + + mu sync.Mutex + credential string + // turn is cancelled when the credential is cleared or replaced, and + // every request forwarded with that credential ends with it. + turn context.Context + endTurn context.CancelFunc +} + +type forwardTarget struct { + url *url.URL + headers map[string]string + // requestHeaders are the headers of the client's request that reach the + // upstream; the client chooses no other header the credential travels with. + requestHeaders []string + callerAuthorization bool + proxy *httputil.ReverseProxy +} + +// NewCredentialForwarder binds an authenticated loopback endpoint per server +// and per caller route. Only streamable HTTP servers are supported: an SSE +// server announces its message endpoint from the upstream host, which the +// forwarder does not rewrite. maxBodyBytes bounds MCP request bodies; caller +// route bodies (git packs) are streamed unbounded. +func NewCredentialForwarder(servers map[string]UpstreamMCPServer, routes map[string]CallerRoute, maxBodyBytes int) (*CredentialForwarder, error) { + if len(servers)+len(routes) == 0 || maxBodyBytes <= 0 { + return nil, fmt.Errorf("MCP servers or caller routes and a positive body limit are required") + } + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { + return nil, fmt.Errorf("generate credential forwarder token: %w", err) + } + forwarder := &CredentialForwarder{ + token: hex.EncodeToString(tokenBytes), maxBody: int64(maxBodyBytes), + targets: make(map[string]*forwardTarget, len(servers)), + routes: make(map[string]*forwardTarget, len(routes)), + } + for name, server := range servers { + entry, err := forwarder.newTarget("MCP server", name, server.URL, server.Headers, mcpRequestHeaders) + if err != nil { + return nil, err + } + forwarder.targets[name] = entry + } + for name, route := range routes { + entry, err := forwarder.newTarget("caller route", name, route.URL, nil, routeRequestHeaders) + if err != nil { + return nil, err + } + forwarder.routes[name] = entry + } + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("listen for Claude MCP calls: %w", err) + } + forwarder.listener = listener + forwarder.server = &http.Server{ + Handler: forwarder.authorize(http.HandlerFunc(forwarder.serve)), + ReadHeaderTimeout: 5 * time.Second, + } + go func() { _ = forwarder.server.Serve(listener) }() + return forwarder, nil +} + +func (f *CredentialForwarder) newTarget(kind, name, rawURL string, staticHeaders map[string]string, requestHeaders []string) (*forwardTarget, error) { + if strings.TrimSpace(name) == "" || strings.ContainsAny(name, "/?#") { + return nil, fmt.Errorf("%s name %q cannot be forwarded", kind, name) + } + target, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || target.Host == "" || (target.Scheme != "http" && target.Scheme != "https") { + return nil, fmt.Errorf("%s %q URL %q must be an absolute http(s) URL", kind, name, rawURL) + } + headers := make(map[string]string, len(staticHeaders)) + for header, value := range staticHeaders { + headers[http.CanonicalHeaderKey(header)] = value + } + // A target that configures Authorization owns it: the egress gateway + // replaces its placeholder with the Secret, and only when the request + // carries one. Every other target takes the caller's credential. + _, ownsAuthorization := headers["Authorization"] + entry := &forwardTarget{ + url: target, headers: headers, requestHeaders: requestHeaders, + callerAuthorization: !ownsAuthorization, + } + entry.proxy = &httputil.ReverseProxy{ + Rewrite: f.rewrite(entry), + FlushInterval: -1, + } + return entry, nil +} + +// URL is the loopback endpoint Claude uses for the named server. +func (f *CredentialForwarder) URL(name string) string { + return "http://" + f.listener.Addr().String() + forwarderPathPrefix + name +} + +// RouteURL is the loopback endpoint of the named caller route. +func (f *CredentialForwarder) RouteURL(name string) string { + return "http://" + f.listener.Addr().String() + routePathPrefix + name + "/" +} + +// BaseURL is the loopback origin every endpoint of the forwarder shares. +func (f *CredentialForwarder) BaseURL() string { + return "http://" + f.listener.Addr().String() + "/" +} + +// Headers returns the credentials for the loopback endpoint. +func (f *CredentialForwarder) Headers() map[string]string { + return map[string]string{"Authorization": "Bearer " + f.token} +} + +// Names lists the fronted servers in a stable order. +func (f *CredentialForwarder) Names() []string { + names := make([]string, 0, len(f.targets)) + for name := range f.targets { + names = append(names, name) + } + slices.Sort(names) + return names +} + +// Bind sets the caller's credential every forwarded request carries until +// Clear, and ends the requests of a credential bound before it. +func (f *CredentialForwarder) Bind(credential string) { + f.mu.Lock() + defer f.mu.Unlock() + f.endTurnLocked() + f.credential = credential + f.turn, f.endTurn = context.WithCancel(context.Background()) +} + +// Clear drops the caller's credential and ends every request still in flight +// with it, so no request acts as the caller once the turn is over; requests +// are then forwarded without one. +func (f *CredentialForwarder) Clear() { + f.mu.Lock() + defer f.mu.Unlock() + f.endTurnLocked() + f.credential = "" +} + +func (f *CredentialForwarder) endTurnLocked() { + if f.endTurn != nil { + f.endTurn() + f.turn, f.endTurn = nil, nil + } +} + +// Close stops the loopback listener. +func (f *CredentialForwarder) Close() error { return f.server.Close() } + +// current returns the bound credential and the turn it belongs to; turn is nil +// when no credential is bound. +func (f *CredentialForwarder) current() (credential string, turn context.Context) { + f.mu.Lock() + defer f.mu.Unlock() + return f.credential, f.turn +} + +func (f *CredentialForwarder) authorize(next http.Handler) http.Handler { + return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + want, got := "Bearer "+f.token, request.Header.Get("Authorization") + if subtle.ConstantTimeCompare([]byte(want), []byte(got)) != 1 { + http.Error(response, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(response, request) + }) +} + +func (f *CredentialForwarder) serve(response http.ResponseWriter, request *http.Request) { + // The suffix is joined onto the upstream URL, which resolves dot segments: + // a request must not climb out of its server's or route's path with the + // credential. + if !forwardablePath(request) { + http.Error(response, "path holds a dot segment, an encoded slash or a character the forwarder refuses", http.StatusBadRequest) + return + } + // Read once: the route's turn check and the forwarded header see the same + // credential even when the turn ends while the request is in flight. + credential, turn := f.current() + if rest, ok := strings.CutPrefix(request.URL.Path, forwarderPathPrefix); ok { + request.Body = http.MaxBytesReader(response, request.Body, f.maxBody) + f.forward(response, request, f.targets, rest, credential, turn) + return + } + if rest, ok := strings.CutPrefix(request.URL.Path, routePathPrefix); ok { + // A route acts only as the caller: with no turn running there is no + // one to act as, and the request never leaves this process. + if credential == "" { + http.Error(response, "no turn is running: caller routes carry the credential of the turn's caller only while a turn runs", http.StatusUnauthorized) + return + } + f.forward(response, request, f.routes, rest, credential, turn) + return + } + http.NotFound(response, request) +} + +// forward hands the request to the named target's proxy. A request that +// carries the caller's credential ends with the turn it was bound for; one to +// a target that owns its Authorization carries no credential and outlives it. +func (f *CredentialForwarder) forward(response http.ResponseWriter, request *http.Request, targets map[string]*forwardTarget, rest, credential string, turn context.Context) { + name, suffix, _ := strings.Cut(rest, "/") + target, ok := targets[name] + if !ok { + http.NotFound(response, request) + return + } + if !target.callerAuthorization { + credential = "" + } else if turn != nil { + ctx, cancel := context.WithCancel(request.Context()) + defer cancel() + defer context.AfterFunc(turn, cancel)() + request = request.WithContext(ctx) + } + request = request.WithContext(context.WithValue(request.Context(), forwardedRequestKey{}, forwardedRequest{suffix: suffix, credential: credential})) + target.proxy.ServeHTTP(response, request) +} + +type forwardedRequestKey struct{} + +// forwardedRequest is what serve resolved for one request before handing it +// to the target's proxy. +type forwardedRequest struct { + suffix string + credential string +} + +func (f *CredentialForwarder) rewrite(target *forwardTarget) func(*httputil.ProxyRequest) { + return func(proxied *httputil.ProxyRequest) { + out := proxied.Out + out.URL.Scheme = target.url.Scheme + out.URL.Host = target.url.Host + out.Host = target.url.Host + forwarded, _ := proxied.In.Context().Value(forwardedRequestKey{}).(forwardedRequest) + out.URL.Path, out.URL.RawPath = joinPath(target.url, forwarded.suffix) + out.URL.RawQuery = joinQuery(target.url.RawQuery, proxied.In.URL.RawQuery) + // The loopback token authenticates Claude to this process only, and + // the client chooses no other header the caller's credential travels with. + out.Header = forwardedHeaders(proxied.In.Header, target.requestHeaders) + for header, value := range target.headers { + out.Header.Set(header, value) + } + if !target.callerAuthorization { + return + } + // A turn without a caller credential reaches the upstream without + // Authorization, never with another credential in its place. + if forwarded.credential != "" { + out.Header.Set("Authorization", forwarded.credential) + } + } +} + +// forwardablePath reports whether a request path has no "." or ".." segment, +// decoded or not, no encoded slash, no backslash and no encoded percent sign, +// which an upstream decoding once more would turn into one of the others. The +// raw request target is checked as well as the parsed path: url.URL drops a +// RawPath it cannot reproduce, and EscapedPath then re-encodes the decoded path, +// in which an encoded slash is already a separator. A ";", which some servers +// strip with what follows it in a segment, and control bytes are refused too. +func forwardablePath(request *http.Request) bool { + rawPath, _, _ := strings.Cut(request.RequestURI, "?") + for _, escaped := range []string{rawPath, request.URL.EscapedPath()} { + escaped = strings.ToLower(escaped) + for _, refused := range []string{"%2f", "%5c", "%25", "\\"} { + if strings.Contains(escaped, refused) { + return false + } + } + } + if strings.ContainsFunc(request.URL.Path, func(r rune) bool { return r == ';' || r < 0x20 || r == 0x7f }) { + return false + } + for segment := range strings.SplitSeq(request.URL.Path, "/") { + if segment == "." || segment == ".." { + return false + } + } + return true +} + +// mcpRequestHeaders are what the streamable HTTP transport and trace +// propagation need. +var mcpRequestHeaders = []string{ + "Accept", "Content-Type", "Mcp-Session-Id", "Mcp-Protocol-Version", "Last-Event-Id", + "User-Agent", "Traceparent", "Tracestate", "Baggage", +} + +// routeRequestHeaders are what git's smart HTTP protocol needs: protocol v2 +// rides Git-Protocol, and git gzips request bodies under Content-Encoding. +var routeRequestHeaders = []string{ + "Accept", "Accept-Encoding", "Content-Type", "Content-Encoding", "Git-Protocol", + "User-Agent", "Traceparent", "Tracestate", "Baggage", +} + +func forwardedHeaders(in http.Header, allowed []string) http.Header { + out := make(http.Header, len(allowed)) + for _, header := range allowed { + if values := in.Values(header); len(values) != 0 { + out[header] = slices.Clone(values) + } + } + return out +} + +func joinPath(target *url.URL, suffix string) (path, rawPath string) { + if suffix == "" { + return target.Path, target.RawPath + } + joined := target.JoinPath(suffix) + // JoinPath keeps a pathless URL relative, which is not a valid request line. + if !strings.HasPrefix(joined.Path, "/") { + joined.Path = "/" + joined.Path + if joined.RawPath != "" { + joined.RawPath = "/" + joined.RawPath + } + } + return joined.Path, joined.RawPath +} + +func joinQuery(base, extra string) string { + switch { + case base == "": + return extra + case extra == "": + return base + default: + return base + "&" + extra + } +} + +// CallerCredential returns the caller's credential of the A2A call that owns +// ctx, or the empty string when the call carried none. +func CallerCredential(ctx context.Context) string { + callCtx, ok := a2asrv.CallContextFrom(ctx) + if !ok || callCtx == nil { + return "" + } + params := callCtx.ServiceParams() + if params == nil { + return "" + } + values, ok := params.Get(callerCredentialParameter) + if !ok || len(values) == 0 { + return "" + } + return strings.TrimSpace(values[0]) +} diff --git a/go/harness/claude/internal/driver/forwarder_paths_test.go b/go/harness/claude/internal/driver/forwarder_paths_test.go new file mode 100644 index 0000000000..b8031020b2 --- /dev/null +++ b/go/harness/claude/internal/driver/forwarder_paths_test.go @@ -0,0 +1,100 @@ +package driver + +import ( + "bufio" + "fmt" + "net" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +// sendRawPath writes the request line byte for byte. +func sendRawPath(t *testing.T, forwarder *CredentialForwarder, path string) int { + t.Helper() + connection, err := net.Dial("tcp4", forwarder.listener.Addr().String()) + require.NoError(t, err) + t.Cleanup(func() { _ = connection.Close() }) + _, err = fmt.Fprintf(connection, "POST %s HTTP/1.1\r\nHost: %s\r\nAuthorization: %s\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}", + path, forwarder.listener.Addr(), forwarder.Headers()["Authorization"]) + require.NoError(t, err) + response, err := http.ReadResponse(bufio.NewReader(connection), nil) + require.NoError(t, err) + _ = response.Body.Close() + return response.StatusCode +} + +func TestCredentialForwarderRefusesPathsThatLeaveTheServer(t *testing.T) { + upstream, requests := newRecordingUpstream(t) + forwarder, err := NewCredentialForwarder(map[string]UpstreamMCPServer{ + "tools": {URL: upstream.URL + "/mcp/tools"}, + }, nil, 1<<20) + require.NoError(t, err) + t.Cleanup(func() { _ = forwarder.Close() }) + forwarder.Bind("Bearer person-token") + + for _, path := range []string{ + "/mcp/tools/../../other", + "/mcp/tools/./x", + "/mcp/tools/..", + "/mcp/tools/%2e%2e/%2e%2e/other", + "/mcp/tools/%2E%2e/other", + "/mcp/tools/.%2e/other", + "/mcp/tools/..%2f..%2fother", + "/mcp/tools/a%2Fb", + "/mcp/%2e%2e/tools", + "/mcp/tools%2f..%2fother", + "/mcp/tools/%252e%252e/%252e%252e/other", + "/mcp/tools/..%5c..%5cother", + "/mcp/tools/..\\..\\elsewhere", + `/mcp/tools/a%2Fb"`, + "/mcp/tools/..%2Fother{", + `/mcp/tools/a%5cb"`, + "/mcp/tools/..;/other", + "/mcp/tools;x/other", + "/mcp/tools/%00/other", + "/mcp/tools/%7f", + "/route/github.com/../../other", + "/route/github.com/%2e%2e/%2e%2e/other", + "/route/github.com/..%2f..%2fother", + "/route/%2e%2e/mcp/tools", + } { + require.Equal(t, http.StatusBadRequest, sendRawPath(t, forwarder, path), path) + } + require.Empty(t, requests(), "a refused path reached the upstream") + + require.Equal(t, http.StatusOK, sendRawPath(t, forwarder, "/mcp/tools/a..b/.well-known")) + seen := requests() + require.Len(t, seen, 1) + require.Equal(t, "/mcp/tools/a..b/.well-known", seen[0].Path) +} + +func TestCallerRouteRefusesPathsThatLeaveTheRoute(t *testing.T) { + upstream, requests := newRecordingUpstream(t) + forwarder, err := NewCredentialForwarder(nil, map[string]CallerRoute{ + "github.com": {URL: upstream.URL + "/route/github.com/"}, + }, 1<<20) + require.NoError(t, err) + t.Cleanup(func() { _ = forwarder.Close() }) + forwarder.Bind("Bearer person-token") + + for _, path := range []string{ + "/route/github.com/../../mcp/muster", + "/route/github.com/owner/repo/../../../other", + "/route/github.com/%2e%2e/%2e%2e/mcp/muster", + "/route/github.com/%2E%2E/other", + "/route/github.com/..%2f..%2fmcp/muster", + "/route/github.com/owner%2Frepo/info/refs", + "/route/%2e%2e/mcp/muster", + "/route/github.com%2f..%2fother", + } { + require.Equal(t, http.StatusBadRequest, sendRawPath(t, forwarder, path), path) + } + require.Empty(t, requests(), "a refused path reached the upstream") + + require.Equal(t, http.StatusOK, sendRawPath(t, forwarder, "/route/github.com/owner/repo.git/info/refs?service=git-upload-pack")) + seen := requests() + require.Len(t, seen, 1) + require.Equal(t, "/route/github.com/owner/repo.git/info/refs", seen[0].Path) +} diff --git a/go/harness/claude/internal/driver/forwarder_routes_test.go b/go/harness/claude/internal/driver/forwarder_routes_test.go new file mode 100644 index 0000000000..777cb5f252 --- /dev/null +++ b/go/harness/claude/internal/driver/forwarder_routes_test.go @@ -0,0 +1,243 @@ +package driver + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func callRoute(t *testing.T, forwarder *CredentialForwarder, method, url string, body io.Reader) *http.Response { + t.Helper() + request, err := http.NewRequestWithContext(t.Context(), method, url, body) + require.NoError(t, err) + for header, value := range forwarder.Headers() { + request.Header.Set(header, value) + } + response, err := http.DefaultClient.Do(request) + require.NoError(t, err) + t.Cleanup(func() { _ = response.Body.Close() }) + return response +} + +func TestCallerRouteActsOnlyDuringATurn(t *testing.T) { + upstream, requests := newRecordingUpstream(t) + forwarder, err := NewCredentialForwarder(nil, map[string]CallerRoute{ + "github.com": {URL: upstream.URL + "/git/github.com/"}, + }, 1<<20) + require.NoError(t, err) + t.Cleanup(func() { _ = forwarder.Close() }) + route := forwarder.RouteURL("github.com") + require.True(t, strings.HasPrefix(route, forwarder.BaseURL()), "route %q outside the forwarder's origin %q", route, forwarder.BaseURL()) + + response := callRoute(t, forwarder, http.MethodGet, route+"owner/repo/info/refs?service=git-upload-pack", nil) + require.Equal(t, http.StatusUnauthorized, response.StatusCode) + require.Empty(t, requests(), "a route request without a turn reached the upstream") + + forwarder.Bind("Bearer person-token") + response = callRoute(t, forwarder, http.MethodGet, route+"owner/repo/info/refs?service=git-upload-pack", nil) + require.Equal(t, http.StatusOK, response.StatusCode) + forwarder.Clear() + response = callRoute(t, forwarder, http.MethodGet, route+"owner/repo/info/refs?service=git-upload-pack", nil) + require.Equal(t, http.StatusUnauthorized, response.StatusCode) + + seen := requests() + require.Len(t, seen, 1) + require.Equal(t, "/git/github.com/owner/repo/info/refs", seen[0].Path) + require.Equal(t, "service=git-upload-pack", seen[0].Query) + require.Equal(t, "Bearer person-token", seen[0].Authorization) + require.NotContains(t, seen[0].Authorization, forwarder.token) +} + +func TestCallerRouteStreamsBodiesPastTheMCPLimit(t *testing.T) { + var received atomic.Int64 + upstream := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) { + n, _ := io.Copy(io.Discard, request.Body) + received.Store(n) + })) + t.Cleanup(upstream.Close) + const limit = 1 << 10 + forwarder, err := NewCredentialForwarder( + map[string]UpstreamMCPServer{"tools": {URL: upstream.URL}}, + map[string]CallerRoute{"github.com": {URL: upstream.URL}}, + limit, + ) + require.NoError(t, err) + t.Cleanup(func() { _ = forwarder.Close() }) + forwarder.Bind("Bearer person-token") + + pack := strings.Repeat("x", 4*limit) + response := callRoute(t, forwarder, http.MethodPost, forwarder.RouteURL("github.com")+"owner/repo/git-receive-pack", strings.NewReader(pack)) + require.Equal(t, http.StatusOK, response.StatusCode) + require.EqualValues(t, len(pack), received.Load()) + + received.Store(0) + response = callRoute(t, forwarder, http.MethodPost, forwarder.URL("tools"), strings.NewReader(pack)) + require.NotEqual(t, http.StatusOK, response.StatusCode) + require.Less(t, received.Load(), int64(len(pack)), "an MCP body past the limit reached the upstream whole") +} + +func TestCallerRouteForwardsGitHeadersOnly(t *testing.T) { + received := make(chan http.Header, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) { + received <- request.Header.Clone() + })) + t.Cleanup(upstream.Close) + forwarder, err := NewCredentialForwarder(nil, map[string]CallerRoute{"github.com": {URL: upstream.URL}}, 1<<20) + require.NoError(t, err) + t.Cleanup(func() { _ = forwarder.Close() }) + forwarder.Bind("Bearer person-token") + + request, err := http.NewRequestWithContext(t.Context(), http.MethodPost, forwarder.RouteURL("github.com")+"owner/repo/git-upload-pack", strings.NewReader("pack")) + require.NoError(t, err) + for header, value := range forwarder.Headers() { + request.Header.Set(header, value) + } + request.Header.Set("Git-Protocol", "version=2") + request.Header.Set("Content-Encoding", "gzip") + request.Header.Set("Content-Type", "application/x-git-upload-pack-request") + request.Header.Set("Cookie", "session=claude-chosen") + request.Header.Set("X-Forwarded-User", "someone-else") + response, err := http.DefaultClient.Do(request) + require.NoError(t, err) + t.Cleanup(func() { _ = response.Body.Close() }) + require.Equal(t, http.StatusOK, response.StatusCode) + + seen := <-received + require.Equal(t, "version=2", seen.Get("Git-Protocol")) + require.Equal(t, "gzip", seen.Get("Content-Encoding")) + require.Equal(t, "application/x-git-upload-pack-request", seen.Get("Content-Type")) + require.Equal(t, "Bearer person-token", seen.Get("Authorization")) + require.Empty(t, seen.Get("Cookie")) + require.Empty(t, seen.Get("X-Forwarded-User")) +} + +func TestCallerRouteRejectsUnknownRoutesAndInputs(t *testing.T) { + upstream, requests := newRecordingUpstream(t) + forwarder, err := NewCredentialForwarder(nil, map[string]CallerRoute{"github.com": {URL: upstream.URL}}, 1<<20) + require.NoError(t, err) + t.Cleanup(func() { _ = forwarder.Close() }) + forwarder.Bind("Bearer person-token") + + response := callRoute(t, forwarder, http.MethodGet, forwarder.RouteURL("gitlab.com")+"owner/repo/info/refs", nil) + require.Equal(t, http.StatusNotFound, response.StatusCode) + response = callRoute(t, forwarder, http.MethodGet, forwarder.URL("github.com"), nil) + require.Equal(t, http.StatusNotFound, response.StatusCode, "a caller route answered on the MCP prefix") + require.Empty(t, requests()) + + for name, routes := range map[string]map[string]CallerRoute{ + "relative URL": {"github.com": {URL: "/git/"}}, + "bad scheme": {"github.com": {URL: "ssh://github.com"}}, + "name with '/'": {"github.com/x": {URL: upstream.URL}}, + } { + _, err := NewCredentialForwarder(nil, routes, 1<<20) + require.Error(t, err, name) + } +} + +func TestJoinPathKeepsAnAbsolutePathForAPathlessUpstream(t *testing.T) { + for _, tc := range []struct{ upstream, suffix, want string }{ + {upstream: "http://gw:8080", suffix: "owner/repo/git-receive-pack", want: "/owner/repo/git-receive-pack"}, + {upstream: "http://gw:8080/", suffix: "owner/repo", want: "/owner/repo"}, + {upstream: "http://gw:8080/git/github.com/", suffix: "owner/repo", want: "/git/github.com/owner/repo"}, + {upstream: "http://gw:8080/mcp", suffix: "", want: "/mcp"}, + } { + target, err := url.Parse(tc.upstream) + require.NoError(t, err) + path, _ := joinPath(target, tc.suffix) + require.Equal(t, tc.want, path, "%s + %q", tc.upstream, tc.suffix) + } +} + +// TestRewriteSendsTheCredentialServeChecked pins the proxy's outgoing +// Authorization to the credential serve read, not to the forwarder's state +// when the proxy rewrites: a turn that ends or starts in between changes +// nothing about a request already admitted. +func TestRewriteSendsTheCredentialServeChecked(t *testing.T) { + forwarder, err := NewCredentialForwarder(nil, map[string]CallerRoute{"github.com": {URL: "http://gw:8080/route/github.com/"}}, 1<<20) + require.NoError(t, err) + t.Cleanup(func() { _ = forwarder.Close() }) + target := forwarder.routes["github.com"] + + for _, tc := range []struct{ name, checked, bound, want string }{ + {name: "turn ended after the check", checked: "Bearer person-token", bound: "", want: "Bearer person-token"}, + {name: "turn started after the check", checked: "", bound: "Bearer later-token", want: ""}, + } { + forwarder.Bind(tc.bound) + in := httptest.NewRequestWithContext( + context.WithValue(t.Context(), forwardedRequestKey{}, forwardedRequest{suffix: "owner/repo/info/refs", credential: tc.checked}), + http.MethodGet, forwarder.RouteURL("github.com")+"owner/repo/info/refs", nil, + ) + in.Header.Set("Authorization", forwarder.Headers()["Authorization"]) + out := in.Clone(in.Context()) + forwarder.rewrite(target)(&httputil.ProxyRequest{In: in, Out: out}) + require.Equal(t, tc.want, out.Header.Get("Authorization"), tc.name) + require.Equal(t, "/route/github.com/owner/repo/info/refs", out.URL.Path, tc.name) + } +} + +func TestClearEndsTheTurnsRequestsInFlight(t *testing.T) { + arrived, cancelled := make(chan struct{}), make(chan struct{}) + upstream := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.WriteHeader(http.StatusOK) + response.(http.Flusher).Flush() + close(arrived) + <-request.Context().Done() + close(cancelled) + })) + t.Cleanup(upstream.Close) + forwarder, err := NewCredentialForwarder(nil, map[string]CallerRoute{"github.com": {URL: upstream.URL + "/"}}, 1<<20) + require.NoError(t, err) + t.Cleanup(func() { _ = forwarder.Close() }) + + forwarder.Bind("Bearer person-token") + response := callRoute(t, forwarder, http.MethodPost, forwarder.RouteURL("github.com")+"owner/repo/git-upload-pack", nil) + require.Equal(t, http.StatusOK, response.StatusCode) + <-arrived + forwarder.Clear() + select { + case <-cancelled: + case <-time.After(5 * time.Second): + t.Fatal("a request admitted during the turn still acts as the caller after Clear") + } + _, err = io.ReadAll(response.Body) + require.Error(t, err, "the client sees the stream cut, not a clean end") +} + +func TestClearLeavesTheRequestsOfAServerThatOwnsItsAuthorization(t *testing.T) { + arrived, released := make(chan struct{}), make(chan struct{}) + var authorization atomic.Value + upstream := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + authorization.Store(request.Header.Get("Authorization")) + response.WriteHeader(http.StatusOK) + response.(http.Flusher).Flush() + close(arrived) + <-released + _, _ = io.WriteString(response, "done") + })) + t.Cleanup(upstream.Close) + forwarder, err := NewCredentialForwarder(map[string]UpstreamMCPServer{ + "knowledge": {URL: upstream.URL, Headers: map[string]string{"Authorization": "gateway-placeholder"}}, + }, nil, 1<<20) + require.NoError(t, err) + t.Cleanup(func() { _ = forwarder.Close() }) + + forwarder.Bind("Bearer person-token") + response := callRoute(t, forwarder, http.MethodPost, forwarder.URL("knowledge"), strings.NewReader("{}")) + require.Equal(t, http.StatusOK, response.StatusCode) + <-arrived + require.Equal(t, "gateway-placeholder", authorization.Load()) + forwarder.Clear() + close(released) + body, err := io.ReadAll(response.Body) + require.NoError(t, err, "a request that never carried the caller's credential is not the turn's to end") + require.Equal(t, "done", string(body)) +} diff --git a/go/harness/claude/internal/driver/forwarder_test.go b/go/harness/claude/internal/driver/forwarder_test.go new file mode 100644 index 0000000000..3544860a5f --- /dev/null +++ b/go/harness/claude/internal/driver/forwarder_test.go @@ -0,0 +1,286 @@ +package driver + +import ( + "bufio" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/a2aproject/a2a-go/v2/a2asrv" + "github.com/stretchr/testify/require" +) + +type recordedRequest struct { + Path, Query, Authorization, Toolset, Host string + Header http.Header +} + +func newRecordingUpstream(t *testing.T) (*httptest.Server, func() []recordedRequest) { + t.Helper() + var mu sync.Mutex + var seen []recordedRequest + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + mu.Lock() + seen = append(seen, recordedRequest{ + Path: request.URL.Path, Query: request.URL.RawQuery, Host: request.Host, + Authorization: request.Header.Get("Authorization"), Toolset: request.Header.Get("X-Toolset"), + Header: request.Header.Clone(), + }) + mu.Unlock() + _, _ = io.WriteString(response, "ok") + })) + t.Cleanup(server.Close) + return server, func() []recordedRequest { + mu.Lock() + defer mu.Unlock() + return append([]recordedRequest(nil), seen...) + } +} + +func callForwarder(t *testing.T, forwarder *CredentialForwarder, url string, authorized bool) *http.Response { + t.Helper() + request, err := http.NewRequestWithContext(t.Context(), http.MethodPost, url, strings.NewReader(`{"jsonrpc":"2.0"}`)) + if err != nil { + t.Fatal(err) + } + if authorized { + for header, value := range forwarder.Headers() { + request.Header.Set(header, value) + } + } + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = response.Body.Close() }) + return response +} + +func TestCredentialForwarderCarriesTheTurnCredentialOnly(t *testing.T) { + upstream, requests := newRecordingUpstream(t) + forwarder, err := NewCredentialForwarder(map[string]UpstreamMCPServer{ + "tools": {URL: upstream.URL + "/mcp?tenant=test", Headers: map[string]string{"X-Toolset": "preset:read-only"}}, + }, nil, 1<<20) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = forwarder.Close() }) + if names := forwarder.Names(); len(names) != 1 || names[0] != "tools" { + t.Fatalf("Names() = %v", names) + } + if !strings.HasPrefix(forwarder.URL("tools"), "http://127.0.0.1:") { + t.Fatalf("URL() = %q, want a loopback address", forwarder.URL("tools")) + } + + if response := callForwarder(t, forwarder, forwarder.URL("tools"), false); response.StatusCode != http.StatusUnauthorized { + t.Fatalf("unauthenticated loopback call status = %d, want 401", response.StatusCode) + } + if seen := requests(); len(seen) != 0 { + t.Fatalf("an unauthenticated loopback call reached the upstream: %#v", seen) + } + + forwarder.Bind("Bearer person-token") + if response := callForwarder(t, forwarder, forwarder.URL("tools"), true); response.StatusCode != http.StatusOK { + t.Fatalf("forwarded call status = %d", response.StatusCode) + } + forwarder.Clear() + if response := callForwarder(t, forwarder, forwarder.URL("tools")+"/session?id=7", true); response.StatusCode != http.StatusOK { + t.Fatalf("forwarded call status = %d", response.StatusCode) + } + + host := strings.TrimPrefix(upstream.URL, "http://") + seen := requests() + for i := range seen { + seen[i].Header = nil + } + require.Equal(t, []recordedRequest{ + {Path: "/mcp", Query: "tenant=test", Authorization: "Bearer person-token", Toolset: "preset:read-only", Host: host}, + {Path: "/mcp/session", Query: "tenant=test&id=7", Toolset: "preset:read-only", Host: host}, + }, seen, "the loopback token must never leave the process") +} + +func TestCredentialForwarderAuthorizationHasOneOwner(t *testing.T) { + for _, test := range []struct { + name string + headers map[string]string + credential string + want []string + }{ + { + name: "gateway-owned keeps the placeholder over the caller credential", + headers: map[string]string{"authorization": "kagent-credential-injected"}, + credential: "Bearer person-token", + want: []string{"kagent-credential-injected"}, + }, + { + name: "gateway-owned keeps the placeholder without a caller credential", + headers: map[string]string{"Authorization": "kagent-credential-injected"}, + want: []string{"kagent-credential-injected"}, + }, + { + name: "caller-owned sends the caller credential", + credential: "Bearer person-token", + want: []string{"Bearer person-token"}, + }, + { + name: "caller-owned sends nothing without a caller credential", + }, + } { + t.Run(test.name, func(t *testing.T) { + upstream, requests := newRecordingUpstream(t) + forwarder, err := NewCredentialForwarder(map[string]UpstreamMCPServer{ + "tools": {URL: upstream.URL + "/mcp", Headers: test.headers}, + }, nil, 1<<20) + require.NoError(t, err) + t.Cleanup(func() { _ = forwarder.Close() }) + if test.credential != "" { + forwarder.Bind(test.credential) + } + require.Equal(t, http.StatusOK, callForwarder(t, forwarder, forwarder.URL("tools"), true).StatusCode) + seen := requests() + require.Len(t, seen, 1) + require.Equal(t, test.want, seen[0].Header.Values("Authorization")) + }) + } +} + +func TestCredentialForwarderRejectsUnknownServersAndInputs(t *testing.T) { + upstream, requests := newRecordingUpstream(t) + forwarder, err := NewCredentialForwarder(map[string]UpstreamMCPServer{"tools": {URL: upstream.URL}}, nil, 1<<20) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = forwarder.Close() }) + if response := callForwarder(t, forwarder, forwarder.URL("other"), true); response.StatusCode != http.StatusNotFound { + t.Fatalf("unknown server status = %d, want 404", response.StatusCode) + } + if seen := requests(); len(seen) != 0 { + t.Fatalf("an unknown server name reached the upstream: %#v", seen) + } + for name, servers := range map[string]map[string]UpstreamMCPServer{ + "none": {}, + "relative URL": {"tools": {URL: "/mcp"}}, + "bad scheme": {"tools": {URL: "ftp://mcp.example.com"}}, + "name with '/'": {"a/b": {URL: upstream.URL}}, + } { + if _, err := NewCredentialForwarder(servers, nil, 1<<20); err == nil { + t.Errorf("NewCredentialForwarder(%s) accepted invalid input", name) + } + } +} + +func TestCredentialForwarderStreamsResponses(t *testing.T) { + release := make(chan struct{}) + upstream := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.Header().Set("Content-Type", "text/event-stream") + flusher, ok := response.(http.Flusher) + if !ok { + t.Error("upstream response is not flushable") + return + } + _, _ = io.WriteString(response, "event: message\ndata: first\n\n") + flusher.Flush() + <-release + _, _ = io.WriteString(response, "event: message\ndata: second\n\n") + })) + t.Cleanup(upstream.Close) + forwarder, err := NewCredentialForwarder(map[string]UpstreamMCPServer{"tools": {URL: upstream.URL}}, nil, 1<<20) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = forwarder.Close() }) + + response := callForwarder(t, forwarder, forwarder.URL("tools"), true) + if response.StatusCode != http.StatusOK { + t.Fatalf("stream status = %d", response.StatusCode) + } + reader := bufio.NewReader(response.Body) + first := make(chan string, 1) + go func() { + line, _ := reader.ReadString('\n') + first <- line + }() + select { + case line := <-first: + if line != "event: message\n" { + t.Fatalf("first line = %q", line) + } + case <-time.After(5 * time.Second): + t.Fatal("the forwarder buffered the stream until the upstream finished") + } + close(release) + rest, err := io.ReadAll(reader) + if err != nil || !strings.Contains(string(rest), "data: second") { + t.Fatalf("rest of stream = %q, %v", rest, err) + } +} + +func TestCallerCredentialReadsTheA2ACall(t *testing.T) { + if got := CallerCredential(context.Background()); got != "" { + t.Fatalf("CallerCredential(no call) = %q", got) + } + ctx, _ := a2asrv.NewCallContext(t.Context(), a2asrv.NewServiceParams(map[string][]string{"x-user-id": {"someone"}})) + if got := CallerCredential(ctx); got != "" { + t.Fatalf("CallerCredential(no authorization) = %q", got) + } + ctx, _ = a2asrv.NewCallContext(t.Context(), a2asrv.NewServiceParams(map[string][]string{"authorization": {" Bearer person "}})) + if got := CallerCredential(ctx); got != "Bearer person" { + t.Fatalf("CallerCredential() = %q", got) + } +} + +func TestCredentialForwarderKeepsThePathAbsoluteForAPathlessUpstream(t *testing.T) { + upstream, requests := newRecordingUpstream(t) + forwarder, err := NewCredentialForwarder(map[string]UpstreamMCPServer{"tools": {URL: upstream.URL}}, nil, 1<<20) + require.NoError(t, err) + t.Cleanup(func() { _ = forwarder.Close() }) + forwarder.Bind("Bearer person-token") + require.Equal(t, http.StatusOK, callForwarder(t, forwarder, forwarder.URL("tools")+"/session", true).StatusCode) + seen := requests() + require.Len(t, seen, 1) + require.Equal(t, "/session", seen[0].Path) +} + +func TestCredentialForwarderForwardsTransportHeadersOnly(t *testing.T) { + upstream, requests := newRecordingUpstream(t) + forwarder, err := NewCredentialForwarder(map[string]UpstreamMCPServer{ + "tools": {URL: upstream.URL + "/mcp", Headers: map[string]string{"X-Toolset": "preset:read-only"}}, + }, nil, 1<<20) + require.NoError(t, err) + t.Cleanup(func() { _ = forwarder.Close() }) + forwarder.Bind("Bearer person-token") + + request, err := http.NewRequestWithContext(t.Context(), http.MethodPost, forwarder.URL("tools"), strings.NewReader(`{"jsonrpc":"2.0"}`)) + require.NoError(t, err) + request.Header.Set("Authorization", forwarder.Headers()["Authorization"]) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json, text/event-stream") + request.Header.Set("Mcp-Session-Id", "session-1") + request.Header.Set("Mcp-Protocol-Version", "2025-06-18") + request.Header.Set("Traceparent", "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01") + request.Header.Set("X-Toolset", "*") + request.Header.Set("Cookie", "session=other") + request.Header.Set("X-Custom", "value") + response, err := http.DefaultClient.Do(request) + require.NoError(t, err) + t.Cleanup(func() { _ = response.Body.Close() }) + require.Equal(t, http.StatusOK, response.StatusCode) + + seen := requests() + require.Len(t, seen, 1) + header := seen[0].Header + require.Equal(t, "Bearer person-token", header.Get("Authorization")) + require.Equal(t, "preset:read-only", header.Get("X-Toolset"), "the client must not choose the toolset") + require.Equal(t, "application/json", header.Get("Content-Type")) + require.Equal(t, "application/json, text/event-stream", header.Get("Accept")) + require.Equal(t, "session-1", header.Get("Mcp-Session-Id")) + require.Equal(t, "2025-06-18", header.Get("Mcp-Protocol-Version")) + require.NotEmpty(t, header.Get("Traceparent")) + require.Empty(t, header.Get("Cookie")) + require.Empty(t, header.Get("X-Custom")) +} diff --git a/go/harness/claude/internal/driver/parser.go b/go/harness/claude/internal/driver/parser.go index 53f24f69c2..d38c6bcb78 100644 --- a/go/harness/claude/internal/driver/parser.go +++ b/go/harness/claude/internal/driver/parser.go @@ -7,6 +7,8 @@ import ( "fmt" "io" "strconv" + + "github.com/kagent-dev/kagent/go/harness/runtime" ) type parser struct { @@ -71,14 +73,22 @@ func readBoundedLine(r *bufio.Reader, max int) ([]byte, error) { func (p *parser) parseLine(line []byte, emit func(Event) error) error { var envelope struct { - Type string `json:"type"` - Subtype string `json:"subtype"` - SessionID string `json:"session_id"` - IsError bool `json:"is_error"` - Result string `json:"result"` - Event json.RawMessage `json:"event"` - Message json.RawMessage `json:"message"` - Origin struct { + Type string `json:"type"` + Subtype string `json:"subtype"` + SessionID string `json:"session_id"` + IsError bool `json:"is_error"` + Result string `json:"result"` + Cost float64 `json:"total_cost_usd"` + NumTurns int `json:"num_turns"` + Usage struct { + InputTokens int `json:"input_tokens"` + CacheReadTokens int `json:"cache_read_input_tokens"` + CacheCreationTokens int `json:"cache_creation_input_tokens"` + OutputTokens int `json:"output_tokens"` + } `json:"usage"` + Event json.RawMessage `json:"event"` + Message json.RawMessage `json:"message"` + Origin struct { Kind string `json:"kind"` } `json:"origin"` } @@ -101,14 +111,26 @@ func (p *parser) parseLine(line []byte, emit func(Event) error) error { return nil } p.terminal = true + usage := &runtime.Usage{ + TotalCostUSD: envelope.Cost, NumTurns: envelope.NumTurns, + InputTokens: envelope.Usage.InputTokens + envelope.Usage.CacheReadTokens + envelope.Usage.CacheCreationTokens, + CachedTokens: envelope.Usage.CacheReadTokens, + OutputTokens: envelope.Usage.OutputTokens, + } + // A turn that reached one of its own limits is a completed turn: the work + // so far is kept, the session continues on the next turn, and the limit + // is reported rather than raised as a failure. + if stopped, ok := runtime.LimitReached(envelope.Subtype); ok { + return emit(Event{Kind: EventCompleted, SessionID: envelope.SessionID, Result: envelope.Result, Category: stopped, Usage: usage}) + } if envelope.IsError || envelope.Subtype != "success" { message := envelope.Result if message == "" { message = "Claude execution failed" } - return emit(Event{Kind: EventFailed, Category: envelope.Subtype, SafeMessage: message}) + return emit(Event{Kind: EventFailed, Category: envelope.Subtype, SafeMessage: message, Usage: usage}) } - return emit(Event{Kind: EventCompleted, SessionID: envelope.SessionID, Result: envelope.Result}) + return emit(Event{Kind: EventCompleted, SessionID: envelope.SessionID, Result: envelope.Result, Usage: usage}) } return nil } diff --git a/go/harness/claude/internal/driver/parser_test.go b/go/harness/claude/internal/driver/parser_test.go index 964106cec8..d112b4da53 100644 --- a/go/harness/claude/internal/driver/parser_test.go +++ b/go/harness/claude/internal/driver/parser_test.go @@ -7,6 +7,8 @@ import ( "os" "strings" "testing" + + "github.com/kagent-dev/kagent/go/harness/runtime" ) const pinnedClaudeVersion = "2.1.260" @@ -78,8 +80,28 @@ func TestParseJSONLTerminalFailure(t *testing.T) { if err := ParseJSONL(bytes.NewReader(b), 4096, func(event Event) error { last = event; return nil }); err != nil { t.Fatal(err) } - if last.Kind != EventFailed || last.Category != "error_max_budget_usd" { - t.Fatalf("last event = %#v", last) + if last.Kind != EventCompleted || last.Category != runtime.LimitBudget || last.Usage == nil { + t.Fatalf("a turn that reached its budget is complete with the limit named: %#v", last) + } + events := []string{ + `{"type":"system","subtype":"init","session_id":"22222222-2222-4222-8222-222222222222"}`, + `{"type":"result","subtype":"error_max_turns","is_error":true,"result":"turn limit","total_cost_usd":0.25,"num_turns":3,"usage":{"input_tokens":120,"cache_read_input_tokens":3000,"cache_creation_input_tokens":500,"output_tokens":40},"session_id":"22222222-2222-4222-8222-222222222222"}`, + } + if err := ParseJSONL(strings.NewReader(strings.Join(events, "\n")+"\n"), 4096, func(event Event) error { last = event; return nil }); err != nil { + t.Fatal(err) + } + if last.Kind != EventCompleted || last.Category != runtime.LimitTurns || last.Usage == nil || last.Usage.TotalCostUSD != 0.25 || last.Usage.NumTurns != 3 || last.Usage.InputTokens != 3620 || last.Usage.CachedTokens != 3000 || last.Usage.OutputTokens != 40 { + t.Fatalf("turn-limited result = %#v, usage %#v", last, last.Usage) + } + events[1] = `{"type":"result","subtype":"error_during_execution","is_error":true,"result":"boom","total_cost_usd":0.1,"num_turns":2,"usage":{"input_tokens":50,"output_tokens":7},"session_id":"22222222-2222-4222-8222-222222222222"}` + if err := ParseJSONL(strings.NewReader(strings.Join(events, "\n")+"\n"), 4096, func(event Event) error { last = event; return nil }); err != nil { + t.Fatal(err) + } + if last.Kind != EventFailed || last.Category != "error_during_execution" { + t.Fatalf("any other error is still a failure: %#v", last) + } + if last.Usage == nil || last.Usage.TotalCostUSD != 0.1 || last.Usage.InputTokens != 50 || last.Usage.OutputTokens != 7 { + t.Fatalf("a failed turn keeps the usage it spent: %#v", last.Usage) } } diff --git a/go/harness/claude/internal/driver/process.go b/go/harness/claude/internal/driver/process.go index 4c65fc41d9..29652b010e 100644 --- a/go/harness/claude/internal/driver/process.go +++ b/go/harness/claude/internal/driver/process.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "os/exec" + "strconv" "strings" "sync" "time" @@ -21,15 +22,17 @@ import ( // ProcessConfig contains validated, compiler-owned inputs for one Claude Code // process. Actor-owned paths and environment are supplied by the adapter. type ProcessConfig struct { - Executable string - ExpectedVersion string - StrictVersion bool - Workspace string - Model string - AppendSystemPrompt string - AgentsJSON string - MCPConfigPath string - SettingsPath string + Executable string + ExpectedVersion string + StrictVersion bool + Workspace string + Model string + AppendSystemPrompt string + AgentsJSON string + MCPConfigPath string + SettingsPath string + // RunAs, when set, is the user Claude Code runs as. + RunAs *Identity PermissionPromptTool string SkillRoot string PluginDirs []string @@ -38,9 +41,15 @@ type ProcessConfig struct { MaxStderrBytes int InterruptGrace time.Duration ApprovalBroker *ApprovalBroker + MaxBudgetUSD string + MaxTurns int // AwaitTelemetry holds each prompt until Claude Code telemetry has // initialized. AwaitTelemetry bool + // CallerCredentials receives the caller's credential of each turn and is + // cleared before the turn's outcome is returned, so a parked or suspended + // Actor holds none. + CallerCredentials CallerCredentialBinder } // ProcessDriver supervises one Claude Code process per ordinary runtime turn @@ -92,6 +101,11 @@ func (s resumedEventSink) TextDelta(event runtime.TextDelta) error { return s.EventSink.TextDelta(event) } +// Identity is a POSIX user and group. +type Identity struct { + UID, GID uint32 +} + // NewProcessDriver constructs a Claude Code process driver. func NewProcessDriver(config ProcessConfig) *ProcessDriver { return &ProcessDriver{config: config} @@ -104,6 +118,7 @@ func (d *ProcessDriver) Validate(ctx context.Context) error { return fmt.Errorf("find Claude executable %q: %w", d.config.Executable, err) } cmd := exec.CommandContext(ctx, path, "--version") + d.runAs(cmd) cmd.Dir = d.config.Workspace cmd.Env = append([]string(nil), d.config.Environment...) output, err := cmd.CombinedOutput() @@ -128,16 +143,23 @@ func (d *ProcessDriver) Args(turn runtime.Turn) []string { "--strict-mcp-config", "--dangerously-skip-permissions", } + if d.config.SettingsPath != "" { + // No --setting-sources: skills under --add-dir belong to the project + // source, so closing the sources drops the compiled skills. + args = append(args, "--settings", d.config.SettingsPath) + } if d.config.ApprovalBroker != nil { - args = append(args, - "--setting-sources", "", - "--settings", d.config.SettingsPath, - "--permission-prompt-tool", d.config.PermissionPromptTool, - ) + args = append(args, "--permission-prompt-tool", d.config.PermissionPromptTool) } if d.config.Model != "" { args = append(args, "--model", d.config.Model) } + if d.config.MaxBudgetUSD != "" { + args = append(args, "--max-budget-usd", d.config.MaxBudgetUSD) + } + if d.config.MaxTurns > 0 { + args = append(args, "--max-turns", strconv.Itoa(d.config.MaxTurns)) + } if d.config.AppendSystemPrompt != "" { args = append(args, "--append-system-prompt", d.config.AppendSystemPrompt) } @@ -163,11 +185,18 @@ func (d *ProcessDriver) Args(turn runtime.Turn) []string { return args } +func (d *ProcessDriver) runAs(cmd *exec.Cmd) { + if d.config.RunAs != nil { + utils.RunAs(cmd, d.config.RunAs.UID, d.config.RunAs.GID) + } +} + // Run supervises one Claude Code process and emits its ordered runtime events. func (d *ProcessDriver) Run(ctx context.Context, turn runtime.Turn, sink runtime.EventSink) (runtime.Outcome, error) { if strings.TrimSpace(turn.Prompt) == "" { return runtime.Outcome{}, fmt.Errorf("Claude prompt is required") } + defer d.bindCallerCredential(ctx)() message, err := userMessage(turn.Prompt) if err != nil { return runtime.Outcome{}, err @@ -183,6 +212,7 @@ func (d *ProcessDriver) Run(ctx context.Context, turn runtime.Turn, sink runtime } cmd := exec.Command(d.config.Executable, d.Args(turn)...) utils.ConfigureProcessGroup(cmd) + d.runAs(cmd) cmd.Dir = d.config.Workspace cmd.Env = environment stdin, err := cmd.StdinPipe() @@ -242,6 +272,18 @@ func (d *ProcessDriver) Run(ctx context.Context, turn runtime.Turn, sink runtime return outcome, err } +// bindCallerCredential hands the turn's caller credential to the binder and +// returns the function that clears it. The credential lives in this process +// only while a turn runs; a parked turn resumes with the credential of the +// call that resumes it. +func (d *ProcessDriver) bindCallerCredential(ctx context.Context) func() { + if d.config.CallerCredentials == nil { + return func() {} + } + d.config.CallerCredentials.Bind(CallerCredential(ctx)) + return d.config.CallerCredentials.Clear +} + // traceEnvironment injects the trace context into the environment variables. // Claude reads it once at startup, so a turn that is resumed after an approval // keeps emitting under the trace of the request that started the process. The @@ -353,7 +395,10 @@ func (d *ProcessDriver) consume(ctx context.Context, session *processSession, si } return runtime.Outcome{}, item.err } - if waitErr := <-session.wait; waitErr != nil { + // Claude Code exits non-zero when it stops at one of its own limits, + // after reporting the limit in its result; that exit is the limit's, + // not a crash's. + if waitErr := <-session.wait; waitErr != nil && (session.terminal == nil || session.terminal.StoppedBy == "") { return runtime.Outcome{}, fmt.Errorf("claude exited with an error: %w: %s", waitErr, session.stderr.String()) } if session.terminal == nil { @@ -384,6 +429,7 @@ func (p *pendingTurn) Resume(ctx context.Context, response runtime.InputResponse if decision.ID != p.pending.request.ID { return runtime.Outcome{}, fmt.Errorf("tool approval response ID %q does not match pending ID %q", decision.ID, p.pending.request.ID) } + defer p.driver.bindCallerCredential(ctx)() if err := p.pending.resolve(*decision); err != nil { return runtime.Outcome{}, err } @@ -401,13 +447,17 @@ func (p *pendingTurn) Cancel(_ context.Context) error { return nil } -// Close releases the Actor-local permission MCP listener. Pending process ownership is +// Close releases the Actor-local loopback listeners. Pending process ownership is // transferred to the runtime.PendingTurn returned by Run. func (d *ProcessDriver) Close() error { + var errs []error if d.config.ApprovalBroker != nil { - return d.config.ApprovalBroker.Close() + errs = append(errs, d.config.ApprovalBroker.Close()) } - return nil + if forwarder, ok := d.config.CallerCredentials.(*CredentialForwarder); ok && forwarder != nil { + errs = append(errs, forwarder.Close()) + } + return errors.Join(errs...) } // emitEvent translates a Claude event to a runtime event and emits it to the @@ -435,9 +485,9 @@ func emitEvent(event Event, sink runtime.EventSink, terminal bool) (*runtime.Out return nil, fmt.Errorf("claude tool activity has unsupported phase %q", event.ToolPhase) } case EventCompleted: - return &runtime.Outcome{}, nil + return &runtime.Outcome{Usage: event.Usage, StoppedBy: event.Category}, nil case EventFailed: - return &runtime.Outcome{Failure: &runtime.Failure{Message: event.SafeMessage}}, nil + return &runtime.Outcome{Failure: &runtime.Failure{Message: event.SafeMessage}, Usage: event.Usage}, nil default: return nil, fmt.Errorf("unsupported Claude event kind %q", event.Kind) } diff --git a/go/harness/claude/internal/driver/process_test.go b/go/harness/claude/internal/driver/process_test.go index 51bb5557d4..52b6906a9b 100644 --- a/go/harness/claude/internal/driver/process_test.go +++ b/go/harness/claude/internal/driver/process_test.go @@ -6,10 +6,13 @@ import ( "path/filepath" "slices" "strings" + "sync" "testing" "time" + "github.com/a2aproject/a2a-go/v2/a2asrv" "github.com/kagent-dev/kagent/go/harness/runtime" + "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/trace" ) @@ -228,3 +231,108 @@ func TestProcessDriverCancellation(t *testing.T) { t.Fatalf("cancellation took too long") } } + +type recordingBinder struct { + mu sync.Mutex + events []string +} + +func (b *recordingBinder) Bind(credential string) { + b.mu.Lock() + b.events = append(b.events, "bind:"+credential) + b.mu.Unlock() +} + +func (b *recordingBinder) Clear() { + b.mu.Lock() + b.events = append(b.events, "clear") + b.mu.Unlock() +} + +func callerContext(t *testing.T, credential string) context.Context { + t.Helper() + ctx, _ := a2asrv.NewCallContext(t.Context(), a2asrv.NewServiceParams(map[string][]string{"authorization": {credential}})) + return ctx +} + +func TestProcessDriverBindsTheCallerCredentialPerTurn(t *testing.T) { + dir := t.TempDir() + decision := filepath.Join(dir, "decision") + executable := filepath.Join(dir, "claude") + script := "#!/bin/sh\nprintf '%s\\n' '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"11111111-1111-4111-8111-111111111111\"}'\nwhile [ ! -f \"$DECISION\" ]; do sleep 0.01; done\nprintf '%s\\n' '{\"type\":\"result\",\"subtype\":\"success\",\"session_id\":\"11111111-1111-4111-8111-111111111111\"}'\n" + if err := os.WriteFile(executable, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + broker := &ApprovalBroker{requests: make(chan *PendingApprovalRequest, 1)} + binder := &recordingBinder{} + driver := NewProcessDriver(ProcessConfig{ + Executable: executable, Workspace: dir, Environment: []string{"DECISION=" + decision}, + MaxEventBytes: 4096, MaxStderrBytes: 1024, InterruptGrace: 100 * time.Millisecond, + ApprovalBroker: broker, SettingsPath: filepath.Join(dir, "settings.json"), CallerCredentials: binder, + }) + pending := newTestPending("approval-1", "call-1") + broker.requests <- pending + bridgeDecisionToFile(t, pending, decision) + + outcome, err := driver.Run(callerContext(t, "Bearer first-sender"), runtime.Turn{Prompt: "write"}, &recordingSink{}) + if err != nil || outcome.Pending == nil { + t.Fatalf("Run() = %#v, %v", outcome, err) + } + if got := strings.Join(binder.events, ","); got != "bind:Bearer first-sender,clear" { + t.Fatalf("a parked turn must hold no credential: binder events = %q", got) + } + outcome, err = outcome.Pending.Resume(callerContext(t, "Bearer second-sender"), &runtime.ApprovalDecision{ID: "approval-1", Approved: true}, &recordingSink{}) + if err != nil || outcome.Pending != nil || outcome.Failure != nil { + t.Fatalf("Resume() = %#v, %v", outcome, err) + } + if got := strings.Join(binder.events, ","); got != "bind:Bearer first-sender,clear,bind:Bearer second-sender,clear" { + t.Fatalf("the resuming call's credential must replace the first: binder events = %q", got) + } +} + +// A root harness carries its permission rules in Claude Code's managed +// settings, so an approval turn passes the prompt tool without --settings. +func TestArgsPassTheSettingsFileOnlyWhenThereIsOne(t *testing.T) { + broker, err := NewApprovalBroker([]string{"production_db"}, 4096) + require.NoError(t, err) + t.Cleanup(func() { _ = broker.Close() }) + driver := NewProcessDriver(ProcessConfig{ApprovalBroker: broker, PermissionPromptTool: "mcp__kagent_hitl__approve"}) + args := driver.Args(runtime.Turn{Prompt: "test"}) + require.NotContains(t, args, "--settings") + require.Subset(t, args, []string{"--permission-prompt-tool", "mcp__kagent_hitl__approve"}) +} +func TestProcessDriverPassesTheTurnLimits(t *testing.T) { + args := strings.Join(NewProcessDriver(ProcessConfig{Executable: "claude", Workspace: t.TempDir(), MaxBudgetUSD: "2.50", MaxTurns: 40}).Args(runtime.Turn{Prompt: "go"}), "\n") + "\n" + for _, want := range []string{"--max-budget-usd\n2.50\n", "--max-turns\n40\n"} { + if !strings.Contains(args, want) { + t.Errorf("arguments lack %q: %s", strings.TrimSpace(want), args) + } + } + if args := strings.Join(NewProcessDriver(ProcessConfig{Executable: "claude", Workspace: t.TempDir()}).Args(runtime.Turn{Prompt: "go"}), "\n"); strings.Contains(args, "--max-") { + t.Fatalf("no limit configured, yet the arguments bound the turn: %s", args) + } +} + +func TestProcessDriverCompletesALimitedTurnDespiteTheExitStatus(t *testing.T) { + dir := t.TempDir() + executable := filepath.Join(dir, "claude") + script := "#!/bin/sh\nprintf '%s\\n' '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"11111111-1111-4111-8111-111111111111\"}' '{\"type\":\"result\",\"subtype\":\"error_max_turns\",\"is_error\":true,\"result\":\"turn limit\",\"total_cost_usd\":0.02,\"num_turns\":3,\"session_id\":\"11111111-1111-4111-8111-111111111111\"}'\nexit 1\n" + if err := os.WriteFile(executable, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + d := NewProcessDriver(ProcessConfig{Executable: executable, Workspace: dir, MaxTurns: 3, MaxEventBytes: 4096, MaxStderrBytes: 1024, InterruptGrace: 50 * time.Millisecond}) + outcome, err := d.Run(t.Context(), runtime.Turn{Prompt: "go"}, &recordingSink{}) + if err != nil { + t.Fatalf("Run() error = %v; a limit is not a crash", err) + } + if outcome.Failure != nil || outcome.StoppedBy != runtime.LimitTurns || outcome.Usage == nil || outcome.Usage.NumTurns != 3 { + t.Fatalf("Run() outcome = %#v", outcome) + } + script = "#!/bin/sh\nprintf '%s\\n' '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"11111111-1111-4111-8111-111111111111\"}' '{\"type\":\"result\",\"subtype\":\"success\",\"result\":\"done\",\"session_id\":\"11111111-1111-4111-8111-111111111111\"}'\nexit 1\n" + if err := os.WriteFile(executable, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + if _, err := d.Run(t.Context(), runtime.Turn{Prompt: "go"}, &recordingSink{}); err == nil { + t.Fatal("a non-zero exit after a successful result is still an error") + } +} diff --git a/go/harness/claude/internal/driver/process_unix_test.go b/go/harness/claude/internal/driver/process_unix_test.go new file mode 100644 index 0000000000..4f33131115 --- /dev/null +++ b/go/harness/claude/internal/driver/process_unix_test.go @@ -0,0 +1,22 @@ +//go:build unix + +package driver + +import ( + "os/exec" + "testing" +) + +func TestProcessDriverRunsClaudeAsTheConfiguredUser(t *testing.T) { + d := NewProcessDriver(ProcessConfig{Executable: "claude", Workspace: t.TempDir(), RunAs: &Identity{UID: 65532, GID: 65532}}) + cmd := exec.Command("claude") + d.runAs(cmd) + if cmd.SysProcAttr == nil || cmd.SysProcAttr.Credential == nil || cmd.SysProcAttr.Credential.Uid != 65532 || cmd.SysProcAttr.Credential.Gid != 65532 || cmd.SysProcAttr.Credential.NoSetGroups || len(cmd.SysProcAttr.Credential.Groups) != 0 { + t.Fatalf("Claude does not run as the configured user: %#v", cmd.SysProcAttr) + } + plain := exec.Command("claude") + NewProcessDriver(ProcessConfig{Executable: "claude"}).runAs(plain) + if plain.SysProcAttr != nil { + t.Fatalf("a driver without RunAs changes the process credential: %#v", plain.SysProcAttr) + } +} diff --git a/go/harness/codex/Dockerfile b/go/harness/codex/Dockerfile index c0d25a5c6a..1ffcad7306 100644 --- a/go/harness/codex/Dockerfile +++ b/go/harness/codex/Dockerfile @@ -45,7 +45,9 @@ RUN apk upgrade --no-cache && apk add --no-cache bash ca-certificates git libgcc COPY --from=builder /kagent-codex /usr/local/bin/kagent-codex USER 65532:65532 -WORKDIR /data/workspace +# The harness sets Codex's working directory itself; /data is a volume that is +# empty on first start. +WORKDIR / EXPOSE 80 8081 LABEL org.opencontainers.image.source="https://github.com/kagent-dev/kagent" diff --git a/go/harness/internal/utils/process_group_nonunix.go b/go/harness/internal/utils/process_group_nonunix.go index 4c1d26b615..461023fee3 100644 --- a/go/harness/internal/utils/process_group_nonunix.go +++ b/go/harness/internal/utils/process_group_nonunix.go @@ -10,6 +10,9 @@ import ( // ConfigureProcessGroup is a no-op on platforms without Unix process groups. func ConfigureProcessGroup(*exec.Cmd) {} +// RunAs is a no-op where processes carry no POSIX credential. +func RunAs(*exec.Cmd, uint32, uint32) {} + // InterruptProcessGroup interrupts the process. func InterruptProcessGroup(process *os.Process) error { return process.Signal(os.Interrupt) diff --git a/go/harness/internal/utils/process_group_unix.go b/go/harness/internal/utils/process_group_unix.go index 258482e73d..db6ad18fe1 100644 --- a/go/harness/internal/utils/process_group_unix.go +++ b/go/harness/internal/utils/process_group_unix.go @@ -13,6 +13,14 @@ func ConfigureProcessGroup(command *exec.Cmd) { command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} } +// RunAs makes the command run as uid:gid with no supplementary groups. +func RunAs(command *exec.Cmd, uid, gid uint32) { + if command.SysProcAttr == nil { + command.SysProcAttr = &syscall.SysProcAttr{} + } + command.SysProcAttr.Credential = &syscall.Credential{Uid: uid, Gid: gid} +} + // InterruptProcessGroup interrupts the process group. func InterruptProcessGroup(process *os.Process) error { return syscall.Kill(-process.Pid, syscall.SIGINT) diff --git a/go/harness/internal/utils/utils.go b/go/harness/internal/utils/utils.go index 958f8d6b28..1a6d4899ec 100644 --- a/go/harness/internal/utils/utils.go +++ b/go/harness/internal/utils/utils.go @@ -63,11 +63,74 @@ func EnsurePrivateDir(path string) error { // ReplacePrivateFile atomically replaces path with owner-only contents. The // temporary file is created beside path so the rename stays on one filesystem. -func ReplacePrivateFile(path string, contents []byte) (returnErr error) { - directory := filepath.Dir(path) - if err := EnsurePrivateDir(directory); err != nil { +func ReplacePrivateFile(path string, contents []byte) error { + if err := EnsurePrivateDir(filepath.Dir(path)); err != nil { + return err + } + return replaceFile(path, contents, 0o600, -1) +} + +// EnsureGroupReadableDir creates path when necessary, rejects symlinks and +// non-directories, and leaves it owned by the caller and group gid with mode +// 0750: members of gid can list and traverse it but not change its entries. +// Missing parents are created 0755 so that the group can reach it. +func EnsureGroupReadableDir(path string, gid int) error { + if err := mkdirParents(filepath.Dir(path)); err != nil { + return fmt.Errorf("create parents of group-readable directory %q: %w", path, err) + } + if err := os.Mkdir(path, 0o750); err != nil && !errors.Is(err, fs.ErrExist) { + return fmt.Errorf("create group-readable directory %q: %w", path, err) + } + info, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("inspect group-readable directory %q: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("group-readable path %q is not a directory", path) + } + if err := os.Lchown(path, os.Geteuid(), gid); err != nil { + return fmt.Errorf("set group of %q: %w", path, err) + } + if err := os.Chmod(path, 0o750); err != nil { + return fmt.Errorf("secure group-readable directory %q: %w", path, err) + } + return nil +} + +// mkdirParents creates path and its missing parents with mode 0755 whatever +// the umask, leaving existing directories as they are. +func mkdirParents(path string) error { + if info, err := os.Stat(path); err == nil { + if !info.IsDir() { + return fmt.Errorf("%q is not a directory", path) + } + return nil + } else if !errors.Is(err, fs.ErrNotExist) { + return err + } + if err := mkdirParents(filepath.Dir(path)); err != nil { + return err + } + if err := os.Mkdir(path, 0o755); err != nil && !errors.Is(err, fs.ErrExist) { + return err + } + return os.Chmod(path, 0o755) +} + +// ReplaceGroupReadableFile atomically replaces path with contents that the +// caller owns and group gid can read but not write, in a directory prepared by +// EnsureGroupReadableDir, so members of gid can neither edit nor replace it. +func ReplaceGroupReadableFile(path string, contents []byte, gid int) error { + if err := EnsureGroupReadableDir(filepath.Dir(path), gid); err != nil { return err } + return replaceFile(path, contents, 0o640, gid) +} + +// replaceFile writes contents to a temporary file beside path with mode and, +// when gid is not negative, group gid, then renames it over path. +func replaceFile(path string, contents []byte, mode os.FileMode, gid int) (returnErr error) { + directory := filepath.Dir(path) temporary, err := os.CreateTemp(directory, "."+filepath.Base(path)+"-*.tmp") if err != nil { return fmt.Errorf("create temporary file for %q: %w", path, err) @@ -84,7 +147,12 @@ func ReplacePrivateFile(path string, contents []byte) (returnErr error) { } } }() - if err := temporary.Chmod(0o600); err != nil { + if gid >= 0 { + if err := temporary.Chown(os.Geteuid(), gid); err != nil { + return fmt.Errorf("set group of temporary file for %q: %w", path, err) + } + } + if err := temporary.Chmod(mode); err != nil { return fmt.Errorf("secure temporary file for %q: %w", path, err) } if _, err := temporary.Write(contents); err != nil { @@ -99,8 +167,78 @@ func ReplacePrivateFile(path string, contents []byte) (returnErr error) { } closed = true if err := os.Rename(temporaryPath, path); err != nil { - return fmt.Errorf("replace private file %q: %w", path, err) + return fmt.Errorf("replace file %q: %w", path, err) } renamed = true return nil } + +// ShareTreeReadOnly leaves root and everything under it owned by the calling +// root process and readable by group gid only: directories 0750, regular files +// 0640, or 0750 when their owner may execute them. Symlinks keep their target +// and only change owner; no symlink is followed. Members of gid can read the +// tree but not change it. +func ShareTreeReadOnly(root string, gid int) error { + return filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if err := os.Lchown(path, 0, gid); err != nil { + return err + } + switch { + case entry.Type()&fs.ModeSymlink != 0: + return nil + case entry.IsDir(): + return os.Chmod(path, 0o750) + case entry.Type().IsRegular(): + info, err := entry.Info() + if err != nil { + return err + } + if info.Mode().Perm()&0o100 != 0 { + return os.Chmod(path, 0o750) + } + return os.Chmod(path, 0o640) + default: + return fmt.Errorf("%s is neither a directory, a regular file nor a symlink", path) + } + }) +} + +// SecureTree makes root and everything under it the calling root process's +// alone, for state another user may have owned: it removes every symlink, +// takes ownership and clears group and other write. A missing root is left +// alone; a root that is a symlink is removed. +func SecureTree(root string) error { + info, err := os.Lstat(root) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if info.Mode()&fs.ModeSymlink != 0 { + return os.Remove(root) + } + return filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.Type()&fs.ModeSymlink != 0 { + return os.Remove(path) + } + if err := os.Lchown(path, 0, 0); err != nil { + return err + } + info, err := entry.Info() + if err != nil { + return err + } + mode := info.Mode().Perm() &^ 0o022 + if entry.IsDir() { + mode |= 0o700 + } + return os.Chmod(path, mode) + }) +} diff --git a/go/harness/internal/utils/utils_unix_test.go b/go/harness/internal/utils/utils_unix_test.go new file mode 100644 index 0000000000..8fa22fabfc --- /dev/null +++ b/go/harness/internal/utils/utils_unix_test.go @@ -0,0 +1,34 @@ +//go:build unix + +package utils + +import ( + "os" + "path/filepath" + "syscall" + "testing" +) + +func TestReplaceGroupReadableFileLeavesTheGroupReadOnly(t *testing.T) { + defer syscall.Umask(syscall.Umask(0o077)) + base := t.TempDir() + path := filepath.Join(base, "missing", "policy", "settings.json") + if err := ReplaceGroupReadableFile(path, []byte("{}"), os.Getgid()); err != nil { + t.Fatalf("ReplaceGroupReadableFile() = %v", err) + } + for want, target := range map[os.FileMode]string{0o755: filepath.Join(base, "missing"), 0o750: filepath.Dir(path), 0o640: path} { + info, err := os.Lstat(target) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != want { + t.Errorf("%s mode = %o, want %o", target, got, want) + } + } + if err := ReplaceGroupReadableFile(path, []byte(`{"a":1}`), os.Getgid()); err != nil { + t.Fatalf("replacing the file again = %v", err) + } + if contents, err := os.ReadFile(path); err != nil || string(contents) != `{"a":1}` { + t.Fatalf("contents = %q, %v", contents, err) + } +} diff --git a/go/harness/runtime/a2a/executor.go b/go/harness/runtime/a2a/executor.go index d286ebebf8..5b4e4f93b8 100644 --- a/go/harness/runtime/a2a/executor.go +++ b/go/harness/runtime/a2a/executor.go @@ -339,12 +339,21 @@ func (e *Executor) Execute(ctx context.Context, reqCtx *a2asrv.ExecutorContext) if outcome.Failure == nil { result = tracing.Result{TaskState: string(a2atype.TaskStateCompleted)} endInvocation() - yield(a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateCompleted, nil), nil) + var message *a2atype.Message + if outcome.Usage != nil || outcome.StoppedBy != "" { + message = taskMessage(reqCtx, limitNotice(outcome.StoppedBy)) + message.Metadata = usageMetadata(outcome) + apia2a.SetTimelinePosition(message, sink.nextTimelinePosition()) + } + yield(a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateCompleted, message), nil) return } result = tracing.Result{TaskState: string(a2atype.TaskStateFailed), Error: "runtime_failure"} endInvocation() message := taskMessage(reqCtx, safeFailure(outcome.Failure.Message)) + if outcome.Usage != nil { + message.Metadata = usageMetadata(outcome) + } apia2a.SetTimelinePosition(message, sink.nextTimelinePosition()) yield(a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateFailed, message), nil) } @@ -588,7 +597,10 @@ func (e *Executor) Cancel(ctx context.Context, reqCtx *a2asrv.ExecutorContext) i } return case nil: + // The runtime holds nothing for the task, so there is nothing to + // stop; the caller's saved task still needs its terminal boundary. e.mu.Unlock() + yield(a2atype.NewStatusUpdateEvent(reqCtx, a2atype.TaskStateCanceled, nil), nil) return default: e.mu.Unlock() @@ -747,3 +759,38 @@ func safeFailure(message string) string { var _ runtime.EventSink = (*executionSink)(nil) var _ a2asrv.AgentExecutor = (*Executor)(nil) + +// StoppedByMetadataKey names the limit that ended a completed turn. +const StoppedByMetadataKey = "stopped_by" + +func limitNotice(stoppedBy string) string { + switch stoppedBy { + case runtime.LimitBudget: + return "The turn stopped at its budget limit; what it did so far is kept, and a follow-up message continues from here." + case runtime.LimitTurns: + return "The turn stopped at its limit of model turns; what it did so far is kept, and a follow-up message continues from here." + default: + return "" + } +} + +// usageMetadata carries a turn's usage under the canonical usage key, in the +// token fields every runtime reports, plus the cost and model turns Claude Code +// also reports, and the limit that stopped the turn. +func usageMetadata(outcome runtime.Outcome) map[string]any { + metadata := map[string]any{} + if outcome.StoppedBy != "" { + metadata[StoppedByMetadataKey] = outcome.StoppedBy + } + if usage := outcome.Usage; usage != nil { + metadata[apia2a.UsageMetadataKey] = map[string]any{ + "promptTokenCount": usage.InputTokens, + "cachedContentTokenCount": usage.CachedTokens, + "candidatesTokenCount": usage.OutputTokens, + "totalTokenCount": usage.InputTokens + usage.OutputTokens, + "costUsd": usage.TotalCostUSD, + "numTurns": usage.NumTurns, + } + } + return metadata +} diff --git a/go/harness/runtime/a2a/executor_test.go b/go/harness/runtime/a2a/executor_test.go index 01826f116f..d605fc9d77 100644 --- a/go/harness/runtime/a2a/executor_test.go +++ b/go/harness/runtime/a2a/executor_test.go @@ -263,6 +263,36 @@ func TestExecuteFailureBoundary(t *testing.T) { if last.Status.State != a2atype.TaskStateFailed || last.Status.Message.Parts[0].Text() != "budget limit reached" { t.Fatalf("failure event = %#v", last) } + if _, ok := last.Status.Message.Metadata[apia2a.UsageMetadataKey]; ok { + t.Fatalf("a failure without usage reports none: %#v", last.Status.Message.Metadata) + } +} + +func TestExecuteReportsTheUsageAFailedTurnSpent(t *testing.T) { + executor, err := New(fakeRunner{run: func(context.Context, runtime.Turn, runtime.EventSink) (runtime.Outcome, error) { + return runtime.Outcome{ + Failure: &runtime.Failure{Message: "boom"}, + Usage: &runtime.Usage{TotalCostUSD: 0.1, NumTurns: 2, InputTokens: 50, OutputTokens: 7}, + }, nil + }}, &fakeContinuation{}, tracing.RuntimeTelemetry{}) + if err != nil { + t.Fatal(err) + } + events, errs := collect(executor.Execute(t.Context(), requestContext("task-1", "hello"))) + if len(errs) != 0 { + t.Fatal(errs) + } + last := events[len(events)-1].(*a2atype.TaskStatusUpdateEvent) + if last.Status.State != a2atype.TaskStateFailed || last.Status.Message.Parts[0].Text() != "boom" { + t.Fatalf("failure event = %#v", last) + } + usage, _ := last.Status.Message.Metadata[apia2a.UsageMetadataKey].(map[string]any) + if usage["promptTokenCount"] != 50 || usage["candidatesTokenCount"] != 7 || usage["costUsd"] != 0.1 { + t.Fatalf("usage metadata = %#v", usage) + } + if _, ok := last.Status.Message.Metadata[apia2a.TimelinePositionMetadataKey]; !ok { + t.Fatalf("the failure keeps its timeline position: %#v", last.Status.Message.Metadata) + } } func TestExecuteReleasesActiveTaskBeforeTerminalEvent(t *testing.T) { @@ -544,3 +574,56 @@ func collect(seq iter.Seq2[a2atype.Event, error]) ([]a2atype.Event, []error) { } return events, errs } + +// A cancel for a task the runtime holds nothing for still yields the CANCELED +// boundary, so the caller's saved task does not stay SUBMITTED or WORKING. +func TestCancelWithoutActiveTaskYieldsCanceled(t *testing.T) { + executor, err := New(fakeRunner{run: func(context.Context, runtime.Turn, runtime.EventSink) (runtime.Outcome, error) { + t.Fatal("nothing runs on a cancel") + return runtime.Outcome{}, nil + }}, &fakeContinuation{}, tracing.RuntimeTelemetry{}) + if err != nil { + t.Fatal(err) + } + cancelEvents, cancelErrs := collect(executor.Cancel(t.Context(), requestContext("task-1", "ignored"))) + if len(cancelErrs) != 0 || len(cancelEvents) != 1 { + t.Fatalf("Cancel() events/errors = %#v/%v", cancelEvents, cancelErrs) + } + update, ok := cancelEvents[0].(*a2atype.TaskStatusUpdateEvent) + if !ok || update.Status.State != a2atype.TaskStateCanceled || update.TaskID != "task-1" { + t.Fatalf("cancel event = %#v", cancelEvents[0]) + } +} +func TestExecuteReportsALimitedTurnAsCompletedWithUsage(t *testing.T) { + executor, err := New(fakeRunner{run: func(_ context.Context, _ runtime.Turn, sink runtime.EventSink) (runtime.Outcome, error) { + if err := sink.SessionStarted(runtime.SessionStarted{ContinuationID: testSessionID}); err != nil { + return runtime.Outcome{}, err + } + return runtime.Outcome{ + StoppedBy: runtime.LimitBudget, + Usage: &runtime.Usage{TotalCostUSD: 0.05, NumTurns: 3, InputTokens: 120, OutputTokens: 40}, + }, nil + }}, &fakeContinuation{data: testSessionID}, tracing.RuntimeTelemetry{}) + if err != nil { + t.Fatal(err) + } + events, errs := collect(executor.Execute(t.Context(), requestContext("task-1", "hello"))) + if len(errs) != 0 { + t.Fatalf("Execute() errors = %v", errs) + } + last, ok := events[len(events)-1].(*a2atype.TaskStatusUpdateEvent) + if !ok || last.Status.State != a2atype.TaskStateCompleted { + t.Fatalf("last event = %#v, want a completed status", events[len(events)-1]) + } + message := last.Status.Message + if message == nil || !strings.Contains(message.Parts[0].Text(), "budget") { + t.Fatalf("a limited turn names its limit: %#v", message) + } + if message.Metadata[StoppedByMetadataKey] != runtime.LimitBudget { + t.Fatalf("message metadata = %#v", message.Metadata) + } + usage, _ := message.Metadata[apia2a.UsageMetadataKey].(map[string]any) + if usage["costUsd"] != 0.05 || usage["numTurns"] != 3 { + t.Fatalf("usage metadata = %#v", usage) + } +} diff --git a/go/harness/runtime/runtime.go b/go/harness/runtime/runtime.go index 00c40074ee..66454b671a 100644 --- a/go/harness/runtime/runtime.go +++ b/go/harness/runtime/runtime.go @@ -79,9 +79,43 @@ type PendingTurn interface { // Outcome describes why a runtime operation stopped producing events. Failure // ends the turn, Pending transfers its live resources to the caller, and an // empty Outcome is successful completion. Failure and Pending are exclusive. +// StoppedBy names the turn's own limit that ended it (LimitBudget, +// LimitTurns); the turn is complete and the next one continues the session. type Outcome struct { - Failure *Failure - Pending PendingTurn + Failure *Failure + Pending PendingTurn + Usage *Usage + StoppedBy string +} + +// Usage is what one turn consumed, as the runtime reported it. +type Usage struct { + TotalCostUSD float64 + NumTurns int + // InputTokens counts every prompt token, CachedTokens the part of them + // read from the provider's prompt cache. + InputTokens int + CachedTokens int + OutputTokens int +} + +const ( + LimitBudget = "budget" + LimitTurns = "turns" +) + +// LimitReached maps a runtime's terminal reason to the limit it names, for +// the reasons that are limits: Claude Code's error_max_budget_usd and +// error_max_turns result subtypes. +func LimitReached(reason string) (string, bool) { + switch reason { + case "error_max_budget_usd": + return LimitBudget, true + case "error_max_turns": + return LimitTurns, true + default: + return "", false + } } // InputRequest is one structured request that parks a native turn. diff --git a/go/sandbox/guest/cmd/main_test.go b/go/sandbox/guest/cmd/main_test.go index bc194409f7..22d8225a09 100644 --- a/go/sandbox/guest/cmd/main_test.go +++ b/go/sandbox/guest/cmd/main_test.go @@ -92,10 +92,13 @@ func TestServe(t *testing.T) { processes := ateenvv1alpha.NewProcessServiceClient(conn) started, err := processes.StartProcess(ctx, &ateenvv1alpha.StartProcessRequest{Command: []string{"sh", "-c", "printf initialized > first.txt"}}) require.NoError(t, err, "first operation must not depend on a preceding file write") + // The bound measures the guest, not the runner: a loaded runner takes over a + // second to start the shell, while a passing run returns at the first COMPLETED. + const processWait = 5 * time.Second require.Eventually(t, func() bool { result, err := processes.GetProcess(ctx, &ateenvv1alpha.GetProcessRequest{ProcessId: started.ProcessId}) return err == nil && result.Status == ateenvv1alpha.ProcessStatus_PROCESS_STATUS_COMPLETED - }, time.Second, 10*time.Millisecond) + }, processWait, 10*time.Millisecond) first, err := os.ReadFile(filepath.Join(cfg.Workspace, "first.txt")) require.NoError(t, err) require.Equal(t, "initialized", string(first)) diff --git a/helm/kagent-crds/templates/api.kagent.dev_agents.yaml b/helm/kagent-crds/templates/api.kagent.dev_agents.yaml index 84023a3624..a989124820 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_agents.yaml @@ -4,6 +4,7 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.19.0 + helm.sh/resource-policy: keep name: agents.api.kagent.dev spec: group: api.kagent.dev @@ -51,6 +52,21 @@ spec: AgentSpec pairs portable behavior with a runner. References are local to the Agent's namespace, including references nested in inline specs. properties: + egress: + description: |- + Egress lists HTTP(S) origins the Agent may reach besides the destinations + its revision compiles (the model, its MCP servers, its skill and plugin + sources, telemetry), such as "https://proxy.golang.org". The host may + have "*" as its leftmost label: "https://*.githubusercontent.com" matches + one label, such as raw.githubusercontent.com. A wildcard needs two labels + under it, so nothing opens a whole top-level domain or every host. + items: + maxLength: 270 + pattern: ^https?://(\*\.([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)+|([a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?\.)*)[a-z]([-a-z0-9]{0,61}[a-z0-9])?(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?$ + type: string + maxItems: 64 + type: array + x-kubernetes-list-type: set harness: description: HarnessSpec defines a reusable runtime and its infrastructure policy. @@ -61,6 +77,31 @@ spec: type: object claude: description: ClaudeHarness selects the Claude runtime adapter. + properties: + limits: + description: |- + Limits bounds every turn of every agent that runs on the Harness + (Claude Code's --max-budget-usd and --max-turns). A turn that reaches a + limit ends completed, with the reason and the usage on the task, and a + follow-up continues the conversation. + properties: + budgetUSD: + description: |- + BudgetUSD is the most one turn may spend, in US dollars, as a decimal + string such as "2.50". + pattern: ^(0|[1-9][0-9]{0,5})(\.[0-9]{1,4})?$ + type: string + maxTurns: + description: MaxTurns is the most model round-trips one + turn may take. + format: int32 + maximum: 10000 + minimum: 1 + type: integer + type: object + x-kubernetes-validations: + - message: at least one of budgetUSD or maxTurns must be set + rule: has(self.budgetUSD) || has(self.maxTurns) type: object codex: description: CodexHarness selects the Codex runtime adapter. @@ -389,6 +430,37 @@ spec: commit: pattern: ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ type: string + credentialRef: + description: |- + CredentialRef references a key in a same-namespace Secret whose value is + the base64 of ":" for the URL's host (GitHub takes + "x-access-token:"). The egress gateway replaces the Authorization + header of a request the actor sends to that host with "Basic ", + and the fetch of this source sends a placeholder for it to replace. A + request without the header gets no credential, and the actor never + holds the value. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic url: minLength: 1 pattern: ^https?://[^[:space:]]+$ @@ -397,6 +469,12 @@ spec: - commit - url type: object + x-kubernetes-validations: + - message: credentialRef name and key must not be empty + rule: '!has(self.credentialRef) || (self.credentialRef.name.size() + > 0 && self.credentialRef.key.size() > 0)' + - message: credentialRef requires an https URL + rule: '!has(self.credentialRef) || self.url.startsWith(''https://'')' oci: description: OCI is a digest-pinned image reference. pattern: ^[^[:space:]@]+@sha256:[0-9a-f]{64}$ @@ -502,6 +580,37 @@ spec: commit: pattern: ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ type: string + credentialRef: + description: |- + CredentialRef references a key in a same-namespace Secret whose value is + the base64 of ":" for the URL's host (GitHub takes + "x-access-token:"). The egress gateway replaces the Authorization + header of a request the actor sends to that host with "Basic ", + and the fetch of this source sends a placeholder for it to replace. A + request without the header gets no credential, and the actor never + holds the value. + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic url: minLength: 1 pattern: ^https?://[^[:space:]]+$ @@ -510,6 +619,12 @@ spec: - commit - url type: object + x-kubernetes-validations: + - message: credentialRef name and key must not be empty + rule: '!has(self.credentialRef) || (self.credentialRef.name.size() + > 0 && self.credentialRef.key.size() > 0)' + - message: credentialRef requires an https URL + rule: '!has(self.credentialRef) || self.url.startsWith(''https://'')' oci: description: OCI is a digest-pinned image reference. pattern: ^[^[:space:]@]+@sha256:[0-9a-f]{64}$ diff --git a/helm/kagent-crds/templates/api.kagent.dev_agenttemplates.yaml b/helm/kagent-crds/templates/api.kagent.dev_agenttemplates.yaml index ebe57de679..f5e57b8a64 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_agenttemplates.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_agenttemplates.yaml @@ -4,6 +4,7 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.19.0 + helm.sh/resource-policy: keep name: agenttemplates.api.kagent.dev spec: group: api.kagent.dev @@ -144,6 +145,37 @@ spec: commit: pattern: ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ type: string + credentialRef: + description: |- + CredentialRef references a key in a same-namespace Secret whose value is + the base64 of ":" for the URL's host (GitHub takes + "x-access-token:"). The egress gateway replaces the Authorization + header of a request the actor sends to that host with "Basic ", + and the fetch of this source sends a placeholder for it to replace. A + request without the header gets no credential, and the actor never + holds the value. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic url: minLength: 1 pattern: ^https?://[^[:space:]]+$ @@ -152,6 +184,12 @@ spec: - commit - url type: object + x-kubernetes-validations: + - message: credentialRef name and key must not be empty + rule: '!has(self.credentialRef) || (self.credentialRef.name.size() + > 0 && self.credentialRef.key.size() > 0)' + - message: credentialRef requires an https URL + rule: '!has(self.credentialRef) || self.url.startsWith(''https://'')' oci: description: OCI is a digest-pinned image reference. pattern: ^[^[:space:]@]+@sha256:[0-9a-f]{64}$ @@ -255,6 +293,37 @@ spec: commit: pattern: ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ type: string + credentialRef: + description: |- + CredentialRef references a key in a same-namespace Secret whose value is + the base64 of ":" for the URL's host (GitHub takes + "x-access-token:"). The egress gateway replaces the Authorization + header of a request the actor sends to that host with "Basic ", + and the fetch of this source sends a placeholder for it to replace. A + request without the header gets no credential, and the actor never + holds the value. + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic url: minLength: 1 pattern: ^https?://[^[:space:]]+$ @@ -263,6 +332,12 @@ spec: - commit - url type: object + x-kubernetes-validations: + - message: credentialRef name and key must not be empty + rule: '!has(self.credentialRef) || (self.credentialRef.name.size() + > 0 && self.credentialRef.key.size() > 0)' + - message: credentialRef requires an https URL + rule: '!has(self.credentialRef) || self.url.startsWith(''https://'')' oci: description: OCI is a digest-pinned image reference. pattern: ^[^[:space:]@]+@sha256:[0-9a-f]{64}$ diff --git a/helm/kagent-crds/templates/api.kagent.dev_harnesses.yaml b/helm/kagent-crds/templates/api.kagent.dev_harnesses.yaml index 50d59e5113..667e2eb0d6 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_harnesses.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_harnesses.yaml @@ -4,6 +4,7 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.19.0 + helm.sh/resource-policy: keep name: harnesses.api.kagent.dev spec: group: api.kagent.dev @@ -55,6 +56,31 @@ spec: type: object claude: description: ClaudeHarness selects the Claude runtime adapter. + properties: + limits: + description: |- + Limits bounds every turn of every agent that runs on the Harness + (Claude Code's --max-budget-usd and --max-turns). A turn that reaches a + limit ends completed, with the reason and the usage on the task, and a + follow-up continues the conversation. + properties: + budgetUSD: + description: |- + BudgetUSD is the most one turn may spend, in US dollars, as a decimal + string such as "2.50". + pattern: ^(0|[1-9][0-9]{0,5})(\.[0-9]{1,4})?$ + type: string + maxTurns: + description: MaxTurns is the most model round-trips one turn + may take. + format: int32 + maximum: 10000 + minimum: 1 + type: integer + type: object + x-kubernetes-validations: + - message: at least one of budgetUSD or maxTurns must be set + rule: has(self.budgetUSD) || has(self.maxTurns) type: object codex: description: CodexHarness selects the Codex runtime adapter. diff --git a/helm/kagent-crds/templates/api.kagent.dev_modelconfigs.yaml b/helm/kagent-crds/templates/api.kagent.dev_modelconfigs.yaml index 4432c5055d..6c22f07da8 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_modelconfigs.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_modelconfigs.yaml @@ -4,6 +4,7 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.19.0 + helm.sh/resource-policy: keep name: modelconfigs.api.kagent.dev spec: group: api.kagent.dev @@ -56,9 +57,55 @@ spec: baseUrl: description: Base URL for the Anthropic API (overrides default) type: string + cacheTTL: + default: 5m + description: |- + CacheTTL controls how long Anthropic retains a cached prefix when + PromptCaching is enabled. Only meaningful when PromptCaching is true. + + - "5m" (default): the standard 5-minute cache. Each cache hit refreshes + the window, so an agent loop whose calls are less than 5 minutes + apart keeps its prefix cached for the whole task. + - "1h": the extended 1-hour cache, useful for tasks whose model calls + are spaced more than 5 minutes apart. + + NOTE: "1h" is NOT strictly better than "5m". 1-hour cache writes are + billed at a higher per-token rate than 5-minute writes. Only choose + "1h" when calls are spaced far enough apart that a 5-minute cache would + expire between them; otherwise the higher write cost is wasted. See the + Anthropic prompt-caching docs above. + enum: + - 5m + - 1h + type: string maxTokens: description: Maximum tokens to generate type: integer + promptCaching: + default: false + description: |- + PromptCaching enables Anthropic prompt caching by marking the reusable + prefix of every Messages API request with `cache_control` breakpoints: + the last tool definition, the last system prompt block and the last + content block of the most recent conversation turn. Anthropic caches the + prefix up to each breakpoint and bills a cache hit at a fraction of the + normal input price on later requests within the TTL. Because the + conversation breakpoint moves with every turn, each call of an agent + loop reads the whole previous history from the cache and writes only + the new turn. + + Recommended for tool-using agents that make many model calls per task + with a stable system prompt and tool set — without it the full history + is billed as fresh input on every call. Cache writes are billed at a + premium over normal input, so a prefix has to be read at least once to + pay off, and each model has a minimum cacheable prefix (1024–4096 + tokens depending on the model) below which the markers are silently + ignored. + + See https://platform.claude.com/docs/en/build-with-claude/prompt-caching + for the current list of supported models, minimum prefix sizes and + pricing. + type: boolean temperature: description: Temperature for sampling type: string @@ -400,6 +447,15 @@ spec: type: string description: Options for the Ollama API type: object + think: + description: |- + Think switches a thinking model's reasoning on or off. It is sent as the + top-level `think` field of every chat request, not as an option. Unset + leaves Ollama's default, under which a model with the thinking capability + (Qwen3, DeepSeek-R1, Granite 4.2, ...) thinks before every answer. Set it + to false for an agent whose model should answer directly. Ollama refuses + true for a model without the thinking capability. + type: boolean type: object openAI: description: OpenAI-specific configuration diff --git a/helm/kagent-crds/templates/api.kagent.dev_modelproviderconfigs.yaml b/helm/kagent-crds/templates/api.kagent.dev_modelproviderconfigs.yaml index f802a8598e..e2c15b363f 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_modelproviderconfigs.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_modelproviderconfigs.yaml @@ -4,6 +4,7 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.19.0 + helm.sh/resource-policy: keep name: modelproviderconfigs.api.kagent.dev spec: group: api.kagent.dev diff --git a/helm/kagent-crds/templates/api.kagent.dev_remotemcpservers.yaml b/helm/kagent-crds/templates/api.kagent.dev_remotemcpservers.yaml index 3c2f5c21e5..2696d71178 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_remotemcpservers.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_remotemcpservers.yaml @@ -4,6 +4,7 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.19.0 + helm.sh/resource-policy: keep name: remotemcpservers.api.kagent.dev spec: group: api.kagent.dev diff --git a/helm/kagent-crds/templates/api.kagent.dev_sandboxtemplates.yaml b/helm/kagent-crds/templates/api.kagent.dev_sandboxtemplates.yaml index 80b1a4b595..f122b5e135 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_sandboxtemplates.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_sandboxtemplates.yaml @@ -4,6 +4,7 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.19.0 + helm.sh/resource-policy: keep name: sandboxtemplates.api.kagent.dev spec: group: api.kagent.dev diff --git a/helm/kagent/files/nginx.conf b/helm/kagent/files/nginx.conf index 5c2a62be39..daa8072be0 100644 --- a/helm/kagent/files/nginx.conf +++ b/helm/kagent/files/nginx.conf @@ -52,9 +52,18 @@ http { log_format main '[$time_local] $remote_addr - $remote_user - $request $status $body_bytes_sent $http_referer $http_user_agent $http_x_forwarded_for'; log_format upstreamlog '[$time_local] $remote_addr - $remote_user - $server_name $host to: $upstream_addr: $request $status upstream_response_time $upstream_response_time msec $msec request_time $request_time'; - upstream kagent_backend { - server {{ include "kagent.controllerServiceAuthority" . }}; - } + {{- /* + The controller is not named in a static upstream block: nginx resolves + those once, at start, and refuses to start while the name does not + resolve yet, so a UI that comes up before cluster DNS or the controller + Service crash-loops. Proxying through a variable makes nginx look the name + up per request with the resolver below, and a request made before the + controller answers gets a 502 instead. That resolver uses neither the + system's nameservers nor its search path, so init.sh renders it from the + pod's /etc/resolv.conf on every start, together with + $kagent_cluster_domain for the fully qualified Service name. + */}} + include /tmp/nginx/resolver.conf; map $http_upgrade $connection_upgrade { default upgrade; @@ -68,6 +77,8 @@ http { {{- end }} server_name localhost; + set $kagent_backend "{{ include "kagent.controllerServiceHost" . }}.$kagent_cluster_domain:{{ .Values.controller.service.ports.port | int }}"; + root /usr/share/nginx/html; index index.html; @@ -94,7 +105,12 @@ http { } location /a2a/ { - proxy_pass http://kagent_backend/a2a/; + {{- /* + No URI part: with a variable in proxy_pass a URI would replace the + whole request path, while without one the request URI passes + unchanged, which is what /a2a/, /mcp and /api/ need. + */}} + proxy_pass http://$kagent_backend; proxy_http_version 1.1; proxy_set_header Connection ''; proxy_set_header Host $host; @@ -114,7 +130,7 @@ http { } location = /mcp { - proxy_pass http://kagent_backend/mcp; + proxy_pass http://$kagent_backend; proxy_http_version 1.1; proxy_set_header Connection ''; proxy_set_header Host $host; @@ -130,7 +146,7 @@ http { } location /api/ { - proxy_pass http://kagent_backend/api/; + proxy_pass http://$kagent_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; diff --git a/helm/kagent/templates/_helpers.tpl b/helm/kagent/templates/_helpers.tpl index 306c8be36e..4e28ebdf5c 100644 --- a/helm/kagent/templates/_helpers.tpl +++ b/helm/kagent/templates/_helpers.tpl @@ -17,10 +17,12 @@ Create a default fully qualified app name. Common labels */}} {{- define "kagent.labels" -}} -helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimAll "-._" }} {{ include "kagent.selectorLabels" . }} {{- if .Chart.Version }} -app.kubernetes.io/version: {{ .Chart.Version | quote }} +{{- /* helm-controller installs OCI charts under `+`; a `+` is not valid in a label value, so the build metadata is sanitised the way helm.sh/chart is. + A label value must also end alphanumeric: truncating to 63 characters can cut a long version right after a `.`, `-` or `_`, so every trailing one is trimmed, not only a `-`. */}} +app.kubernetes.io/version: {{ .Chart.Version | replace "+" "_" | trunc 63 | trimAll "-._" | quote }} {{- end }} app.kubernetes.io/managed-by: {{ .Release.Service }} app.kubernetes.io/part-of: kagent @@ -310,10 +312,19 @@ Password secret name - returns the chart-managed Secret name for POSTGRES_PASSWO {{- end -}} {{/* -Controller Service host:port for nginx upstream (no scheme). +Controller Service host up to `.svc`, for the UI's nginx, which appends the +cluster domain init.sh reads from the pod's resolv.conf: nginx's resolver does +not apply the search path. +*/}} +{{- define "kagent.controllerServiceHost" -}} +{{- printf "%s-controller.%s.svc" (include "kagent.fullname" .) (include "kagent.namespace" .) -}} +{{- end -}} + +{{/* +Controller Service host:port (no scheme). */}} {{- define "kagent.controllerServiceAuthority" -}} -{{- printf "%s-controller.%s.svc:%d" (include "kagent.fullname" .) (include "kagent.namespace" .) (.Values.controller.service.ports.port | int) -}} +{{- printf "%s:%d" (include "kagent.controllerServiceHost" .) (.Values.controller.service.ports.port | int) -}} {{- end -}} {{/* @@ -469,6 +480,18 @@ Call with (dict "root" $ "image" ). {{- end -}} {{- end -}} +{{/* +Digest-pinned reference of a runtime image, /@, from +an image block (registry — empty selects the global one —, repository, digest). +Empty while the digest is empty: a chart rendered from git carries none, a +published chart carries its build's. +*/}} +{{- define "kagent.runtimeImage" -}} +{{- if .image.digest -}} +{{- printf "%s/%s@%s" (.image.registry | default .root.Values.registry) .image.repository .image.digest -}} +{{- end -}} +{{- end -}} + {{/* The ui container image. Same tag precedence as the controller: the top-level tag wins over the component tag. @@ -493,3 +516,26 @@ Operator resource attributes as an OTEL_RESOURCE_ATTRIBUTES value. {{- end -}} {{- join "," $entries -}} {{- end }} + +{{/* +The image the chart's Harness runs: harness.image when set, else the chart's own +Go ADK runtime image (controller.agentImage) at its digest; empty when neither is known. +*/}} +{{- define "kagent.harness.image" -}} +{{- .Values.harness.image | default (include "kagent.runtimeImage" (dict "root" . "image" .Values.controller.agentImage)) -}} +{{- end -}} + +{{/* +The runtime image of a harnesses[] entry: its image, else the chart's image of +its runtime (claude, the default: runtimeImages.claudeHarness; kagent: +controller.agentImage) at its digest. Empty when neither is set or the runtime +is unknown. Usage: include "kagent.harnesses.image" (dict "root" $ "entry" $entry) +*/}} +{{- define "kagent.harnesses.image" -}} +{{- $runtime := .entry.runtime | default "claude" -}} +{{- $chartImages := dict "claude" .root.Values.runtimeImages.claudeHarness "kagent" .root.Values.controller.agentImage -}} +{{- $default := "" -}} +{{- with get $chartImages $runtime }}{{- $default = include "kagent.runtimeImage" (dict "root" $.root "image" .) }}{{- end -}} +{{- .entry.image | default $default -}} +{{- end -}} + diff --git a/helm/kagent/templates/controller-configmap.yaml b/helm/kagent/templates/controller-configmap.yaml index f62b21cec8..39cd273aa2 100644 --- a/helm/kagent/templates/controller-configmap.yaml +++ b/helm/kagent/templates/controller-configmap.yaml @@ -13,6 +13,9 @@ data: KAGENT_CONTROLLER_NAME: {{ include "kagent.fullname" . }}-controller KAGENT_SESSION_IDLE_TTL: {{ .Values.controller.sessionIdleTTL | quote }} KAGENT_SESSION_SHARE_MAX_TTL: {{ .Values.controller.sessionShareMaxTTL | quote }} + KAGENT_STALLED_TURN_TIMEOUT: {{ .Values.controller.stalledTurnTimeout | quote }} + KAGENT_PAUSED_RUNTIME_TTL: {{ .Values.controller.pausedRuntimeTTL | quote }} + KAGENT_REVISION_REPOINT_INTERVAL: {{ .Values.controller.revisionRepointInterval | quote }} {{- if .Values.ui.externalUrl }} KAGENT_UI_URL: {{ .Values.ui.externalUrl | quote }} {{- end }} diff --git a/helm/kagent/templates/harness.yaml b/helm/kagent/templates/harness.yaml new file mode 100644 index 0000000000..694294ff84 --- /dev/null +++ b/helm/kagent/templates/harness.yaml @@ -0,0 +1,54 @@ +{{- if .Values.harness.create }} +{{- /* +The chart's own Harness (api.kagent.dev/v1alpha3): how an agent runs on the API +v2, a digest-pinned runtime image, an environment and a Substrate policy (a +WorkerPool, a snapshot location). An Agent names the Harness it runs on by +spec.harnessRef.name. The runtime is the chart's Go ADK image +(controller.agentImage) at the digest the chart was published with; +harness.image overrides it. The CRD pins the image by digest (a moving tag +would silently change every agent on the Harness), so a tag fails here, at +render time, instead of at admission. +*/}} +{{- $image := include "kagent.harness.image" . }} +{{- if not $image }} +{{- fail "harness.create=true needs the runtime image's digest: set controller.agentImage.digest (sha256:<64 hex digits>; a published chart carries it) or harness.image (@sha256:<64 hex digits>)" }} +{{- end }} +{{- if not (regexMatch "^[^[:space:]@]+@sha256:[a-f0-9]{64}$" $image) }} +{{- fail (printf "harness image %q: a Harness pins its runtime image by digest (@sha256:<64 hex digits>); a tag is not accepted" $image) }} +{{- end }} +{{- if not .Values.harness.snapshotLocation }} +{{- fail "harness.snapshotLocation is required when harness.create=true (the Substrate snapshot store of the actors run under the Harness, e.g. s3:///)" }} +{{- end }} +apiVersion: api.kagent.dev/v1alpha3 +kind: Harness +metadata: + name: {{ .Values.harness.name | quote }} + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.labels" . | nindent 4 }} + annotations: + # The runtime of every agent that references it: kept across an uninstall of the chart. + helm.sh/resource-policy: keep +spec: + {{- /* Exactly one of kagent|codex|claude|byo; the Go ADK runtime adapter. + Compaction is the Harness's (Harness.spec.kagent.compaction): every agent + on the Harness runs with it. */}} + {{- with .Values.harness.compaction }} + kagent: + compaction: + {{- toYaml . | nindent 6 }} + {{- else }} + kagent: {} + {{- end }} + workload: + image: {{ $image | quote }} + {{- with .Values.harness.env }} + env: + {{- toYaml . | nindent 4 }} + {{- end }} + substrate: + workerPoolRef: + name: {{ .Values.harness.workerPoolRef | default .Values.substrateWorkerPool.name | quote }} + snapshotPolicy: + location: {{ .Values.harness.snapshotLocation | quote }} +{{- end }} diff --git a/helm/kagent/templates/harnesses.yaml b/helm/kagent/templates/harnesses.yaml new file mode 100644 index 0000000000..04dbb6334d --- /dev/null +++ b/helm/kagent/templates/harnesses.yaml @@ -0,0 +1,82 @@ +{{- /* +Further Harnesses beside the chart's own (harness.yaml), one per entry of +`harnesses`: a coding Harness on the chart's claude-harness image, a +toolchain Harness on an image built from it, or another Go ADK Harness. Each +entry has the policy shape of harness.*: a WorkerPool, a snapshot location, +an environment. An Agent names the Harness it runs +on by spec.harnessRef.name, so the names of the entries and of the chart's own +Harness must be distinct, and the render fails on a repeat. +*/}} +{{- $root := . }} +{{- $names := dict }} +{{- if .Values.harness.create }} +{{- $_ := set $names .Values.harness.name true }} +{{- end }} +{{- range $index, $entry := .Values.harnesses }} +{{- $key := printf "harnesses[%d]" $index }} +{{- $name := required (printf "%s.name is required" $key) $entry.name }} +{{- if hasKey $names $name }} +{{- fail (printf "%s: a Harness named %q is already rendered by this chart" $key $name) }} +{{- end }} +{{- $_ := set $names $name true }} +{{- $runtime := $entry.runtime | default "claude" }} +{{- if not (has $runtime (list "claude" "kagent")) }} +{{- fail (printf "%s.runtime %q: claude or kagent" $key $runtime) }} +{{- end }} +{{- if and (eq $runtime "kagent") $entry.limits }} +{{- fail (printf "%s.limits: the kagent runtime has no turn limits (Harness.spec.claude.limits is the Claude harness's)" $key) }} +{{- end }} +{{- if and (eq $runtime "claude") $entry.compaction }} +{{- fail (printf "%s.compaction: the claude runtime has no compaction setting (Harness.spec.kagent.compaction is the Go ADK's)" $key) }} +{{- end }} +{{- $image := include "kagent.harnesses.image" (dict "root" $root "entry" $entry) }} +{{- if not $image }} +{{- fail (printf "%s (%s) needs its runtime image's digest: set %s (sha256:<64 hex digits>; a published chart carries it) or %s.image (@sha256:<64 hex digits>)" $key $name (ternary "runtimeImages.claudeHarness.digest" "controller.agentImage.digest" (eq $runtime "claude")) $key) }} +{{- end }} +{{- if not (regexMatch "^[^[:space:]@]+@sha256:[a-f0-9]{64}$" $image) }} +{{- fail (printf "%s image %q: a Harness pins its runtime image by digest (@sha256:<64 hex digits>); a tag is not accepted" $key $image) }} +{{- end }} +{{- if not $entry.snapshotLocation }} +{{- fail (printf "%s.snapshotLocation is required (the Substrate snapshot store of the actors run under the Harness %q, e.g. s3:///)" $key $name) }} +{{- end }} +--- +apiVersion: api.kagent.dev/v1alpha3 +kind: Harness +metadata: + name: {{ $name | quote }} + namespace: {{ include "kagent.namespace" $root }} + labels: + {{- include "kagent.labels" $root | nindent 4 }} + annotations: + # The runtime of every agent admitted under it: kept across an uninstall of the chart. + helm.sh/resource-policy: keep +spec: + {{- if eq $runtime "claude" }} + {{- with $entry.limits }} + claude: + limits: + {{- toYaml . | nindent 6 }} + {{- else }} + claude: {} + {{- end }} + {{- else }} + {{- with $entry.compaction }} + kagent: + compaction: + {{- toYaml . | nindent 6 }} + {{- else }} + kagent: {} + {{- end }} + {{- end }} + workload: + image: {{ $image | quote }} + {{- with $entry.env }} + env: + {{- toYaml . | nindent 4 }} + {{- end }} + substrate: + workerPoolRef: + name: {{ $entry.workerPoolRef | default $root.Values.substrateWorkerPool.name | quote }} + snapshotPolicy: + location: {{ $entry.snapshotLocation | quote }} +{{- end }} diff --git a/helm/kagent/templates/runtime-images-configmap.yaml b/helm/kagent/templates/runtime-images-configmap.yaml new file mode 100644 index 0000000000..ed8a77eadc --- /dev/null +++ b/helm/kagent/templates/runtime-images-configmap.yaml @@ -0,0 +1,40 @@ +{{- /* +The chart's runtime images, digest-pinned, as a values fragment for Agent +Substrate: atelet's image cache pre-pulls `imageCache.pinnedImages` on every +worker node, so an agent's first turn does not wait for the pull of its runtime +image. The ConfigMap sits in the release namespace — not the chart's +namespaceOverride — so a Substrate release installed next to this one reads it +as values (a Flux HelmRelease's valuesFrom) without crossing namespaces. Listed: +the image the chart's Harness runs (harness.image, else the chart's own Go ADK +image at controller.agentImage.digest), the images under runtimeImages and the +image of each harnesses[] entry, each once; an image whose digest is empty is +left out, so a chart rendered from git lists nothing and a published chart +lists its build. The ConfigMap carries the label helm-controller watches (reconcile.fluxcd.io/watch: Enabled, its default +--watch-configs-label-selector): a HelmRelease reading it through valuesFrom +is upgraded as soon as an image reference changes, instead of on its next +interval. Any other reader ignores the label. +*/}} +{{- $images := list }} +{{- with include "kagent.harness.image" . }} +{{- $images = append $images . }} +{{- end }} +{{- with include "kagent.runtimeImage" (dict "root" . "image" .Values.runtimeImages.claudeHarness) }} +{{- $images = append $images . }} +{{- end }} +{{- range .Values.harnesses }} +{{- with include "kagent.harnesses.image" (dict "root" $ "entry" .) }} +{{- $images = append $images . }} +{{- end }} +{{- end }} +{{- $images = uniq $images }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "kagent.fullname" . }}-images + namespace: {{ .Release.Namespace }} + labels: + {{- include "kagent.labels" . | nindent 4 }} + reconcile.fluxcd.io/watch: Enabled +data: + substrate-values.yaml: | + {{- toYaml (dict "atelet" (dict "imageCache" (dict "pinnedImages" $images))) | nindent 4 }} diff --git a/helm/kagent/templates/toolserver-api.yaml b/helm/kagent/templates/toolserver-api.yaml index 6634235060..0ed2e095f3 100644 --- a/helm/kagent/templates/toolserver-api.yaml +++ b/helm/kagent/templates/toolserver-api.yaml @@ -5,6 +5,11 @@ metadata: namespace: {{ include "kagent.namespace" . }} labels: {{- include "kagent.labels" . | nindent 4 }} + {{- if eq .Values.controller.auth.mode "trusted-proxy" }} + # The controller cannot present a validated bearer to its own MCP endpoint, + # so under trusted-proxy auth its tool listing would answer 401 forever. + kagent.dev/discovery: disabled + {{- end }} spec: description: "Kagent API tools for Sessions, checkpoints, and sandboxes" protocol: STREAMABLE_HTTP diff --git a/helm/kagent/tests/controller-configmap_test.yaml b/helm/kagent/tests/controller-configmap_test.yaml index ab9e421f0d..bb16790217 100644 --- a/helm/kagent/tests/controller-configmap_test.yaml +++ b/helm/kagent/tests/controller-configmap_test.yaml @@ -38,6 +38,48 @@ tests: path: data.KAGENT_SESSION_SHARE_MAX_TTL value: 72h + - it: defaults the stalled turn timeout to one hour + asserts: + - equal: + path: data.KAGENT_STALLED_TURN_TIMEOUT + value: 1h + + - it: allows the stalled turn sweep to be disabled + set: + controller.stalledTurnTimeout: "0" + asserts: + - equal: + path: data.KAGENT_STALLED_TURN_TIMEOUT + value: "0" + + - it: defaults the paused runtime TTL to two minutes + asserts: + - equal: + path: data.KAGENT_PAUSED_RUNTIME_TTL + value: 2m + + - it: defaults the revision repoint interval to five minutes + asserts: + - equal: + path: data.KAGENT_REVISION_REPOINT_INTERVAL + value: 5m + + - it: allows the revision repoint sweep to be disabled + set: + controller.revisionRepointInterval: "0" + asserts: + - equal: + path: data.KAGENT_REVISION_REPOINT_INTERVAL + value: "0" + + - it: allows pauses to stay in place until the reply + set: + controller.pausedRuntimeTTL: "0" + asserts: + - equal: + path: data.KAGENT_PAUSED_RUNTIME_TTL + value: "0" + - it: defaults to insecure controller authentication asserts: - isSubset: diff --git a/helm/kagent/tests/harness_test.yaml b/helm/kagent/tests/harness_test.yaml new file mode 100644 index 0000000000..bc4ab582a8 --- /dev/null +++ b/helm/kagent/tests/harness_test.yaml @@ -0,0 +1,133 @@ +suite: test harness +templates: + - harness.yaml +set: + controller.agentImage.digest: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + harness.snapshotLocation: s3://snapshots/kagent +tests: + - it: should render no Harness by default + set: + harness.create: false + asserts: + - hasDocuments: + count: 0 + + - it: should render exactly one Harness on the chart's own image at its digest + set: + harness.create: true + namespaceOverride: kagent + asserts: + - hasDocuments: + count: 1 + - isKind: + of: Harness + - isAPIVersion: + of: api.kagent.dev/v1alpha3 + - equal: + path: metadata.name + value: kagent + - equal: + path: metadata.namespace + value: kagent + - equal: + path: metadata.annotations["helm.sh/resource-policy"] + value: keep + - equal: + path: spec.kagent + value: {} + - equal: + path: spec.workload.image + value: ghcr.io/kagent-dev/kagent/golang-adk@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + - equal: + path: spec.substrate.workerPoolRef.name + value: kagent-default + - equal: + path: spec.substrate.snapshotPolicy.location + value: s3://snapshots/kagent + - notExists: + path: spec.env + - notExists: + path: spec.allowedAgentTemplates + + - it: should render the compaction of the Harness under the kagent adapter + set: + harness.create: true + harness.compaction: + tokenThreshold: 24000 + eventRetentionSize: 4 + summarizer: + modelConfigRef: + name: cheap + asserts: + - equal: + path: spec.kagent.compaction.tokenThreshold + value: 24000 + - equal: + path: spec.kagent.compaction.eventRetentionSize + value: 4 + - equal: + path: spec.kagent.compaction.summarizer.modelConfigRef.name + value: cheap + + - it: should follow the agent image registry over the global one + set: + harness.create: true + registry: registry.example.com + controller.agentImage.registry: mirror.example.com + controller.agentImage.repository: acme/golang-adk + asserts: + - equal: + path: spec.workload.image + value: mirror.example.com/acme/golang-adk@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + + - it: should let harness.image override the chart's own image + set: + harness.create: true + harness.image: localhost:5001/kagent-dev/kagent/golang-adk@sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 + asserts: + - equal: + path: spec.workload.image + value: localhost:5001/kagent-dev/kagent/golang-adk@sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 + + - it: should forward the worker pool, the snapshot location and the env + set: + harness.create: true + harness.name: go-adk + harness.workerPoolRef: large + harness.env: + - name: KAGENT_PROPAGATE_TOKEN + value: "true" + asserts: + - equal: + path: metadata.name + value: go-adk + - equal: + path: spec.substrate.workerPoolRef.name + value: large + - equal: + path: spec.env[0].name + value: KAGENT_PROPAGATE_TOKEN + + - it: should fail on a tag instead of a digest + set: + harness.create: true + harness.image: ghcr.io/kagent-dev/kagent/golang-adk:0.11.0 + asserts: + - failedTemplate: + errorPattern: "a Harness pins its runtime image by digest" + + - it: should fail without a digest + set: + harness.create: true + controller.agentImage.digest: "" + asserts: + - failedTemplate: + errorPattern: "needs the runtime image's digest" + + - it: should fail without a snapshot location + set: + harness.create: true + harness.snapshotLocation: "" + asserts: + - failedTemplate: + errorPattern: "harness.snapshotLocation is required" diff --git a/helm/kagent/tests/harnesses_test.yaml b/helm/kagent/tests/harnesses_test.yaml new file mode 100644 index 0000000000..f2988f78e6 --- /dev/null +++ b/helm/kagent/tests/harnesses_test.yaml @@ -0,0 +1,217 @@ +suite: test harnesses +templates: + - harnesses.yaml +set: + controller.agentImage.digest: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + runtimeImages.claudeHarness.digest: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + harness.snapshotLocation: s3://snapshots/kagent +tests: + - it: should render no further Harness by default + asserts: + - hasDocuments: + count: 0 + + - it: should render a claude Harness on the chart's Claude image at its digest + set: + namespaceOverride: kagent + harnesses: + - name: claude + snapshotLocation: s3://snapshots/claude + env: + - name: KAGENT_PROPAGATE_TOKEN + value: "true" + asserts: + - hasDocuments: + count: 1 + - isKind: + of: Harness + - isAPIVersion: + of: api.kagent.dev/v1alpha3 + - equal: + path: metadata.name + value: claude + - equal: + path: metadata.namespace + value: kagent + - equal: + path: metadata.annotations["helm.sh/resource-policy"] + value: keep + - equal: + path: spec.claude + value: {} + - notExists: + path: spec.kagent + - equal: + path: spec.workload.image + value: ghcr.io/kagent-dev/kagent/claude-harness@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + - equal: + path: spec.substrate.workerPoolRef.name + value: kagent-default + - equal: + path: spec.substrate.snapshotPolicy.location + value: s3://snapshots/claude + - equal: + path: spec.env[0].name + value: KAGENT_PROPAGATE_TOKEN + + - it: should render one Harness per entry, a toolchain image and a second Go ADK Harness included + set: + harnesses: + - name: claude + snapshotLocation: s3://snapshots/claude + - name: claude-go + image: example.com/harness-go@sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 + workerPoolRef: large + snapshotLocation: s3://snapshots/claude-go + env: + - name: GOFLAGS + value: -mod=readonly + - name: adk-large + runtime: kagent + snapshotLocation: s3://snapshots/adk-large + compaction: + tokenThreshold: 600000 + eventRetentionSize: 4 + asserts: + - hasDocuments: + count: 3 + - equal: + path: metadata.name + value: claude-go + documentIndex: 1 + - equal: + path: spec.workload.image + value: example.com/harness-go@sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 + documentIndex: 1 + - equal: + path: spec.substrate.workerPoolRef.name + value: large + documentIndex: 1 + - equal: + path: spec.env[0].name + value: GOFLAGS + documentIndex: 1 + - equal: + path: metadata.name + value: adk-large + documentIndex: 2 + - equal: + path: spec.kagent.compaction.tokenThreshold + value: 600000 + documentIndex: 2 + - equal: + path: spec.workload.image + value: ghcr.io/kagent-dev/kagent/golang-adk@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + documentIndex: 2 + - notExists: + path: spec.claude + documentIndex: 2 + + - it: should fail on a name the chart's own Harness already uses + set: + harness.create: true + harnesses: + - name: kagent + snapshotLocation: s3://snapshots/x + asserts: + - failedTemplate: + errorPattern: a Harness named "kagent" is already rendered + + - it: should fail on two entries with one name + set: + harnesses: + - name: claude + snapshotLocation: s3://snapshots/a + - name: claude + snapshotLocation: s3://snapshots/b + asserts: + - failedTemplate: + errorPattern: 'harnesses\[1\]: a Harness named "claude" is already rendered' + + - it: should fail on an unknown runtime + set: + harnesses: + - name: x + runtime: codex + snapshotLocation: s3://snapshots/x + asserts: + - failedTemplate: + errorPattern: 'harnesses\[0\].runtime "codex": claude or kagent' + + - it: should fail on a tag instead of a digest + set: + harnesses: + - name: claude + image: example.com/claude-harness:latest + snapshotLocation: s3://snapshots/claude + asserts: + - failedTemplate: + errorPattern: a tag is not accepted + + - it: should fail without a digest for the chart's Claude image + set: + runtimeImages.claudeHarness.digest: "" + harnesses: + - name: claude + snapshotLocation: s3://snapshots/claude + asserts: + - failedTemplate: + errorPattern: 'harnesses\[0\] \(claude\) needs its runtime image.s digest: set runtimeImages.claudeHarness.digest' + + - it: should fail without a snapshot location + set: + harnesses: + - name: claude + asserts: + - failedTemplate: + errorPattern: 'harnesses\[0\].snapshotLocation is required' + + - it: should render the turn limits of a claude entry + set: + harnesses: + - name: claude + snapshotLocation: s3://snapshots/claude + limits: + budgetUSD: "2.50" + maxTurns: 40 + asserts: + - equal: + path: spec.claude.limits + value: + budgetUSD: "2.50" + maxTurns: 40 + + - it: should fail on limits for a kagent entry + set: + harnesses: + - name: adk + runtime: kagent + snapshotLocation: s3://snapshots/adk + limits: + maxTurns: 40 + asserts: + - failedTemplate: + errorPattern: 'harnesses\[0\].limits: the kagent runtime has no turn limits' + + - it: should fail on compaction for a claude entry + set: + harnesses: + - name: claude + snapshotLocation: s3://snapshots/claude + compaction: + tokenThreshold: 600000 + asserts: + - failedTemplate: + errorPattern: 'harnesses\[0\].compaction: the claude runtime has no compaction setting' + + - it: should render the entries beside the chart's own Harness + set: + harness.create: true + harnesses: + - name: claude + snapshotLocation: s3://snapshots/claude + - name: claude-go + snapshotLocation: s3://snapshots/claude-go + asserts: + - hasDocuments: + count: 2 diff --git a/helm/kagent/tests/runtime-images-configmap_test.yaml b/helm/kagent/tests/runtime-images-configmap_test.yaml new file mode 100644 index 0000000000..f200f21007 --- /dev/null +++ b/helm/kagent/tests/runtime-images-configmap_test.yaml @@ -0,0 +1,88 @@ +suite: test runtime images configmap +templates: + - runtime-images-configmap.yaml +tests: + - it: should render an empty pinned set when no digest is known + asserts: + - hasDocuments: + count: 1 + - isKind: + of: ConfigMap + - equal: + path: metadata.name + value: RELEASE-NAME-images + - equal: + path: metadata.namespace + value: NAMESPACE + - matchRegex: + path: data["substrate-values.yaml"] + pattern: "pinnedImages: \\[\\]" + + - it: should carry the label helm-controller watches, so a valuesFrom consumer follows a changed set at once + asserts: + - equal: + path: metadata.labels["reconcile.fluxcd.io/watch"] + value: Enabled + + - it: should list the chart's own Harness image and the Claude harness image at their digests + set: + fullnameOverride: kagent + controller.agentImage.digest: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + runtimeImages.claudeHarness.digest: "sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + asserts: + - equal: + path: metadata.name + value: kagent-images + - equal: + path: data["substrate-values.yaml"] + value: | + atelet: + imageCache: + pinnedImages: + - ghcr.io/kagent-dev/kagent/golang-adk@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + - ghcr.io/kagent-dev/kagent/claude-harness@sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 + + - it: should follow a harness.image override + set: + controller.agentImage.digest: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + harness.image: localhost:5001/kagent-dev/kagent/golang-adk@sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 + asserts: + - matchRegex: + path: data["substrate-values.yaml"] + pattern: "- localhost:5001/kagent-dev/kagent/golang-adk@sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + - notMatchRegex: + path: data["substrate-values.yaml"] + pattern: "golang-adk@sha256:0123456789abcdef" + + - it: should stay in the release namespace when the chart's namespace is overridden + set: + namespaceOverride: kagent + controller.agentImage.digest: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + asserts: + - equal: + path: metadata.namespace + value: NAMESPACE + + - it: should list each harnesses[] image once beside the chart's images + set: + controller.agentImage.digest: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + runtimeImages.claudeHarness.digest: "sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + harnesses: + - name: claude + snapshotLocation: s3://snapshots/claude + - name: claude-go + image: example.com/harness-go@sha256:1111111111111111111111111111111111111111111111111111111111111111 + snapshotLocation: s3://snapshots/claude-go + - name: adk-large + runtime: kagent + snapshotLocation: s3://snapshots/adk-large + asserts: + - equal: + path: data["substrate-values.yaml"] + value: | + atelet: + imageCache: + pinnedImages: + - ghcr.io/kagent-dev/kagent/golang-adk@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + - ghcr.io/kagent-dev/kagent/claude-harness@sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 + - example.com/harness-go@sha256:1111111111111111111111111111111111111111111111111111111111111111 diff --git a/helm/kagent/tests/toolserver-api_test.yaml b/helm/kagent/tests/toolserver-api_test.yaml index 44901a649b..54cc8cac48 100644 --- a/helm/kagent/tests/toolserver-api_test.yaml +++ b/helm/kagent/tests/toolserver-api_test.yaml @@ -59,3 +59,16 @@ tests: - equal: path: spec.url value: http://staging-control-controller.agents.svc:8083/mcp + + - it: opts out of tool discovery under trusted-proxy auth + set: + controller.auth.mode: trusted-proxy + asserts: + - equal: + path: metadata.labels["kagent.dev/discovery"] + value: disabled + + - it: keeps tool discovery on under insecure auth + asserts: + - notExists: + path: metadata.labels["kagent.dev/discovery"] diff --git a/helm/kagent/tests/ui-nginx-configmap_test.yaml b/helm/kagent/tests/ui-nginx-configmap_test.yaml index e662415b0a..629f56a699 100644 --- a/helm/kagent/tests/ui-nginx-configmap_test.yaml +++ b/helm/kagent/tests/ui-nginx-configmap_test.yaml @@ -15,7 +15,7 @@ tests: asserts: - matchRegex: path: data["nginx.conf"] - pattern: 'location = /mcp \{\s+proxy_pass http://kagent_backend/mcp;' + pattern: 'location = /mcp \{\s+proxy_pass http://\$kagent_backend;' - it: strips internal agent identity headers in trusted proxy mode even if opted in set: @@ -71,12 +71,62 @@ tests: path: data["nginx.conf"] pattern: "listen \\[::\\]:8080;" - - it: should proxy api upstream to controller Cluster DNS + # --------------------------------------------------------------------------- + # Controller upstream + # + # A static upstream block is resolved once at start, and nginx refuses to + # start while the name does not resolve yet. The controller is reached through + # a variable instead, resolved per request by the resolver init.sh renders. + # --------------------------------------------------------------------------- + - it: should resolve the controller per request, for IPv4 and IPv6 alike + template: ui-nginx-configmap.yaml + set: + ipv6: + enabled: true + asserts: &variableUpstream + - matchRegex: + path: data["nginx.conf"] + pattern: 'include /tmp/nginx/resolver\.conf;' + - matchRegex: + path: data["nginx.conf"] + pattern: 'set \$kagent_backend "RELEASE-NAME-controller\.NAMESPACE\.svc\.\$kagent_cluster_domain:8083";' + - notMatchRegex: + path: data["nginx.conf"] + pattern: 'upstream kagent_backend' + + - it: should resolve the controller per request without IPv6 + template: ui-nginx-configmap.yaml + asserts: *variableUpstream + + - it: should follow the controller service port + template: ui-nginx-configmap.yaml + set: + controller: + service: + ports: + port: 9090 + asserts: + - matchRegex: + path: data["nginx.conf"] + pattern: 'set \$kagent_backend "RELEASE-NAME-controller\.NAMESPACE\.svc\.\$kagent_cluster_domain:9090";' + + - it: should pass the request URI unchanged to the controller template: ui-nginx-configmap.yaml asserts: + # With a variable in proxy_pass, a URI part would replace the whole path. + - matchRegex: + path: data["nginx.conf"] + pattern: 'location /api/ \{\s+proxy_pass http://\$kagent_backend;' + - matchRegex: + path: data["nginx.conf"] + pattern: 'location /a2a/ \{\s+proxy_pass http://\$kagent_backend;' + - notMatchRegex: + path: data["nginx.conf"] + pattern: 'proxy_pass http://\$kagent_backend/' + # WebSocket upgrades on /api/ stay as they were. - matchRegex: path: data["nginx.conf"] - pattern: 'server RELEASE-NAME-controller\.NAMESPACE\.svc:8083;' + pattern: 'proxy_set_header Connection \$connection_upgrade;' # --------------------------------------------------------------------------- # Static SPA serving @@ -152,7 +202,7 @@ tests: asserts: - matchRegex: path: data["nginx.conf"] - pattern: 'proxy_pass http://kagent_backend/a2a/;' + pattern: 'location /a2a/ \{\s+proxy_pass http://\$kagent_backend;' - notMatchRegex: path: data["nginx.conf"] pattern: 'proxy_pass http://kagent_ui' diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index 2b270ffcaa..3a4023dbbf 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -238,6 +238,11 @@ controller: registry: "" repository: kagent-dev/kagent/golang-adk tag: "" # Will default to global, then Chart version + # -- Index digest of the image published at that tag, `sha256:<64 hex digits>`. + # A Harness (api.kagent.dev/v1alpha3) pins its runtime image by digest only, so + # the chart's own Harness (`harness` below) needs it; empty in git, a + # published chart carries the digest of its build. + digest: "" # -- @deprecated Removed in 0.10.0. The A2A SDK now handles SSE buffering and timeouts # internally. These values have no effect and will be removed in a future release. streaming: null @@ -420,6 +425,20 @@ controller: # Longest lifetime a session share may request; shares created without one receive it. # 0 leaves shares valid until revoked or the session is deleted. sessionShareMaxTTL: "0" + # A turn whose runtime recorded no event for this long is failed so the session + # takes its next message. 0 disables the sweep. + stalledTurnTimeout: "1h" + + # A runtime paused for input is suspended to a durable snapshot once its pause + # is older than this, so a reply after a node loss still finds its state. + # 0 keeps every pause in place until the reply. + pausedRuntimeTTL: "2m" + + # A conversation whose runtime revision its agent has superseded is moved onto + # the agent's current revision while its runtime is quiesced, this often, so a + # conversation nobody writes to again does not keep the old revision and its + # ActorTemplate alive. A turn moves its own runtime regardless. 0 disables the sweep. + revisionRepointInterval: "5m" # Standalone workspaces, enabled by default. # Each SandboxTemplate supplies its own digest-pinned tools image. @@ -436,6 +455,9 @@ controller: # Agent Substrate. Requires ate-system installed. kagent creates ate-api # ActorTemplates and references an existing Kubernetes WorkerPool. + # The quiescence fence needs a Substrate whose Actor API takes the fencing + # token (the chart's substrate dependency version or newer); with an older one + # the controller logs a warning and pauses and suspends without the fence. substrate: enabled: false ateApiEndpoint: "" @@ -491,6 +513,82 @@ substrateWorkerPool: # nodeSelector / tolerations / priorityClassName / nodeAffinity / resources. template: {} +# -- Optional Harness (api.kagent.dev/v1alpha3) for the chart's own runtime image, the +# Go ADK image at controller.agentImage. On the API v2 an agent runs on a Harness: +# a digest-pinned runtime image, an environment and a Substrate policy (a +# WorkerPool, a snapshot location); an Agent names the Harness it runs on by +# spec.harnessRef.name. Off by default — a consumer rendering its own Harness +# objects keeps doing so. +harness: + create: false + name: kagent + # -- Runtime image, `@sha256:<64 hex digits>` (a tag fails the + # render: the CRD accepts nothing but a digest). Empty selects the chart's own + # image, /@. + image: "" + # -- The Substrate WorkerPool (in the chart's namespace) the Harness runs on; + # empty selects substrateWorkerPool.name. + workerPoolRef: "" + # -- Snapshot store of the actors run under the Harness, e.g. `s3:///` + # (Harness.spec.substrate.snapshotPolicy.location); required when create is true. + snapshotLocation: "" + # -- Environment of the runtime (Harness.spec.env: name with value or credentialRef). + env: [] + # -- Context compaction of every agent on the Harness + # (Harness.spec.kagent.compaction): tail retention (`tokenThreshold` with + # `eventRetentionSize`), the sliding window (`compactionInterval` with + # `overlapSize`), and a `summarizer` (`modelConfigRef.name`, `promptTemplate`). + # At least one strategy when set; empty leaves the history uncompacted. + compaction: {} + +# -- Further Harnesses beside the chart's own (harness.*), one entry each: a +# coding Harness on the chart's claude-harness image, a toolchain Harness on an +# image built from it, or another Go ADK Harness. Empty by default. Each entry +# takes the keys of harness.* (name, image, workerPoolRef, snapshotLocation, +# env, compaction for a kagent runtime) plus `runtime`: `claude` (the +# default; image empty selects the chart's Claude image, +# runtimeImages.claudeHarness at its digest) or `kagent` (image empty selects +# the chart's Go ADK image). The digest rules of harness.* apply per entry. An +# Agent names the Harness it runs on (spec.harnessRef.name), so every name must +# differ from the chart's own Harness and from the other entries; the render +# fails on a repeat. compaction on a claude entry fails the render. `limits` +# (claude entries only, Harness.spec.claude.limits: `budgetUSD` as a decimal +# string, `maxTurns`) bounds every turn of every agent on the Harness; on a +# kagent entry it fails the render. Each entry's image is listed in the +# `-images` ConfigMap. +# +# On the Claude harness, KAGENT_PROPAGATE_TOKEN forwards the turn caller's +# credential on MCP calls. Keep each entry's snapshotLocation apart from the +# others': a snapshot is the actor's whole memory and filesystem. +# +# Example: +# harnesses: +# - name: claude +# snapshotLocation: s3://snapshots/claude +# env: +# - name: KAGENT_PROPAGATE_TOKEN +# value: "true" +# limits: +# budgetUSD: "5.00" +# maxTurns: 100 +harnesses: [] + +# -- Runtime images the chart publishes besides the Go ADK image +# (controller.agentImage). The chart lists its runtime images, digest-pinned, in +# the ConfigMap `-images` (in the release namespace) as a values +# fragment for Agent Substrate's image cache (`atelet.imageCache.pinnedImages`), +# so worker nodes pre-pull the images agents run on: the image the chart's +# Harness runs and the ones below. An image without a digest is left out; a +# published chart carries the digests of its build. The ConfigMap is labelled +# `reconcile.fluxcd.io/watch: Enabled`, so a Flux HelmRelease that reads it +# through valuesFrom follows a changed image reference at once. +runtimeImages: + # -- The Claude runtime image (the `claude` Harness adapter). + claudeHarness: + registry: "" + repository: kagent-dev/kagent/claude-harness + digest: "" + # ============================================================================== # UI CONFIGURATION # ============================================================================== @@ -757,6 +855,12 @@ providers: apiKeySecretRef: kagent-anthropic apiKeySecretKey: ANTHROPIC_API_KEY # apiKey: "" + # Provider options rendered into spec.anthropic of the generated ModelConfig. + # promptCaching bills the stable prefix of every agent-loop call (tools, + # system prompt, previous turns) as a cache read instead of fresh input. + # config: + # promptCaching: true + # cacheTTL: "5m" # or "1h" for calls spaced more than 5 minutes apart azureOpenAI: provider: AzureOpenAI model: "gpt-4.1-mini" diff --git a/helm/tools/grafana-mcp/templates/_helpers.tpl b/helm/tools/grafana-mcp/templates/_helpers.tpl index a1bf0b5413..da3854fdfd 100644 --- a/helm/tools/grafana-mcp/templates/_helpers.tpl +++ b/helm/tools/grafana-mcp/templates/_helpers.tpl @@ -27,7 +27,7 @@ If release name contains chart name it will be used as a full name. Create chart name and version as used by the chart label. */}} {{- define "grafana-mcp.chart" -}} -{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimAll "-._" }} {{- end }} {{/* diff --git a/proto/kagent/api/v1alpha1/sessions.proto b/proto/kagent/api/v1alpha1/sessions.proto index 9dcb49ef48..519aeefe15 100644 --- a/proto/kagent/api/v1alpha1/sessions.proto +++ b/proto/kagent/api/v1alpha1/sessions.proto @@ -62,6 +62,22 @@ message Session { // Output only. A2A conversation ID, equal to this session's ID. // Forks receive a new conversation ID and new task IDs. string context_id = 14; + // Output only. Why the runtime boundary after a turn was released without a + // pause or a snapshot, once the suspend or pause and its retry failed or the + // runtime was gone. A later turn's snapshot clears it. + QuiescenceFailure last_quiescence_failure = 16; +} + +// QuiescenceFailure explains a turn whose runtime could not be paused or +// suspended after it settled. The turn is stored; without a snapshot it cannot +// be checkpointed, and the next turn takes the runtime as it is. +message QuiescenceFailure { + // The turn whose boundary was released. + string task_id = 1; + // SuspendFailed, PauseFailed or RuntimeUnavailable. + string reason = 2; + string message = 3; + google.protobuf.Timestamp failed_at = 4; } message CreateSessionRequest { diff --git a/python/packages/kagent-adk/src/kagent/adk/models/_anthropic.py b/python/packages/kagent-adk/src/kagent/adk/models/_anthropic.py index 7bbdde069d..d63f9f02d6 100644 --- a/python/packages/kagent-adk/src/kagent/adk/models/_anthropic.py +++ b/python/packages/kagent-adk/src/kagent/adk/models/_anthropic.py @@ -1,13 +1,15 @@ -"""Anthropic model implementation with api_key_passthrough, base_url, and header support.""" +"""Anthropic model implementation with api_key_passthrough, base_url, header, and prompt caching support.""" from __future__ import annotations import logging import os from functools import cached_property -from typing import Optional +from typing import Any, Literal, Optional from anthropic import AsyncAnthropic, DefaultAsyncHttpxClient +from anthropic.resources.messages import AsyncMessages +from anthropic.types import CacheControlEphemeralParam from google.adk.models.anthropic_llm import AnthropicLlm from ._azure import ( @@ -19,15 +21,107 @@ logger = logging.getLogger(__name__) +# Anthropic rejects a cache breakpoint on a reasoning block, so the conversation +# breakpoint skips past them when the latest turn ends in one. +_UNCACHEABLE_BLOCK_TYPES = frozenset({"thinking", "redacted_thinking"}) + + +def cache_control_param(cache_ttl: Optional[str] = None) -> CacheControlEphemeralParam: + """Return the ``cache_control`` marker for the configured retention window. + + ``None`` or ``"5m"`` omits ``ttl``, giving the API's default 5-minute cache; + ``"1h"`` opts into the 1-hour cache, whose writes are billed at a higher rate + (see the ModelConfig CRD's ``anthropic.cacheTTL`` doc for the trade-off). + """ + if cache_ttl == "1h": + return {"type": "ephemeral", "ttl": "1h"} + return {"type": "ephemeral"} + + +def mark_prompt_cache_breakpoints(kwargs: dict[str, Any], cache_control: CacheControlEphemeralParam) -> None: + """Attach the prompt-cache breakpoints to a ``messages.create`` request, in place. + + The Messages API renders tools, then system, then messages, and caches the + prefix up to each breakpoint. Marking the last tool definition, the last + system block and the last block of the latest turn keeps the stable head of + an agent loop cached while the conversation grows: every call reads the + previous prefix from the cache and writes only the new turn. That uses three + of the four breakpoints Anthropic allows per request. + + Marked blocks are copied rather than mutated so the caller's own message and + tool objects stay untouched. + """ + tools = kwargs.get("tools") + if isinstance(tools, list) and tools: + kwargs["tools"] = [*tools[:-1], {**tools[-1], "cache_control": cache_control}] + + system = kwargs.get("system") + if isinstance(system, str) and system: + kwargs["system"] = [{"type": "text", "text": system, "cache_control": cache_control}] + elif isinstance(system, list) and system: + kwargs["system"] = [*system[:-1], {**system[-1], "cache_control": cache_control}] + + messages = kwargs.get("messages") + if not isinstance(messages, list): + return + for index in range(len(messages) - 1, -1, -1): + message = messages[index] + if not isinstance(message, dict): + continue + content = message.get("content") + if isinstance(content, str): + content = [{"type": "text", "text": content}] if content else [] + if not isinstance(content, list): + continue + for block_index in range(len(content) - 1, -1, -1): + block = content[block_index] + if not isinstance(block, dict) or block.get("type") in _UNCACHEABLE_BLOCK_TYPES: + continue + marked = [*content] + marked[block_index] = {**block, "cache_control": cache_control} + kwargs["messages"] = [*messages[:index], {**message, "content": marked}, *messages[index + 1 :]] + return + + +class PromptCachingMessages: + """``AsyncMessages`` stand-in that marks the reusable prompt prefix before each request. + + google-adk's ``AnthropicLlm`` builds every request itself and hands it to + ``client.messages.create`` on both the streaming and the non-streaming path, + so the client is the one seam where kagent can shape the request without + re-implementing the request builder. Everything but ``create`` is delegated + to the wrapped resource. + """ + + def __init__(self, messages: AsyncMessages, cache_control: CacheControlEphemeralParam) -> None: + self._messages = messages + self._cache_control = cache_control + + def __getattr__(self, name: str) -> Any: + return getattr(self._messages, name) + + async def create(self, **kwargs: Any) -> Any: + mark_prompt_cache_breakpoints(kwargs, self._cache_control) + return await self._messages.create(**kwargs) + class KAgentAnthropicLlm(KAgentTLSMixin, AnthropicLlm): - """Anthropic model with api_key_passthrough, custom base_url, header, and TLS support.""" + """Anthropic model with api_key_passthrough, custom base_url, header, TLS, and prompt caching support.""" api_key_passthrough: Optional[bool] = None _api_key: Optional[str] = None base_url: Optional[str] = None extra_headers: Optional[dict[str, str]] = None + # When True, every request carries cache_control breakpoints on the last + # tool definition, the last system block and the last block of the latest + # turn, so Anthropic bills the stable prefix of the agent loop as a cache + # read. See mark_prompt_cache_breakpoints. + prompt_caching: bool = False + # When prompt_caching is on, cache_ttl selects the retention window: "5m"/None + # (the API's default 5-minute cache) or "1h" (higher cache-write cost). See + # cache_control_param. + cache_ttl: Optional[Literal["5m", "1h"]] = None model_config = {"arbitrary_types_allowed": True} @@ -60,7 +154,12 @@ def _anthropic_client(self) -> AsyncAnthropic: if http_client is not None: kwargs["http_client"] = http_client - return AsyncAnthropic(**kwargs) + client = AsyncAnthropic(**kwargs) + if self.prompt_caching: + # `messages` is a functools.cached_property on the SDK client, so the + # instance attribute takes precedence for the client's lifetime. + client.messages = PromptCachingMessages(client.messages, cache_control_param(self.cache_ttl)) + return client class FoundryAnthropic(KAgentAnthropicLlm): diff --git a/python/packages/kagent-adk/src/kagent/adk/models/_ollama.py b/python/packages/kagent-adk/src/kagent/adk/models/_ollama.py index 6f71880e2f..3032faef9b 100644 --- a/python/packages/kagent-adk/src/kagent/adk/models/_ollama.py +++ b/python/packages/kagent-adk/src/kagent/adk/models/_ollama.py @@ -139,12 +139,20 @@ class KAgentOllamaLlm(KAgentTLSMixin, BaseLlm): variable (set by the kagent controller from ModelConfig.ollama.host). Falls back to ``http://localhost:11434`` if the variable is not set. - Ollama does not require an API key; api_key_passthrough is accepted in the - config for interface compatibility but has no effect. + ``think`` is sent as the chat request's top-level ``think`` field. None sends + none, so a model with the thinking capability thinks (Ollama's default). + + A local daemon takes no API key. For Ollama Cloud the controller sets + ``OLLAMA_API_BASE`` to api.ollama.com and ``OLLAMA_API_KEY`` to the egress + gateway's placeholder; the Ollama SDK sends the key as a Bearer + ``Authorization`` header, and the gateway replaces it with the Secret. + api_key_passthrough is accepted in the config for interface compatibility + but has no effect. """ type: Literal["ollama"] = "ollama" ollama_options: Optional[dict[str, object]] = None + think: Optional[bool] = None default_headers: Optional[dict[str, str]] = None api_key_passthrough: Optional[bool] = None @@ -192,6 +200,7 @@ async def generate_content_async( messages=messages, tools=tools, options=self.ollama_options or None, + think=self.think, stream=True, ) async for chunk in response: @@ -231,6 +240,7 @@ async def generate_content_async( messages=messages, tools=tools, options=self.ollama_options or None, + think=self.think, stream=False, ) parts = [] @@ -260,6 +270,7 @@ def create_ollama_llm( model: str, options: dict[str, object] | None, extra_headers: dict[str, str], + think: Optional[bool] = None, tls_disable_verify: Optional[bool] = None, tls_ca_cert_path: Optional[str] = None, tls_disable_system_cas: Optional[bool] = None, @@ -274,6 +285,7 @@ def create_ollama_llm( return KAgentOllamaLlm( model=model, ollama_options=options or None, + think=think, default_headers=extra_headers or {}, tls_disable_verify=tls_disable_verify, tls_ca_cert_path=tls_ca_cert_path, diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index 9d4d03ce41..08167bb683 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -305,6 +305,16 @@ class Foundry(BaseLLM): class Anthropic(BaseLLM): base_url: str | None = None + # prompt_caching enables Anthropic prompt caching: cache_control breakpoints + # are set on the last tool definition, the last system prompt block and the + # last block of the latest conversation turn, so the stable prefix of an + # agent loop is billed as a cache read instead of fresh input on every call. + prompt_caching: bool = False + # cache_ttl selects the cache retention window when prompt_caching is on: + # "5m" (default) for the standard 5-minute cache, or "1h" for the 1-hour + # cache. 1-hour cache writes are billed at a higher rate, so "1h" only pays + # off when the calls sharing a prefix are more than 5 minutes apart. + cache_ttl: Literal["5m", "1h"] | None = None type: Literal["anthropic"] @@ -320,6 +330,8 @@ class GeminiAnthropic(BaseLLM): class Ollama(BaseLLM): options: dict[str, str] | None = None + # Sent as the chat request's top-level ``think`` field; None sends none. + think: bool | None = None type: Literal["ollama"] @@ -725,6 +737,8 @@ def _create_llm_from_model_config(model_config: ModelUnion): model=model_config.model, base_url=base_url, extra_headers=extra_headers, + prompt_caching=model_config.prompt_caching, + cache_ttl=model_config.cache_ttl, **_transport_kwargs(model_config), ) if model_config.type == "gemini_vertex_ai": @@ -740,6 +754,7 @@ def _create_llm_from_model_config(model_config: ModelUnion): return create_ollama_llm( model=model_config.model, options=ollama_options, + think=model_config.think, extra_headers=extra_headers, **_transport_kwargs(model_config), ) diff --git a/python/packages/kagent-adk/tests/unittests/models/test_anthropic.py b/python/packages/kagent-adk/tests/unittests/models/test_anthropic.py index c000fa99eb..d6ba2517c7 100644 --- a/python/packages/kagent-adk/tests/unittests/models/test_anthropic.py +++ b/python/packages/kagent-adk/tests/unittests/models/test_anthropic.py @@ -1,15 +1,25 @@ """Tests for KAgentAnthropicLlm.""" +import copy +import json from unittest import mock import httpx2 import pytest from anthropic import AsyncAnthropic from anthropic.lib.credentials import AccessToken -from anthropic.types import ThinkingBlock +from anthropic.types import Message, TextBlock, ThinkingBlock, Usage from google.adk.models.anthropic_llm import content_block_to_part - -from kagent.adk.models._anthropic import FoundryAnthropic, KAgentAnthropicLlm +from google.adk.models.llm_request import LlmRequest +from google.genai import types + +from kagent.adk.models._anthropic import ( + FoundryAnthropic, + KAgentAnthropicLlm, + PromptCachingMessages, + cache_control_param, + mark_prompt_cache_breakpoints, +) from kagent.adk.models._azure import AI_FOUNDRY_SCOPE @@ -290,3 +300,229 @@ def test_thinking_block_decodes_to_thought_part(self): assert part.thought is True assert part.text == "working through it" + + +class TestPromptCachingConfig: + def test_default_construction_has_caching_off(self): + llm = KAgentAnthropicLlm(model="claude-sonnet-4-6") + assert llm.prompt_caching is False + assert llm.cache_ttl is None + + def test_create_llm_forwards_prompt_caching_and_ttl(self): + from kagent.adk.types import Anthropic, _create_llm_from_model_config + + config = Anthropic(type="anthropic", model="claude-sonnet-4-6", prompt_caching=True, cache_ttl="1h") + result = _create_llm_from_model_config(config) + assert isinstance(result, KAgentAnthropicLlm) + assert result.prompt_caching is True + assert result.cache_ttl == "1h" + + def test_cache_control_default_ttl_omits_ttl(self): + assert cache_control_param() == {"type": "ephemeral"} + # "5m" is the API default, so it is left implicit. + assert cache_control_param("5m") == {"type": "ephemeral"} + + def test_cache_control_one_hour_ttl(self): + assert cache_control_param("1h") == {"type": "ephemeral", "ttl": "1h"} + + def test_client_without_caching_keeps_sdk_messages_resource(self): + llm = KAgentAnthropicLlm(model="claude-sonnet-4-6") + with mock.patch("kagent.adk.models._anthropic.AsyncAnthropic") as mock_anthropic: + client = mock.MagicMock(spec=AsyncAnthropic) + mock_anthropic.return_value = client + assert llm._anthropic_client.messages is client.messages + + def test_client_with_caching_wraps_messages_resource(self): + llm = KAgentAnthropicLlm(model="claude-sonnet-4-6", prompt_caching=True, cache_ttl="1h") + with mock.patch("kagent.adk.models._anthropic.AsyncAnthropic") as mock_anthropic: + client = mock.MagicMock(spec=AsyncAnthropic) + mock_anthropic.return_value = client + messages = llm._anthropic_client.messages + assert isinstance(messages, PromptCachingMessages) + assert messages._cache_control == {"type": "ephemeral", "ttl": "1h"} + + +class TestMarkPromptCacheBreakpoints: + """Request shaping: which blocks carry the cache_control marker.""" + + CC = {"type": "ephemeral"} + + def _agent_loop_kwargs(self): + return { + "model": "claude-sonnet-4-6", + "system": "You are a Kubernetes assistant.", + "tools": [ + {"name": "get_weather", "description": "lookup weather", "input_schema": {"type": "object"}}, + {"name": "list_pods", "description": "list pods", "input_schema": {"type": "object"}}, + ], + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "list the pods"}]}, + { + "role": "assistant", + "content": [{"type": "tool_use", "id": "call-1", "name": "list_pods", "input": {}}], + }, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "call-1", "content": "pod-a"}]}, + {"role": "user", "content": [{"type": "text", "text": "anything else?"}]}, + ], + } + + def test_marks_last_tool_system_and_latest_turn_only(self): + kwargs = self._agent_loop_kwargs() + mark_prompt_cache_breakpoints(kwargs, self.CC) + + assert kwargs["tools"][-1]["cache_control"] == self.CC + assert "cache_control" not in kwargs["tools"][0] + assert kwargs["tools"][-1]["name"] == "list_pods", "tool order must be preserved" + + assert kwargs["system"] == [ + {"type": "text", "text": "You are a Kubernetes assistant.", "cache_control": self.CC}, + ] + + assert kwargs["messages"][-1]["content"][-1]["cache_control"] == self.CC + for message in kwargs["messages"][:-1]: + for block in message["content"]: + assert "cache_control" not in block, "only the latest turn carries the moving breakpoint" + + def test_marks_trailing_tool_result(self): + kwargs = self._agent_loop_kwargs() + kwargs["messages"] = kwargs["messages"][:3] + mark_prompt_cache_breakpoints(kwargs, self.CC) + block = kwargs["messages"][-1]["content"][-1] + assert block["type"] == "tool_result" + assert block["cache_control"] == self.CC + + def test_skips_thinking_blocks_at_the_end_of_the_turn(self): + kwargs = self._agent_loop_kwargs() + kwargs["messages"].append( + { + "role": "assistant", + "content": [ + {"type": "text", "text": "let me check"}, + {"type": "thinking", "thinking": "...", "signature": "sig"}, + {"type": "redacted_thinking", "data": "..."}, + ], + } + ) + mark_prompt_cache_breakpoints(kwargs, self.CC) + content = kwargs["messages"][-1]["content"] + assert content[0]["cache_control"] == self.CC + assert "cache_control" not in content[1] + assert "cache_control" not in content[2] + + def test_system_block_list_marks_last_block(self): + kwargs = {"system": [{"type": "text", "text": "a"}, {"type": "text", "text": "b"}], "messages": []} + mark_prompt_cache_breakpoints(kwargs, self.CC) + assert kwargs["system"] == [ + {"type": "text", "text": "a"}, + {"type": "text", "text": "b", "cache_control": self.CC}, + ] + + def test_string_message_content_becomes_a_marked_text_block(self): + kwargs = {"messages": [{"role": "user", "content": "hi"}]} + mark_prompt_cache_breakpoints(kwargs, self.CC) + assert kwargs["messages"] == [ + {"role": "user", "content": [{"type": "text", "text": "hi", "cache_control": self.CC}]} + ] + + def test_leaves_requests_without_cacheable_content_alone(self): + for kwargs in ( + {}, + {"system": "", "tools": [], "messages": []}, + {"messages": [{"role": "user", "content": ""}]}, + ): + before = copy.deepcopy(kwargs) + mark_prompt_cache_breakpoints(kwargs, self.CC) + assert kwargs == before + + def test_does_not_mutate_caller_objects(self): + kwargs = self._agent_loop_kwargs() + tools, messages = kwargs["tools"], kwargs["messages"] + before_tools, before_messages = copy.deepcopy(tools), copy.deepcopy(messages) + mark_prompt_cache_breakpoints(kwargs, self.CC) + assert tools == before_tools + assert messages == before_messages + + +class TestPromptCachingRequests: + """End to end through google-adk's request builder with the SDK client mocked out.""" + + def _request(self): + return LlmRequest( + model="claude-sonnet-4-6", + contents=[ + types.Content(role="user", parts=[types.Part.from_text(text="list the pods")]), + types.Content( + role="model", + parts=[types.Part.from_function_call(name="list_pods", args={})], + ), + types.Content( + role="user", + parts=[types.Part.from_function_response(name="list_pods", response={"result": "pod-a"})], + ), + types.Content(role="user", parts=[types.Part.from_text(text="anything else?")]), + ], + config=types.GenerateContentConfig( + system_instruction="You are a Kubernetes assistant.", + tools=[ + types.Tool( + function_declarations=[ + types.FunctionDeclaration(name="get_weather", description="lookup weather"), + types.FunctionDeclaration(name="list_pods", description="list pods"), + ] + ) + ], + ), + ) + + def _message(self): + return Message( + id="msg_1", + type="message", + role="assistant", + model="claude-sonnet-4-6", + content=[TextBlock(type="text", text="pod-a")], + stop_reason="end_turn", + stop_sequence=None, + usage=Usage(input_tokens=4, output_tokens=5, cache_read_input_tokens=900, cache_creation_input_tokens=96), + ) + + async def _run(self, llm): + create = mock.AsyncMock(return_value=self._message()) + client = mock.MagicMock(spec=AsyncAnthropic) + client.messages = mock.MagicMock() + client.messages.create = create + with mock.patch("kagent.adk.models._anthropic.AsyncAnthropic", return_value=client): + responses = [r async for r in llm.generate_content_async(self._request())] + return create.call_args.kwargs, responses + + @pytest.mark.asyncio + async def test_disabled_sends_no_cache_control(self): + kwargs, _ = await self._run(KAgentAnthropicLlm(model="claude-sonnet-4-6")) + assert "cache_control" not in json.dumps(kwargs, default=str) + + @pytest.mark.asyncio + async def test_enabled_marks_tools_system_and_latest_turn(self): + cc = {"type": "ephemeral", "ttl": "1h"} + kwargs, _ = await self._run(KAgentAnthropicLlm(model="claude-sonnet-4-6", prompt_caching=True, cache_ttl="1h")) + + assert json.dumps(kwargs, default=str).count('"cache_control"') == 3 + assert kwargs["tools"][-1]["name"] == "list_pods" + assert kwargs["tools"][-1]["cache_control"] == cc + assert kwargs["system"] == [{"type": "text", "text": "You are a Kubernetes assistant.", "cache_control": cc}] + last = kwargs["messages"][-1] + assert last["role"] == "user" + assert last["content"][-1]["cache_control"] == cc + + @pytest.mark.asyncio + async def test_cache_usage_reaches_usage_metadata(self): + """Regression guard for the google-adk floor: cache reads must surface as cached tokens. + + google-adk folds Anthropic's cache_read/cache_creation counts into + prompt_token_count and reports the read portion as + cached_content_token_count; that is what the UI's usage view shows. + """ + _, responses = await self._run(KAgentAnthropicLlm(model="claude-sonnet-4-6", prompt_caching=True)) + usage = responses[-1].usage_metadata + assert usage.prompt_token_count == 1000 + assert usage.cached_content_token_count == 900 + assert usage.candidates_token_count == 5 diff --git a/python/packages/kagent-adk/tests/unittests/models/test_ollama.py b/python/packages/kagent-adk/tests/unittests/models/test_ollama.py index bb620a9fd7..12db9bcbe1 100644 --- a/python/packages/kagent-adk/tests/unittests/models/test_ollama.py +++ b/python/packages/kagent-adk/tests/unittests/models/test_ollama.py @@ -67,6 +67,23 @@ def test_client_forwards_headers(self): _ = llm._client assert mock_client.call_args.kwargs["headers"] == {"X-Custom": "val"} + def test_cloud_client_sends_the_gateway_placeholder(self): + # The egress gateway replaces only an Authorization the request + # carries, so the client must send the placeholder it is given. + llm = KAgentOllamaLlm(model="gpt-oss:120b-cloud") + environment = {"OLLAMA_API_BASE": "https://api.ollama.com", "OLLAMA_API_KEY": "kagent-credential-injected"} + with mock.patch.dict(os.environ, environment): + client = llm._client + assert str(client._client.base_url).startswith("https://api.ollama.com") + assert client._client.headers["authorization"] == "Bearer kagent-credential-injected" + + def test_local_client_sends_no_authorization(self): + llm = KAgentOllamaLlm(model="llama3.2:latest") + environment = {k: v for k, v in os.environ.items() if k not in ("OLLAMA_API_BASE", "OLLAMA_API_KEY")} + with mock.patch.dict(os.environ, environment, clear=True): + client = llm._client + assert "authorization" not in client._client.headers + def test_ollama_options_stored(self): opts = {"temperature": 0.8, "top_k": 40, "num_ctx": 4096} llm = KAgentOllamaLlm(model="llama3.2:latest", ollama_options=opts) @@ -120,6 +137,37 @@ async def test_generate_content_forwards_ollama_options(self): assert mock_client.chat.call_args.kwargs["options"] == opts + @pytest.mark.asyncio + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("think", [None, False, True]) + async def test_generate_content_forwards_think(self, think, stream): + llm = KAgentOllamaLlm(model="qwen3.5:2b", think=think) + + mock_response = mock.MagicMock() + mock_response.message.content = "hi" + mock_response.message.tool_calls = [] + mock_response.done = True + mock_response.done_reason = "stop" + mock_response.prompt_eval_count = None + mock_response.eval_count = None + + async def chunks(): + yield mock_response + + mock_client = mock.AsyncMock() + mock_client.chat = mock.AsyncMock(return_value=chunks() if stream else mock_response) + + request = mock.MagicMock() + request.model = "qwen3.5:2b" + request.contents = [] + request.config = None + + with mock.patch.object(type(llm), "_client", new_callable=lambda: property(lambda self: mock_client)): + responses = [r async for r in llm.generate_content_async(request, stream=stream)] + + assert mock_client.chat.call_args.kwargs["think"] is think + assert responses[-1].content.parts[0].text == "hi" + @pytest.mark.asyncio async def test_generate_content_streaming_accumulates_tool_calls_before_done_chunk(self): llm = KAgentOllamaLlm(model="llama3.2:latest") @@ -234,3 +282,12 @@ def test_create_llm_from_ollama_model_config(self): # Options are type-coerced by _convert_ollama_options before reaching create_ollama_llm assert result.ollama_options["temperature"] == 0.8 assert result.ollama_options["top_p"] == 0.9 + assert result.think is None + + def test_create_llm_from_ollama_model_config_think(self): + from kagent.adk.types import Ollama, _create_llm_from_model_config + + config = Ollama.model_validate({"type": "ollama", "model": "qwen3.5:2b", "think": False}) + result = _create_llm_from_model_config(config) + assert isinstance(result, KAgentOllamaLlm) + assert result.think is False diff --git a/renovate.json5 b/renovate.json5 new file mode 100644 index 0000000000..b1f8023060 --- /dev/null +++ b/renovate.json5 @@ -0,0 +1,10 @@ +// The Giant Swarm line of kagent-dev/kagent. Renovate is off here on +// purpose: this line is an upstream release plus the carried patches, so +// upstream owns the dependency graph. A bump opened here would diverge from +// the pinned upstream release and every re-pin would have to carry or revert +// it. Dependencies move when the line is re-pinned; see FORK.md. +{ + reviewers: ['teemow'], + $schema: 'https://docs.renovatebot.com/renovate-schema.json', + enabled: false, +} diff --git a/scripts/fork/git-push-as.sh b/scripts/fork/git-push-as.sh new file mode 100755 index 0000000000..7aa3185bbb --- /dev/null +++ b/scripts/fork/git-push-as.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Push as a given token: `scripts/fork/git-push-as.sh `. +# The workflows use it to choose who pushes: the workflow's own GITHUB_TOKEN +# (a push that triggers no workflow and may not touch workflow files — right +# for the ledger) or the App's token (a push that does trigger workflows and +# may update .github/workflows — the consumed branch, the sync candidates and +# the mirror). +set -euo pipefail +token=$1 +shift +auth=$(printf 'x-access-token:%s' "$token" | base64 -w0) +# Mask the encoded credential in the Actions log; outside Actions the line would +# print it, so it is only emitted where the runner interprets it. +[ -n "${GITHUB_ACTIONS:-}" ] && echo "::add-mask::${auth}" +exec git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth}" push "$@" diff --git a/scripts/fork/govulncheck.sh b/scripts/fork/govulncheck.sh new file mode 100755 index 0000000000..9838dd502b --- /dev/null +++ b/scripts/fork/govulncheck.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Scan the Go module graph with govulncheck and fail on any finding that is +# reached from kagent's code, unless .govulncheck-ignore carries a time-boxed +# ignore for it (FORK.md, "CI and security"). govulncheck has no ignore file of +# its own; the filter here gives the fork the same discipline as .nancy-ignore: +# +# GO-YYYY-NNNN until=YYYY-MM-DD # : why unresolvable, what to track +# +# An expired entry is a failure ("re-review"), never a silent pass. +set -euo pipefail +root=$(cd "$(dirname "$0")/../.." && pwd) +ignore_file="${root}/.govulncheck-ignore" +cd "${root}/go" + +go run golang.org/x/vuln/cmd/govulncheck@latest -format json ./... > govulncheck.json + +# Findings with a call trace are reached from the module's own code; the rest +# (imported or required only) is informational, as in govulncheck's own verdict. +mapfile -t found < <(jq -r 'select(.finding != null) | .finding | select(.trace[0].function != null) | .osv' govulncheck.json | sort -u) + +describe() { + jq -r --arg id "$1" 'select(.osv != null) | .osv | select(.id == $id) | "\(.id): \(.summary) — \(.affected[0].package.name) (fixed: \([.affected[0].ranges[0].events[]? | .fixed // empty] | first // "none"))"' govulncheck.json +} + +today=$(date -u +%F) +failed=0 +for id in "${found[@]}"; do + entry=$(grep -E "^${id}[[:space:]]" "$ignore_file" 2>/dev/null || true) + if [ -z "$entry" ]; then + echo "::error::${id} reached from kagent code and not ignored — $(describe "$id")" + failed=1 + continue + fi + until=$(sed -n 's/.*until=\([0-9-]*\).*/\1/p' <<< "$entry") + if [ -z "$until" ] || [[ "$until" < "$today" ]]; then + echo "::error::${id} ignore expired (${until:-no until=}) — re-review: $(describe "$id")" + failed=1 + continue + fi + echo "::notice::${id} ignored until ${until}: ${entry#*# }" +done +echo "govulncheck: ${#found[@]} finding(s) reached from kagent code, $(jq -r 'select(.finding != null) | .finding | select(.trace[0].function == null) | .osv' govulncheck.json | sort -u | wc -l) informational" +exit "$failed" diff --git a/scripts/fork/ledger-append.sh b/scripts/fork/ledger-append.sh new file mode 100755 index 0000000000..294a17b68d --- /dev/null +++ b/scripts/fork/ledger-append.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Append one table row to a file on the `ledger` branch (FORK.md, "Ledger"): +# scripts/fork/ledger-append.sh builds.md '| … |' +# The branch is an orphan the workflows own; it is created on first use. A +# concurrent append is retried on a rejected push. LEDGER_TOKEN (default +# GITHUB_TOKEN) pushes; no workflow reacts to the ledger branch. +set -euo pipefail +file=$1 +row=$2 +token=${LEDGER_TOKEN:-${GITHUB_TOKEN:?LEDGER_TOKEN or GITHUB_TOKEN required}} +remote=${LEDGER_REMOTE_URL:-$(git remote get-url origin)} +here=$(cd "$(dirname "$0")" && pwd) + +header() { + case $1 in + builds.md) cat <<'H' +# Builds + +Every dev build and release published by the CircleCI pipeline, newest last (see FORK.md). Image +digests are the multi-arch index digests — what a `Harness` pins. + +| published (UTC) | version | commit | upstream pin | controller | ui | golang-adk | claude-harness | chart kagent | chart kagent-crds | run | +|---|---|---|---|---|---|---|---|---|---|---| +H + ;; + re-pins.md) cat <<'H' +# Re-pins + +Every re-pin of the consumed branch onto upstream main, newest last (see FORK.md). A re-pin +that stopped (conflict, failed checks) is a pull request against the consumed branch, not a row. + +| date (UTC) | previous pin | new pin | consumed head | replayed | dropped | run | +|---|---|---|---|---|---|---| +H + ;; + *) printf '# %s\n\n' "$1" ;; + esac +} + +dir=$(mktemp -d) +trap 'rm -rf "$dir"' EXIT +for attempt in 1 2 3 4 5; do + rm -rf "$dir" && git init -q "$dir" + ( + cd "$dir" + git remote add origin "$remote" + if git fetch -q origin ledger 2>/dev/null; then + git checkout -q -B ledger FETCH_HEAD + else + git checkout -q --orphan ledger + printf '# Ledger\n\nMachine-written records of the kagent line (see FORK.md on the consumed branch): builds.md — every published build with its digests; re-pins.md — every re-pin onto upstream main.\n' > README.md + fi + [ -f "$file" ] || header "$file" > "$file" + printf '%s\n' "$row" >> "$file" + git add -A + git -c user.name="${LEDGER_GIT_NAME:-github-actions[bot]}" -c user.email="${LEDGER_GIT_EMAIL:-41898282+github-actions[bot]@users.noreply.github.com}" \ + commit -q -m "ledger: ${file}" -m "${row}" + "$here/git-push-as.sh" "$token" origin HEAD:refs/heads/ledger + ) && exit 0 + echo "ledger push rejected (attempt ${attempt}), retrying" >&2 + sleep $((attempt * 5)) +done +echo "could not append to the ledger" >&2 +exit 1 diff --git a/scripts/fork/mirror-quiet.sh b/scripts/fork/mirror-quiet.sh new file mode 100755 index 0000000000..2e4dc86e74 --- /dev/null +++ b/scripts/fork/mirror-quiet.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Cancel the workflow runs that upstream's own workflow files start when the +# mirror is pushed (FORK.md, "The mirror"): the mirrored refs carry upstream's +# ci.yaml / image-scan.yaml, which ask for runners the fork does not +# have and publish to registries it must not touch. +# +# scripts/fork/mirror-quiet.sh +# +# The refs file is what mirror.sh wrote: one branch or tag name per line. +set -euo pipefail +since=$1 +file=$2 +[ -s "$file" ] || { echo "nothing was mirrored, nothing to cancel"; exit 0; } +mapfile -t refs < "$file" +repo=${GITHUB_REPOSITORY:-$(gh repo view --json nameWithOwner -q .nameWithOwner)} +declare -A done=() +deadline=$((SECONDS + 90)) +while :; do + while read -r id branch name; do + [ -n "$id" ] && [ -z "${done[$id]:-}" ] || continue + for ref in "${refs[@]}"; do + [ "$branch" = "$ref" ] || continue + if gh run cancel "$id" --repo "$repo" > /dev/null 2>&1; then + done[$id]=1 + echo "cancelled run ${id}: ${name} on ${branch}" + fi + done + done < <(gh run list --repo "$repo" --event push --created ">=${since}" --limit 100 \ + --json databaseId,headBranch,workflowName,status --jq '.[] | select(.status != "completed") | "\(.databaseId) \(.headBranch) \(.workflowName)"') + [ "$SECONDS" -gt "$deadline" ] && break + sleep 15 +done +echo "${#done[@]} run(s) cancelled after mirroring ${#refs[@]} ref(s)" diff --git a/scripts/fork/mirror.sh b/scripts/fork/mirror.sh new file mode 100755 index 0000000000..e9b3e2c729 --- /dev/null +++ b/scripts/fork/mirror.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Mirror upstream into the fork, fast-forward only (FORK.md, "The mirror"): +# `main`, every `release/v*.x` branch and every tag upstream has. Nothing is +# ever moved back or deleted; a mirror ref that is not an ancestor of its +# upstream ref was edited and stops the run. +# +# scripts/fork/mirror.sh [--check] +# +# The token has to be one that may update workflow files — GITHUB_TOKEN may +# not, not even in a mirrored upstream commit — so the sync passes the App's +# token; its pushes start upstream's workflows at the mirrored refs, which +# mirror-quiet.sh cancels. The pushed branch and tag names are written to +# $MIRROR_PUSHED (default mirror-pushed.txt). --check only prints them. +set -euo pipefail +token=$1 +mode=${2:-} +here=$(cd "$(dirname "$0")" && pwd) + +git fetch -q upstream 'refs/heads/main:refs/remotes/upstream/main' \ + 'refs/heads/release/*:refs/remotes/upstream/release/*' '+refs/tags/*:refs/tags/*' +git fetch -q origin 'refs/heads/main:refs/remotes/origin/main' \ + 'refs/heads/release/*:refs/remotes/origin/release/*' 2>/dev/null || true + +pushed_file=${MIRROR_PUSHED:-mirror-pushed.txt} +: > "$pushed_file" +refspecs=() +names=() +for branch in main $(git for-each-ref --format='%(refname:strip=3)' refs/remotes/upstream/release/); do + up=$(git rev-parse "refs/remotes/upstream/${branch}") + if cur=$(git rev-parse -q --verify "refs/remotes/origin/${branch}"); then + [ "$cur" = "$up" ] && continue + if ! git merge-base --is-ancestor "$cur" "$up"; then + echo "::error::origin/${branch} is not an ancestor of upstream/${branch} — the mirror was edited; restore it by hand (FORK.md, 'The mirror')." + exit 1 + fi + fi + refspecs+=("${up}:refs/heads/${branch}") + names+=("$branch") + echo "branch ${branch}: ${cur:-(new)} -> ${up}" +done + +# Tags: every upstream tag the fork lacks is copied; one the fork already holds +# at the same object is current. One the fork holds at ANOTHER object is a +# collision — the line's own release tags share upstream's `vX.Y.Z` shape and +# live in a major above upstream's (FORK.md, "Release scheme") — and stops the +# run, naming the tag, instead of one side silently winning. +tags_of() { git ls-remote --tags "$1" | sed -n 's#^\([0-9a-f]*\)[[:space:]]*refs/tags/\([^^]*\)$#\2 \1#p' | sort; } +declare -A fork_tag +while read -r tag sha; do + [ -n "$tag" ] && fork_tag[$tag]=$sha +done < <(tags_of origin) +while read -r tag sha; do + [ -n "$tag" ] || continue + if [ -n "${fork_tag[$tag]:-}" ]; then + if [ "${fork_tag[$tag]}" != "$sha" ]; then + echo "::error::tag ${tag} exists in the fork at ${fork_tag[$tag]} and upstream at ${sha} — a release tag of the line collides with upstream's; move the line to its next major first (FORK.md, 'Release scheme')." + exit 1 + fi + continue + fi + refspecs+=("refs/tags/${tag}:refs/tags/${tag}") + names+=("$tag") + echo "tag ${tag}: new" +done < <(tags_of upstream) + +if [ ${#refspecs[@]} -eq 0 ]; then + echo "mirror is current" + exit 0 +fi +[ "$mode" = --check ] && exit 0 +"${here}/git-push-as.sh" "$token" origin "${refspecs[@]}" +printf '%s\n' "${names[@]}" > "$pushed_file" diff --git a/scripts/fork/repin.sh b/scripts/fork/repin.sh new file mode 100755 index 0000000000..3e6bea3c0d --- /dev/null +++ b/scripts/fork/repin.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# The git half of the re-pin (FORK.md, "Re-pin"): rebase the consumed branch's +# carried commits onto the upstream head into a candidate branch and report. +# Pushing, testing and landing the candidate belong to the sync workflow — or +# to the operator running the same steps by hand. +# +# CONSUMED=giantswarm UPSTREAM_REMOTE=upstream UPSTREAM_REF=main \ +# CANDIDATE=sync/upstream-- scripts/fork/repin.sh +# +# Exit 0: the candidate branch exists locally (status=rebased) or nothing is to +# do (status=up-to-date). Exit 2: a carried commit conflicts (status=conflict); +# the rebase is aborted and the working tree is back on the consumed head. +# The report is written to $REPORT (markdown) and $REPORT_ENV (key=value). +set -euo pipefail + +CONSUMED=${CONSUMED:-giantswarm} +UPSTREAM_REMOTE=${UPSTREAM_REMOTE:-upstream} +UPSTREAM_REF=${UPSTREAM_REF:-main} +REPORT=${REPORT:-repin-report.md} +REPORT_ENV=${REPORT_ENV:-repin-report.env} + +git fetch -q origin "$CONSUMED" +git fetch -q "$UPSTREAM_REMOTE" "$UPSTREAM_REF" +head=$(git rev-parse "origin/$CONSUMED") +target=$(git rev-parse "$UPSTREAM_REMOTE/$UPSTREAM_REF") +pin=$(git merge-base "$head" "$target") +CANDIDATE=${CANDIDATE:-sync/upstream-$(date -u +%Y%m%d)-${target:0:7}} + +short() { git rev-parse --short=7 "$1"; } +subject() { git show -s --format=%s "$1"; } +cdate() { git show -s --format=%cI "$1"; } +item() { printf -- '- `%s` %s\n' "$(short "$1")" "$(subject "$1")"; } +patch_id() { git diff-tree -p "$1" | git patch-id --stable | awk '{print $1}'; } +env_out() { printf '%s=%s\n' "$1" "$2" >> "$REPORT_ENV"; } + +mapfile -t carried < <(git rev-list --reverse --no-merges "$pin..$head") + +: > "$REPORT_ENV" +env_out consumed "$CONSUMED" +env_out old_head "$head" +env_out pin "$pin" +env_out target "$target" +env_out candidate "$CANDIDATE" + +{ + printf '### Re-pin `%s` onto `%s/%s`\n\n' "$CONSUMED" "$UPSTREAM_REMOTE" "$UPSTREAM_REF" + printf '| | commit | date |\n|---|---|---|\n' + printf '| previous pin | `%s` | %s |\n' "$(short "$pin")" "$(cdate "$pin")" + printf '| new pin | `%s` | %s |\n' "$(short "$target")" "$(cdate "$target")" + printf '| consumed head before | `%s` | %s |\n\n' "$(short "$head")" "$(cdate "$head")" + printf '%d upstream commits since the previous pin.\n\n' "$(git rev-list --count "$pin..$target")" + printf '#### Carried commits (%d, in order)\n\n' "${#carried[@]}" + for c in "${carried[@]}"; do item "$c"; done + printf '\n' +} > "$REPORT" + +if [ "$pin" = "$target" ]; then + printf 'Nothing to do: the consumed branch is already based on the upstream head.\n' >> "$REPORT" + env_out status up-to-date + exit 0 +fi + +# Upstream commits since the pin by patch id: names the upstream commit a +# dropped carried commit was merged as. +declare -A upstream_by_patch=() +while read -r sha; do + upstream_by_patch[$(patch_id "$sha")]=$sha +done < <(git rev-list --no-merges "$pin..$target") + +git checkout -q -B "$CANDIDATE" "$head" +if ! git rebase --quiet --no-reapply-cherry-picks --empty=drop --onto "$target" "$pin" 2> rebase.err; then + conflict=$(git rev-parse REBASE_HEAD) + applied=$(git rev-parse HEAD) + mapfile -t files < <(git diff --name-only --diff-filter=U) + git rebase --abort + git checkout -q --detach "$head" + { + printf '#### Conflict\n\n' + printf 'Carried commit `%s` %s does not apply onto `%s`.\n\n' "$(short "$conflict")" "$(subject "$conflict")" "$(short "$target")" + printf 'Conflicting files:\n\n' + for f in "${files[@]}"; do printf -- '- `%s`\n' "$f"; done + printf '\nCommits replayed before it: %d (`%s`).\n\n' "$(git rev-list --count "$target..$applied")" "$(short "$applied")" + printf 'Resolve by hand (FORK.md, "Manual re-pin"): rebase the consumed branch onto `%s`, fix the conflict in that commit, run the checks on a `sync/**` branch, force-push.\n' "$(short "$target")" + } >> "$REPORT" + env_out status conflict + env_out conflict "$conflict" + env_out conflict_subject "$(subject "$conflict")" + exit 2 +fi + +new_head=$(git rev-parse HEAD) +mapfile -t replayed < <(git rev-list --reverse --no-merges "$target..$new_head") +dropped=() +{ + printf '#### Replayed (%d)\n\n' "${#replayed[@]}" + for c in "${replayed[@]}"; do item "$c"; done + printf '\n' +} >> "$REPORT" + +# A carried commit with no replayed commit of the same subject was dropped: +# merged upstream (same patch id in the upstream range) or superseded. +declare -A replayed_subjects=() +for c in "${replayed[@]}"; do replayed_subjects[$(subject "$c")]=$c; done +for c in "${carried[@]}"; do + s=$(subject "$c") + [ -n "${replayed_subjects[$s]:-}" ] && continue + dropped+=("$c") +done +if [ ${#dropped[@]} -gt 0 ]; then + printf '#### Dropped (%d)\n\n' "${#dropped[@]}" >> "$REPORT" + for c in "${dropped[@]}"; do + up=${upstream_by_patch[$(patch_id "$c")]:-} + if [ -n "$up" ]; then + printf -- '- `%s` %s — merged upstream as `%s`\n' "$(short "$c")" "$(subject "$c")" "$(short "$up")" + else + printf -- '- `%s` %s — empty after the rebase (superseded upstream)\n' "$(short "$c")" "$(subject "$c")" + fi + done >> "$REPORT" + printf '\nRemove the dropped rows from FORK.md and giantswarm/giantswarm#37742 in a follow-up pull request.\n\n' >> "$REPORT" +fi + +env_out status rebased +env_out new_head "$new_head" +env_out replayed_count "${#replayed[@]}" +env_out dropped_count "${#dropped[@]}" +env_out dropped "$(IFS=,; echo "${dropped[*]:-}")" diff --git a/telemetry/deps.sh b/telemetry/deps.sh new file mode 100755 index 0000000000..69c22c1f0e --- /dev/null +++ b/telemetry/deps.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash + +# Puts the upstream sources of the telemetry contract at the refs pinned in +# telemetry/versions.env into telemetry/deps, where the registry manifest and +# semconv-check read them from disk: the registries the manifest depends on and +# the shared Weaver policies. A registry a checkout depends on by git URL is +# pointed at the checkout of that same pin, so Weaver clones nothing; one pinned +# to another ref is refused, since the contract and its dependencies must agree +# on the core conventions. A checkout at the pinned ref is reused without +# touching the network; only a moved pin fetches again. + +set -o errexit -o nounset -o pipefail + +ROOT="$(git rev-parse --show-toplevel)" +cd "${ROOT}" + +# shellcheck source=telemetry/versions.env +source telemetry/versions.env + +DEPS=telemetry/deps +SOURCES=("${SEMCONV_CORE_REGISTRY}" "${SEMCONV_GENAI_REGISTRY}" "${WEAVER_PACKAGES}") + +# A dependency line of a registry manifest: `registry_path: @[]`. +GIT_DEPENDENCY='^([[:space:]]*registry_path:[[:space:]]*)(https://[^[:space:]]+\.git@[^[[:space:]]+)\[([^]]+)\][[:space:]]*$' + +# checkout_of @ names the directory a pinned source is put in. +checkout_of() { + local url="${1%@*}" + echo "${DEPS}/$(basename "${url}" .git)" +} + +# pinned @ succeeds when that source is one of the pins. +pinned() { + local source + for source in "${SOURCES[@]}"; do + [[ "${source}" == "$1" ]] && return 0 + done + return 1 +} + +# localise points each git dependency of a fetched registry's +# manifest at the checkout of the same pin. +localise() { + local manifest="$1" line + local out="${manifest}.localised" + while IFS= read -r line || [[ -n "${line}" ]]; do + if [[ "${line}" =~ ${GIT_DEPENDENCY} ]]; then + if ! pinned "${BASH_REMATCH[2]}"; then + echo "FAIL: ${manifest} depends on ${BASH_REMATCH[2]}, which telemetry/versions.env does not pin: align the pins." >&2 + exit 1 + fi + line="${BASH_REMATCH[1]}$(checkout_of "${BASH_REMATCH[2]}")/${BASH_REMATCH[3]}" + fi + printf '%s\n' "${line}" >>"${out}" + done <"${manifest}" + mv "${out}" "${manifest}" +} + +# materialise @ puts that ref of the repository into its checkout, +# unless it is there already. +materialise() { + local source="$1" url="${1%@*}" ref="${1##*@}" dir name manifest + dir="$(checkout_of "${source}")" + name="$(basename "${dir}")" + + if [[ -f "${dir}/.pin" && "$(<"${dir}/.pin")" == "${source}" ]]; then + echo "${name}: ${ref} present in ${dir}, no fetch" + return + fi + + echo "${name}: fetching ${ref} from ${url} into ${dir}" + rm -rf "${dir}" + git init --quiet "${dir}" + git -C "${dir}" fetch --quiet --depth 1 "${url}" "${ref}" + git -C "${dir}" checkout --quiet FETCH_HEAD + rm -rf "${dir}/.git" + while IFS= read -r manifest; do + localise "${manifest}" + done < <(find "${dir}" -name registry_manifest.yaml -o -name manifest.yaml) + echo "${source}" >"${dir}/.pin" +} + +for source in "${SOURCES[@]}"; do + materialise "${source}" +done diff --git a/telemetry/registry/manifest.yaml b/telemetry/registry/manifest.yaml index 185c047d03..00b60fd287 100644 --- a/telemetry/registry/manifest.yaml +++ b/telemetry/registry/manifest.yaml @@ -3,9 +3,10 @@ description: The telemetry contract of kagent runtimes. # An identifier for this registry version. Nothing is published there. schema_url: https://kagent.dev/schemas/telemetry/0.1.0 stability: development +# The registries are read from the checkouts `make semconv-deps` puts in +# telemetry/deps at the refs pinned in telemetry/versions.env. dependencies: - schema_url: https://opentelemetry.io/schemas/1.44.0 - registry_path: https://github.com/open-telemetry/semantic-conventions.git@v1.44.0[model] - # semantic-conventions-genai has no releases yet, so it is pinned by commit. + registry_path: telemetry/deps/semantic-conventions/model - schema_url: https://opentelemetry.io/schemas/gen-ai-dev/1.42.0-dev - registry_path: https://github.com/open-telemetry/semantic-conventions-genai.git@8ffdf568e1b4391a99adb081db16e8102e36918e[model] + registry_path: telemetry/deps/semantic-conventions-genai/model diff --git a/telemetry/versions.env b/telemetry/versions.env index d7981e37fd..0fc1a245f3 100644 --- a/telemetry/versions.env +++ b/telemetry/versions.env @@ -1,3 +1,11 @@ WEAVER_VERSION=v0.26.1 WEAVER_IMAGE=docker.io/otel/weaver:v0.26.1@sha256:9094862c0ab261bdbcb079bb981f9a573b3659b130a6d2ab8616eca6ba37aaec +# The upstream sources of the contract, each a git URL at a pinned ref, which +# `make semconv-deps` puts into telemetry/deps for the registry manifest and +# semconv-check to read from disk. +# The registries telemetry/registry/manifest.yaml depends on. +SEMCONV_CORE_REGISTRY=https://github.com/open-telemetry/semantic-conventions.git@v1.44.0 +# semantic-conventions-genai has no releases yet, so it is pinned by commit. +SEMCONV_GENAI_REGISTRY=https://github.com/open-telemetry/semantic-conventions-genai.git@8ffdf568e1b4391a99adb081db16e8102e36918e +# The shared Weaver policies semconv-check runs beside kagent's own. WEAVER_PACKAGES=https://github.com/open-telemetry/opentelemetry-weaver-packages.git@d84341cf20a1fef1a833ef44d318c41a770e6e64 diff --git a/ui/playwright/DEFERRED.md b/ui/playwright/DEFERRED.md index be664c4f12..53e148bc42 100644 --- a/ui/playwright/DEFERRED.md +++ b/ui/playwright/DEFERRED.md @@ -332,3 +332,12 @@ navigation had just reset. What actually removes the coupling is a second `test` same file — one recording for the lifecycle, one for the states, neither able to abort the other. That costs `conventions.test.ts` its "one spec, one test" rule, so it is an amendment to the convention rather than a reshuffle, and belongs in its own change. + +## An Agent edit keeps the egress the form does not show + +`AgentFormPage` does not author `spec.egress`, and the update sends the whole spec, so +the save carries the Agent's existing `egress` over. Nothing asserts it: no spec opens +an Agent that declares egress, saves it, and reads the request back. The case it guards +is silent — a save that dropped the line would widen nothing and narrow the Agent's +egress to what its revision compiles, with no message on the page. It belongs in the +agent lifecycle spec, with a fixture Agent whose `egress` matches the CRD shape. diff --git a/ui/playwright/conventions.test.ts b/ui/playwright/conventions.test.ts index 29252746d8..282a78fbec 100644 --- a/ui/playwright/conventions.test.ts +++ b/ui/playwright/conventions.test.ts @@ -118,21 +118,36 @@ describe("playwright layout", () => { * came to hold twelve steps on the thirty-second default and time out in CI at * step eight. A resource folder is not what makes a test long; the number of * steps sharing one budget is. + * + * Counted per test, not per file. Counting per file let a journey through as long + * as the file held a second test: the transcript journey in `chat/transcript.spec.ts` + * sat at fifteen steps on the default beside five single claims, and timed out on + * every attempt of a loaded Firefox run. A test is the text from its `test(` to the + * next one; it carries the budget if it sets it itself, or if it is its file's only + * test and the file configures it. */ const offenders: string[] = []; for (const dir of [TESTS, ...folders().map((name) => join(TESTS, name))]) { for (const spec of specsIn(dir)) { const source = readFileSync(join(dir, spec), "utf8"); - const tests = [...source.matchAll(/^test(\.skip)?\(/gm)].length; - const steps = [...source.matchAll(/test\.step\(/g)].length; - if (tests === 1 && steps >= 10 && !source.includes("LIFECYCLE_TIMEOUT")) { - offenders.push(`${spec} (${steps} steps)`); - } + const starts = [...source.matchAll(/^test(\.skip)?\(/gm)].map((match) => match.index); + const configured = source.includes("test.describe.configure({ timeout: LIFECYCLE_TIMEOUT })"); + starts.forEach((start, index) => { + const body = source.slice(start, starts[index + 1]); + const steps = [...body.matchAll(/test\.step\(/g)].length; + const budgeted = + body.includes("test.setTimeout(LIFECYCLE_TIMEOUT)") || + (starts.length === 1 && configured); + if (steps >= 10 && !budgeted) { + const line = source.slice(0, start).split("\n").length; + offenders.push(`${spec}:${line} (${steps} steps)`); + } + }); } } expect( offenders, - "a single test of ten or more steps needs `test.describe.configure({ timeout: LIFECYCLE_TIMEOUT })`", + "a test of ten or more steps needs the lifecycle budget: `test.describe.configure({ timeout: LIFECYCLE_TIMEOUT })` when it is its file's only test, `test.setTimeout(LIFECYCLE_TIMEOUT)` in its body otherwise", ).toEqual([]); }); diff --git a/ui/playwright/helpers/chat.ts b/ui/playwright/helpers/chat.ts index e20d4bc43d..ca515c2b30 100644 --- a/ui/playwright/helpers/chat.ts +++ b/ui/playwright/helpers/chat.ts @@ -79,6 +79,28 @@ export async function expectTurnFinished(page: Page, turn: SentTurn): Promise { + // The transcript replaces its loading skeleton in the commit that renders the history, + // pending diagrams included, so a count of none means drawn rather than not yet + // mounted. Only a message sent before the history arrived opens it sooner, which is + // why this is for a conversation whose history is already on screen. + await expect(page.getByTestId("chat-transcript")).toBeVisible(); + await expect(page.getByTestId("chat-mermaid-pending")).toHaveCount(0); +} + /** Sends a message and waits out the turn it starts, asserting nothing in between. */ export async function sendAndAwaitTurn(page: Page, text: string): Promise { await expectTurnFinished(page, await sendMessage(page, text)); diff --git a/ui/playwright/helpers/resource.ts b/ui/playwright/helpers/resource.ts index 35f3e2f261..5210c73ea4 100644 --- a/ui/playwright/helpers/resource.ts +++ b/ui/playwright/helpers/resource.ts @@ -25,9 +25,11 @@ import { withScenario } from "./app"; * the default says nothing about its health there — only about its length. * * **By shape, not by folder.** Any one test of ten or more steps needs it, wherever it - * lives — `conventions.test.ts` checks that. Applying it to the resource folders alone - * left `chat/questions.spec.ts` with twelve steps on the default, which is what timed - * out in CI at step eight while passing twenty times in isolation. + * lives — `conventions.test.ts` checks that, test by test. Applying it to the resource + * folders alone left `chat/questions.spec.ts` with twelve steps on the default, which + * is what timed out in CI at step eight while passing twenty times in isolation. A + * journey that shares its file with single tests sets it in its own body with + * `test.setTimeout(LIFECYCLE_TIMEOUT)`, so the single tests keep the default. * * Sixty rather than ninety, and the difference is the point: loose enough for a * contended run, tight enough that a journey which doubles in cost is still a @@ -101,6 +103,14 @@ export async function pressUntil( // what says so: at thirty the test expired first and reported its own timeout. timeout = 15_000, ): Promise { + /* + * Arrived before the first attempt, as `pressOnce` waits. The guard in the loop is + * for the attempts after a press that worked; on the first it found an option still + * being drawn into its dropdown, pressed nothing, and spent `settled`'s whole five + * seconds before trying again — measured on the schedules lifecycle, where that was + * the difference between its budget and a timeout. + */ + await expect(button).toBeVisible(); await expect(async () => { // Bounded, because `toPass` checks its deadline between attempts and no // `actionTimeout` is configured: a click blocked by an overlay would otherwise hang diff --git a/ui/playwright/tests/agent-templates/agent-templates.spec.ts b/ui/playwright/tests/agent-templates/agent-templates.spec.ts index c820ec242e..74eb1fadd9 100644 --- a/ui/playwright/tests/agent-templates/agent-templates.spec.ts +++ b/ui/playwright/tests/agent-templates/agent-templates.spec.ts @@ -249,11 +249,22 @@ test("agent templates: a template is created, read, edited and deleted", async ( }); await test.step("13. deletion also works for an unused template", async () => { + // Narrowed before the row is pressed, as step 2 waits for it: the search is written + // to the address and the list re-renders to its one row after it. Pressed while it + // did, the link took the focus and nothing navigated. await page.getByTestId("templates-filters-search").fill("note-taker"); + await expect(dataRows(page)).toHaveCount(1); await page.getByTestId("template-link-note-taker").click(); await page.waitForURL(/\/agent-templates\/kagent\/note-taker/); - await page.getByTestId("delete-note-taker").click(); + // The page's own Delete, told apart by its label as in step 11. The address changes + // before the page does — the router renders the new route in a transition — and + // until then the list is still on screen, whose row carries an unlabelled delete + // under the same test id. Pressed there, it opened the row's confirmation, which + // left with the list. + const deleteButton = page.getByTestId("delete-note-taker"); + await expect(deleteButton).toContainText("Delete template"); + await deleteButton.click(); await expect(confirmation(page)).toContainText( "last successful revisions are retained", ); @@ -268,6 +279,7 @@ test("agent templates: a template is created, read, edited and deleted", async ( await page.getByRole("button", { name: "Back to templates" }).click(); await page.waitForURL(/\/agents\?.*tab=templates/); await page.getByTestId("templates-filters-search").fill("k8s-agent-7f3a91c"); + await expect(dataRows(page)).toHaveCount(1); await page.getByTestId("template-link-k8s-agent-7f3a91c").click(); await page.waitForURL(/\/agent-templates\/kagent\/k8s-agent-7f3a91c/); diff --git a/ui/playwright/tests/chat/checkpoints.spec.ts b/ui/playwright/tests/chat/checkpoints.spec.ts index c6815bb633..ac61f61f17 100644 --- a/ui/playwright/tests/chat/checkpoints.spec.ts +++ b/ui/playwright/tests/chat/checkpoints.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from "../../fixtures/test"; import { agentChat, instances } from "../../helpers/app"; import { pressOnce } from "../../helpers/resource"; -import { sendAndAwaitTurn } from "../../helpers/chat"; +import { expectTranscriptSettled, sendAndAwaitTurn } from "../../helpers/chat"; /** The menu that is actually on screen: antd leaves a closed dropdown mounted. */ const openMenu = (page: import("@playwright/test").Page) => @@ -18,6 +18,8 @@ async function openSnapshot( page: import("@playwright/test").Page, divider: import("@playwright/test").Locator, ) { + // The mark sits under the seeded diagram, the first thing it moves when it lands. + await expectTranscriptSettled(page); await divider.locator('[data-testid^="chat-checkpoint-open-"]').click(); await expect(page.getByTestId("snapshot-details-body")).toBeVisible(); } @@ -73,6 +75,8 @@ test("chat: the mark names itself, carries its controls, and opens its record", }) => { await page.goto(agentChat(instances.ready)); await expect(dividers(page)).toHaveCount(1, { timeout: 30_000 }); + // Its controls are pressed below, and the seeded diagram above them lands late. + await expectTranscriptSettled(page); const line = dividers(page).first(); const id = ((await line.getAttribute("data-testid")) ?? "").replace( @@ -118,7 +122,8 @@ test("chat: the mark names itself, carries its controls, and opens its record", await expect(dividers(page)).toHaveCount(1); await line.getByTestId(`chat-checkpoint-delete-${id}`).click(); - await page.getByTestId(`chat-checkpoint-delete-confirm-${id}`).click(); + // The same popconfirm arriving again, so its Delete is pressed as its Cancel was. + await pressOnce(page.getByTestId(`chat-checkpoint-delete-confirm-${id}`)); await expect(dividers(page)).toHaveCount(0); }); }); @@ -210,7 +215,10 @@ test("chat: a snapshot is deleted from its record, and stays deleted", async ({ await test.step("asks before deleting, and cancelling leaves both", async () => { await openSnapshot(page, page.getByTestId(`chat-checkpoint-mark-${id}`)); - await page.getByTestId("snapshot-details-delete").click(); + // The record is still zooming in: `openSnapshot` returns once its body shows, and + // a raw click computed then landed beside the button on a loaded Firefox run, with + // the record open and settled in the screenshot and no confirmation asked for. + await pressOnce(page.getByTestId("snapshot-details-delete")); await expect(page.getByText("Delete this snapshot?")).toBeVisible(); // The modal animates in like the popconfirm above: press once it has stopped arriving. await pressOnce(page.getByTestId("snapshot-details-delete-cancel")); @@ -220,7 +228,7 @@ test("chat: a snapshot is deleted from its record, and stays deleted", async ({ await test.step("confirming takes that line, closes the record and leaves the other", async () => { await page.getByTestId("snapshot-details-delete").click(); - await page.getByTestId("snapshot-details-delete-confirm").click(); + await pressOnce(page.getByTestId("snapshot-details-delete-confirm")); await expect(page.getByTestId(`chat-checkpoint-mark-${id}`)).toHaveCount(0); await expect(page.getByTestId("snapshot-details-body")).toHaveCount(0); await expect(dividers(page)).toHaveCount(1); diff --git a/ui/playwright/tests/chat/questions.spec.ts b/ui/playwright/tests/chat/questions.spec.ts index 42ec56f0f9..97bc97497a 100644 --- a/ui/playwright/tests/chat/questions.spec.ts +++ b/ui/playwright/tests/chat/questions.spec.ts @@ -19,6 +19,18 @@ import { LIFECYCLE_TIMEOUT } from "../../helpers/resource"; * answered, discarded, or it blocks the conversation. Each step returns the * conversation to a state the next can start from, which is what makes them steps. * + * The one-field question is a second journey rather than a ninth step. It is a + * different shape of question with its own way in — a fresh conversation, a fresh + * park — and what it claims, that the caret is already in the field and Enter sends, + * rests on nothing the choices journey did. Sharing one budget with it made the two + * the suite's longest test: twelve steps and five page loads of a dev server, which + * on a loaded Firefox run is what the lifecycle budget then measured, rather than + * either journey. A page load is the expensive move here — the app boots and the + * mock worker installs, ten seconds and more on a busy runner — so the journey makes + * three, each one a claim: the way in, the reload that proves the park is the + * task's, and the fresh address that proves an ordinary turn runs after the last + * question has gone. + * * The fixture is the controller's behaviour, copied — the parked turn survives a * reload, a send while it stands is refused in the controller's own words, and * cancelling its task frees the conversation. @@ -27,14 +39,13 @@ import { LIFECYCLE_TIMEOUT } from "../../helpers/resource"; const AGENT_CHAT = agentChat(instances.ready); /* - * A journey in one test, so it gets the lifecycle budget rather than the default. - * Sized for the number of steps, not for the folder it sits in — see `LIFECYCLE_TIMEOUT`. + * Journeys, so they get the lifecycle budget rather than the default. Sized for the + * number of steps, not for the folder they sit in — see `LIFECYCLE_TIMEOUT`: the + * choices journey is eight steps around three page loads, each of them an app boot. */ test.describe.configure({ timeout: LIFECYCLE_TIMEOUT }); -test("chat: a question is asked, answered, given up, and answered with the keyboard", async ({ - page, -}) => { +test("chat: a question is asked, answered, and given up", async ({ page }) => { await test.step("1. a turn ends by asking rather than by finishing", async () => { await page.goto(`${AGENT_CHAT}?chat=asks`); await page.getByTestId("chat-input").fill("What should I order?"); @@ -114,24 +125,21 @@ test("chat: a question is asked, answered, given up, and answered with the keybo await expect(page.getByTestId("chat-awaiting-reply")).toHaveCount(0); }); - await test.step("6. and the conversation takes ordinary messages again", async () => { - // The proof that the turn really closed is a *new* turn running, not an alert - // that disappeared: a task still parked would refuse this. - await page.goto(`${AGENT_CHAT}?chat=ok`); - await sendAndAwaitTurn(page, "How many pods are running?"); - await expect(page.getByTestId("chat-turn-error")).toHaveCount(0); - }); - - await test.step("7. a second question can be given up rather than answered", async () => { - // Parked again, because the previous steps answered the first one: discarding is - // the alternative to answering, so it needs its own question to discard. - await page.goto(`${AGENT_CHAT}?chat=asks`); + await test.step("6. and the conversation takes a new turn again", async () => { + // The proof that the turn really closed is a *new* turn accepted, not an alert + // that disappeared: a task still parked would refuse this one at the door, in + // the controller's words. On the same page, deliberately — the address still + // says `asks`, so this turn parks as well, which is what the next step needs: + // discarding is the alternative to answering, and it needs its own question. await page.getByTestId("chat-input").fill("What should I order?"); await page.getByTestId("chat-send").click(); + await expect(page.getByTestId("chat-turn-error")).toHaveCount(0); await expect(page.getByTestId("chat-awaiting-reply")).toBeVisible({ timeout: 20_000, }); + }); + await test.step("7. a question can be given up rather than answered", async () => { await page.getByTestId("chat-dismiss-question").click(); await page.getByTestId("chat-dismiss-question").click(); await expect(page.getByTestId("chat-awaiting-reply")).toHaveCount(0); @@ -140,28 +148,32 @@ test("chat: a question is asked, answered, given up, and answered with the keybo // and this step is about the question being gone rather than about the state. }); - await test.step("8. and an unrelated message is accepted again", async () => { + await test.step("8. and an ordinary message is accepted again", async () => { await page.goto(`${AGENT_CHAT}?chat=ok`); await sendAndAwaitTurn(page, "How many pods are running?"); await expect(page.getByTestId("chat-turn-error")).toHaveCount(0); }); +}); - await test.step("9. a question that is one prose field takes the caret", async () => { +test("chat: a question that is one prose field takes the caret, and Enter answers it", async ({ + page, +}) => { + await test.step("1. a question with no choices is offered as a field", async () => { await page.goto(`${AGENT_CHAT}?chat=asks-text`); await page.getByTestId("chat-input").fill("Order me a pizza"); await page.getByTestId("chat-send").click(); await expect(page.getByTestId("chat-awaiting-reply")).toBeVisible({ timeout: 20_000 }); - // One question, and no choices — the shape the rest of this test is about. + // One question, and no choices — the shape the rest of this journey is about. await expect(page.getByTestId("chat-question")).toHaveCount(1); await expect(page.getByTestId("chat-choices-0")).toHaveCount(0); }); - await test.step("10. the field has the caret already", async () => { + await test.step("2. the field has the caret already", async () => { await expect(page.getByTestId("chat-answer-text-0")).toBeFocused(); }); - await test.step("11. Enter sends it, without reaching for the button", async () => { + await test.step("3. Enter sends it, without reaching for the button", async () => { // Typed with the keyboard rather than filled, because what is under test is that // the caret was already in the right place — `fill` would put it there itself and // pass whether or not step 2 held. @@ -178,7 +190,7 @@ test("chat: a question is asked, answered, given up, and answered with the keybo await expect(page.getByTestId("chat-awaiting-reply")).toHaveCount(0); }); - await test.step("12. and the caret comes back to the composer", async () => { + await test.step("4. and the caret comes back to the composer", async () => { // The field it was in is gone with the question, so a caret left there is a caret // nowhere — and the next thing typed is an ordinary message. await expect(page.getByTestId("chat-input")).toBeFocused(); diff --git a/ui/playwright/tests/chat/transcript.spec.ts b/ui/playwright/tests/chat/transcript.spec.ts index 564091e1bc..1bc71a324e 100644 --- a/ui/playwright/tests/chat/transcript.spec.ts +++ b/ui/playwright/tests/chat/transcript.spec.ts @@ -14,6 +14,7 @@ import { sendMessage, type SentTurn, } from "../../helpers/chat"; +import { LIFECYCLE_TIMEOUT } from "../../helpers/resource"; /** * Chat — the conversation journey. @@ -73,6 +74,17 @@ const replyTo = (question: string) => ].join("\n"); test("chat: history, sending, streaming, and tool rendering", async ({ page }) => { + /* + * A journey, so it gets the lifecycle budget rather than the default — see + * `LIFECYCLE_TIMEOUT`. Fifteen steps around two page loads, three streamed turns and + * a switch to another conversation: about fifteen seconds idle, thirty-one to + * thirty-four on a loaded Firefox run, where every step waits on the page and the + * default measured their sum. Set here rather than for the file, because the tests + * after this one are single claims, and for those the thirty seconds still mean + * something. + */ + test.setTimeout(LIFECYCLE_TIMEOUT); + const messages = page.getByTestId("chat-message"); // Scoped by role: the agent quotes the question back in its reply, so a plain // text filter matches the answer as well as the question that prompted it. @@ -395,6 +407,11 @@ test("chat: the composer stays put when switching conversations", async ({ page await rail.locator(`a[data-testid="chat-session-${SIBLING_OF_READY}"]`).click(); await page.waitForURL(new RegExp(`/agents/${SIBLING_OF_READY}/chat$`)); await expect(page.getByTestId("chat-composer")).toBeVisible(); + // After this conversation has arrived too, for the same reason as above: while + // its transcript loads the composer sits under the loading skeleton, and that + // state is not what the assertion is about. The sibling has no messages, so + // loaded means the empty state is showing. + await expect(page.getByTestId("chat-empty")).toBeVisible({ timeout: 30_000 }); // Settled, not mid-transition — the assertion is about where it ends up. await page.waitForTimeout(1000); diff --git a/ui/playwright/tests/chat/turns.spec.ts b/ui/playwright/tests/chat/turns.spec.ts index fca8a40d6f..784cabb832 100644 --- a/ui/playwright/tests/chat/turns.spec.ts +++ b/ui/playwright/tests/chat/turns.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from "../../fixtures/test"; import { agentChat, instances } from "../../helpers/app"; +import { expectTranscriptSettled } from "../../helpers/chat"; /** * A turn that does not finish the way it should. @@ -56,6 +57,7 @@ test("chat: a failed turn is reported and can be retried", async ({ page }) => { * `useChat` and never passes through the composer, so a page that resumed only * around `send` would leave this one button broken while every other path worked. */ + await expectTranscriptSettled(page); await page.getByRole("button", { name: "Retry" }).click(); // Still the failing scenario, so it fails again — the point is that the // retry ran at all, and that the page did not double up the user's message. diff --git a/ui/playwright/tests/extension-points/extension-points-absent.spec.ts b/ui/playwright/tests/extension-points/extension-points-absent.spec.ts index 4b63a3b149..67f92f1430 100644 --- a/ui/playwright/tests/extension-points/extension-points-absent.spec.ts +++ b/ui/playwright/tests/extension-points/extension-points-absent.spec.ts @@ -10,9 +10,14 @@ import { CORE_NAV_ORDER, allSlots, navOrder } from "../../helpers/extensions"; * unnoticed, because every other spec and every screenshot is taken with the * example switched on. A framework that only works when something is plugged * into it is a framework that breaks the day a deployment ships bare. + * + * Four tests rather than one journey: each claim reads a page of its own and none + * builds on another, so one journey was six page loads on the thirty-second budget of a + * single claim — and a load of the dev server is seconds on a busy runner, so the last + * one ran out of time on a loaded Firefox run with nothing wrong on the page. */ -test("extension points: a bare build renders the application and nothing else", async ({ +test("extension points: a bare build mounts nothing, and its sidebar is the application's", async ({ page, }) => { await test.step("1. no point mounts anything anywhere", async () => { @@ -35,42 +40,47 @@ test("extension points: a bare build renders the application and nothing else", await test.step("2. the sidebar shows exactly what the application ships", async () => { expect(await navOrder(page)).toEqual(CORE_NAV_ORDER); }); +}); - await test.step("3. the app is otherwise whole", async () => { - // The absence of contributions must not take any of the app with it. - await loadPage(page, routes.agents, { title: "Agents" }); - await expectShell(page); - // By the harness as well as the template, because an agent is the pair: the - // template alone appears on two rows, so matching it would pass on a build that - // had lost the harness column entirely. - await expect( - page - .getByRole("row") - .filter({ hasText: agents.k8s.template }) - .filter({ hasText: agents.k8s.harness }), - ).toHaveCount(1); - }); +test("extension points: a bare build is otherwise whole", async ({ page }) => { + // The absence of contributions must not take any of the app with it. + await loadPage(page, routes.agents, { title: "Agents" }); + await expectShell(page); + // By the harness as well as the template, because an agent is the pair: the + // template alone appears on two rows, so matching it would pass on a build that + // had lost the harness column entirely. + await expect( + page + .getByRole("row") + .filter({ hasText: agents.k8s.template }) + .filter({ hasText: agents.k8s.harness }), + ).toHaveCount(1); +}); - await test.step("4. the agent rail carries the application's two entries and no more", async () => { - await loadPage(page, agentChat(instances.ready)); - const rail = page.getByTestId("chat-sessions-nav"); - // Both destinations are addresses of this agent, and the chat page derives them - // from the instance it is still fetching — so the nav is empty for a moment and - // reading it straight away reads nothing. - await expect(rail.getByTestId("agent-nav-agent-conversations")).toBeVisible({ - timeout: 30_000, - }); - const order = await rail - .locator("[data-testid]") - .evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid") ?? "")); - expect(order).toEqual(["agent-nav-agent-conversations", "chat-new-session"]); +test("extension points: a bare build's agent rail carries the application's two entries and no more", async ({ + page, +}) => { + await loadPage(page, agentChat(instances.ready)); + const rail = page.getByTestId("chat-sessions-nav"); + // Both destinations are addresses of this agent, and the chat page derives them + // from the instance it is still fetching — so the nav is empty for a moment and + // reading it straight away reads nothing. Half the test's budget, so that a rail + // which never fills is what the failure names, not the test's own timeout. + await expect(rail.getByTestId("agent-nav-agent-conversations")).toBeVisible({ + timeout: 15_000, }); + const order = await rail + .locator("[data-testid]") + .evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid") ?? "")); + expect(order).toEqual(["agent-nav-agent-conversations", "chat-new-session"]); +}); - await test.step("4. a route only an extension would contribute is a 404", async () => { - // The example contributes this path; with nothing installed the router must - // not have quietly kept a slot for it. - await loadPage(page, "/example/insights"); - await expect(page.getByTestId("not-found")).toBeVisible(); - await expect(allSlots(page)).toHaveCount(0); - }); +test("extension points: a route only an extension would contribute is a 404 in a bare build", async ({ + page, +}) => { + // The example contributes this path; with nothing installed the router must + // not have quietly kept a slot for it. + await loadPage(page, "/example/insights"); + await expect(page.getByTestId("not-found")).toBeVisible(); + await expect(allSlots(page)).toHaveCount(0); }); diff --git a/ui/playwright/tests/extension-points/extension-points.withExtension.spec.ts b/ui/playwright/tests/extension-points/extension-points.withExtension.spec.ts index 01a3e59ba4..a617bfe820 100644 --- a/ui/playwright/tests/extension-points/extension-points.withExtension.spec.ts +++ b/ui/playwright/tests/extension-points/extension-points.withExtension.spec.ts @@ -39,6 +39,7 @@ test.describe.configure({ timeout: LIFECYCLE_TIMEOUT }); test("extension points: configured components mount where the point promises", async ({ page, }) => { + test.setTimeout(LIFECYCLE_TIMEOUT); await test.step("1. shell points mount on every in-app page", async () => { await loadPage(page, routes.agents, { title: "Agents" }); await expectShell(page); diff --git a/ui/playwright/tests/schedules/schedules.spec.ts b/ui/playwright/tests/schedules/schedules.spec.ts index be1e79cb15..9f343fd554 100644 --- a/ui/playwright/tests/schedules/schedules.spec.ts +++ b/ui/playwright/tests/schedules/schedules.spec.ts @@ -470,8 +470,16 @@ test("schedules: a schedule is created, read, run, changed and deleted", async ( }); await test.step("20. and it is not offered in the list it was removed from", async () => { - await page.goto("/schedules?mock=ok"); + // Back the way a reader goes, rather than another page load: the list is the same + // read either way, and a load is seconds of the dev server this journey is short of. + await page.getByTestId("schedule-back").click(); + // Anchored on a row that is there, because "not offered" is also true of a list + // that has not rendered yet. + await expect( + page.getByTestId("schedule-link-Daily cluster report"), + ).toBeVisible(); await expect( page.getByTestId("schedule-link-Retired sweep"), ).toHaveCount(0); - });}); + }); +}); diff --git a/ui/scripts/init.sh b/ui/scripts/init.sh index b7268985df..247ac1e662 100644 --- a/ui/scripts/init.sh +++ b/ui/scripts/init.sh @@ -82,6 +82,51 @@ EOF sed "s| /tmp/kagent/index.html grep -q " to ${BASE_PATH}/" >&2 +# Resolver for the controller upstream. +# +# nginx.conf proxies to the controller through a variable, so nginx resolves the +# Service name per request and starts even while cluster DNS or the Service does +# not answer yet; a request made then gets a 502. nginx's own resolver reads +# neither /etc/resolv.conf nor its search path, so both come from the pod's +# resolv.conf here: the nameservers (IPv6 ones in brackets, as nginx wants them) +# and the cluster domain, taken from the `svc.` search entry the +# kubelet writes for the pod. +RESOLVER_CONF=/tmp/nginx/resolver.conf +nameservers=() +cluster_domain="" +while read -r key values; do + case "$key" in + nameserver) + if [[ "$values" == *:* ]]; then + nameservers+=("[${values}]") + else + nameservers+=("$values") + fi + ;; + search) + for domain in $values; do + if [[ -z "$cluster_domain" && "$domain" == svc.* ]]; then + cluster_domain=${domain#svc.} + fi + done + ;; + esac +done < /etc/resolv.conf + +if (( ${#nameservers[@]} == 0 )) || [[ -z "$cluster_domain" ]]; then + echo "init.sh: /etc/resolv.conf names no nameserver or no svc. search entry; the UI needs the pod's cluster DNS (dnsPolicy ClusterFirst) to reach the controller" >&2 + exit 1 +fi + +# valid= bounds how long an answer, or a failure before the controller exists, +# is cached, so the UI follows the Service within seconds. +cat > "$RESOLVER_CONF" <; + think?: boolean; } export interface GeminiConfig { diff --git a/ui/src/components/agent/AgentRail.tsx b/ui/src/components/agent/AgentRail.tsx index 9cd3618b86..25685ca9cf 100644 --- a/ui/src/components/agent/AgentRail.tsx +++ b/ui/src/components/agent/AgentRail.tsx @@ -1531,10 +1531,16 @@ function ChatEntry({ tells two of them apart. antd only raises it when the text actually clips. */} diff --git a/ui/src/components/chat/CheckpointDivider.tsx b/ui/src/components/chat/CheckpointDivider.tsx index 60ed5e103f..90b0ae498b 100644 --- a/ui/src/components/chat/CheckpointDivider.tsx +++ b/ui/src/components/chat/CheckpointDivider.tsx @@ -292,8 +292,8 @@ export function CheckpointDivider({ confirmation is still resting on this button, so the tooltip stays up and its container covers the confirmation's own buttons — which is a reader unable to press Cancel, not just a test that cannot. What it - would have said, the confirmation says. */} - + would have said, the confirmation says; the aria-label says it to a + screen reader. */} - + ) : null} diff --git a/ui/src/components/chat/MermaidDiagram.test.tsx b/ui/src/components/chat/MermaidDiagram.test.tsx index 1a853b0cde..04d4576b8d 100644 --- a/ui/src/components/chat/MermaidDiagram.test.tsx +++ b/ui/src/components/chat/MermaidDiagram.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import { ThemeProvider } from "@emotion/react"; import { describe, expect, it, vi } from "vitest"; import { themeFor } from "@/theme/theme"; @@ -41,6 +41,9 @@ describe("MermaidDiagram", () => { expect(fallbackCode()).toBe(SOURCE); expect(screen.queryByTestId("chat-mermaid")).toBeNull(); + // Said to be pending: the browser suite waits on this before pressing anything the + // taller diagram would move. + expect(screen.getByTestId("chat-mermaid-pending")).toBeTruthy(); }); it("renders the SVG mermaid produces", async () => { @@ -53,12 +56,16 @@ describe("MermaidDiagram", () => { const diagram = await screen.findByTestId("chat-mermaid"); expect(diagram.querySelector("svg")).toBeTruthy(); expect(diagram.textContent).toContain("Container starts"); + expect(screen.queryByTestId("chat-mermaid-pending")).toBeNull(); }); it("falls back to the raw source when mermaid cannot render the diagram", async () => { renderMermaid.mockRejectedValue(new Error("parse error")); renderDiagram(SOURCE); + // Settled rather than pending: the loading frame shows the same source, so + // without this the assertions below would pass before the render had failed. + await waitFor(() => expect(screen.queryByTestId("chat-mermaid-pending")).toBeNull()); // The fallback is the source in a code frame, not a broken diagram. expect(await screen.findByText(/flowchart TD/)).toBeTruthy(); expect(fallbackCode()).toBe(SOURCE); diff --git a/ui/src/components/chat/MermaidDiagram.tsx b/ui/src/components/chat/MermaidDiagram.tsx index 6172d1114e..7261c463a7 100644 --- a/ui/src/components/chat/MermaidDiagram.tsx +++ b/ui/src/components/chat/MermaidDiagram.tsx @@ -62,8 +62,12 @@ export function MermaidDiagram({ source }: { source: string }) { if (render.status === "loading") { // Still importing or rendering: show the source so the block is never a gap. + // + // Marked, because the diagram that replaces it is taller and moves everything + // below it: a caller that presses something further down the transcript waits + // for no block to be pending first. return ( -
+      
         {source}
       
); diff --git a/ui/src/components/model-form/ModelForm.tsx b/ui/src/components/model-form/ModelForm.tsx index caa45a0272..6aa80022f4 100644 --- a/ui/src/components/model-form/ModelForm.tsx +++ b/ui/src/components/model-form/ModelForm.tsx @@ -37,6 +37,7 @@ import { } from "@/api"; import { authTypeForProvider, + BOOLEAN_PARAM_KEYS, buildModelPayload, emptyModelDraft, isBadNumber, @@ -355,12 +356,28 @@ export function ModelForm({ : undefined } > - setParam(key, event.target.value)} - /> + {BOOLEAN_PARAM_KEYS.has(key) ? ( + setParam(key, event.target.value)} + /> + )} ); }; diff --git a/ui/src/components/model-form/modelDraft.test.ts b/ui/src/components/model-form/modelDraft.test.ts index 95f046eec4..9a2b91f27c 100644 --- a/ui/src/components/model-form/modelDraft.test.ts +++ b/ui/src/components/model-form/modelDraft.test.ts @@ -62,6 +62,27 @@ describe("buildModelPayload — provider params", () => { }); }); +describe("buildModelPayload — boolean params", () => { + it("sends Ollama's think as a boolean and leaves it out when unset", () => { + const ollama = (think: string) => + buildModelPayload( + draft({ provider: "Ollama", model: "qwen3.5", params: { think } }), + ).spec.ollama; + expect(ollama("false")).toEqual({ think: false }); + expect(ollama("true")).toEqual({ think: true }); + expect(ollama("")).toBeUndefined(); + }); + + it("round-trips a stored think through the draft", () => { + const d = modelDraftFrom({ + ref: "kagent/qwen", + spec: { provider: "Ollama", model: "qwen3.5:2b", ollama: { think: false } }, + }); + expect(d.params).toEqual({ think: "false" }); + expect(buildModelPayload(d).spec.ollama).toEqual({ think: false }); + }); +}); + describe("buildModelPayload — authentication modes", () => { it("apiKey: sends an inline key and lets the backend name the Secret", () => { const req = buildModelPayload(draft({ authType: "apiKey", apiKey: "sk-123" })); diff --git a/ui/src/components/model-form/modelDraft.ts b/ui/src/components/model-form/modelDraft.ts index 979c98d454..263ee0e1bf 100644 --- a/ui/src/components/model-form/modelDraft.ts +++ b/ui/src/components/model-form/modelDraft.ts @@ -119,23 +119,31 @@ const NUMERIC_PARAM_KEYS = new Set([ "candidateCount", ]); +/** + * Parameters the CRD types as booleans. The form offers them as a choice rather + * than free text, and leaving the choice empty keeps the provider's default. + */ +export const BOOLEAN_PARAM_KEYS = new Set(["think"]); + /** * The provider block from the form's flat string values. * - * Empty values are dropped rather than sent blank, and the few numeric fields are - * parsed so the controller receives the type its CRD declares. A numeric field - * that will not parse is left out — validation flags it before this runs. + * Empty values are dropped rather than sent blank, and the few numeric and boolean + * fields are parsed so the controller receives the type its CRD declares. A value + * that will not parse is left out — validation flags a numeric one before this runs. */ export function coerceParams( params: Record, -): Record { - const out: Record = {}; +): Record { + const out: Record = {}; for (const [key, raw] of Object.entries(params)) { const value = raw.trim(); if (!value) continue; if (NUMERIC_PARAM_KEYS.has(key)) { const parsed = Number(value); if (!Number.isNaN(parsed)) out[key] = parsed; + } else if (BOOLEAN_PARAM_KEYS.has(key)) { + if (value === "true" || value === "false") out[key] = value === "true"; } else { out[key] = value; } diff --git a/ui/src/generated/kagent/api/v1alpha1/sessions_pb.ts b/ui/src/generated/kagent/api/v1alpha1/sessions_pb.ts index 3b09c38031..0e4b887b48 100644 --- a/ui/src/generated/kagent/api/v1alpha1/sessions_pb.ts +++ b/ui/src/generated/kagent/api/v1alpha1/sessions_pb.ts @@ -17,7 +17,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file kagent/api/v1alpha1/sessions.proto. */ export const file_kagent_api_v1alpha1_sessions: GenFile = /*@__PURE__*/ - fileDesc("CiJrYWdlbnQvYXBpL3YxYWxwaGExL3Nlc3Npb25zLnByb3RvEhNrYWdlbnQuYXBpLnYxYWxwaGExIioKB0ZhaWx1cmUSDgoGcmVhc29uGAEgASgJEg8KB21lc3NhZ2UYAiABKAki3wMKB1Nlc3Npb24SCgoCaWQYASABKAkSDwoHY3JlYXRvchgCIAEoCRI1CgVhZ2VudBgPIAEoCzImLmthZ2VudC5hcGkudjFhbHBoYTEuUmVzb3VyY2VSZWZlcmVuY2USGQoRcHJlcGFyZWRfcmV2aXNpb24YBSABKAkSFQoNYTJhX2F1dGhvcml0eRgGIAEoCRIwCgVzdGF0ZRgHIAEoDjIhLmthZ2VudC5hcGkudjFhbHBoYTEuUnVudGltZVN0YXRlEjgKCW9wZXJhdGlvbhgIIAEoDjIlLmthZ2VudC5hcGkudjFhbHBoYTEuUnVudGltZU9wZXJhdGlvbhItCgdmYWlsdXJlGAkgASgLMhwua2FnZW50LmFwaS52MWFscGhhMS5GYWlsdXJlEi4KCmNyZWF0ZWRfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnVwZGF0ZWRfYXQYCyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEgwKBG5hbWUYDSABKAkSEgoKY29udGV4dF9pZBgOIAEoCUoECAMQBEoECAQQBUoECAwQDVIHaGFybmVzc1IOYWdlbnRfdGVtcGxhdGVSBmxhYmVscyLnAQoUQ3JlYXRlU2Vzc2lvblJlcXVlc3QSPQoFYWdlbnQYBSABKAsyJi5rYWdlbnQuYXBpLnYxYWxwaGExLlJlc291cmNlUmVmZXJlbmNlQga6SAPIAQESHgoKcmVxdWVzdF9pZBgDIAEoCUIKukgHcgUQARiAARJLCgRuYW1lGAQgASgJQj26SDpyOBjIATIzXig/OiR8W15ccHtafVxwe0NjfV0oPzpbXlxwe0NjfV0qW15ccHtafVxwe0NjfV0pPykkSgQIARACSgQIAhADUgdoYXJuZXNzUg5hZ2VudF90ZW1wbGF0ZSJGChVDcmVhdGVTZXNzaW9uUmVzcG9uc2USLQoHc2Vzc2lvbhgBIAEoCzIcLmthZ2VudC5hcGkudjFhbHBoYTEuU2Vzc2lvbiIxChFHZXRTZXNzaW9uUmVxdWVzdBIcCgpzZXNzaW9uX2lkGAEgASgJQgi6SAVyA7ABASJDChJHZXRTZXNzaW9uUmVzcG9uc2USLQoHc2Vzc2lvbhgBIAEoCzIcLmthZ2VudC5hcGkudjFhbHBoYTEuU2Vzc2lvbiLLAQoTTGlzdFNlc3Npb25zUmVxdWVzdBIUCgxhbGxfY3JlYXRvcnMYAiABKAgSLgoEcGFnZRgDIAEoCzIgLmthZ2VudC5hcGkudjFhbHBoYTEuUGFnZVJlcXVlc3QSNQoFYWdlbnQYBiABKAsyJi5rYWdlbnQuYXBpLnYxYWxwaGExLlJlc291cmNlUmVmZXJlbmNlSgQIARACSgQIBBAFSgQIBRAGUgxtYXRjaF9sYWJlbHNSDmFnZW50X3RlbXBsYXRlUgdoYXJuZXNzIncKFExpc3RTZXNzaW9uc1Jlc3BvbnNlEi4KCHNlc3Npb25zGAEgAygLMhwua2FnZW50LmFwaS52MWFscGhhMS5TZXNzaW9uEi8KBHBhZ2UYAiABKAsyIS5rYWdlbnQuYXBpLnYxYWxwaGExLlBhZ2VSZXNwb25zZSKFAQoYVXBkYXRlU2Vzc2lvbk5hbWVSZXF1ZXN0EhwKCnNlc3Npb25faWQYASABKAlCCLpIBXIDsAEBEksKBG5hbWUYAiABKAlCPbpIOnI4GMgBMjNeKD86JHxbXlxwe1p9XHB7Q2N9XSg/OlteXHB7Q2N9XSpbXlxwe1p9XHB7Q2N9XSk/KSQiSgoZVXBkYXRlU2Vzc2lvbk5hbWVSZXNwb25zZRItCgdzZXNzaW9uGAEgASgLMhwua2FnZW50LmFwaS52MWFscGhhMS5TZXNzaW9uIjUKFVN1c3BlbmRTZXNzaW9uUmVxdWVzdBIcCgpzZXNzaW9uX2lkGAEgASgJQgi6SAVyA7ABASJHChZTdXNwZW5kU2Vzc2lvblJlc3BvbnNlEi0KB3Nlc3Npb24YASABKAsyHC5rYWdlbnQuYXBpLnYxYWxwaGExLlNlc3Npb24iNAoUUmVzdW1lU2Vzc2lvblJlcXVlc3QSHAoKc2Vzc2lvbl9pZBgBIAEoCUIIukgFcgOwAQEiRgoVUmVzdW1lU2Vzc2lvblJlc3BvbnNlEi0KB3Nlc3Npb24YASABKAsyHC5rYWdlbnQuYXBpLnYxYWxwaGExLlNlc3Npb24iNAoURGVsZXRlU2Vzc2lvblJlcXVlc3QSHAoKc2Vzc2lvbl9pZBgBIAEoCUIIukgFcgOwAQEiRgoVRGVsZXRlU2Vzc2lvblJlc3BvbnNlEi0KB3Nlc3Npb24YASABKAsyHC5rYWdlbnQuYXBpLnYxYWxwaGExLlNlc3Npb24izwEKDFNlc3Npb25TaGFyZRIKCgJpZBgBIAEoCRISCgpzZXNzaW9uX2lkGAIgASgJEj8KCnBlcm1pc3Npb24YAyABKA4yKy5rYWdlbnQuYXBpLnYxYWxwaGExLlNlc3Npb25TaGFyZVBlcm1pc3Npb24SLgoKY3JlYXRlZF9hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLgoKZXhwaXJlc19hdBgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiuAEKGUNyZWF0ZVNlc3Npb25TaGFyZVJlcXVlc3QSHAoKc2Vzc2lvbl9pZBgBIAEoCUIIukgFcgOwAQESSwoKcGVybWlzc2lvbhgCIAEoDjIrLmthZ2VudC5hcGkudjFhbHBoYTEuU2Vzc2lvblNoYXJlUGVybWlzc2lvbkIKukgHggEEEAEgABIwCgN0dGwYAyABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRpb25CCLpIBaoBAioAIl0KGkNyZWF0ZVNlc3Npb25TaGFyZVJlc3BvbnNlEjAKBXNoYXJlGAEgASgLMiEua2FnZW50LmFwaS52MWFscGhhMS5TZXNzaW9uU2hhcmUSDQoFdG9rZW4YAiABKAkiaAoYTGlzdFNlc3Npb25TaGFyZXNSZXF1ZXN0EhwKCnNlc3Npb25faWQYASABKAlCCLpIBXIDsAEBEi4KBHBhZ2UYAiABKAsyIC5rYWdlbnQuYXBpLnYxYWxwaGExLlBhZ2VSZXF1ZXN0In8KGUxpc3RTZXNzaW9uU2hhcmVzUmVzcG9uc2USMQoGc2hhcmVzGAEgAygLMiEua2FnZW50LmFwaS52MWFscGhhMS5TZXNzaW9uU2hhcmUSLwoEcGFnZRgCIAEoCzIhLmthZ2VudC5hcGkudjFhbHBoYTEuUGFnZVJlc3BvbnNlIjcKGVJldm9rZVNlc3Npb25TaGFyZVJlcXVlc3QSGgoIc2hhcmVfaWQYASABKAlCCLpIBXIDsAEBIhwKGlJldm9rZVNlc3Npb25TaGFyZVJlc3BvbnNlKpMBChZTZXNzaW9uU2hhcmVQZXJtaXNzaW9uEigKJFNFU1NJT05fU0hBUkVfUEVSTUlTU0lPTl9VTlNQRUNJRklFRBAAEiYKIlNFU1NJT05fU0hBUkVfUEVSTUlTU0lPTl9SRUFEX09OTFkQARInCiNTRVNTSU9OX1NIQVJFX1BFUk1JU1NJT05fUkVBRF9XUklURRACMs0ICg5TZXNzaW9uU2VydmljZRJmCg1DcmVhdGVTZXNzaW9uEikua2FnZW50LmFwaS52MWFscGhhMS5DcmVhdGVTZXNzaW9uUmVxdWVzdBoqLmthZ2VudC5hcGkudjFhbHBoYTEuQ3JlYXRlU2Vzc2lvblJlc3BvbnNlEl0KCkdldFNlc3Npb24SJi5rYWdlbnQuYXBpLnYxYWxwaGExLkdldFNlc3Npb25SZXF1ZXN0Gicua2FnZW50LmFwaS52MWFscGhhMS5HZXRTZXNzaW9uUmVzcG9uc2USYwoMTGlzdFNlc3Npb25zEigua2FnZW50LmFwaS52MWFscGhhMS5MaXN0U2Vzc2lvbnNSZXF1ZXN0Gikua2FnZW50LmFwaS52MWFscGhhMS5MaXN0U2Vzc2lvbnNSZXNwb25zZRJyChFVcGRhdGVTZXNzaW9uTmFtZRItLmthZ2VudC5hcGkudjFhbHBoYTEuVXBkYXRlU2Vzc2lvbk5hbWVSZXF1ZXN0Gi4ua2FnZW50LmFwaS52MWFscGhhMS5VcGRhdGVTZXNzaW9uTmFtZVJlc3BvbnNlEmkKDlN1c3BlbmRTZXNzaW9uEioua2FnZW50LmFwaS52MWFscGhhMS5TdXNwZW5kU2Vzc2lvblJlcXVlc3QaKy5rYWdlbnQuYXBpLnYxYWxwaGExLlN1c3BlbmRTZXNzaW9uUmVzcG9uc2USZgoNUmVzdW1lU2Vzc2lvbhIpLmthZ2VudC5hcGkudjFhbHBoYTEuUmVzdW1lU2Vzc2lvblJlcXVlc3QaKi5rYWdlbnQuYXBpLnYxYWxwaGExLlJlc3VtZVNlc3Npb25SZXNwb25zZRJmCg1EZWxldGVTZXNzaW9uEikua2FnZW50LmFwaS52MWFscGhhMS5EZWxldGVTZXNzaW9uUmVxdWVzdBoqLmthZ2VudC5hcGkudjFhbHBoYTEuRGVsZXRlU2Vzc2lvblJlc3BvbnNlEnUKEkNyZWF0ZVNlc3Npb25TaGFyZRIuLmthZ2VudC5hcGkudjFhbHBoYTEuQ3JlYXRlU2Vzc2lvblNoYXJlUmVxdWVzdBovLmthZ2VudC5hcGkudjFhbHBoYTEuQ3JlYXRlU2Vzc2lvblNoYXJlUmVzcG9uc2UScgoRTGlzdFNlc3Npb25TaGFyZXMSLS5rYWdlbnQuYXBpLnYxYWxwaGExLkxpc3RTZXNzaW9uU2hhcmVzUmVxdWVzdBouLmthZ2VudC5hcGkudjFhbHBoYTEuTGlzdFNlc3Npb25TaGFyZXNSZXNwb25zZRJ1ChJSZXZva2VTZXNzaW9uU2hhcmUSLi5rYWdlbnQuYXBpLnYxYWxwaGExLlJldm9rZVNlc3Npb25TaGFyZVJlcXVlc3QaLy5rYWdlbnQuYXBpLnYxYWxwaGExLlJldm9rZVNlc3Npb25TaGFyZVJlc3BvbnNlQklaR2dpdGh1Yi5jb20va2FnZW50LWRldi9rYWdlbnQvZ28vYXBpL2dlbi9rYWdlbnQvYXBpL3YxYWxwaGExO2FwaXYxYWxwaGExYgZwcm90bzM", [file_buf_validate_validate, file_google_protobuf_duration, file_google_protobuf_timestamp, file_kagent_api_v1alpha1_common, file_kagent_api_v1alpha1_runtime]); + fileDesc("CiJrYWdlbnQvYXBpL3YxYWxwaGExL3Nlc3Npb25zLnByb3RvEhNrYWdlbnQuYXBpLnYxYWxwaGExIioKB0ZhaWx1cmUSDgoGcmVhc29uGAEgASgJEg8KB21lc3NhZ2UYAiABKAkiqAQKB1Nlc3Npb24SCgoCaWQYASABKAkSDwoHY3JlYXRvchgCIAEoCRI1CgVhZ2VudBgPIAEoCzImLmthZ2VudC5hcGkudjFhbHBoYTEuUmVzb3VyY2VSZWZlcmVuY2USGQoRcHJlcGFyZWRfcmV2aXNpb24YBSABKAkSFQoNYTJhX2F1dGhvcml0eRgGIAEoCRIwCgVzdGF0ZRgHIAEoDjIhLmthZ2VudC5hcGkudjFhbHBoYTEuUnVudGltZVN0YXRlEjgKCW9wZXJhdGlvbhgIIAEoDjIlLmthZ2VudC5hcGkudjFhbHBoYTEuUnVudGltZU9wZXJhdGlvbhItCgdmYWlsdXJlGAkgASgLMhwua2FnZW50LmFwaS52MWFscGhhMS5GYWlsdXJlEi4KCmNyZWF0ZWRfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnVwZGF0ZWRfYXQYCyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEgwKBG5hbWUYDSABKAkSEgoKY29udGV4dF9pZBgOIAEoCRJHChdsYXN0X3F1aWVzY2VuY2VfZmFpbHVyZRgQIAEoCzImLmthZ2VudC5hcGkudjFhbHBoYTEuUXVpZXNjZW5jZUZhaWx1cmVKBAgDEARKBAgEEAVKBAgMEA1SB2hhcm5lc3NSDmFnZW50X3RlbXBsYXRlUgZsYWJlbHMidAoRUXVpZXNjZW5jZUZhaWx1cmUSDwoHdGFza19pZBgBIAEoCRIOCgZyZWFzb24YAiABKAkSDwoHbWVzc2FnZRgDIAEoCRItCglmYWlsZWRfYXQYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIucBChRDcmVhdGVTZXNzaW9uUmVxdWVzdBI9CgVhZ2VudBgFIAEoCzImLmthZ2VudC5hcGkudjFhbHBoYTEuUmVzb3VyY2VSZWZlcmVuY2VCBrpIA8gBARIeCgpyZXF1ZXN0X2lkGAMgASgJQgq6SAdyBRABGIABEksKBG5hbWUYBCABKAlCPbpIOnI4GMgBMjNeKD86JHxbXlxwe1p9XHB7Q2N9XSg/OlteXHB7Q2N9XSpbXlxwe1p9XHB7Q2N9XSk/KSRKBAgBEAJKBAgCEANSB2hhcm5lc3NSDmFnZW50X3RlbXBsYXRlIkYKFUNyZWF0ZVNlc3Npb25SZXNwb25zZRItCgdzZXNzaW9uGAEgASgLMhwua2FnZW50LmFwaS52MWFscGhhMS5TZXNzaW9uIjEKEUdldFNlc3Npb25SZXF1ZXN0EhwKCnNlc3Npb25faWQYASABKAlCCLpIBXIDsAEBIkMKEkdldFNlc3Npb25SZXNwb25zZRItCgdzZXNzaW9uGAEgASgLMhwua2FnZW50LmFwaS52MWFscGhhMS5TZXNzaW9uIssBChNMaXN0U2Vzc2lvbnNSZXF1ZXN0EhQKDGFsbF9jcmVhdG9ycxgCIAEoCBIuCgRwYWdlGAMgASgLMiAua2FnZW50LmFwaS52MWFscGhhMS5QYWdlUmVxdWVzdBI1CgVhZ2VudBgGIAEoCzImLmthZ2VudC5hcGkudjFhbHBoYTEuUmVzb3VyY2VSZWZlcmVuY2VKBAgBEAJKBAgEEAVKBAgFEAZSDG1hdGNoX2xhYmVsc1IOYWdlbnRfdGVtcGxhdGVSB2hhcm5lc3MidwoUTGlzdFNlc3Npb25zUmVzcG9uc2USLgoIc2Vzc2lvbnMYASADKAsyHC5rYWdlbnQuYXBpLnYxYWxwaGExLlNlc3Npb24SLwoEcGFnZRgCIAEoCzIhLmthZ2VudC5hcGkudjFhbHBoYTEuUGFnZVJlc3BvbnNlIoUBChhVcGRhdGVTZXNzaW9uTmFtZVJlcXVlc3QSHAoKc2Vzc2lvbl9pZBgBIAEoCUIIukgFcgOwAQESSwoEbmFtZRgCIAEoCUI9ukg6cjgYyAEyM14oPzokfFteXHB7Wn1ccHtDY31dKD86W15ccHtDY31dKlteXHB7Wn1ccHtDY31dKT8pJCJKChlVcGRhdGVTZXNzaW9uTmFtZVJlc3BvbnNlEi0KB3Nlc3Npb24YASABKAsyHC5rYWdlbnQuYXBpLnYxYWxwaGExLlNlc3Npb24iNQoVU3VzcGVuZFNlc3Npb25SZXF1ZXN0EhwKCnNlc3Npb25faWQYASABKAlCCLpIBXIDsAEBIkcKFlN1c3BlbmRTZXNzaW9uUmVzcG9uc2USLQoHc2Vzc2lvbhgBIAEoCzIcLmthZ2VudC5hcGkudjFhbHBoYTEuU2Vzc2lvbiI0ChRSZXN1bWVTZXNzaW9uUmVxdWVzdBIcCgpzZXNzaW9uX2lkGAEgASgJQgi6SAVyA7ABASJGChVSZXN1bWVTZXNzaW9uUmVzcG9uc2USLQoHc2Vzc2lvbhgBIAEoCzIcLmthZ2VudC5hcGkudjFhbHBoYTEuU2Vzc2lvbiI0ChREZWxldGVTZXNzaW9uUmVxdWVzdBIcCgpzZXNzaW9uX2lkGAEgASgJQgi6SAVyA7ABASJGChVEZWxldGVTZXNzaW9uUmVzcG9uc2USLQoHc2Vzc2lvbhgBIAEoCzIcLmthZ2VudC5hcGkudjFhbHBoYTEuU2Vzc2lvbiLPAQoMU2Vzc2lvblNoYXJlEgoKAmlkGAEgASgJEhIKCnNlc3Npb25faWQYAiABKAkSPwoKcGVybWlzc2lvbhgDIAEoDjIrLmthZ2VudC5hcGkudjFhbHBoYTEuU2Vzc2lvblNoYXJlUGVybWlzc2lvbhIuCgpjcmVhdGVkX2F0GAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgpleHBpcmVzX2F0GAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCK4AQoZQ3JlYXRlU2Vzc2lvblNoYXJlUmVxdWVzdBIcCgpzZXNzaW9uX2lkGAEgASgJQgi6SAVyA7ABARJLCgpwZXJtaXNzaW9uGAIgASgOMisua2FnZW50LmFwaS52MWFscGhhMS5TZXNzaW9uU2hhcmVQZXJtaXNzaW9uQgq6SAeCAQQQASAAEjAKA3R0bBgDIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbkIIukgFqgECKgAiXQoaQ3JlYXRlU2Vzc2lvblNoYXJlUmVzcG9uc2USMAoFc2hhcmUYASABKAsyIS5rYWdlbnQuYXBpLnYxYWxwaGExLlNlc3Npb25TaGFyZRINCgV0b2tlbhgCIAEoCSJoChhMaXN0U2Vzc2lvblNoYXJlc1JlcXVlc3QSHAoKc2Vzc2lvbl9pZBgBIAEoCUIIukgFcgOwAQESLgoEcGFnZRgCIAEoCzIgLmthZ2VudC5hcGkudjFhbHBoYTEuUGFnZVJlcXVlc3QifwoZTGlzdFNlc3Npb25TaGFyZXNSZXNwb25zZRIxCgZzaGFyZXMYASADKAsyIS5rYWdlbnQuYXBpLnYxYWxwaGExLlNlc3Npb25TaGFyZRIvCgRwYWdlGAIgASgLMiEua2FnZW50LmFwaS52MWFscGhhMS5QYWdlUmVzcG9uc2UiNwoZUmV2b2tlU2Vzc2lvblNoYXJlUmVxdWVzdBIaCghzaGFyZV9pZBgBIAEoCUIIukgFcgOwAQEiHAoaUmV2b2tlU2Vzc2lvblNoYXJlUmVzcG9uc2UqkwEKFlNlc3Npb25TaGFyZVBlcm1pc3Npb24SKAokU0VTU0lPTl9TSEFSRV9QRVJNSVNTSU9OX1VOU1BFQ0lGSUVEEAASJgoiU0VTU0lPTl9TSEFSRV9QRVJNSVNTSU9OX1JFQURfT05MWRABEicKI1NFU1NJT05fU0hBUkVfUEVSTUlTU0lPTl9SRUFEX1dSSVRFEAIyzQgKDlNlc3Npb25TZXJ2aWNlEmYKDUNyZWF0ZVNlc3Npb24SKS5rYWdlbnQuYXBpLnYxYWxwaGExLkNyZWF0ZVNlc3Npb25SZXF1ZXN0Gioua2FnZW50LmFwaS52MWFscGhhMS5DcmVhdGVTZXNzaW9uUmVzcG9uc2USXQoKR2V0U2Vzc2lvbhImLmthZ2VudC5hcGkudjFhbHBoYTEuR2V0U2Vzc2lvblJlcXVlc3QaJy5rYWdlbnQuYXBpLnYxYWxwaGExLkdldFNlc3Npb25SZXNwb25zZRJjCgxMaXN0U2Vzc2lvbnMSKC5rYWdlbnQuYXBpLnYxYWxwaGExLkxpc3RTZXNzaW9uc1JlcXVlc3QaKS5rYWdlbnQuYXBpLnYxYWxwaGExLkxpc3RTZXNzaW9uc1Jlc3BvbnNlEnIKEVVwZGF0ZVNlc3Npb25OYW1lEi0ua2FnZW50LmFwaS52MWFscGhhMS5VcGRhdGVTZXNzaW9uTmFtZVJlcXVlc3QaLi5rYWdlbnQuYXBpLnYxYWxwaGExLlVwZGF0ZVNlc3Npb25OYW1lUmVzcG9uc2USaQoOU3VzcGVuZFNlc3Npb24SKi5rYWdlbnQuYXBpLnYxYWxwaGExLlN1c3BlbmRTZXNzaW9uUmVxdWVzdBorLmthZ2VudC5hcGkudjFhbHBoYTEuU3VzcGVuZFNlc3Npb25SZXNwb25zZRJmCg1SZXN1bWVTZXNzaW9uEikua2FnZW50LmFwaS52MWFscGhhMS5SZXN1bWVTZXNzaW9uUmVxdWVzdBoqLmthZ2VudC5hcGkudjFhbHBoYTEuUmVzdW1lU2Vzc2lvblJlc3BvbnNlEmYKDURlbGV0ZVNlc3Npb24SKS5rYWdlbnQuYXBpLnYxYWxwaGExLkRlbGV0ZVNlc3Npb25SZXF1ZXN0Gioua2FnZW50LmFwaS52MWFscGhhMS5EZWxldGVTZXNzaW9uUmVzcG9uc2USdQoSQ3JlYXRlU2Vzc2lvblNoYXJlEi4ua2FnZW50LmFwaS52MWFscGhhMS5DcmVhdGVTZXNzaW9uU2hhcmVSZXF1ZXN0Gi8ua2FnZW50LmFwaS52MWFscGhhMS5DcmVhdGVTZXNzaW9uU2hhcmVSZXNwb25zZRJyChFMaXN0U2Vzc2lvblNoYXJlcxItLmthZ2VudC5hcGkudjFhbHBoYTEuTGlzdFNlc3Npb25TaGFyZXNSZXF1ZXN0Gi4ua2FnZW50LmFwaS52MWFscGhhMS5MaXN0U2Vzc2lvblNoYXJlc1Jlc3BvbnNlEnUKElJldm9rZVNlc3Npb25TaGFyZRIuLmthZ2VudC5hcGkudjFhbHBoYTEuUmV2b2tlU2Vzc2lvblNoYXJlUmVxdWVzdBovLmthZ2VudC5hcGkudjFhbHBoYTEuUmV2b2tlU2Vzc2lvblNoYXJlUmVzcG9uc2VCSVpHZ2l0aHViLmNvbS9rYWdlbnQtZGV2L2thZ2VudC9nby9hcGkvZ2VuL2thZ2VudC9hcGkvdjFhbHBoYTE7YXBpdjFhbHBoYTFiBnByb3RvMw", [file_buf_validate_validate, file_google_protobuf_duration, file_google_protobuf_timestamp, file_kagent_api_v1alpha1_common, file_kagent_api_v1alpha1_runtime]); /** * @generated from message kagent.api.v1alpha1.Failure @@ -110,6 +110,15 @@ export type Session = Message<"kagent.api.v1alpha1.Session"> & { * @generated from field: string context_id = 14; */ contextId: string; + + /** + * Output only. Why the runtime boundary after a turn was released without a + * pause or a snapshot, once the suspend or pause and its retry failed or the + * runtime was gone. A later turn's snapshot clears it. + * + * @generated from field: kagent.api.v1alpha1.QuiescenceFailure last_quiescence_failure = 16; + */ + lastQuiescenceFailure?: QuiescenceFailure | undefined; }; /** @@ -119,6 +128,46 @@ export type Session = Message<"kagent.api.v1alpha1.Session"> & { export const SessionSchema: GenMessage = /*@__PURE__*/ messageDesc(file_kagent_api_v1alpha1_sessions, 1); +/** + * QuiescenceFailure explains a turn whose runtime could not be paused or + * suspended after it settled. The turn is stored; without a snapshot it cannot + * be checkpointed, and the next turn takes the runtime as it is. + * + * @generated from message kagent.api.v1alpha1.QuiescenceFailure + */ +export type QuiescenceFailure = Message<"kagent.api.v1alpha1.QuiescenceFailure"> & { + /** + * The turn whose boundary was released. + * + * @generated from field: string task_id = 1; + */ + taskId: string; + + /** + * SuspendFailed, PauseFailed or RuntimeUnavailable. + * + * @generated from field: string reason = 2; + */ + reason: string; + + /** + * @generated from field: string message = 3; + */ + message: string; + + /** + * @generated from field: google.protobuf.Timestamp failed_at = 4; + */ + failedAt?: Timestamp | undefined; +}; + +/** + * Describes the message kagent.api.v1alpha1.QuiescenceFailure. + * Use `create(QuiescenceFailureSchema)` to create a new message. + */ +export const QuiescenceFailureSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_kagent_api_v1alpha1_sessions, 2); + /** * @generated from message kagent.api.v1alpha1.CreateSessionRequest */ @@ -146,7 +195,7 @@ export type CreateSessionRequest = Message<"kagent.api.v1alpha1.CreateSessionReq * Use `create(CreateSessionRequestSchema)` to create a new message. */ export const CreateSessionRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1alpha1_sessions, 2); + messageDesc(file_kagent_api_v1alpha1_sessions, 3); /** * @generated from message kagent.api.v1alpha1.CreateSessionResponse @@ -163,7 +212,7 @@ export type CreateSessionResponse = Message<"kagent.api.v1alpha1.CreateSessionRe * Use `create(CreateSessionResponseSchema)` to create a new message. */ export const CreateSessionResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1alpha1_sessions, 3); + messageDesc(file_kagent_api_v1alpha1_sessions, 4); /** * @generated from message kagent.api.v1alpha1.GetSessionRequest @@ -180,7 +229,7 @@ export type GetSessionRequest = Message<"kagent.api.v1alpha1.GetSessionRequest"> * Use `create(GetSessionRequestSchema)` to create a new message. */ export const GetSessionRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1alpha1_sessions, 4); + messageDesc(file_kagent_api_v1alpha1_sessions, 5); /** * @generated from message kagent.api.v1alpha1.GetSessionResponse @@ -197,7 +246,7 @@ export type GetSessionResponse = Message<"kagent.api.v1alpha1.GetSessionResponse * Use `create(GetSessionResponseSchema)` to create a new message. */ export const GetSessionResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1alpha1_sessions, 5); + messageDesc(file_kagent_api_v1alpha1_sessions, 6); /** * @generated from message kagent.api.v1alpha1.ListSessionsRequest @@ -228,7 +277,7 @@ export type ListSessionsRequest = Message<"kagent.api.v1alpha1.ListSessionsReque * Use `create(ListSessionsRequestSchema)` to create a new message. */ export const ListSessionsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1alpha1_sessions, 6); + messageDesc(file_kagent_api_v1alpha1_sessions, 7); /** * @generated from message kagent.api.v1alpha1.ListSessionsResponse @@ -250,7 +299,7 @@ export type ListSessionsResponse = Message<"kagent.api.v1alpha1.ListSessionsResp * Use `create(ListSessionsResponseSchema)` to create a new message. */ export const ListSessionsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1alpha1_sessions, 7); + messageDesc(file_kagent_api_v1alpha1_sessions, 8); /** * @generated from message kagent.api.v1alpha1.UpdateSessionNameRequest @@ -275,7 +324,7 @@ export type UpdateSessionNameRequest = Message<"kagent.api.v1alpha1.UpdateSessio * Use `create(UpdateSessionNameRequestSchema)` to create a new message. */ export const UpdateSessionNameRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1alpha1_sessions, 8); + messageDesc(file_kagent_api_v1alpha1_sessions, 9); /** * @generated from message kagent.api.v1alpha1.UpdateSessionNameResponse @@ -292,7 +341,7 @@ export type UpdateSessionNameResponse = Message<"kagent.api.v1alpha1.UpdateSessi * Use `create(UpdateSessionNameResponseSchema)` to create a new message. */ export const UpdateSessionNameResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1alpha1_sessions, 9); + messageDesc(file_kagent_api_v1alpha1_sessions, 10); /** * @generated from message kagent.api.v1alpha1.SuspendSessionRequest @@ -309,7 +358,7 @@ export type SuspendSessionRequest = Message<"kagent.api.v1alpha1.SuspendSessionR * Use `create(SuspendSessionRequestSchema)` to create a new message. */ export const SuspendSessionRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1alpha1_sessions, 10); + messageDesc(file_kagent_api_v1alpha1_sessions, 11); /** * @generated from message kagent.api.v1alpha1.SuspendSessionResponse @@ -326,7 +375,7 @@ export type SuspendSessionResponse = Message<"kagent.api.v1alpha1.SuspendSession * Use `create(SuspendSessionResponseSchema)` to create a new message. */ export const SuspendSessionResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1alpha1_sessions, 11); + messageDesc(file_kagent_api_v1alpha1_sessions, 12); /** * @generated from message kagent.api.v1alpha1.ResumeSessionRequest @@ -343,7 +392,7 @@ export type ResumeSessionRequest = Message<"kagent.api.v1alpha1.ResumeSessionReq * Use `create(ResumeSessionRequestSchema)` to create a new message. */ export const ResumeSessionRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1alpha1_sessions, 12); + messageDesc(file_kagent_api_v1alpha1_sessions, 13); /** * @generated from message kagent.api.v1alpha1.ResumeSessionResponse @@ -360,7 +409,7 @@ export type ResumeSessionResponse = Message<"kagent.api.v1alpha1.ResumeSessionRe * Use `create(ResumeSessionResponseSchema)` to create a new message. */ export const ResumeSessionResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1alpha1_sessions, 13); + messageDesc(file_kagent_api_v1alpha1_sessions, 14); /** * @generated from message kagent.api.v1alpha1.DeleteSessionRequest @@ -377,7 +426,7 @@ export type DeleteSessionRequest = Message<"kagent.api.v1alpha1.DeleteSessionReq * Use `create(DeleteSessionRequestSchema)` to create a new message. */ export const DeleteSessionRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1alpha1_sessions, 14); + messageDesc(file_kagent_api_v1alpha1_sessions, 15); /** * @generated from message kagent.api.v1alpha1.DeleteSessionResponse @@ -394,7 +443,7 @@ export type DeleteSessionResponse = Message<"kagent.api.v1alpha1.DeleteSessionRe * Use `create(DeleteSessionResponseSchema)` to create a new message. */ export const DeleteSessionResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1alpha1_sessions, 15); + messageDesc(file_kagent_api_v1alpha1_sessions, 16); /** * @generated from message kagent.api.v1alpha1.SessionShare @@ -433,7 +482,7 @@ export type SessionShare = Message<"kagent.api.v1alpha1.SessionShare"> & { * Use `create(SessionShareSchema)` to create a new message. */ export const SessionShareSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1alpha1_sessions, 16); + messageDesc(file_kagent_api_v1alpha1_sessions, 17); /** * @generated from message kagent.api.v1alpha1.CreateSessionShareRequest @@ -464,7 +513,7 @@ export type CreateSessionShareRequest = Message<"kagent.api.v1alpha1.CreateSessi * Use `create(CreateSessionShareRequestSchema)` to create a new message. */ export const CreateSessionShareRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1alpha1_sessions, 17); + messageDesc(file_kagent_api_v1alpha1_sessions, 18); /** * @generated from message kagent.api.v1alpha1.CreateSessionShareResponse @@ -488,7 +537,7 @@ export type CreateSessionShareResponse = Message<"kagent.api.v1alpha1.CreateSess * Use `create(CreateSessionShareResponseSchema)` to create a new message. */ export const CreateSessionShareResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1alpha1_sessions, 18); + messageDesc(file_kagent_api_v1alpha1_sessions, 19); /** * @generated from message kagent.api.v1alpha1.ListSessionSharesRequest @@ -510,7 +559,7 @@ export type ListSessionSharesRequest = Message<"kagent.api.v1alpha1.ListSessionS * Use `create(ListSessionSharesRequestSchema)` to create a new message. */ export const ListSessionSharesRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1alpha1_sessions, 19); + messageDesc(file_kagent_api_v1alpha1_sessions, 20); /** * @generated from message kagent.api.v1alpha1.ListSessionSharesResponse @@ -532,7 +581,7 @@ export type ListSessionSharesResponse = Message<"kagent.api.v1alpha1.ListSession * Use `create(ListSessionSharesResponseSchema)` to create a new message. */ export const ListSessionSharesResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1alpha1_sessions, 20); + messageDesc(file_kagent_api_v1alpha1_sessions, 21); /** * @generated from message kagent.api.v1alpha1.RevokeSessionShareRequest @@ -549,7 +598,7 @@ export type RevokeSessionShareRequest = Message<"kagent.api.v1alpha1.RevokeSessi * Use `create(RevokeSessionShareRequestSchema)` to create a new message. */ export const RevokeSessionShareRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1alpha1_sessions, 21); + messageDesc(file_kagent_api_v1alpha1_sessions, 22); /** * @generated from message kagent.api.v1alpha1.RevokeSessionShareResponse @@ -562,7 +611,7 @@ export type RevokeSessionShareResponse = Message<"kagent.api.v1alpha1.RevokeSess * Use `create(RevokeSessionShareResponseSchema)` to create a new message. */ export const RevokeSessionShareResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_kagent_api_v1alpha1_sessions, 22); + messageDesc(file_kagent_api_v1alpha1_sessions, 23); /** * @generated from enum kagent.api.v1alpha1.SessionSharePermission diff --git a/ui/src/mocks/fixtures.ts b/ui/src/mocks/fixtures.ts index ebe1da8813..e12572760e 100644 --- a/ui/src/mocks/fixtures.ts +++ b/ui/src/mocks/fixtures.ts @@ -137,7 +137,7 @@ export const mockProviders: Provider[] = [ name: "Ollama", type: "Ollama", requiredParams: [], - optionalParams: ["host", "options"], + optionalParams: ["host", "options", "think"], }, { name: "Gemini", diff --git a/ui/src/pages/agents/AgentFormPage.tsx b/ui/src/pages/agents/AgentFormPage.tsx index 75693eb0e3..d1d615229a 100644 --- a/ui/src/pages/agents/AgentFormPage.tsx +++ b/ui/src/pages/agents/AgentFormPage.tsx @@ -137,6 +137,8 @@ function AgentForm({ agent }: { agent?: Agent }) { ...(harnessSource === "reference" ? { harnessRef: { name: harnessRef! } } : { harness: harnessSpecFromDraft(harnessDraft, spec?.harness) }), + // The form does not author egress; an edit keeps what the Agent declares. + ...(spec?.egress ? { egress: spec.egress } : {}), } as AgentSpec; const input = { namespace,